Qwen 3.8 lokal: Einheitlicher OpenAI-Endpunkt für LiteLLM, llama-swap und Qwen auf der DGX Spark

Zeigt eine Skizze der Architektur

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.

Weiter
Weiter

Mehr Speed für die DGX Spark: Wie wir Gemma 4 auf über 100 tok/s katapultiert haben