Skip to content

Lab · HomeLab

Local AI infrastructure with a control plane.

A four-machine private AI cluster, one OpenAI-compatible gateway, deterministic routing, observable failure modes, and an agent that operates the lab without taking routing authority away from policy.

System architecture

Four machines, one policy-controlled gateway, deterministic routing, and observable failure behavior.

CLIENTSCursorOpenCodeHermesHome AssistantFrigateRing pipelineGATEWAYhomelab-routerubuntu-4090The Eyesubuntu-6000The HandsSparkThe Brainwin-5090The Mouth
One gateway in front of four GPUs. Routing decisions are deterministic, observable, and hidden from client configuration.
  • 4Dedicated machines
  • 1OpenAI-compatible gateway
  • 10Routing buckets
  • 503Fail-closed terminal state

Gateway / router

Every client speaks to the gateway. No client knows which GPU is healthy, busy, or selected.

Router principle

Deterministic, not LLM-based

Routing decisions are made by a keyword classifier scoring the prompt against a YAML policy file, plus three override mechanisms — request header, pinned model name, metadata hint.

Router principle

A control plane, not a fourth model

The router never generates text itself. Every decision is auditable and reproducible.

Router principle

Safety-first tie-breaks

When the classifier scores multiple buckets: vision > coding > reasoning > chat. 'Review the security of this code' lands on Spark, not on the chat model.

Router principle

Image presence is always honored

Any request carrying an image_url part goes to ubuntu-4090 regardless of keywords — nothing else in the rig can see.

Router principle

Camera tasks never silently say 'ignore'

Schema-validation failure on a camera event produces a synthetic escalate_to_spark envelope, never a suppression. The router fails closed.

Router principle

Hot-reloadable policy

Routing rules live in routing_policy.yaml and can be reloaded over an admin endpoint without restarting the gateway.

MethodPathPurpose
POST/v1/chat/completionsOpenAI Chat Completions shim — every client speaks here
GET/v1/modelsFriendly model catalog: homelab/auto, homelab/code, homelab/vision, homelab/agent, …
POST/v1/embeddingsEmbeddings shim for downstream RAG and vector stores
GET/metricsPrometheus exposition — counters, histograms, gauges
GET/statsJSON snapshot of the same counters for humans
GET/admin/upstreamsPer-route health, load, saturation, drained flag
POST/admin/drain/<route>Drain a route without restarting — explicit pins still pass through
POST/admin/reload-configHot-reload routing_policy.yaml
POST/admin/recover-cudaAuthenticated one-button CUDA recovery

Capability buckets

Bucket selection is deterministic policy, then size promotion decides whether cheap work should move to a heavier model.

BucketDefault targetExample prompts
visionubuntu-4090image, screenshot, ring, frigate, motion
notificationwin-5090, or ubuntu-4090 when image-bearingsms, alert, push notification
fastubuntu-4090classify, json, tags, label, summarize briefly
general_chatwin-5090tell me about, ask, what is, how do I, opinion
utilitywin-5090rewrite, clean up, format, paraphrase, polish, translate
medium_summarywin-5090summarize, tl;dr, condense, recap, executive summary
codingubuntu-6000refactor, implement, code, FastAPI, pytest
agenticubuntu-6000agent, multi-step, tool use, OpenCode, Hermes
reasoningSparkarchitecture, trade-off, review, risk, plan
unknownSparknothing matched — route to senior model for safety

Size promotions

Large prompts promote after bucket selection. Vision and coding stay pinned; general chat promotes to Spark when context gets large.

Bucket<= 8K tokens8K-24K tokens> 24K tokens
visionubuntu-4090ubuntu-4090ubuntu-4090
coding / agenticubuntu-6000ubuntu-6000ubuntu-6000
reasoning / reviewSparkSparkSpark
fastubuntu-4090ubuntu-6000Spark
chat / utility / summarywin-5090win-5090Spark

Fallback chain

The fallback policy is intentionally asymmetric: text can spill to a smaller local model, image work cannot.

SelectedBusy / saturated5xx / outageEverything down
ubuntu-4090Text → win-5090 • Image → SparkSpark503; camera events get a synthetic escalate envelope
ubuntu-6000win-5090Spark503
win-5090SparkSpark503
Sparknonenone503

Asymmetric vision fallback

When ubuntu-4090 is saturated, text-only requests can spill to win-5090. Image-bearing requests go directly to Spark because win-5090 cannot see.

Spark is terminal

The senior model never falls back. If Spark is down, the router returns 503 instead of silently downgrading a high-risk decision.

Request timeline

