Files
homelabstack/docs/superpowers/specs/2026-06-26-llm-backend-hermes-design.md
T

11 KiB
Raw Blame History

LLM inference backend for Hermes (valhalla) — design

Date: 2026-06-26 Status: Approved (pending spec review) Stack: new stacks/llm/

Goal

Stand up a local, OpenAI-compatible LLM inference endpoint on valhalla and point the existing Hermes agent backend at it, so Hermes runs against a self-hosted 14B model instead of an external provider.

Key decision: llama.cpp, not vLLM

The request was "set up vLLM," but valhalla's GPU is a Tesla P100-PCIE-16GB, die GP100, compute capability 6.0. That rules out vLLM for the desired model class:

  • The GP100 (cc 6.0) lacks the DP4A INT8 instructions that vLLM's AWQ/GPTQ kernels require (those need Turing 7.5 / Ampere). So vLLM cannot run quantized 13B+ here.
  • An unquantized 13B in fp16 is ~26 GB → does not fit 16 GB.
  • Current vLLM refuses cc < 7.0 outright; even a ~7B fp16 would need a pinned old vLLM + --dtype float16 + VLLM_ATTENTION_BACKEND=XFORMERS, and still no 13B.

Since the user wants a 13B+ class model and only needs an OpenAI-compatible API (confirmed), the right engine is llama.cpp's llama-server: rock-solid Pascal (sm_60) support, GGUF quantization, and a native OpenAI-compatible /v1 API that Hermes consumes unchanged.

Hardware / host facts (verified live 2026-06-26)

  • GPU: 1× Tesla P100-PCIE-16GB, cc 6.0, driver 580.159.04, CUDA 13. Idle. (2nd staged P100 not installed — single-GPU design.)
  • Docker 29.5.2, NVIDIA Container Toolkit 1.19.1.
  • CDI already configured: nvidia.com/gpu=0 is a valid device. No host runtime changes needed — the compose just references the CDI device.
  • /storage1 (ZFS, virtiofs): 17 TB free. Weights live here; VM root is only 200 GB.
  • Hermes runs on the host (not Docker) from /home/ginnoir/.hermes/:
    • hermes dashboard --host 172.20.0.1 --port 9119 (gateway/agent API+WS, pid 1116)
    • hermes_cli.main gateway run (pid 1119)
    • hermes-webui/server.py on 172.20.0.1:8787 (pid 1128)
    • Bound to 172.20.0.1 = the Docker bridge gateway, so host↔container reachability is trivial: a container published on 172.20.0.1:<port> is reachable by Hermes and by other containers. No Caddy hop in the inference path.

Architecture

Hermes agent/gateway (host, 172.20.0.1)
        │  OpenAI base_url → http://172.20.0.1:8090/v1   (api_key = LLM_API_KEY)
        ▼
llama-server container (stacks/llm)  ──CDI nvidia.com/gpu=0──▶  Tesla P100
   model: Qwen2.5-14B-Instruct-Q4_K_M.gguf
   weights bind-mounted from /storage1/labdata/llm/models

No Caddy endpoint (Hermes-only, per decision). Endpoint is unauthenticated-but-API-keyed and only reachable on the host/bridge — matching the Pattern-B fallback for internal tools.

The stack — stacks/llm/docker-compose.yml

