LocalAI/PLAN-multimodal.md

14 KiB
Raw Blame History

Plan: Transkription + Bildgenerierung, Anbindung an Laravel (laravel/ai)

Stand der Umsetzung (2026-10-15) – Server fertig, Laravel-Teil dokumentiert:

  • Weder A noch B, sondern Variante C: Vulkan schafft nur 54 % der ROCm-Prompt-Verarbeitung (Messwerte unten). Deshalb baut llama-swap/Dockerfile auf dem ROCm-Image auf und kopiert whisper-server + sd-server aus unified-vulkan-265 dazu. LLMs laufen weiter auf ROCm, Whisper und Bilder über Vulkan, und alles verwaltet ein llama-swap.
  • Vulkan-Gerät 0 ist die iGPU. Die RX 6950 XT ist Gerät 1 → VK_VISIBLE_DEVICES=1.
  • Whisper läuft auf der GPU statt der CPU: 0,46 s statt 3,3 s (CPU, 12 Threads, -l de) bzw. 9,3 s (CPU, 8 Threads, -l auto) für 6,6 s Sprache. Es braucht ~1,2 GB VRAM und kostet die Chat-Geschwindigkeit nachweislich nichts (852 t/s mit und ohne).
  • ffmpeg ist im ROCm-Image schon enthalten, --convert funktioniert: WAV, WebM/Opus und M4A/AAC werden fehlerfrei transkribiert.
  • Bilder: Z-Image-Turbo Q8, VAE von Comfy-Org/z_image_turbo (nicht gated). Die Dauer hängt an der GPU-Rechenleistung (~4 s/Schritt bei 1024²). --offload-to-cpu / --backend te=cpu ändern daran nichts, --offload-to-cpu bleibt (spart VRAM).
  • OpenAI-Format getestet: Alias gpt-image-1, size: 1536x1024, quality (wird ignoriert), ohne response_format → b64_json.
  • scripts/start.sh: baut das Image, lädt Whisper vor, --pull aktualisiert die Basis-Images und baut neu.
  • Phase 5 (Laravel) ist als Anleitung in docs/laravel.md umgesetzt. In einer echten Laravel-App ist sie noch nicht getestet (offen: wo die App läuft).
  • Nebenbefund, älter als dieser Plan: Der Startup-Preload von llama-swap meldet für qwen2.5-coder-1.5b/nomic-embed „status 404“ (llama-server ohne Web-UI antwortet auf / mit 404). Die Modelle laden trotzdem.

Context

Eine Laravel-Anwendung soll über das Laravel AI SDK (laravel/ai) diesen Server nutzen:

  1. Agent (Chat, Tools, Streaming, Embeddings). Das geht schon heute ohne Server-Änderung.
  2. Spracheingabe: Aufnahmetaste im Browser → Transkription (Whisper) → Text als Prompt an den Agenten.
  3. Bildgenerierung, auch als Tool des Agenten.

