- POST /v1/images/generations (OpenAI-kompatibel, prompt/size/n/seed/quality) - quality: standard=30 Steps (Default), high=50 Steps - Größen: 1024x1024, 1536x1024, 1024x1536, 1920x1088, 1088x1920 - GPU-Hotswap: Qwen stoppen -> FLUX laden -> Bild -> FLUX entladen -> Qwen wiederherstellen (exakt vorheriges Profil) - Zentrales GPU/Modell-Lock (Profilwechsel und Bild teilen sich das Lock) - Chat-Requests warten während Bild-Job (kein 502), Timeout CHAT_WAIT_TIMEOUT - Robuste Recovery: try/finally, Worker-Beendigung, VRAM-Check, Qwen-Readiness - /status: image.phase, image.worker, image.model_loaded, qwen.available, qwen.active_chats - GET /images, GET /images/<datei> (validiert, nur images/-Verzeichnis) - image_worker.py: FLUX-Worker (eigener Prozess, JSON-Protokoll, bf16 + enable_model_cpu_offload) - deploy: venv (torch/diffusers/transformers/accelerate), Modell-Download, Image-Dir, systemd-Unit mit Image-Umgebungsvariablen - dev: Mock-Worker, fake-systemctl, Benchmarks (GPU-Resident, Offload, Steps, Quality-Compare), 32 lokale Tests - README: Bildgenerierung, Hotswap, Recovery, Benchmarks (RTX 5080), Python-Pakete Benchmarks (RTX 5080, 16 GB, CPU-Offload): - 512x512 / 10 Steps: ~9.3 s - 1024x1024 / 30 Steps: ~31.3 s - 1024x1024 / 50 Steps: ~45.3 s - 1920x1088 / 50 Steps: ~91 s - Peak-VRAM: ~8.4-8.9 GB - Hotswap-Gesamtzeit: ~41-42 s (1024x1024 / 30 Steps)
265 lines
11 KiB
Markdown
265 lines
11 KiB
Markdown
# AI Profile Router
|
||
|
||
Kleiner OpenAI-kompatibler Proxy (Python, nur Standardbibliothek) vor einem
|
||
lokalen llama.cpp-Server. Er leitet normale OpenAI-Requests transparent
|
||
weiter (Streaming, Tool Calls, JSON), schaltet zwischen drei festen
|
||
llama.cpp-Profilen um und orchestriert lokale Bildgenerierung mit
|
||
FLUX.2 [klein] 4B Base (GPU-Hotswap: Qwen stoppen → FLUX laden → Bild
|
||
→ FLUX entladen → Qwen wiederherstellen).
|
||
|
||
## Zielsystem
|
||
|
||
| | |
|
||
|---|---|
|
||
| Host | 192.168.1.196 |
|
||
| SSH | `root` mit Key `lmstudio_unraid` |
|
||
| llama.cpp | `http://127.0.0.1:8080` (Service `mike-ai-llama-ui.service`) |
|
||
| Profil-Skript | `/usr/local/bin/llama-profile {fast\|medium\|long}` |
|
||
| Router-Port | **8081** |
|
||
| Router-Service | `mike-ai-profile-router.service` |
|
||
|
||
## Profile / virtuelle Modelle
|
||
|
||
| Profil | Modell | Kontext |
|
||
|---|---|---|
|
||
| `fast` | `qwen-fast` | 73728 |
|
||
| `medium` | `qwen-medium` | 94208 |
|
||
| `long` | `qwen-long` | 131072 |
|
||
|
||
## Endpunkte
|
||
|
||
| Endpunkt | Beschreibung |
|
||
|---|---|
|
||
| `GET /v1/models` | Die drei virtuellen Modelle inkl. `context_length`/`context_window` |
|
||
| `GET /status` | Aktives Profil, Upstream-Zustand, Modell, Kontext, Uptime |
|
||
| `POST /fast` `/medium` `/long` | Profilwechsel (auch `GET` möglich) |
|
||
| `POST /v1/chat/completions` | Weiterleitung an llama.cpp (Streaming + Tool Calls) |
|
||
| `POST /v1/images/generations` | Bildgenerierung (FLUX.2 [klein] 4B Base, OpenAI-kompatibel) |
|
||
| `GET /images` | Liste der gespeicherten Bilder (max. 200) |
|
||
| `GET /images/<datei>` | PNG-Download (nur `images/`-Verzeichnis, validiert) |
|
||
| alles andere | Transparente Weiterleitung an llama.cpp |
|
||
|
||
### Verhalten
|
||
|
||
- **Virtuelles Modell** (`qwen-fast`/`qwen-medium`/`qwen-long` in
|
||
`chat.completions`): Der Router stellt sicher, dass das passende Profil
|
||
aktiv ist (notfalls Wechsel + Warten), ersetzt das Modell durch das echte
|
||
llama.cpp-Modell und leitet weiter.
|
||
- **Profilwechsel** (`POST /fast` …): Führt `/usr/local/bin/llama-profile
|
||
<profil>` aus (ohne Shell, feste Argumente → keine Injection), wartet dann,
|
||
bis llama.cpp wieder erreichbar ist, und liefert erst dann `200`.
|
||
- **Impliziter Wechsel bei downem llama.cpp**: Ein Chat-Request mit virtuellem
|
||
Modell liefert sofort `502`, wenn das Profil bereits aktiv ist, aber
|
||
llama.cpp down ist (kein stiller Neustart). Der Neustart wird explizit über
|
||
`POST /<profil>` angestoßen.
|
||
- **Ungültige Profile/Modelle**: `POST /<anderes>` → `400`;
|
||
`qwen-<anderes>` als Modell → `400`. Nur die drei festen Profile sind
|
||
schaltbar.
|
||
- **Fehlerformat**: OpenAI-kompatibel (`{"error": {"message", "type", "code"}}`).
|
||
|
||
## Bildgenerierung (FLUX.2 [klein] 4B Base)
|
||
|
||
Der Router orchestriert lokale Bildgenerierung mit
|
||
`black-forest-labs/FLUX.2-klein-base-4B` (Apache 2.0, ~13 GB, bf16 +
|
||
CPU-Offload). Da Qwen (llama.cpp) und FLUX denselben GPU/VRAM teilen, macht
|
||
der Router einen **GPU-Hotswap**:
|
||
|
||
1. Zentrales GPU/Modell-Lock übernehmen (Profilwechsel und Bild teilen sich
|
||
dasselbe Lock → kein Race).
|
||
2. Aktives Qwen-Profil merken.
|
||
3. `mike-ai-llama-ui.service` stoppen, warten bis Port + VRAM frei sind.
|
||
4. FLUX-Worker starten (eigener Prozess, `Flux2KleinPipeline`, bf16 +
|
||
`enable_model_cpu_offload()`), Bild generieren, PNG speichern.
|
||
5. Worker **beenden** (nicht nur entladen), VRAM-Freiheit verifizieren.
|
||
6. Vorheriges Qwen-Profil exakt wiederherstellen, Readiness-Check
|
||
(Modell geladen + Kontext passt).
|
||
7. Erst dann antworten und das GPU-Lock freigeben.
|
||
|
||
### Endpunkt `POST /v1/images/generations`
|
||
|
||
OpenAI-kompatibel. Unterstützt `prompt`, `size`, `n`, `seed`, `quality`,
|
||
`response_format`.
|
||
|
||
| Parameter | Werte | Default |
|
||
|---|---|---|
|
||
| `prompt` | Text (Pflicht) | – |
|
||
| `size` | `1024x1024`, `1536x1024`, `1024x1536`, `1920x1088`, `1088x1920` | `1024x1024` |
|
||
| `n` | 1–4 | 1 |
|
||
| `seed` | int (reproduzierbar) | zufällig |
|
||
| `quality` | `standard` (30 Steps), `high` (50 Steps) | `standard` |
|
||
| `response_format` | `url` (Default), `b64_json` | `url` |
|
||
|
||
Beispiel:
|
||
|
||
```bash
|
||
curl -s http://192.168.1.196:8081/v1/images/generations \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"prompt":"ein roter Würfel auf weißem Grund","size":"1024x1024","quality":"standard"}'
|
||
```
|
||
|
||
Die Antwort enthält `data[].url` (absolute URL, über den Router abrufbar)
|
||
und `data[].b64_json` (optional). Jedes Bild wird unter
|
||
`/opt/mike-ai/ai-profile-router/images/` gespeichert (kollisionsfreie Namen,
|
||
`img-<zeitstempel>-<seed>-<i>.png`) und ist über `GET /images/<datei>`
|
||
abrufbar.
|
||
|
||
### Verhalten während eines Bild-Jobs
|
||
|
||
- **Chat-Requests warten** (kein 502): Der Router merkt sich, dass Qwen
|
||
vorübergehend nicht verfügbar ist (`qwen.available=false`), und Chat-Requests
|
||
warten, bis Qwen wieder bereit ist (Timeout `CHAT_WAIT_TIMEOUT`, Default
|
||
300 s). So gibt es keine `502 llama.cpp nicht erreichbar` während des
|
||
Hotswaps.
|
||
- **Profilwechsel warten**: Ein Profilwechsel während eines Bild-Jobs
|
||
blockiert auf dem GPU-Lock, bis der Bild-Job fertig ist (kein Race).
|
||
- **`/status`** zeigt den aktuellen Zustand: `image.phase` (`idle`,
|
||
`stopping-qwen`, `loading-image`, `generating`, `unloading-image`,
|
||
`restoring-qwen`), `image.worker`, `image.model_loaded`,
|
||
`image.last_image`, `image.last_seconds`, `image.last_error`,
|
||
`qwen.available`, `qwen.active_chats`.
|
||
|
||
### Recovery (robust)
|
||
|
||
- **`try/finally`**: Qwen wird **immer** wiederhergestellt, egal ob die
|
||
Bildgenerierung erfolgreich war, fehlgeschlagen ist (OOM, Python-Fehler,
|
||
ungültiger Prompt, Speichern-Fehler, Client-Disconnect, Timeout) oder der
|
||
Worker abstürzt.
|
||
- **Worker-Beendigung**: Nach jedem Job wird der Worker beendet (SIGTERM →
|
||
SIGKILL), nicht nur entladen. So wird der VRAM (inkl. CUDA-Kontext) frei.
|
||
- **VRAM-Check**: Nach dem Worker-Beenden wartet der Router, bis der VRAM
|
||
unter 1000 MiB fällt (`nvidia-smi`), bevor Qwen neu startet.
|
||
- **Qwen-Readiness**: Nach dem Neustart wartet der Router, bis llama.cpp
|
||
erreichbar ist, das Modell geladen ist und der Kontext zum Profil passt.
|
||
- **`qwen.available`**: Bleibt `false`, wenn die Wiederherstellung fehlschlägt
|
||
(Chat-Requests warten weiter, statt 502 zu liefern). Der Fehler wird in
|
||
`image.last_error` und im Log protokolliert.
|
||
|
||
### Benchmarks (RTX 5080, 16 GB, CPU-Offload, gemessen)
|
||
|
||
| Auflösung | Steps | Zeit | Peak-VRAM (torch) |
|
||
|---|---|---|---|
|
||
| 512×512 | 10 | ~9.3 s | ~8.4 GB |
|
||
| 1024×1024 | 30 | ~31.3 s | ~8.4 GB |
|
||
| 1024×1024 | 50 | ~45.3 s | ~8.4 GB |
|
||
| 1920×1088 | 50 | ~91 s | ~8.9 GB |
|
||
|
||
**Entscheidung:** `standard` = 30 Steps (Default, ~31 s bei 1024×1024),
|
||
`high` = 50 Steps (maximale Qualität, ~45 s bei 1024×1024). Ab 20–30 Steps
|
||
ist der Qualitätsgewinn bei einfachen Motiven gering; 50 Steps lohnt sich
|
||
für komplexe Szenen.
|
||
|
||
**Hinweis:** FLUX.2 [klein] 4B Base passt **nicht** vollständig GPU-resident
|
||
in 16 GB (OOM bei ~15.5 GB). Deshalb wird `enable_model_cpu_offload()`
|
||
verwendet (Modelle werden pro Layer zwischen CPU und GPU gewechselt).
|
||
|
||
**Hotswap-Gesamtzeit:** Ein vollständiger Bild-Job (Qwen stoppen → FLUX laden
|
||
→ Bild → FLUX entladen → Qwen wiederherstellen) dauert ~41–42 s bei
|
||
1024×1024 / 30 Steps (davon ~31 s Generierung, ~10 s Qwen-Stop/Start +
|
||
VRAM-Check).
|
||
|
||
### Erforderliche Python-Pakete (im Venv)
|
||
|
||
- `torch` (2.11.0+cu128, CUDA 12.8)
|
||
- `diffusers` (0.40.0.dev0, für `Flux2KleinPipeline`)
|
||
- `transformers` (5.15.0)
|
||
- `accelerate` (1.14.0, für `enable_model_cpu_offload()`)
|
||
|
||
Das Venv liegt unter `/opt/mike-ai/ai-profile-router/venv/` und wird von
|
||
`install.sh` automatisch angelegt/aktualisiert.
|
||
|
||
## Repository-Struktur
|
||
|
||
```
|
||
router/ai_profile_router.py # der Router (einzige Laufzeit-Datei)
|
||
router/image_worker.py # FLUX-Worker (eigener Prozess, JSON-Protokoll)
|
||
deploy/mike-ai-profile-router.service # systemd-Unit
|
||
deploy/install.sh # läuft auf dem Zielsystem (per SSH)
|
||
deploy/deploy.sh # läuft lokal: SCP + SSH
|
||
dev/ # lokale Tests (Mock-llama.cpp, Mock-Worker, Benchmarks)
|
||
```
|
||
|
||
Entwicklungsdateien (`dev/`) und Deployment-Dateien (`router/`, `deploy/`)
|
||
sind getrennt. Auf dem Zielsystem landet nur `router/` + `deploy/`.
|
||
|
||
## Deployment
|
||
|
||
Voraussetzung: SSH-Key `~/.ssh/lmstudio_unraid` (bereits vorhanden).
|
||
|
||
```bash
|
||
./deploy/deploy.sh
|
||
```
|
||
|
||
Das Skript:
|
||
|
||
1. Überträgt `ai_profile_router.py`, `image_worker.py`, `install.sh` und die
|
||
systemd-Unit per SCP nach `/tmp/ai-profile-router/` auf dem Zielsystem.
|
||
2. Führt `install.sh` per SSH aus, das:
|
||
- den alten Router (`mike-ai-local-llm-router.service` +
|
||
`/opt/mike-ai/local-llm-router`) **mit Backup** entfernt,
|
||
- den neuen Router + Worker nach `/opt/mike-ai/ai-profile-router/`
|
||
installiert,
|
||
- ein Python-Venv mit `torch`, `diffusers`, `transformers`, `accelerate`
|
||
anlegt (nur wenn noch nicht vorhanden),
|
||
- das FLUX-Modell nach `/opt/mike-ai/models/FLUX.2-klein-base-4B` lädt
|
||
(nur wenn noch nicht vorhanden, ~15 GB),
|
||
- `mike-ai-profile-router.service` aktiviert (Start beim Boot) und startet,
|
||
- `GET /status` verifiziert.
|
||
|
||
Die Installation ist idempotent (Update = erneut ausführen).
|
||
|
||
## Konfiguration (Umgebungsvariablen in der systemd-Unit)
|
||
|
||
| Variable | Default | Bedeutung |
|
||
|---|---|---|
|
||
| `ROUTER_HOST` | `0.0.0.0` | Bind-Adresse |
|
||
| `ROUTER_PORT` | `8081` | Port |
|
||
| `UPSTREAM_URL` | `http://127.0.0.1:8080` | llama.cpp |
|
||
| `PROFILE_SCRIPT` | `/usr/local/bin/llama-profile` | Profil-Skript |
|
||
| `PROFILE_DIR` | `/etc/systemd/system/mike-ai-llama-ui.service.d` | Ort der `override.conf` |
|
||
| `SWITCH_TIMEOUT` | `600` | Warten auf llama.cpp nach Wechsel (s) |
|
||
| `REQUEST_TIMEOUT` | `600` | Read-Timeout für Upstream-Requests (s) |
|
||
| `CONNECT_TIMEOUT` | `10` | Connect-Timeout Upstream (s) |
|
||
| `POLL_INTERVAL` | `2` | Polling-Intervall (s) |
|
||
| `LOG_LEVEL` | `INFO` | Logging-Level |
|
||
| `LLAMA_SERVICE` | `mike-ai-llama-ui.service` | llama.cpp-Service (für Bild-Hotswap) |
|
||
| `IMAGE_WORKER` | `<router-dir>/image_worker.py` | FLUX-Worker-Skript |
|
||
| `IMAGE_PYTHON` | `sys.executable` | Python für den Worker (venv mit torch) |
|
||
| `IMAGE_DIR` | `/opt/mike-ai/ai-profile-router/images` | Bild-Speicherort |
|
||
| `IMAGE_WORKER_LOG` | `/opt/mike-ai/ai-profile-router/worker.log` | Worker-Log |
|
||
| `IMAGE_GEN_TIMEOUT` | `600` | Timeout pro Bild (s) |
|
||
| `IMAGE_VRAM_FREE_TIMEOUT` | `120` | Warten auf VRAM-Freiheit (s) |
|
||
| `CHAT_WAIT_TIMEOUT` | `300` | Chat wartet auf Qwen (s) |
|
||
|
||
## Lokale Tests
|
||
|
||
```bash
|
||
./dev/test_local.sh
|
||
```
|
||
|
||
Startet einen Mock-llama.cpp, einen Mock-Bild-Worker und den Router mit einem
|
||
Fake-Profil-Skript und prüft: `/v1/models`, `/status`, Forwarding, Streaming,
|
||
Tool Calls, Profilwechsel (fast→medium→fast), virtuelles Modell triggert
|
||
Wechsel, ungültige Profile, Upstream down → 502, Recovery, **Bildgenerierung**
|
||
(`standard`→30 Steps, `high`→50 Steps, Validierung, Image-Fehler→Qwen
|
||
wiederhergestellt, Fast/Medium/Long→Image→gleiches Profil, `/status` während
|
||
Bild-Job, paralleler Chat während Bild-Job wartet statt 502).
|
||
|
||
## Betrieb
|
||
|
||
```bash
|
||
systemctl status mike-ai-profile-router
|
||
journalctl -u mike-ai-profile-router -f
|
||
curl -s http://192.168.1.196:8081/status | python3 -m json.tool
|
||
curl -s -X POST http://192.168.1.196:8081/medium
|
||
```
|
||
|
||
## Sicherheit
|
||
|
||
- Keine Shell-Aufrufe: Profil-Skript wird mit `subprocess.run([script, profil])`
|
||
aufgerufen, `profil` ist Whitelist-geprüft (`fast|medium|long`).
|
||
- Keine Secrets/Tokens im Code oder in der Unit.
|
||
- systemd-Hardening: `NoNewPrivileges=true`, `PrivateTmp=true`.
|
||
- Die bestehende llama.cpp-/Profil-Konfiguration wird nicht verändert; der
|
||
Router nutzt nur das vorhandene `llama-profile`-Skript und liest die
|
||
`override.conf`.
|