Single service llama-server:

  • Image: ghcr.io/ggml-org/llama.cpp:server-cuda at a pinned tag.
    • Infra-pinned per repo convention: label com.centurylabs.watchtower.enable=false so Watchtower won't drift it.
    • Verification gate: confirm the pinned prebuilt image includes Pascal sm_60 kernels and is CUDA ≤ 13 compatible. If it errors on the P100, fall back to a locally built image with -DCMAKE_CUDA_ARCHITECTURES=60.
  • GPU: devices: ["nvidia.com/gpu=0"] (CDI).
  • Volumes: /storage1/labdata/llm/models:/models (bind).
  • Command / args (AS DEPLOYED — 64k, required by Hermes' 64K minimum):
    • -m /models/Qwen2.5-14B-Instruct-Q4_K_M.gguf
    • --alias qwen2.5-14b-instruct (stable model name Hermes references)
    • --parallel 1 (one slot gets the FULL context; default 4 slots split it to 32k/seq → fails Hermes)
    • -ngl 99 (full offload — 14B Q4 fits in VRAM)
    • --ctx-size 65536
    • --rope-scaling yarn --rope-scale 2 --yarn-orig-ctx 32768 (YaRN extends Qwen2.5's 32k native → 64k)
    • --override-kv qwen2.context_length=int:65536 (raises GGUF training-context metadata so llama-server does NOT cap the slot back to 32768 — without this the slot is capped and Hermes still sees 32k)
    • --flash-attn on (this build needs the explicit on value; a bare -fa swallows the next arg)
    • --cache-type-k q8_0 --cache-type-v q8_0 (both q8_0 — q4_0 V-cache is pathological on Pascal: 1.28 tok/s gen at 58% GPU util. q8_0/q8_0 → 9.2 tok/s and still fits 64k.)
    • --host 0.0.0.0 --port 8080
    • API key via LLAMA_API_KEY env (env_file) — NOT a CLI flag (no ${VAR} interpolation; llama-server reads the env var natively)
  • Ports: "172.20.0.1:8090:8080" (reachable by Hermes on host + by containers).
  • Networks: private llm net only (no edge — no Caddy endpoint this round).
  • restart: unless-stopped. Healthcheck: GET /health on 8080.
  • env_file: stack.env per repo convention.

stacks/llm/stack.env

  • LLM_API_KEY=<generated> (committed per repo policy — secrets are versioned here).

VRAM budget (Qwen2.5-14B, GQA: 48 layers, 8 KV heads, head_dim 128)

  • KV cache ≈ 0.375 MiB/token fp16 → q8_0 halves to ≈ 0.1875 MiB/token.
  • Weights Q4_K_M ≈ 9.0 GB; reserve ~0.8 GB compute buffers.
  • 32k @ q8_0 KV ≈ 6.0 GB → ~15.7 GB total, fits (tight but safe at 16 GB).

Model acquisition (one-time)

Download Qwen2.5-14B-Instruct-Q4_K_M.gguf (~9 GB) from bartowski/Qwen2.5-14B-Instruct-GGUF into /storage1/labdata/llm/models/ on the host (e.g. huggingface-cli download or wget the single GGUF). Documented host-side step, done before first stack deploy.

Hermes integration (host-side, not in git)

Hermes is the Nous Research Hermes agent (hermes-agent.nousresearch.com). Its config is ~/.hermes/config.yaml, which already has a providers: list whose entries are exactly OpenAI-compatible upstreams — there's a working ollama provider in it today (type: openai, base_url: http://192.168.1.73:11434/v1). Adding the P100 is one more entry of the same shape; no new integration surface.

  1. Add a provider to providers: in ~/.hermes/config.yaml:
    - name: valhalla-p100
      type: openai
      base_url: http://172.20.0.1:8090/v1
      api_key: <LLM_API_KEY>
      models:
      - qwen2.5-14b-instruct
    
    Use hermes config / the hermes CLI where possible; a direct YAML edit + restart is the fallback (the CLI is the source of truth for _config_version).
  2. Select the model as the active one via hermes model (interactive) — or set model.default: qwen2.5-14b-instruct (+ matching provider) if it should be the gateway default rather than a switchable option. The current default is gpt-5.5 / openai-codex; we add ours alongside and let the user choose, rather than silently replacing the default.
  3. Restart the three Hermes processes (gateway dashboard pid-class, gateway run, webui) so the new provider/model is live.
  4. Verify end-to-end: a Hermes prompt routed to qwen2.5-14b-instruct produces a completion served by the P100 (confirm via nvidia-smi showing the llama-server process holding VRAM during generation).

These host-side steps are documented in the plan (and worth a note in CLAUDE.md known-quirks), not committed as repo changes — Hermes isn't in compose, and the alias qwen2.5-14b-instruct set via --alias is the contract between llama-server and this provider entry.

Deployment

stacks/llm/ is a new Portainer git stack → must be registered once (new stacks aren't auto-created by the poller). Per repo precedent (memory: portainer-new-stack-registration): create via MCP/Portainer, poll StackList to confirm, use the stacks' working fine-grained PAT for git creds. Pure env_file (empty Portainer UI env). After registration, normal git-push → 5-min poll redeploys apply.

Final deployed state (verified live 2026-06-26)

Portainer stack llm (id 34), container llama-server healthy. Config: 64k / q8_0 KV / YaRN / --parallel 1 / --override-kv qwen2.context_length=int:65536. VRAM 15.3 GB used, ~0.9 GB free. Hermes model: block points at provider customhttp://172.20.0.1:8090/v1 (matched to the valhalla-p100 entry in the providers: list); active model qwen2.5-14b-instruct. Original config backed up at ~/.hermes/config.yaml.bak.*.

Measured performance (Qwen2.5-14B-Q4_K_M, 64k q8/q8, P100)

  • Generation: ~9.2 tok/s (memory-bound; fine for a personal assistant).
  • Prefill: ~54 tok/s on a large prompt (the misleading ~10 tok/s figure is small-prompt overhead, not throughput).
  • Hermes system prompt ≈ 16,400 tokens → first (cold) turn ≈ 5 min (all prefill).
  • Prompt cache makes it usable: llama-server matches by longest-common-prefix (sim_best = 0.999), so subsequent turns — even new conversations sharing the stable system prompt — reuse the prefix and respond in ~20 s. The 5 min is a one-time post-restart warmup.

Hard-won config gotchas (all verified the slow/broken way first)

  1. -fa needs an explicit value in this build: use --flash-attn on. A bare -fa swallows the next arg (--cache-type-k) and crash-loops.
  2. --parallel 1 — the default 4 slots split --ctx-size to 32k/sequence, which fails Hermes' 64K minimum. One slot serves the full window.
  3. --override-kv qwen2.context_length=int:65536 — without it, llama-server caps the slot back to the GGUF training context (32768) even with YaRN set, so per-seq stays 32k.
  4. q8_0 V-cache, NOT q4_0 — q4_0 V-cache is pathological on the GP100 (cc 6.0, no DP4A): 1.28 tok/s generation at 58% GPU util. q8_0/q8_0 → 9.2 tok/s and still fits 64k.
  5. Hermes requires ≥64K context and rejects smaller models outright (or set model.context_length to override — but then the server must actually serve it).
  6. Hermes provider wiring: a providers: list entry is a "named custom provider", activated only by setting the model: block to provider: custom + matching base_url. It is NOT selectable via --provider <name> (that path wants a providers: dict).

Non-goals / out of scope

  • vLLM (ruled out by hardware — see decision above).
  • SSO/Authentik on the endpoint (LAN/host-only, API-keyed).
  • A public llm.ginnoir.com Caddy endpoint (declined; easy to add later via edge + internal_only).
  • Multi-GPU / 2nd P100 install.

Open risks / follow-ups

  • VRAM is tight (~0.9 GB free). A full 64k prefill held under real load (16k-token Hermes prompt succeeded), but watch for OOM if other GPU users appear; fall back to --ctx-size 60000 or a smaller weight quant for margin.
  • Cold-start latency (~5 min). Inherent to a 16k system prompt at Pascal prefill speed. Mitigation if it annoys: trim Hermes' prompt (disable environment_probe, fewer toolsets) to shrink the cached prefix.
  • Prompt-cache persistence across restarts is in-memory; a container restart re-pays the cold prefill once.