Three always-running vLLM services (text TP=2 GPU0+1, ocr + embed on GPU2, sleep mode) behind a FastAPI router that auto-wakes models on request. Tiered idle (sleep 15 min / offload 3 h), depth-aware 503s with Retry-After, persisted wake-intent recovery, admin API on 127.0.0.1:8010. Routine control via vllmctl is pure HTTP — no docker on the request path. Verified: 91 router unit tests + 15-test E2E on real hardware (measurements in CALIBRATION.md; design record in .claude/memory/router-front-door-plan.md). Old nginx stack files removed before git init; design survives in .claude/memory/sleep-mode-implementation-plan.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6.5 KiB
vLLM model server — router front door, auto wake-on-request
History: 2026-08-13 rework + GPU-fault diagnosis (
NOTES-2026-08-13.md), GPU2 re-check + root cause (NOTES-2026-08-17-gpu2-recheck.md), measured GPU/wake numbers (CALIBRATION.md). Hardware sanity scripts live indiag/(p2p_check.pyis the key one).
Three models are always available at one OpenAI-compatible endpoint. Calling services never need to know whether a model is loaded — the router front door wakes it on request:
http://<host>:8000/v1/...
Everything runs inside the Docker stack. No host-side cron, watchers, or docker commands on the request path.
Models
| Type | Model name (use in model field) |
Service / GPUs |
|---|---|---|
| Text / chat | Qwen3.6-35B-A3B-FP8 (aliases: qwen, text, …) |
vllm-text, TP=2, GPU0+1 |
| OCR (vision) | OvisOCR2 (aliases: ocr, ovis) |
vllm-ocr, GPU2 |
| Embeddings | Qwen3-Embedding-8B (aliases: embed, …) |
vllm-embed, GPU2 |
GET /v1/models lists all three. Model matching is case-insensitive; an
unknown name returns a 404 model_not_found (no wake is triggered).
What a request sees
- Awake model → proxied immediately (streaming passes through unbuffered).
- Sleeping model → the request is held while the router wakes it (single-flight: 10 concurrent requests → one wake), then proxied.
- Wake exceeds the hold deadline →
503with a depth-calibratedRetry-Afterheader AND an OpenAI-style JSON body:
| Waking from | Typical wake | Hold deadline | Retry-After |
body sleep_depth |
|---|---|---|---|---|
| level-1 sleep (weights in RAM) | 2.5–4 s (text), <1 s (small) | 30 s | 10 |
"sleeping" |
| level-2 offload (weights on NFS) | ~23 s (text), 1–4 s (small) | 180 s | 60 |
"offloaded" |
| container restart / cold boot | 2–10 min (text, NFS) | 600 s | 600 |
"restarting" |
{"error": {"type": "model_waking", "code": "model_waking",
"message": "Model '…' is waking from offload; retry shortly",
"sleep_depth": "offloaded", "estimated_wake_seconds": 60}}
Idle management (automatic)
| After idle | Action | Effect |
|---|---|---|
| 15 min | sleep (level 1) | GPU freed; weights parked in host RAM |
| 3 h | offload (level 2) | host RAM freed too; ~5 GB/GPU CUDA context remains |
First request after either tier wakes the model transparently. Timers:
IDLE_SLEEP_MIN / IDLE_OFFLOAD_MIN env on the router service.
Quick start
curl http://127.0.0.1:8000/v1/chat/completions -H 'Content-Type: application/json' \
-d '{"model":"Qwen3.6-35B-A3B-FP8","messages":[{"role":"user","content":"hi"}]}'
curl http://127.0.0.1:8000/v1/embeddings -H 'Content-Type: application/json' \
-d '{"model":"Qwen3-Embedding-8B","input":"hello"}'
vllmctl
./vllmctl status # per-model state table (sleep depth, activity)
./vllmctl up [MODEL] # force-wake (HTTP to the admin API — no docker)
./vllmctl sleep [MODEL] # level-1 sleep now
./vllmctl down [MODEL|all] # level-2 offload now
./vllmctl list # models on disk + served
./vllmctl logs [-f] [N] [SVC]
./vllmctl restart SVC
./vllmctl pull <hf-repo> # download a new model (needs docker)
up/down/sleep/status are pure HTTP against the admin listener;
routine control never needs docker.
Architecture
callers ──► :8000 ──► router (FastAPI, ./router) [only public ingress]
├─► vllm-text GPU0+1 (TP=2, util 0.85, 262K ctx)
├─► vllm-ocr GPU2 (TP=1, util 0.10)
└─► vllm-embed GPU2 (TP=1, util 0.25)
127.0.0.1:8010 ──► router admin API (vllmctl; not reachable off-host)
127.0.0.1:8001-8003 ──► direct debug access to each vLLM (local users only)
- Each vLLM service is always running with sleep mode
(
--enable-sleep-mode,VLLM_SERVER_DEV_MODE=1). The router owns wake/sleep/idle, model routing, streaming proxy, and endpoint hiding (dev endpoints and/admin/*404 on the public port; paths are normalized against traversal). - The router is a single process with two listeners (public + admin) on one event loop, so admin calls share the request path's locks and depth state.
- Wake-intent is persisted (named volume
router-state), so a router restart mid-wake conservatively completesreload_weightsbefore serving — an interrupted level-2 wake can never serve garbage. - All services:
restart: unless-stopped(host reboots self-heal; requests get"restarting"503s during the gap).
Router tunables
All env-driven with sane defaults — see the table at the top of
router/config.py (registry/aliases, listeners, proxy timeouts, wake hold
deadlines, idle tiers, state file). Unit tests: router/tests/
(91 tests, no GPU needed).
Hardware constraints (do not remove)
- GPU P2P DMA corrupts data on this host (4/6 pairs; see NOTES).
NCCL_P2P_DISABLE=1+--disable-custom-all-reduceare mandatory on every vLLM service. Host-staged copies are verified clean. - Text spans GPU0+GPU1 exclusively; OCR and embed live on GPU2 (single-GPU
services never issue P2P). Moving a service = edit its
CUDA_VISIBLE_DEVICESincompose.yml, then re-tighten memory slices (per-GPU sums ≤ 0.88 including CUDA contexts). - Measured footprints in
CALIBRATION.md.
How docker access works
renbaibing is in the docker group (since 2026-08-14) — docker and
docker compose run directly, no sudo. Shells opened before the group
change need sg docker -c "…" or a fresh login. The old .runas.py /
.user.env mechanism is obsolete.
Operations
docker compose up -d # whole stack
docker compose restart router # front door only (state survives)
docker logs vllm-router # structured wake/sleep/idle events
curl 127.0.0.1:8010/admin/status
The pre-router (nginx) stack was removed 2026-08-17 (before git init) —
it survives only as a design doc
(.claude/memory/sleep-mode-implementation-plan.md). Rollback points are
git commits from 2026-08-17 onward.
Notes
.envprovidesVLLM_VERSION,MODEL_ROOT,HF_TOKEN(used by compose) plus client-side vars (OPENAI_BASE_URLetc.). All per-model serving args live incompose.yml.- A
.corrupt-20260817copy of the FP8 model sits next to the repaired original — delete it once the repaired copy is trusted (43 GB). - Design/decision record:
.claude/memory/router-front-door-plan.md(v3.3).