228 lines
14 KiB
Markdown
228 lines
14 KiB
Markdown
# 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`](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`](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`:
|
||
```bash
|
||
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`):
|
||
```bash
|
||
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:
|
||
```yaml
|
||
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:
|
||
```bash
|
||
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:
|
||
```yaml
|
||
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`)
|
||
```php
|
||
'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:**
|
||
```php
|
||
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`.
|