Same client contract, two operational outcomes: direct route when the coding machine is healthy, bounded spillover when it is saturated.

  1. 01

    Client

    Cursor posts to /v1/chat/completions with model homelab/auto.

  2. 02

    Preflight

    homelab-router parses metadata and carries has_image through the full route.

  3. 03

    Classifier

    Keyword policy maps refactor, FastAPI, and pytest to the coding bucket.

  4. 04

    Primary route

    ubuntu-6000 handles the normal path with GLM-4.5-Air-AWQ.

  5. 05

    Saturation route

    If ubuntu-6000 is busy, the request spills to win-5090 instead of queueing forever.

  6. 06

    Audit record

    The router logs bucket, route, reason, latency, and fallback path.

Two-stage camera pipeline

Ring and Frigate events get a fast local classifier first. Ambiguous or positive events escalate; confident negatives drop locally.

Ring / Frigatemotion eventvision-notifiersidecarubuntu-4090fast classifierSparkdetailed descriptionHome Assistantfan-outConfident negatives drop
Fast local triage first; ambiguous and positive events escalate. A Spark outage degrades description quality, not notification delivery.

Reliability

Defensive Ring listener

Malformed cloud payloads no longer kill the listener thread.

Reliability

Parallel MQTT push

A second push path keeps camera notifications independent from the cloud-side listener.

Reliability

Triple triggers, deduped

Real-time push, parallel push, and polling converge without creating duplicate alerts.

Reliability

DNS pinned in containers

Camera-side REST calls bypass intermittent local DNS issues without widening network exposure.

Observability

Netdata and Home Assistant make the lab observable without presenting missing private screenshots as evidence.

Netdata agentsNetdata parentMQTT bridgeHome Assistant
Per-host metrics feed a parent, MQTT discovery publishes curated sensors, and Home Assistant becomes the phone-readable surface.

Hermes

Hermes is both a normal inference client and an authenticated operator. It can drain, reload, recover, and alert, but deterministic router policy still owns routing.

Hermes WorkspaceHermes Agentcron, skills, memory, Conductorhomelab-routerLLM callsadmin API
Hermes can operate the router, but the router remains the only routing authority.

scheduler

Scheduled health checks

Cron polls /admin/upstreams and watches GPU temps. no_agent mode runs pure script-output watchdogs without spending tokens.

"every 5m" → poll /admin/upstreams

skills

Runbooks as skills

drain-route, recover-cuda, reload-policy become first-class skills the agent can invoke and improve over time.

skill: recover-cuda

conductor

Conductor sub-agents

delegate_task spawns isolated children with their own context and toolset. Parallel log analysis and batch evals stay out of the main loop.

delegate_task → 4× parallel

memory

Persistent memory

Every decision and outcome lives in editable markdown under ~/.hermes/. Honcho builds an evolving model of the operator over time.

~/.hermes/memory/

alerts

Multi-platform alerts

Telegram first; Home Assistant fallback when Hermes itself is down. The lab is never silent on a real failure.

improve

Self-improvement

Skills get better with use. Runbooks evolve as the lab evolves. The agent and the lab compound on each other.

ScheduleTaskMode
every 5mGET /admin/upstreams → flag any not-healthyno_agent=True
*/15 * * * *scrape /metrics, alert if router p95 > 4sagent-assisted
0 9 * * 1weekly: summarize last week's routing-decisions.jsonlagent-assisted
every 2hverify camera pipeline end-to-end on a known sampleagent-assisted
30mone-shot: re-check after an incident-response drainagent-assisted

Machine deep dives

These links resolve to actual deep-dive anchors below.

The Eyes

ubuntu-4090

Fast vision, JSON utility, low-latency routing pre-filter.

Hardware

GPU
RTX 4090 Laptop GPU
VRAM
16 GB
Bus
Internal PCIe (workstation)
OS
Ubuntu

Production model

Model
Qwen2.5-VL-7B-Instruct-AWQ
Served as
qwen2.5-vl-7b-router-awq
Runtime
vLLM (OpenAI-compatible)

What it does

  • Vision — every image-bearing request lands here first
  • Ring camera snapshots
  • Frigate events
  • Screenshot understanding
  • Fast classifier: the “is this even worth escalating?” pre-filter
  • JSON utility: structured outputs for the camera pipeline, notifications, and tagging

Latency

p50 target
≤ 1 s
p95 target
≤ 2 s
p50 measured
0.61 s
p95 measured
0.81 s
Check
4 / 4 routing safety cases pass
Check
Zero false suppressions
Check
Zero invalid JSON