Was das SDK laut Doku (https://laravel.com/docs/ai-sdk) kann:

Funktion Treiber openai-compatible Bemerkung
Text, Streaming, Tools, Structured Output ✅
Embeddings ✅ Standardmodell in der Config nötig
Transkription (STT) ✅ Standardmodell in der Config nötig, keine Sprecher-Trennung (Diarization)
Bilder ❌ Umweg: Treiber openai mit eigener url oder ein eigener HTTP-Aufruf
TTS ❌ nicht Teil dieses Plans

Kernproblem: Image-Wahl (ROCm vs. Vulkan)

Das aktuelle Image llama-swap:vXXX-rocm-bYYYY enthält nur llama-server. whisper-server und sd-server gibt es nur im unified-Image, und das für AMD nur als unified-vulkan (versionierte Tags: unified-vulkan-NNN). Ein ROCm-unified-Image gibt es nicht.

A: alles auf unified-vulkan (bevorzugt) B: ROCm bleibt, Whisper separat
Container 1 × llama-swap llama-swap (ROCm) + whisper-Container (CPU)
VRAM-Koordination Chat ↔ Bildmodell llama-swap übernimmt das (Gruppe chat) keine. Bildmodell und Chat-Modell würden sich um VRAM streiten, Bilder fallen in B weg
Routing alles über llama-swap Caddy leitet /v1/audio/* am llama-swap vorbei
Risiko Vulkan ist evtl. langsamer als ROCm, deshalb Benchmark mehr Teile, keine Bilder

Entscheidung über Benchmark (Phase 1): Kommt Vulkan bei Token-Generierung und Prompt-Verarbeitung auf ≥ ~85 % von ROCm, dann Variante A. Sonst Variante B, und die Bilder bleiben vorerst weg.


Phase 1: Benchmark Vulkan vs. ROCm (~1 h)

  1. Den Tag ermitteln: https://github.com/mostlygeek/llama-swap/pkgs/container/llama-swap → neuestes unified-vulkan-NNN.
  2. Messen, während der laufende Stack aus ist (docker compose stop), mit denselben Modelldateien aus ./models:
    docker run --rm --device /dev/dri --group-add 987 --group-add 983 \
      -e GGML_VK_VISIBLE_DEVICES=0 -v ./models:/models \
      --entrypoint llama-bench ghcr.io/mostlygeek/llama-swap:unified-vulkan-NNN \
      -m /models/<Pfad zur Qwen3.5-35B-A3B-Q4_K_M.gguf> -p 2048 -n 128 -fa 1
    
    Dasselbe mit dem ROCm-Image (/dev/kfd dazu, HIP_VISIBLE_DEVICES=0). Den GGUF-Pfad unter models/models--unsloth--Qwen3.5-35B-A3B-GGUF/snapshots/*/ suchen.
    • Ob llama-bench in beiden Images vorhanden ist, prüfen. Falls nicht: llama-server starten und die t/s aus dem Log einer Test-Anfrage nehmen.
    • Wichtig: Für den 35B-MoE muss die Auslagerung in den RAM gleich sein. Notfalls mit --n-cpu-moe vergleichbar festlegen.
  3. GGML_VK_VISIBLE_DEVICES=0: Der 7900X3D hat eine iGPU, die Vulkan sonst als zweites Gerät sieht. Mit vulkaninfo --summary bzw. dem Startlog prüfen, dass Gerät 0 die RX 6950 XT ist.
  4. Die Ergebnisse unten unter „Messwerte“ eintragen und A oder B entscheiden.

Phase 2 (A): Stack auf unified-vulkan umstellen

  • .env / .env.example: LLAMA_SWAP_TAG=unified-vulkan-NNN. Den alten ROCm-Tag als Kommentar behalten (Rollback).
  • docker-compose.yml (llama-swap):
    • devices: nur noch /dev/dri nötig (/dev/kfd schadet nicht, kann aber weg).
    • environment: GGML_VK_VISIBLE_DEVICES: "${VK_VISIBLE_DEVICES:-0}" statt bzw. zusätzlich zu HIP_VISIBLE_DEVICES.
    • Prüfen, ob das Image mit user: 1000:1000 läuft. Es gibt auch -rootless-Varianten.
    • Prüfen, ob Binärpfade (/app/llama-server vs. llama-server im PATH) zum server-Makro passen.
  • llama-swap/config.yaml: Bestehende Modelle unverändert lassen. Prüfen, ob --fit / --fit-target unter Vulkan genauso greift (VRAM mit rocm-smi beobachten).
  • Gegenprobe: opencode + Continue (Chat, Autocomplete, @codebase) funktionieren wie vorher.
  • README: Tabelle „Auf einen anderen PC übertragen“ anpassen (unified-vulkan ist jetzt Standard).

Phase 3: Whisper (Transkription)

  1. Modell herunterladen (whisper-server kann kein -hf):
    mkdir -p models/whisper
    curl -L -o models/whisper/ggml-large-v3-turbo-q8_0.bin \
      https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q8_0.bin
    
  2. Eintrag in llama-swap/config.yaml, auf der CPU und in always-on, damit er nie das Chat-Modell verdrängt:
    whisper:
      name: Whisper large-v3-turbo (CPU)
      checkEndpoint: /            # prüfen: hat whisper-server /health?
      cmd: >-
        whisper-server --host 127.0.0.1 --port ${PORT}
        -m /models/whisper/ggml-large-v3-turbo-q8_0.bin
        --inference-path /v1/audio/transcriptions
        --convert --tmp-dir /tmp
        -ng -t 8 -l auto    
      ttl: 0
    
    • Die Flags mit docker compose exec llama-swap whisper-server --help prüfen: --inference-path, --convert (braucht ffmpeg im Image, mit which ffmpeg prüfen), -ng/--no-gpu, --tmp-dir.
    • Ohne ffmpeg: --convert weglassen, dann wandelt Laravel vorher in 16-kHz-Mono-WAV um (siehe Phase 5).
    • whisper zu routing…groups.always-on.members und zu hooks.on_startup.preload hinzufügen.
  3. scripts/start.sh: Beim Preload einen Whisper-Request schicken. Dafür eine kurze Test-WAV unter scripts/ ablegen, z. B. 1 s Stille mit ffmpeg -f lavfi -i anullsrc=r=16000:cl=mono -t 1 scripts/silence.wav.
  4. Test:
    curl -H "Authorization: Bearer $KEY" http://127.0.0.1:8800/v1/audio/transcriptions \
      -F model=whisper -F file=@test.webm -F response_format=json
    
    Mit einer echten deutschen Sprachaufnahme testen und die Dauer bei ~10 s Audio notieren (Ziel < 3 s).
  5. Caddy: keine Änderung nötig. Es gibt standardmäßig kein Upload-Limit, und der read_timeout von 30 min reicht.

Phase 4: Bildgenerierung (nur Variante A)

  1. Modell: Z-Image-Turbo (Vorlage aus docker/config.example.yaml von llama-swap, 8 Steps, schnell). Benötigt drei Dateien in models/sd/: z_image_turbo-Q8_0.gguf, ae.safetensors (VAE), qwen3-4b-instruct-2507-q8_0.gguf (Text-Encoder). Die Hugging-Face-Quellen beim Umsetzen heraussuchen. Alternative: FLUX.1-schnell (GGUF).
  2. Eintrag in der chat-Gruppe, damit llama-swap das Chat-Modell entlädt, solange ein Bild generiert wird:
    z-image:
      name: Z-Image Turbo (Bilder)
      checkEndpoint: /
      cmd: |
        sd-server --listen-ip 127.0.0.1 --listen-port ${PORT}
        --diffusion-fa
        --diffusion-model /models/sd/z_image_turbo-Q8_0.gguf
        --vae /models/sd/ae.safetensors
        --llm /models/sd/qwen3-4b-instruct-2507-q8_0.gguf
        --offload-to-cpu --cfg-scale 1.0 --steps 8
        --height 1024 --width 1024    
      aliases: [gpt-image-1, dall-e-3]
      ttl: 600
    
    • Die Flags mit sd-server --help prüfen, besonders den Namen von --listen-ip.
    • Die aliases sind wichtig: Der OpenAI-Treiber von Laravel schickt seinen eigenen Modellnamen.
  3. Test: curl …/v1/images/generations -d '{"model":"z-image","prompt":"a red bicycle","response_format":"b64_json"}'. Dabei messen: Dauer inkl. Modellwechsel, VRAM-Spitze, ob Autocomplete (Continue) weiterläuft.
  4. Hinweis für den Alltag: Jede Bildgenerierung wirft das Chat-Modell raus (Neuladen dauert einige zehn Sekunden). Für einen Agenten, der Bilder als Tool erzeugt, heißt das: Chat → Bild → Chat bedeutet zwei Modellwechsel.

Phase 5: Laravel-Anwendung

5.1 Config (config/ai.php + .env)

'providers' => [
    // Text, Agent, Embeddings, Transkription
    'local' => [
        'driver' => 'openai-compatible',
        'url'    => env('LOCAL_AI_URL'),        // http://<server>:8800/v1
        'key'    => env('LOCAL_AI_API_KEY'),
        'models' => [
            'text'          => ['default' => 'qwen3.5-35b-a3b'],
            'embeddings'    => ['default' => 'nomic-embed', 'dimensions' => 768],
            'transcription' => ['default' => 'whisper'],
        ],
    ],
    // nur für Bilder: OpenAI-Treiber auf den eigenen Server umgebogen
    'local-images' => [
        'driver' => 'openai',
        'key'    => env('LOCAL_AI_API_KEY'),
        'url'    => env('LOCAL_AI_URL'),
    ],
],
  • Den Env-Namen für die URL-Überschreibung je nach SDK-Version prüfen (Doku 13.x: url/OPENAI_URL, 12.x: OPENAI_BASE_URL).
  • Den Schlüssel prüfen, mit dem local zum Standard-Provider für Transkription wird (Transcription hat in der Doku keinen provider:-Parameter).
  • Netz: Der Laravel-Server muss in ALLOWED_NETS liegen, im LAN oder als NetBird-Peer.
  • Timeouts großzügig setzen (timeout: 300 beim Prompt), Agent-Läufe mit Tools als Queue-Job.

5.2 Agent

  • php artisan make:agent Assistant: Anweisungen, Tools.
  • Smoke-Test: (new Assistant)->prompt('Hallo', provider: 'local'), danach ein Tool-Call-Test.

5.3 Spracheingabe (Aufnahmetaste)

  • Frontend: getUserMedia + MediaRecorder (gedrückt halten = aufnehmen). Bevorzugt audio/webm;codecs=opus, Safari liefert audio/mp4. Den Blob per FormData an POST /voice schicken, die Antwort als Stream lesen.
  • HTTPS ist Pflicht. Das Mikrofon geht nur in einem sicheren Kontext. Die Laravel-App braucht deshalb HTTPS, lokal geht auch localhost.
  • Backend:
    Route::post('/voice/transcribe', fn (Request $r) =>
        ['text' => (string) Transcription::fromUpload($r->file('audio'))->generate()]);
    Route::post('/voice/ask', fn (Request $r) =>
        (new Assistant)->stream($r->string('text')));
    
    Zwei Schritte, damit der User den erkannten Text vor dem Absenden sieht und korrigieren kann. Das ist wichtig bei Agenten, deren Tools etwas ausführen.
  • Wenn whisper-server kein --convert kann: In Laravel vorher umwandeln mit ffmpeg -i in.webm -ar 16000 -ac 1 out.wav (Symfony Process), dann Transcription::fromPath(...).
  • Validierung: Upload-Limit (z. B. 20 MB), Mindestlänge, leere Transkripte abfangen.

5.4 Bilder

  • Direkt: Image::of('...')->generate(provider: 'local-images') (die genaue API in der Doku nachsehen). Falls der OpenAI-Treiber Parameter schickt, die sd-server ablehnt: Fallback auf Http::withToken(...)->post(.../images/generations) und b64_json selbst in den Storage schreiben.
  • Als Agent-Tool: GenerateImage-Tool, das eins von beidem aufruft und eine URL bzw. einen Storage-Pfad zurückgibt.

Phase 6: Doku im Repo

  • docs/laravel.md: Config, Netz/Key, Spracheingabe, Bilder, Stolperfallen (HTTPS, Formate, Modellwechsel).
  • README: Modelltabelle um whisper und z-image ergänzen, Images und Rollback beschreiben.
  • PLAN.md-Stil: Abweichungen oben in diesem Plan unter „Stand der Umsetzung“ festhalten.

Verifikation

  1. opencode + Continue funktionieren unverändert (nach dem Image-Wechsel!), die t/s liegen im erwarteten Bereich.
  2. GET /v1/models listet whisper und z-image.
  3. Transkription per curl (deutsch, webm und wav) liefert korrekten Text. Im Log wird dabei kein Chat-Modell entladen.
  4. Bild per curl: PNG kommt zurück, danach lädt das Chat-Modell beim nächsten Request wieder.
  5. Laravel: Agent-Prompt mit Tool-Call, Spracheingabe Ende-zu-Ende im Browser (Chrome + Firefox, wenn möglich Safari), Bild über das Agent-Tool.
  6. Nach einem Reboot ist Whisper vorgeladen (/running).

Rollback

LLAMA_SWAP_TAG in .env zurück auf v265-rocm-b11610, /dev/kfd + HIP_VISIBLE_DEVICES wieder rein, die Einträge whisper/z-image auskommentieren, docker compose up -d.

Messwerte (in Phase 1 ausfüllen)

Image pp2048 t/s tg128 t/s Bemerkung
ROCm v265-rocm-b11610 ~1030 ~49,8 nur 35B geladen, --fit-target 3072
unified-vulkan-265 ~555 ~43,2 gleiche Bedingungen
ROCm im Betrieb (mit Autocomplete + Embeddings) ~850 ~47,5 identisch mit und ohne Whisper auf der GPU

Gemessen mit llama-server (Betriebs-Parameter), 2302 Prompt-Tokens, 256 generierte Tokens, cache_prompt: false.

Bilder (Z-Image-Turbo Q8, 8 Schritte) Dauer
512×512 ~11 s
1024×1024 ~40 s (Diffusion 32 s, VAE 6 s)
1536×1024 ~66 s
erster Request inkl. Laden +4 s
Chat-Modell danach zurückladen ~5 s
VRAM-Spitze beim Bild 8,4 GB (mit Autocomplete + Whisper)

Offene Punkte

  • Wo läuft die Laravel-App (gleiches LAN, NetBird, öffentlicher Server)? Davon hängen ALLOWED_NETS und HTTPS ab.
  • Bildmodell: Reichen Qualität und Geschwindigkeit von Z-Image-Turbo, oder lieber FLUX.1-schnell?
  • Später ggf. TTS (die Antwort vorlesen): audio.cpp ist im unified-Image, aber das Laravel SDK kann TTS nicht über openai-compatible.