Technical Requirements Document: Inference API
Status: Draft v0.1
Owner: Engineering
Last updated: 2026-04-10
Companion to: PRD.md
1. Inference engine: vLLM
Decision
vLLM is the v1 engine. We standardize on it for every model in the launch catalog.
Why vLLM over the alternatives
- PagedAttention gives us best-in-class throughput and memory efficiency for the workloads we care about (long-context chat, batched embeddings). Continuous batching is the single biggest lever for $/token, and vLLM’s implementation is the production benchmark.
- Native LoRA multi-adapter serving (
--enable-lora, --max-loras) is a core PRD requirement. TensorRT-LLM’s LoRA story is weaker and harder to operate.
- Model coverage. vLLM supports every architecture in our launch catalog (Llama, Mistral, Qwen, DeepSeek, BGE, E5) on day one of upstream release. TensorRT-LLM lags by weeks-to-months and requires per-model engine builds.
- Mixed-fleet support. vLLM runs on Ampere (A100), Hopper (H100), and Ada/Blackwell consumer cards (RTX 5090, RTX PRO 6000) from one codebase. TensorRT-LLM’s RTX support is second-class.
- Operational simplicity. vLLM ships as a single Python process per model replica with an OpenAI-compatible HTTP server (
vllm serve). We can run it directly; no Triton ensemble graphs to maintain.
What we give up vs. TensorRT-LLM
- ~10-25% lower per-token throughput on H100 for some model shapes.
- No FP8 KV cache on every model (vLLM is catching up).
We accept this for v1. Operational simplicity and LoRA support outweigh the throughput delta at our scale.
Engine abstraction layer
We wrap vLLM behind an internal InferenceWorker interface so we can swap engines per-model later (e.g., move Llama 405B to TensorRT-LLM if its throughput justifies the operational tax). The interface:
InferenceWorker:
- load(model_id, dtype, tp_size, lora_modules) -> WorkerHandle
- generate(request) -> AsyncIterator[Token]
- embed(request) -> Vector
- unload()
- health() -> Status
- metrics() -> {gpu_util, kv_cache_used, queue_depth, ...}
The router (§2) only talks to this interface. v1 has one implementation: VLLMWorker. v1.5 may add TRTLLMWorker for high-volume models.
2. Architecture
Diagram
flowchart TB
Client[Customer App<br/>OpenAI SDK] -->|HTTPS| LB[Envoy<br/>Edge LB + TLS]
LB --> GW[API Gateway<br/>FastAPI<br/>auth · rate limit · validation]
GW --> Router[Model Router<br/>Go service<br/>tenant queue · model dispatch]
GW --> Meter[(Usage Events<br/>Redpanda)]
Router --> Registry[(Model Registry<br/>Postgres)]
Router --> WP1[vLLM Worker Pool<br/>Llama 70B<br/>2x H100 · TP=2]
Router --> WP2[vLLM Worker Pool<br/>Llama 8B<br/>1x RTX 5090]
Router --> WP3[vLLM Worker Pool<br/>BGE-M3 Embeddings<br/>1x RTX 5090]
Router --> WPN[vLLM Worker Pool<br/>...]
Meter --> CH[(ClickHouse<br/>usage + billing)]
WP1 -.metrics.-> Prom[Prometheus]
WP2 -.metrics.-> Prom
WP3 -.metrics.-> Prom
Prom --> Graf[Grafana + Alertmanager]
GW -.auth lookup.-> KV[(Redis<br/>API key cache<br/>rate limit counters)]
subgraph K8s[Kubernetes Cluster - bare metal]
LB
GW
Router
WP1
WP2
WP3
WPN
end
Layer-by-layer
2.1 Edge: Envoy
- Why Envoy over nginx/Kong: native gRPC, first-class HTTP/2 + SSE handling, ext_authz filter for offloading auth to the gateway, programmatic config via xDS for dynamic upstream changes when we drain nodes. Kong’s plugin model is overkill; nginx’s config reload story is painful for dynamic upstreams.
- TLS termination here (cert-manager + Let’s Encrypt for staging, commercial cert for prod).
- L7 routing:
/v1/* → API gateway. /health, /metrics to internal-only paths.
- Connection limits, basic DDoS shields (per-IP rate limit at Envoy as belt-and-braces in front of per-key limits).
2.2 API Gateway: FastAPI (Python)
A stateless Python service that:
- Validates request schema (Pydantic models matching OpenAI’s OpenAPI spec).
- Resolves API key → tenant via Redis lookup (Postgres on miss).
- Enforces per-tenant rate limits (token bucket in Redis,
redis-cell module).
- Tags request with
tenant_id, model_id, request_id, priority, and forwards to the Model Router over HTTP/2 keep-alive.
- Streams the response back as SSE without buffering (FastAPI
StreamingResponse).
- Emits a usage event to Redpanda on stream completion (or failure).
Why Python here despite the latency tax: the gateway is I/O-bound, and Python lets us share Pydantic schemas with the public OpenAPI spec and SDK validation. Per-request overhead is ~3-5ms which is rounding error vs. TTFT.
2.3 Model Router: Go service
The router is the only component that knows the live state of the worker pool. It:
- Maintains an in-memory map of
model_id → [worker_endpoints] updated from Kubernetes API watch on labeled vLLM pods.
- For each incoming request, picks a worker using least-outstanding-requests with vLLM’s
num_running_requests metric scraped every 1s via the /metrics endpoint. Falls back to round-robin if metrics are stale.
- Maintains per-tenant queues in front of each worker pool to enforce fair scheduling under contention (token-bucket-weighted by tier — reserved tenants get priority lanes).
- Handles SSE proxying with backpressure-aware buffering.
- Handles LoRA adapter routing: requests for
model:adapter are pinned to workers that have that adapter loaded; if none do, the router calls vLLM’s /v1/load_lora_adapter to hot-load.
Why Go here: the router is the hot path for every token streamed. Go’s goroutine model handles thousands of concurrent SSE connections per pod with low memory overhead, and avoids Python GIL contention.
2.4 Worker pool
Each vLLM replica runs in its own pod with vllm serve exposing the OpenAI-compatible HTTP server on a ClusterIP. Pods are labeled model=<id>, version=<sha>, tier=shared|reserved|dedicated, tenant=<id|shared>. The router watches pod events and updates routing tables in real time.
Multi-GPU (tensor parallel) models: a single pod requests N GPUs from the device plugin and runs vllm serve --tensor-parallel-size N. Pods requesting >1 GPU must land on a node with N matching GPUs (enforced by node affinity rules — see §3).
Pipeline parallelism across nodes: not in v1. We avoid multi-node TP because (a) it requires NVLink/InfiniBand we may not have everywhere and (b) it’s an operational nightmare. The largest model that fits on one node sets our ceiling. For Llama 405B in FP8 (~410GB), we need an 8x H100 node.
ASSUMPTION TO VALIDATE: Do you have any 8x H100 nodes? If not, 405B serving requires either FP4 quantization (quality risk) or multi-node TP over InfiniBand. Confirm node configurations and interconnect fabric.
3. Node management and fleet partitioning
Cluster
- k3s for v1. Lighter than RKE2/kubeadm, single-binary, easier upgrades on bare-metal, sufficient for our scale (tens of nodes, not thousands). RKE2 is the v2 upgrade if we need CIS compliance.
- NVIDIA GPU Operator managing drivers, container toolkit, device plugin, DCGM exporter, MIG configuration (we don’t use MIG in v1 — see §10), and node feature discovery.
- Calico CNI (pragmatic choice; Cilium is the v2 upgrade if we need eBPF observability).
Fleet partitioning
The cluster spans nodes used for this product and nodes used for other workloads. We isolate via:
- Node labels:
provocapi.io/pool=inference-api — opt-in to this product.
provocapi.io/gpu-class=h100|a100-80|a100-40|rtx-pro-6000|rtx-5090
provocapi.io/gpu-count=1|2|4|8
provocapi.io/tenant=shared or provocapi.io/tenant=<tenant_id> for dedicated nodes
- Taints: every inference-api node carries
provocapi.io/pool=inference-api:NoSchedule. Only our workloads tolerate it. This prevents accidental scheduling of other workloads onto our nodes — and prevents our workloads from drifting onto nodes outside the pool.
- Namespace: all inference-api workloads live in the
inference-api namespace with a ResourceQuota and LimitRange. NetworkPolicies restrict ingress to Envoy and the management plane only.
- Dedicated tenants get an additional taint
provocapi.io/tenant=<id>:NoSchedule and node affinity rules on their pods.
Node lifecycle
- Health: DCGM exporter + a custom node-problem-detector plugin watches GPU temp, ECC errors, NVLink status, and PCIe bandwidth. Node is marked
NotReady on persistent issues; pods evicted; router drops it from rotation.
- Drain for maintenance:
kubectl drain with a custom PodDisruptionBudget per model (minAvailable: 50% for shared models with ≥2 replicas). The router watches for Terminating pods and stops sending new requests immediately while letting in-flight streams complete (graceful shutdown timeout 60s — long enough for typical chat completions).
- Adding a node to the pool: label + taint, GPU operator provisions, model warmer DaemonSet pre-pulls common base model weights to local NVMe, node becomes schedulable.
ASSUMPTION TO VALIDATE: I’m assuming each node has ≥2TB local NVMe for model weight caching. Llama 405B FP8 is ~410GB on disk, FP16 is ~810GB. Confirm NVMe per node and whether we need a network filesystem (Ceph, JuiceFS) for the weight cache instead.
4. Model loading and lifecycle
Storage hierarchy
- Origin: internal model registry — an S3-compatible object store (MinIO deployed in-cluster on dedicated storage nodes, or a managed S3 if we have one). We mirror HuggingFace Hub there on model intake to (a) avoid HF rate limits, (b) pin exact revisions, (c) survive HF outages.
- Node-local cache:
/var/lib/provocapi/models/<model_id>/<revision>/ on local NVMe. Managed by a model-warmer DaemonSet that pulls weights when a vLLM pod requests them (init container) and runs an LRU eviction when free space drops below 20%.
- GPU VRAM: vLLM loads from the node-local cache at pod start.
Model intake pipeline
When we add a model to the catalog:
- Engineer creates a
Model CR (custom resource) with repo, revision, dtype, tp_size, gpu_class_allowed, serving_args.
- A controller pulls weights from HuggingFace → MinIO (with checksumming).
- Smoke test job runs vLLM against the weights on a test node (correctness benchmark + latency baseline).
- On pass, a Helm release deploys the production worker pool.
- Router picks up the new pods via watch.
Warm vs. cold loading
- Warm pool: every model in the public catalog has ≥1 replica running 24/7 (cost of doing business). Cold start times for 70B-class models are 60-120s and unacceptable on the request path.
- Cold load: dedicated tenants who reserve a model that’s not in the warm pool pay for the cold load window (up to 5min for 70B, 15min for 405B) on initial deployment, but it stays warm for the duration of their reservation.
- Adapter hot-swap: LoRA adapters load in <2s with no base-model reload via vLLM’s adapter API.
Versioning
- Models are addressed by
model_id:revision. The default model_id (e.g., llama-3.1-70b-instruct) aliases to a pinned revision; we never silently upgrade. Customers can pin to model_id@2025-09-01 for reproducibility.
- Rolling out a new revision uses a canary worker pool (5% traffic for 24h) before flipping the alias.
5. Multi-tenancy and isolation
Noisy-neighbor controls
- Per-tenant queues at the router (§2.3) with weighted-fair scheduling. Tier weights: dedicated=∞ (own pool), reserved=10x, shared=1x.
- vLLM admission control: each worker has
--max-num-seqs capped to keep KV cache utilization <85%. The router stops sending requests when a worker hits its admission ceiling rather than queueing inside vLLM (which gives us less observability).
- Per-request token limits enforced at the gateway based on tenant tier (shared: 8k output max default; reserved: model max).
- Output token rate limits per key to prevent runaway agents from one customer eating shared capacity.
Isolation guarantees by tier
| Tier |
Process |
GPU |
Node |
Network |
| Shared |
Shared vLLM workers |
Shared GPUs |
Shared nodes |
Shared namespace |
| Reserved |
Dedicated vLLM workers (own pods) |
Shared GPUs OK |
Shared nodes OK |
Shared namespace |
| Dedicated |
Dedicated vLLM workers |
Dedicated GPUs |
Dedicated nodes (taints) |
Dedicated namespace + NetworkPolicy |
We do not offer cryptographic memory isolation (confidential computing) in v1. If a customer needs that, they need dedicated nodes. Document this clearly.
6. LoRA adapter serving
- vLLM
--enable-lora --max-loras=8 --max-lora-rank=64 on every base model in the catalog where LoRA is supported.
- Adapters are uploaded via
POST /v1/adapters (multipart) → object store → metadata in Postgres → controller decides which workers serving the matching base model should preload it (default: all replicas).
- Hot-load on cache miss: if a request references an adapter not currently loaded on the chosen worker, the router calls vLLM’s
load_lora_adapter and waits (~1-2s) before forwarding.
- Per-tenant quota: 20 adapters/tenant (shared), 200/tenant (reserved). Adapters >500MB rejected.
- Adapters are scoped to the tenant that uploaded them; cross-tenant access is blocked at the gateway.
7. Observability
Metrics (Prometheus)
The metrics that actually matter:
- Per-model: TTFT histogram (p50/p95/p99), output TPS histogram, queue depth, KV cache utilization %, requests/sec, error rate by class (4xx/5xx/timeout).
- Per-GPU (DCGM): SM utilization, memory utilization, memory used bytes, temperature, power draw, ECC errors, NVLink throughput.
- Per-tenant: RPM, TPM, error rate, queue wait time.
- Router internals: worker selection latency, retry count, in-flight requests per worker.
- Gateway: auth latency, rate-limit denials, request validation failures.
Logging
- All services log structured JSON to stdout → Vector → Loki.
- Every request gets a
request_id propagated via X-Request-Id header end-to-end. Trace links request_id across gateway → router → vLLM logs.
- Customer prompt content is not logged by default. Tenants can opt into “request logging” for debugging in their dashboard (90-day retention).
- We log token counts, latencies, model, tenant, error class — never prompts/completions unless opted in.
Tracing
- OpenTelemetry from the gateway through the router. vLLM doesn’t emit OTel natively in v1; we synthesize a span per request from the router’s view of worker timings.
- Tempo as the backend. Sampled at 1% by default, 100% on errors.
Alerts (Alertmanager)
Pageable:
- Any model’s p95 TTFT > 2x SLO for 5min
- Error rate > 1% for 5min
- Worker pool < N healthy replicas for any model in catalog
- GPU temp >85°C or ECC errors > 0
- Queue wait p95 > 1s
Non-pageable (Slack):
- Sustained GPU util > 80% on shared pool (capacity warning)
- Disk usage > 80% on any node (model cache pressure)
8. Security
- TLS: terminated at Envoy. cert-manager issues certs. TLS 1.2 minimum, modern cipher suite. mTLS internally between Envoy → gateway → router → workers (cert-manager-issued internal CA, SPIFFE identities optional in v2).
- API keys: generated as 32 random bytes, base58-encoded with
pk-prov- prefix. Stored as argon2id hash in Postgres. Cached in Redis (also hashed) with 5min TTL. Prefix shown in dashboard for identification.
- Key rotation: customer-initiated; old key valid for 24h grace period after rotation.
- Network segmentation: management plane (k8s API, ArgoCD, Grafana, Prometheus) lives in a separate VLAN reachable only via WireGuard bastion. Tenant traffic ingress only via Envoy on the public VIP. NetworkPolicies enforce that workload pods can only reach the router and metrics endpoints, never the k8s API or management services.
- Secrets:
external-secrets operator with HashiCorp Vault as the backing store. Model registry credentials, Postgres creds, TLS certs, API key signing pepper all live in Vault.
- Image security: all container images built in-house from pinned base images, scanned with Trivy in CI, signed with cosign, admission controller enforces signatures.
- Dependency hygiene: Renovate bot for vLLM, base images, Helm charts. Patch within 7 days for criticals.
- Audit logging: k8s audit logs to Loki, Postgres audit triggers on
api_keys, tenants, billing tables.
- Isolation: prompts/completions are never persisted to disk by vLLM (verify with
--disable-log-requests). Memory is zeroed by GPU driver on pod termination — note that this is not cryptographic isolation; communicate honestly to customers who ask.
9. Deployment and operations
- Packaging: Helm charts per component (
gateway, router, model-worker, model-warmer, usage-collector). One umbrella chart inference-api that depends on them.
- Per-model deployments are generated from a
Model CRD by an internal operator → renders a Helm release with the right node affinity, resource requests, and serving args. Engineers don’t write per-model YAML by hand.
- GitOps: ArgoCD watches the
infra/clusters/<cluster>/ directory of our internal repo. All changes go through PR review. ArgoCD auto-syncs non-prod, manual sync for prod.
- Rolling updates: standard k8s rolling deployment for stateless services (gateway, router). Model workers use a custom rolling strategy: drain a replica → wait for in-flight requests to complete (max 60s) → terminate → bring up new revision → wait for
/health + warmup request → mark ready → next replica.
- Canary for new model versions: the operator supports
canary: { weight: 5, duration: 24h } in the Model CRD. Router does weighted routing between old and new revision pods.
- CI: GitHub Actions builds images, runs unit tests, runs an end-to-end smoke test against an ephemeral kind cluster with a tiny model (BGE-small) and a vLLM worker on CPU.
10. Capacity planning
Model → GPU class fitment table
All numbers FP16 unless noted. KV cache budget assumes 8k context and target concurrency. Validate against benchmarks on your actual hardware.
| Model |
Weights |
KV cache budget |
Min config |
Preferred config |
| BGE-M3 / Nomic Embed |
<2GB |
<2GB |
1x RTX 5090 32GB |
1x RTX 5090 (many replicas) |
| E5-Mistral 7B |
~14GB |
~6GB |
1x RTX 5090 32GB |
1x RTX 5090 |
| Llama 3.1 8B |
~16GB |
~8GB |
1x RTX 5090 32GB |
1x RTX 5090 |
| Mistral Small 3 24B |
~48GB |
~16GB |
1x A100 80GB or 1x RTX PRO 6000 96GB |
1x RTX PRO 6000 |
| Qwen 2.5 Coder 32B |
~64GB |
~16GB |
1x A100 80GB or 1x RTX PRO 6000 96GB |
1x H100 80GB |
| Llama 3.1 70B / Qwen 2.5 72B FP16 |
~140GB |
~30GB |
2x H100 80GB TP=2 OR 2x RTX PRO 6000 96GB TP=2 |
2x H100 |
| Llama 3.1 70B FP8 |
~70GB |
~30GB |
1x H100 80GB |
1x H100 (much better $/token) |
| DeepSeek V3 (671B MoE, FP8) |
~370GB active |
~50GB |
8x H100 80GB or 4x H100 with offload |
8x H100 |
| Llama 3.1 405B FP8 |
~410GB |
~80GB |
8x H100 80GB TP=8 |
8x H100 |
| Llama 3.1 405B FP16 |
~810GB |
~150GB |
Multi-node (not in v1) |
— |
ASSUMPTION TO VALIDATE: RTX PRO 6000 96GB. The Blackwell-based RTX PRO 6000 has 96GB; older Ada RTX 6000 has 48GB. Confirm which SKU you have — it changes the fitment table significantly. Same for whether your A100s are 40GB or 80GB.
Heterogeneous routing rules
The Model CRD declares gpu_class_allowed: [h100, rtx-pro-6000]. Node affinity translates this to scheduling constraints. Rules of thumb baked into operator defaults:
- Embedding models and ≤8B chat models → RTX 5090 (cheapest VRAM/$, plenty for these workloads). High replica counts.
- 24B-32B models → RTX PRO 6000 (96GB single-card sweet spot, no TP overhead).
- 70B-class → H100 single-replica FP8 preferred over multi-card FP16. RTX PRO 6000 TP=2 is the fallback when H100s are saturated, accepting ~30% lower TPS.
- >200B models → H100 only, multi-card TP. Reserved tier only.
Replicas per node
A node hosts as many replicas as fit in VRAM minus a 10% safety margin, and whose combined --max-num-seqs doesn’t saturate the node’s PCIe bandwidth or CPU (the API server side of vLLM is non-trivial). Empirical guidance:
- 1x H100 80GB → 1x Llama 70B FP8, OR 2x Mistral 24B, OR 4x Llama 8B
- 1x RTX PRO 6000 96GB → 1x Llama 70B FP8 (tighter), OR 2x Mistral 24B, OR 4x Llama 8B
- 1x RTX 5090 32GB → 2x Llama 8B FP16, OR 4x Mistral Small quantized, OR 6x BGE-M3 (embeddings parallelize trivially)
Forecasting
- ClickHouse usage data → daily job that fits a linear regression per model on the last 28 days of TPM, projects 14 days forward, alerts when projected utilization exceeds 70% of provisioned capacity.
- New customer onboarding (especially reserved-tier deals >$10k/mo) triggers a manual capacity review before contract signing.
- We maintain a 25% headroom buffer on shared pool at all times. Below that, the operator refuses to schedule new shared customers and ops gets paged to add nodes to the
inference-api pool.
Adding capacity
A new node joins the pool via:
- PXE boot → Talos or Ubuntu → join k3s
- NVIDIA GPU Operator detects GPUs, installs drivers
- Apply labels + taints
- model-warmer DaemonSet pre-pulls catalog weights to local NVMe (~30min for the full catalog)
- Operator schedules worker pods per the per-model replica targets in the Model CRDs
- Router picks up new endpoints via k8s watch
- Node enters rotation
End-to-end target: <2 hours from rack-and-stack to serving traffic, assuming weights are already mirrored in the internal registry.
Open questions for validation
- Node SKUs: exact GPU counts per node, NVMe capacity, NIC speed, NUMA topology. The fitment and routing tables assume canonical configs.
- Interconnect: do we have NVLink within nodes and InfiniBand between nodes? Affects whether multi-node TP is on the table for 405B.
- k8s baseline: is k3s acceptable, or do you already run RKE2/kubeadm? Switching costs are real.
- Object storage: do we have S3-compatible storage in-house, or do we need to deploy MinIO?
- Secret store: Vault assumed; alternative is sealed-secrets if Vault is too heavy.
- Region count for v1: confirmed single region? Multi-region routing changes the gateway design.
- Metering accuracy guarantees: is “near-realtime, end-of-day reconciled” acceptable for billing, or do customers need real-time hard cutoffs?