Qwen 3.8 lokal: Einheitlicher OpenAI-Endpunkt für LiteLLM, llama-swap und Qwen auf der DGX Spark
Ein Endpunkt, alle Modelle: LiteLLM, llama-swap und Qwen3.8 auf der DGX Spark
Auf einer DGX Spark (GB10, aarch64, 121 GB Unified Memory) laufen bei uns mehrere LLMs lokal – und zwar alle hinter genau einem OpenAI-kompatiblen Endpunkt, erreichbar von überall. Interessant an dem Setup ist nicht die Hardware, sondern die Verkabelung: wie man Modelle pro Anfrage wechselt, wie man eine einzelne GPU stabil betreibt und wie man trotzdem einen sauberen Endpunkt bekommt, der sich anfühlt wie jeder Cloud-Anbieter.
Die drei Bausteine:
- LiteLLM – API-Fassade: ein OpenAI-Endpunkt, API-Keys, Kosten-Tracking
- llama-swap – Modell-Router: startet und stoppt pro Anfrage genau einen vLLM-Container
- Qwen3.8 27B (NVFP4) – das Arbeitspferd, mit spekulativem Dekodieren und 256k Kontext
Die Hardware in einem Satz
121 GB Unified Memory: CPU und GPU teilen sich denselben Speicher. Fast alle Stolperfallen in diesem Setup kommen aus genau dieser Eigenschaft – deshalb steht sie vor allem anderen.
Architektur
client (Laptop / Phone / n8n)
│ https://dgx-spark.<tailnet>.ts.net:4001/v1
▼
tailscale serve ──▶ 0.0.0.0:4001 current-model-proxy
│
▼
127.0.0.1:4000 LiteLLM
│
▼
127.0.0.1:8080 llama-swap (Go-Binary, systemd)
│
▼ docker run / docker stop
genau EIN vLLM-Container (127.0.0.1:98xx)
Der Kerngedanke: Statt alle Modelle gleichzeitig im Speicher zu parken, ist immer genau ein Modell geladen. Wer `qwen3-27b` anfragt, bekommt Qwen. Wer `gemma4-26b` anfragt, wartet ein paar Minuten, bis llama-swap den Container gewechselt hat. Für den Ein-Nutzer-Betrieb ist das der beste Trade: maximale Modellgröße pro GB, null Leerlauf-Kosten.
Die vier Modelle in der Rotation:
| id | Checkpoint | Größe | Kontext | Rolle |
|---|---|---|---|---|
| qwen3-27b | unsloth/Qwen3.8-27B-NVFP4 | ~21 GB | 262k | Production, nie entladen |
| gemma4-26b | RedHatAI/gemma-4-26B-A4B-it-FP8-dynamic | ~27 GB | 262k | MoE, 4B aktiv, schnell |
| gemma4-e4b | google/gemma-4-E4B-it-qat-w4a16-ct | ~11 GB | 131k | leichte Automation |
| embed | Qwen/Qwen3-Embedding-0.6B | ~1 GB | 32k | persistiert, überlebt alle Swaps |
Die drei Chat-Modelle bilden in llama-swap eine Swap-Group (eines zur Zeit), `embed` läuft persistent daneben.
llama-swap: ein Go-Binary, ein YAML
LLamaSwap ist ein OpenAI-kompatibler Reverse-Proxy. Beim ersten Request für ein Modell führt es dessen `cmd` aus (im vLLM-Fall ein `docker run`), pollt `/health`, bis der Upstream bereit ist, und proxyt dann weiter. Wird ein anderes Modell angefragt, stoppt es das alte. Kein Kubernetes, kein Orchestrierungs-Layer: ein Binary, eine Config, ein systemd-Service.
Wir betreiben es auf dem Host statt in einem Container – das ist keine Ästhetik. llama-swap braucht die docker CLI, um die vLLM-Container zu starten; in einem Container bräuchte man den Docker-Socket gemountet (root-äquivalent auf dem Host) plus eine docker-CLI, die die offiziellen Images gar nicht mitliefern. Host-Binary umgeht beides.
sudo install -m0755 llama-swap /usr/local/bin/llama-swap sudo install -m0644 llama-swap.service /etc/systemd/system/ sudo systemctl enable --now llama-swap
Der Punkt, der das Ganze wartbar macht: Die Unit liest die Config direkt aus dem Repo-Checkout mit `--watch-config`. Jede Änderung an `config.yaml` ist live, sobald die Datei gespeichert ist – kein Restart, kein sudo, kein Install-Schritt:
ExecStart=/usr/local/bin/llama-swap \
--config <PATH-TO-CONFIG>/config.yaml \
--listen 127.0.0.1:8080 \
--watch-config
Und so sieht der Qwen-Eintrag aus (stark gekürzt, aber alle entscheidenden Flags):
"qwen3-27b":
name: "Qwen3.8 27B (NVFP4, MTP, vision)"
ttl: 0 # Production-Modell: wird nie entladen
cmd: |
${vllm_flags} # docker run --gpus all --memory=90g --shm-size=16g
# + Mounts: hf-cache + vLLM-Compile-Cache
-e HF_HUB_OFFLINE=1 # Gewichte liegen komplett im Cache
vllm/vllm-openai:v0.26.0
unsloth/Qwen3.8-27B-NVFP4
--served-model-name qwen3-27b
--load-format fastsafetensors
--gpu-memory-utilization 0.70
--kv-cache-memory-bytes 30064771072 # 28 GiB, explizit – s. Stolperfallen
--max-model-len 262144
--max-num-seqs 32
--enable-chunked-prefill
--enable-prefix-caching
--limit-mm-per-prompt '{"image": 4, "video": 0}'
--speculative-config '{"method":"mtp","num_speculative_tokens":5}'
--reasoning-parser qwen3
--tool-call-parser qwen3_xml
--enable-auto-tool-choice
cmdStop: docker stop -t 90 vllm-qwen3-27b
Zwei Dinge, die man leicht überliest:
- `cmdStop` ist Pflicht. `docker run` ist nur ein Client – killt man ihn, läuft der Container weiter und `--name` bleibt belegt. Explizites `docker stop -t 90` plus `unloadTimeout: 120` (muss größer sein als die Stop-Zeit, sonst killt llama-swap den Stop mitten drin) ist die saubere Variante.
- `cmd` ist `exec`, kein Shell. `&&`, `|`, `$VAR` funktionieren nicht – stattdessen `${env.VAR}`-Macros.
Qwen3.8 27B: warum genau dieses Modell
Das Arbeitspferd ist `unsloth/Qwen3.8-27B-NVFP4` (~21 GB), nicht das FP8-Original (~30 GB). Warum NVFP4, obwohl der GB10 keine native FP4-Compute-Einheit hat und damit einen Dequantisierungs-Abschlag zahlt? Weil die ~9 GB Differenz in zusätzlichen KV-Cache umgewandelt werden – bei 256k-Kontext das Äquivalent von einem bis zwei parallelen Vollsequenzen. Und unsloths Quant bringt nebenbei verbesserungen bei Tool-Calls mit, die für agentische Nutzung (OpenCode, Coding-Workflows) direkt relevant sind.
Drei Eigenschaften machen das Modell auf der Spark besonders gut:
1. 256k Kontext, und das ist billig. Das Modell ist Hybrid-Attention: jede vierte Schicht ist volle Attention, der Rest Gated-DeltaNet-Lineare-Attention. Nur die vollen Schichten skalieren mit der Sequenzlänge – gemessen landen wir bei ~36 KiB KV pro Token statt der naiven 64 KiB. Deshalb fahren wir `--max-model-len 262144` ungekürzt, ohne dass das KV-Budget explodiert.
2. MTP ist im Checkpoint drin. `mtp_num_hidden_layers: 1` plus 15 `mtp.*`-Tensoren. Checken lohnt sich vor der Aktivierung – einige Checkpoints tragen das Feld in der `config.json` als Relikt, haben aber null MTP-Gewichte:
curl -sL https://huggingface.co/unsloth/Qwen3.8-27B-NVFP4/resolve/main/model.safetensors.index.json \ | grep -c 'mtp\.'
3. Spekulatives Dekodieren ist der größte Hebel. Gemessen auf exakt dieser Hardware (vLLM 0.26.x, Qwen3.8-27B-NVFP4):
| num_speculative_tokens | Decode |
|---|---|
| 0 (aus) | 11.4 tok/s |
| 3 | 23.6 tok/s |
| 5 | 24.7 tok/s |
| 8 | 21.7 tok/s |
Rund 2× schneller, ohne Qualitätsverlust – der Output ist bei MTP mathematisch identisch zur normalen Generierung. `5` ist hier der Sweet Spot; bei der Qwen3.6-Generation war K=1 optimal, bei 3.8 spielt der MTP-Head mehr Karten. Wer das übernommen hat: auf eurer Box neu benchen, die Annahmerate ist eine Eigenschaft des generierten Texts.
LiteLLM: die OpenAI-Fassade
Vorne liegt LiteLLM als Docker-Stack (Proxy + Postgres): ein Endpunkt, API-Keys, Ausgaben-Tracking pro Modell, Prompt-Logs.
services:
litellm:
image: ghcr.io/berriai/litellm:main-stable
command: ["--config", "/app/config.yaml", "--port", "4000", "--host", "127.0.0.1"]
network_mode: host
# + litellm-db (postgres:16-alpine), gebunden auf 127.0.0.1:5432
Die Config leitet alles nach llama-swap um und macht drei Dinge, die den Unterschied ausmachen:
model_list:
- model_name: "current_model" # dynamisch: was gerade geladen ist
litellm_params:
model: "openai/current_model"
api_base: "http://127.0.0.1:8080/v1"
api_key: "os.environ/LLAMA_SWAP_KEY"
- model_name: "*" # Catch-all: alles andere durchreichen
litellm_params:
model: "openai/*"
api_base: "http://127.0.0.1:8080/v1"
api_key: "os.environ/LLAMA_SWAP_KEY"
- model_name: "qwen3-27b"
litellm_params:
model: "openai/qwen3-27b"
api_base: "http://127.0.0.1:8080/v1"
api_key: "os.environ/LLAMA_SWAP_KEY"
model_info: # selbst definierte Preise fürs Spend-Tracking
input_cost_per_token: 0.000000289
output_cost_per_token: 0.0000024
litellm_settings:
drop_params: true
num_retries: 0 # Einzel-GPU: Retries routen nirgendwo hin, sie re-queue nur
Das `current_model`-Alias ist der Kniff: Ein LiteLLM-Callback (`modify_input`) fragt bei jedem Request kurz bei llama-swap unter `/running` an, welches Modell gerade geladen ist, und schreibt den Namen um – bevor LiteLLM das Modell auf einen Backend auflöst. Ein 5-Sekunden-Cache hält die Requests klein, als Fallback greift `qwen3-27b`:
async def modify_input_callback(keys: dict, **kwargs) -> dict:
if (keys.get("model") or "").strip() == "current_model":
running = await fetch_running_model() # llama-swap /running, 5s Cache
keys["model"] = running or "qwen3-27b"
return keys
Vor LiteLLM sitzt noch ein dünner Proxy (`current-model-proxy`, ~200 Zeilen aiohttp), weil der öffentliche Endpunkt auf Port 4001 liegt. Er erledigt dieselbe `current_model`-Auflösung für externe Clients und räumt `/v1/models` auf: Wegen der Wildcard-Einträge listet LiteLLM jedes Modell doppelt plus die Wildcards selbst als „Modelle“. Der Proxy dedupliziert und hängt die Metadaten (u. a. `context_length`) von llama-swap wieder an – ohne das fallen Model-Picker in den Clients auf Defaults zurück.
Tailscale Serve: sicher erreichbar von überall
Alles bindet auf Loopback; der Zugriff kommt ausschließlich über Tailscale. Einmalig (MagicDNS + HTTPS in der Admin-Konsole aktivieren), dann:
tailscale serve --bg https:4001 localhost:4001
Damit bekommt `https://dgx-spark.<tailnet>.ts.net:4001` ein echtes Let's-Encrypt-Zertifikat: öffentlich vertraut, auto-erneuert, keine Zertifikatsinstallation auf iOS/Android nötig. Zwei bewusste Entscheidungen:
- Nicht „einfach per Firewall absichern“: Docker schreibt DNAT-Regeln direkt in `PREROUTING` und umgeht ufw komplett. Wer auf `0.0.0.0` publishen würde, wäre nicht geschützt. Loopback-Bind ist die einzige saubere Grenze.
- Nicht `tailscale funnel`: Funnel stellt den Endpunkt unauthentifiziert ins öffentliche Internet – und der Hostname landet binnen Minuten in den public Certificate-Transparency-Logs. „Den wird niemand raten“ ist falsch.
Client-Anbindung
Für jeden OpenAI-Client – SDK, aider, n8n, Open WebUI:
export OPENAI_BASE_URL=https://dgx-spark.<tailnet>.ts.net:4001/v1
export OPENAI_API_KEY=sk-local-...
curl https://dgx-spark.<tailnet>.ts.net:4001/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-d '{"model": "current_model", "messages": [{"role": "user", "content": "Hallo"}]}'
`current_model` = was gerade geladen ist (im Alltag: Qwen3.8, das nie entladen wird). Wer gezielt wechseln will, fragt einfach `gemma4-26b` oder `gemma4-e4b` an – llama-swap übernimmt den Swap, der Request blockt bis der neue Container health ist.
Stolperfallen (kurz und schmerzvoll)
1. Der Profiling-Crash auf Unified Memory. Einmal hat vLLM beim Start den Host hart eingefroren – nur ein Power-Cycle hat geholfen. Ursache: Während des Speicher-Profiling liest `mem_get_info()` auf Unified Memory den System-Freispeicher, und die Werte waren Müll – ein negativer CUDA-Graph-Bedarf wurde statt abgezogen addiert. Ergebnis: 22 GB Gewichte + 92 GB KV + 10 GB Embedding > 121 GB Pool → Swap-Tod. GPU-OOM-Catches helfen hier nichts, weil der Speicher einheitlich ist: Der Host stirbt, nicht der Prozess. Fix: `--kv-cache-memory-bytes` (28 GiB) statt `--gpu-memory-utilization`-Tweakerei.
2. `--gpu-memory-utilization` ist der Lade-Gate, nicht `--max-model-len`. vLLM hard-errort beim Start, wenn der Freispeicher unter dem gewünschten Anteil liegt – und „frei“ umfasst auf Unified Memory alles, was gerade im DRAM liegt.
3. Ladezeiten. Der Standard-Loader schafft ~146 MB/s, obwohl der NVMe 4.6 GB/s hergibt – der Loader ist das Limit. `--load-format fastsafetensors` bringt die Gewichtsladezeit von 148 s auf 12.4 s (Gemessen an Gemma 4 12B; an Laguna: ~460 s → 27 s). Dazu der gemountete `~/.cache/vllm`-Mount: ohne den kompiliert jeder `--rm`-Container von Null (~25–30 s), mit dem landen wir bei ~3 s. Haken: Single-Shard-Modelle brauchen mehr `--gpu-memory-utilization`, weil der Staging-Puffer nach dem größten Shard dimensioniert ist.
4. Der stille Tokenizer-Bug. Der Qwen3.8-Checkpoint hatte anfangs einen `tokenizer.json` mit `max_length: 2048` für Truncation mit an Bord – alle Prompts wurden still auf 2048 Tokens gekappt, Bilder über ~1.4 MP schlugen mit „Mismatch in image token count“ auf. Fix: den korrigierten Tokenizer read-only überbinden (selbes Muster wie bei einem gepatchten vLLM-Source-File für Gemma). Lehre: Wenn das Modell plötzlich „dumm“ antwortet oder Bilder fehlschlagen, ist die erste Frage: Wird der Prompt gekappt?
5. Retries mit einer GPU: `num_retries: 0`. Ein retryter Request routet nicht auf einen freien Backend – er re-queue auf denselben Container. Retries verstärken die Last zu einem Sturm, statt sie abzufedern. Fail-fast lassen und dem Client das Backoff überlassen.
Fazit
Die Kombination llama-swap (ein Modell pro Request, ein YAML) + LiteLLM (ein OpenAI-Endpunkt, Kosten-Tracking) + Qwen3.8 27B (256k, MTP, Vision) ist auf der DGX Spark der zuverlässigste Weg, ein produktionsreifes lokales LLM-Rig zu betreiben: 121 GB Unified Memory, maximal ein vLLM-Container gleichzeitig, und eine einzige Config-Datei, die ohne Neustart live geht. Die Hardware verlangt Disziplin – Unified Memory vergisst nichts – aber mit explizit dimensioniertem KV-Cache und den Stolperfallen oben im Hinterkopf läuft das Setup seit Monaten stabil. Und das Schönste: Von der Handysicht aus sieht es einfach aus wie ein OpenAI-Endpunkt.