Optimizations

  • AWQ INT4 quantization — one-fifth the weights of FP16
  • vLLM with continuous batching and prefix caching
  • Production context locked to 32K tokens after a latency / KV / VRAM sweep
  • Auto-start as a user-level systemd unit — no manual login after reboot
  • Approximately 9.5 GB of 16 GB used, leaving headroom for CUDA-graph capture and prefix-cache growth

Hard rules

  • Nothing else co-hosts on the 4090 — no coding model, no large-context model
  • The slot is single-purpose on purpose

Recovery story

When the eGPU on the sibling machine has a PCIe link-down/up cycle, the entire NVIDIA driver enters a degraded state where nvidia-smi still works but every fresh CUDA init fails. The router detects this, fails traffic over to Spark, and a recover-cuda.sh script reloads the NVIDIA UVM module and brings everything back up automatically. The whole sequence is documented step-by-step in the runbook.

vLLM launch (excerpt)

# qwen2.5-vl-7b-router-awq on ubuntu-4090
vllm serve Qwen/Qwen2.5-VL-7B-Instruct-AWQ \
  --served-model-name qwen2.5-vl-7b-router-awq \
  --quantization awq_marlin \
  --max-model-len 32768 \
  --gpu-memory-utilization 0.78 \
  --enable-prefix-caching \
  --enforce-eager false \
  --host 127.0.0.1

The Hands

ubuntu-6000

Heavy code generation, refactoring, agentic / multi-file work.

Hardware

GPU
RTX PRO 6000 Blackwell
VRAM
96 GB
Bus
Thunderbolt eGPU
OS
Ubuntu

Production model

Model
GLM-4.5-Air-AWQ
Served as
glm-4.5-air-awq
Runtime
vLLM (OpenAI-compatible)

What it does

  • Code generation
  • Refactoring
  • Multi-file edits
  • Agentic tool-use work
  • Cursor and OpenCode are routed here by default
  • Longer-context utility work

Optimizations

  • AWQ W4A16 quantization using compressed tensors
  • TurboQuant KV cache in k8v4 mode — 8-bit keys, 4-bit values
  • ≈ 2.6× KV compression vs FP8 with negligible quality cost
  • ≈ 14% more KV cache slots than the FP8 baseline at the same context budget
  • Per-layer aware: 42 quantized layers use TurboQuant; 4 boundary layers auto-skip to FLASH_ATTN for quality safety
  • 128K-token context window
  • Auto-start as a user-level systemd unit with --profile long

Hard rules

  • No cross-GPU tensor parallelism with the 4090 — Thunderbolt latency would kill it
  • No model swapping — treat it as a single resident endpoint
  • No CPU offload — 96 GB is plenty of headroom and any swapping would gut throughput

Reliability story — why TurboQuant beat FP8

The earlier FP8 KV configuration with TRITON_ATTN was faster per token on paper, but consistently crashed the AWQ Marlin kernel mid-load with `CUDA error: unspecified launch failure`. TurboQuant k8v4 was slower in raw decode but completed every load on this Blackwell + driver combination. Reliability won. The configuration choice is documented as “the only one that survived the sustained bench.” MTP (multi-token speculative decoding) is intentionally off: the current AWQ checkpoint drops the speculative head’s weights, so draft acceptance is 0%. It will be re-enabled when the checkpoint is fixed upstream.

vLLM launch (excerpt)

# glm-4.5-air-awq on ubuntu-6000 (RTX PRO 6000 Blackwell)
vllm serve cpatonn/GLM-4.5-Air-AWQ \
  --served-model-name glm-4.5-air-awq \
  --quantization compressed-tensors \
  --kv-cache-dtype turboquant_k8v4 \
  --max-model-len 131072 \
  --gpu-memory-utilization 0.92 \
  --enable-prefix-caching \
  --speculative-config '{"method":"none"}' \
  --host 127.0.0.1

The Brain

Spark

Senior reasoning, architecture, final review, safety fallback.

Hardware

GPU
NVIDIA DGX Spark
VRAM
Unified CPU + GPU memory
Bus
GB10 ARM SoC, attached via private overlay
OS
Linux

Production model

Model
Qwen3.6-35B-A3B
Served as
qwen3.6-35b-a3b-fp8
Runtime
vLLM (OpenAI-compatible)

What it does

  • Deep reasoning
  • Architecture review
  • Planning
  • Hard debugging
  • Final review and risk analysis
  • Safety fallback for every other route

What it does not do

  • Spark never falls back — it is the bottom of the chain
  • If Spark is down, the router returns a 503, period
  • No silent downgrade to a less-capable model on a safety-critical decision

Optimizations

  • FP8 weights — throughput and KV-cache efficiency on GB10’s unified memory
  • vLLM with prefix caching
  • Chunked prefill
  • YaRN for long-context overrides
  • Open WebUI front-end pinned to its own data directory so other UIs do not collide
  • Treated by the router as a network-attached upstream over the private overlay

Hard rules

  • Spark never falls back to another model
  • If Spark is down, the answer is 503 — never a quiet downgrade
  • The router does not try to start, stop, or recover Spark — the operator gets paged

vLLM launch (excerpt)

# qwen3.6-35b-a3b-fp8 on Spark (DGX, GB10)
vllm serve Qwen/Qwen3.6-35B-A3B-FP8 \
  --served-model-name qwen3.6-35b-a3b-fp8 \
  --dtype auto \
  --kv-cache-dtype fp8 \
  --max-model-len 131072 \
  --enable-chunked-prefill \
  --enable-prefix-caching \
  --rope-scaling '{"type":"yarn","factor":4.0}' \
  --host 127.0.0.1

The Mouth

win-5090

General chat, rewrites, medium-context summarization, spillover.

Hardware

GPU
RTX 5090
VRAM
32 GB
Bus
Internal PCIe
OS
Windows 11

Production model

Model
Gemma 4 31B
Served as
gemma-4-31b
Runtime
LM Studio (OpenAI-compatible)

What it does

  • General chat
  • Light Q&A
  • Rewrites, format cleanup, paraphrasing, translation
  • Medium-context summarization (≈ 1K–8K tokens)
  • Text-only spillover when ubuntu-4090 is saturated
  • Code spillover when ubuntu-6000 is busy

What it does not do

  • No image work — Gemma cannot see, so the router never sends image-bearing requests here
  • Images always go to ubuntu-4090 or, during an outage, to Spark
  • No final architecture review or deep reasoning — that stays on Spark
  • No camera safety decisions

Optimizations

  • vLLM-style serving via LM Studio with three concurrent slots
  • Router concurrency cap matched exactly to the upstream slot count
  • Third caller gets a `busy` response
  • Fourth caller is sent to the fallback instead of queueing forever
  • gpu-memory-utilization set to 0.86
  • 32K context
  • Batched-token budget tuned to the 32 GB card
  • Inbound traffic pinned to the private overlay network at the Windows Firewall layer

Hard rules

  • SSH, the LLM endpoint, and the Netdata UI are unreachable from public Wi-Fi by design
  • Never the image path — the router carries a has_image flag through preflight so this rule wins per request

LM Studio config (excerpt)

# gemma-4-31b on win-5090 (LM Studio, OpenAI-compatible)
{
  "served_model_name": "gemma-4-31b",
  "context_length": 32768,
  "gpu_memory_utilization": 0.86,
  "max_concurrent_slots": 3,
  "bind": "private-overlay-only",
  "firewall": "block all non-overlay inbound"
}

Design principles

Principle

Every machine has one job

ubuntu-4090 sees. ubuntu-6000 codes. Spark thinks. win-5090 chats. No co-hosting. No swapping.

Principle

One endpoint, no client-side routing

Every client speaks to the gateway. The gateway hides which machine is busy, down, or saturated.

Principle

Deterministic routing, never LLM-based

Every routing decision is made by a keyword classifier scoring against a YAML policy. No model gets to decide where requests go.

Principle

Safety routes fail closed

Camera events never silently produce 'ignore'. Reasoning ambiguity always lands on Spark.

Principle

The senior model never falls back

Spark is the bottom of the stack. If it is down, the answer is 503 — not 'we sent it to a smaller model and hoped.'

Principle

Reliability beats raw speed

FP8 KV on ubuntu-6000, MTP on GLM, and Gemma on ubuntu-4090 were faster on paper. All three were rejected because they were slower under real failure conditions.

Principle

Recovery is one command

Driver poisoning, eGPU PCIe link-down, and container hangs all have documented one-line fixes. Most are exposed as authenticated admin API calls.

Principle

Nothing private leaves the house

Every model runs locally. The only external dependencies are model weights at install time and the private overlay network. Ring cloud is an input, never a destination.

Clients

Every client hash link lands on a real card; none point to missing detail sections.

Client

Cursor

AI-native editor → coding bucket → ubuntu-6000

Client

OpenCode

Multi-file refactors → agentic bucket → ubuntu-6000

Client

Hermes

Personal agent — manages the lab, also a chat client

Client

Home Assistant

Camera + automation orchestrator

Client

Frigate

NVR motion events → vision pipeline

Client

Ring pipeline

Doorbell snapshots → vision pipeline