Home / AI Architecture & System Design / AI System Design

AI System DesignStaffQuestion 13 of 14

Design an LLM inference service that serves open-weight models on your own GPUs to internal teams.

30-second answer

Functional: OpenAI-compatible streaming API, several models behind stable names, interactive and batch priorities, per-team quotas and metering, safe rollouts. Non-functional: TTFT ≤ 800 ms and ≤ 40 ms per output token at p95, which caps decode batch size. Estimate decode and prefill separately: ~300 req/s at peak needs roughly fourteen 8-GPU nodes. Design: gateway with token buckets, a router using prefix-cache affinity with bounded load, engine pools with continuous batching, paged KV, prefix caching and chunked prefill, batch backfill that's preempted first, autoscaling on queue time with warm spares, and canary rollouts with quality checks.

Try answering out loud before reading on. The walkthrough below is paced for a 40-minute interview round.

0–5 min Functional requirements

"Inference service" can mean a thin proxy to an API provider or a GPU fleet you run yourself. Establish which before anything else.

You Are we serving models ourselves on our own GPUs, or routing to external APIs?

Interviewer Ourselves. Open-weight models on our own H100 cluster, for internal product teams.

You Which models, and how many?

Interviewer Start with an 8B model for high-volume, simple tasks and a 70B model for quality-sensitive ones. More models will come, including fine-tuned variants.

You What do clients need? Chat completions with streaming, tool calls, structured output, embeddings?

Interviewer Chat completions with streaming and JSON-schema output, through an OpenAI-compatible API so teams can switch easily.

You Is there offline batch work too, like nightly summarization?

Interviewer Yes, a fair amount. It can be slow but should be cheap.

# Functional requirement Why it matters
F1 OpenAI-compatible chat API with streaming (SSE) and JSON-schema constrained output Low switching cost for product teams
F2 Several models and versions behind stable names (chat-small, chat-large), with pinned versions available Upgrades without client changes; reproducibility
F3 Two priority classes: interactive and batch Batch fills idle capacity without hurting interactive latency
F4 Per-team quotas, rate limits and usage metering Fair sharing and chargeback
F5 Safe rollout of new models and engine versions Model changes are the riskiest operations
F6 Usage and quality telemetry per team, model and version Capacity planning and debugging

Out of scope: training, external-provider fallback (mention as an extension), multimodal input.

5–10 min Non-functional requirements and estimates

You What latency do interactive clients need?

Interviewer First token within about a second at p95 for typical prompts, and streaming at least as fast as people read.

You I'll set TTFT ≤ 800 ms p95 for prompts up to 4k tokens and time per output token ≤ 40 ms p95, which is 25 tokens/s, comfortably faster than reading speed. The time-per-token target caps how big decode batches can get, so it directly sets cost.

Requirement Target What it forces
TTFT (interactive) ≤ 800 ms p95 for ≤ 4k-token prompts Prefix caching, chunked prefill, bounded queues
Time per output token ≤ 40 ms p95 Caps decode batch size per replica
Availability 99.9% per model name Multiple replicas across failure domains; graceful shedding
Throughput Peak 300 req/s interactive; batch backlog drained within 12 h Autoscaling plus batch backfill
Utilization > 60% of GPU time doing useful work Continuous batching, batch backfill, right-sized replicas
Isolation One team can't starve others Per-team token buckets, fair queuing
Rollout safety New versions canaried with automatic rollback Version-aware routing, golden-output checks

Back-of-envelope estimates (peak interactive traffic, 2,000 input and 300 output tokens on average):

  • Split: 70% of requests to the 8B model (210 req/s), 30% to the 70B (90 req/s).
  • 70B decode: 90 × 300 = 27,000 output tokens/s. From roofline analysis, an 8 × H100 node (TP = 8) sustains roughly 6,000 tokens/s within a 40 ms per-token budget in bf16. That's ~4.5 nodes of decode.
  • 70B prefill: 90 × 2,000 = 180k tokens/s × 2 × 70e9 FLOPs ≈ 2.5e16 FLOP/s. At ~50% utilization a node delivers ~4e15, so ~6 nodes of compute without caching; with ~50% prefix-cache hits (shared system prompts), ~3.
  • 8B: 63k output tokens/s at ~4,000 tokens/s per GPU ≈ 16 GPUs; prefill 420k tokens/s × 1.6e10 ≈ 6.7e15 FLOP/s ≈ 7 GPUs with caching.
  • Fleet: ~8 nodes for the 70B and ~3 nodes for the 8B, plus ~30% headroom for failures and spikes: ~14 nodes (112 GPUs) at peak. Off-peak, much of this is free for batch work.
  • Weights: 70B in bf16 is 140 GB. Loading from object storage at ~2 GB/s takes over a minute; from local NVMe at ~5 GB/s, ~30 s. Cold starts are slow, which shapes autoscaling.

10–18 min High-level design

You Three layers: a stateless API layer that authenticates, meters and validates; a router that picks a replica for each request using cache affinity and load; and model pools of inference engines doing continuous batching. A control plane manages model versions, autoscaling and rollouts.

Scroll sideways to see the whole diagram.

Self-hosted LLM inference serviceComponents: Product teams (OpenAI-compatible SDKs); API gateway (auth · quotas · SSE); Router (prefix affinity · load); 70B pool (TP 8 per node); 8B pool (1 GPU per replica); Engine per replica (batching · paged KV); Batch service (low priority, preemptible); Model registry (weights · versions); Autoscaler (queue time · warm spares); Metering (tokens · TTFT · TPOT). Flow: Product teams to API gateway; API gateway to Router; Router to 70B pool; Router to 8B pool; 70B pool to Engine per replica; 8B pool to Engine per replica; Batch service to Router (backfill); Model registry to 8B pool (weights); Autoscaler to 8B pool (scale).Product teamsOpenAI-compatible SDKsAPI gatewayauth · quotas · SSERouterprefix affinity · load70B poolTP 8 per node8B pool1 GPU per replicaEngine per replicabatching · paged KVBatch servicelow priority,preemptibleModel registryweights · versionsAutoscalerqueue time ·warm sparesMeteringtokens · TTFT · TPOTbackfillweightsscale
Self-hosted LLM inference service. A gateway authenticates, meters and applies per-team token buckets. The router picks a replica by prefix-cache affinity under bounded load. Engine pools run continuous batching with paged KV and chunked prefill. Batch jobs backfill idle capacity at low priority; the control plane manages weights, autoscaling and canary rollouts.

Components:

  • API gateway: authentication, OpenAI-compatible schema, per-team token buckets, request validation (max context, max output), SSE streaming to clients.
  • Router: maps the model name to a pool and version, then picks a replica by prefix-cache affinity and current load (outstanding tokens, KV utilization). Interactive and batch requests have separate queues.
  • Model pools: replicas of an inference engine (continuous batching, paged KV cache, prefix caching, CUDA graphs). 70B replicas use TP = 8 on one node; 8B replicas use one GPU each.
  • Batch service: accepts batch jobs, stores inputs in object storage, and feeds requests to the router at low priority when capacity is free.
  • Model registry and weight store: versioned weights (safetensors) with checksums, quantized variants and engine configs; nodes cache weights on local NVMe.
  • Autoscaler: scales pools on queue depth, KV-cache utilization and SLO burn rate, with pre-warmed spares.
  • Metering and observability: per-request tokens, TTFT, time per token, queue time, cache hits, by team, model and version.

One interactive request:

Scroll sideways to see the whole diagram.

One interactive request through the inference serviceClient → Gateway: POST /chat/completions (stream); Gateway: auth · reserve 2,000 in + 512 out tokens; Gateway → Router: model chat-large, prefix hash; Router: preferred replica ≤ 1.25× avg load?; Router → Engine replica: dispatch; Engine replica: 1,500 cached; prefill 500; Engine replica → Client: first token (~300 ms); Engine replica → Client: tokens every ~30 ms …; Engine replica → Gateway: done + usage; Gateway → Metering: record usage, refund unused tokens.ClientGatewayquotasRouterEngine replica70B · TP 8MeteringPOST /chat/completions (stream)auth · reserve 2,000 in + 512 out tokensmodel chat-large, prefix hashpreferred replica ≤ 1.25× avg load?dispatch1,500 cached; prefill 500first token (~300 ms)tokens every ~30 ms …done + usagerecord usage, refund unused tokens
One interactive request through the inference service. The gateway reserves tokens from the team's bucket. The router prefers the replica likely to hold the prompt's prefix in cache unless it's overloaded. The engine skips prefill for cached blocks, prefills the rest in chunks between decode steps, and streams tokens; usage is reconciled at the end.
  1. The gateway authenticates the team, checks its token bucket (estimated input tokens plus max output), and validates the request.
  2. The router hashes the first blocks of the prompt (system prompt and shared context) and prefers the replica that's likely to hold them in its prefix cache, unless that replica is overloaded.
  3. The replica admits the request when KV blocks are available; otherwise it waits in the replica queue (bounded) or the router tries another replica.
  4. Prefill runs in chunks interleaved with ongoing decodes; tokens stream back through the gateway.
  5. On completion, usage is recorded and the token bucket is reconciled with actual tokens.

API sketch:

POST /v1/chat/completions   { model: "chat-large", messages, stream, response_format, max_tokens, priority }
     → SSE: delta … | usage | done
POST /v1/batches            { model, input_file, completion_window: "12h" } → { batch_id }
GET  /v1/models             → names, versions, context limits

18–32 min Deep dive

You I'd go deeper on scheduling and routing, then capacity and autoscaling, then rollouts and failure handling.

Interviewer Start with scheduling. How do you hit the latency targets while keeping GPUs busy?

Deep dive 1 · Scheduling within a replica

  • Continuous batching: sequences join and leave the batch every iteration. The batch size is bounded by two things: free KV blocks, and the time-per-token SLO. Measure step time as a function of batch size and context length per model and set a maximum batch that keeps p95 under 40 ms.
  • Chunked prefill: a 30k-token prompt would stall every decode in the batch for hundreds of milliseconds. Prefill in chunks of ~2k tokens per iteration, so decodes keep their cadence and the long prompt still progresses.
  • Prefix caching: system prompts and few-shot examples are shared by many requests from the same team. Cache KV blocks by content hash; a hit skips that part of prefill, which reduces TTFT and compute. Keep cached blocks in an LRU that yields to active sequences.
  • Priorities: batch requests only take slots when interactive queues are empty and are preempted first (their KV blocks are freed; they're recomputed later). That keeps utilization high off-peak without affecting interactive latency.
  • Admission control: when queue wait would break the TTFT target, reject early with 429 and Retry-After rather than accepting requests that will time out.

Interviewer How does the router choose a replica?

Deep dive 2 · Routing

  • Cache affinity: consistent hashing on a hash of the first N prompt blocks maps requests with the same prefix to the same replica, raising prefix-cache hit rates.
  • Bounded load: pure hashing creates hot spots (one team's popular prompt). Use consistent hashing with bounded loads: if the preferred replica is above, say, 1.25× the pool's average load, go to the next one.
  • Load signal: outstanding tokens (prompt tokens left to prefill plus expected decode tokens) is better than request count, because requests vary 100× in cost. Replicas report KV utilization and queue depth every few hundred milliseconds.
  • Fairness: per-team token buckets at the gateway, and weighted fair queuing in the router so a burst from one team doesn't monopolize a pool.

Deep dive 3 · Capacity, autoscaling and disaggregation

Scroll sideways to see the whole diagram.

Scaling up a 70B poolRouter → Autoscaler: p95 queue time 400 ms, KV 92%; Autoscaler → Router: preempt batch traffic; Autoscaler → Warm spare: activate; Warm spare → Router: ready (seconds); Autoscaler → New node: provision replacement spare; New node → Weight store: load 140 GB weights; Weight store → New node: ~30 s from NVMe / peers; New node: CUDA graphs + warm-up requests; New node → Autoscaler: healthy, held as warm spare.AutoscalerRouterWarm spareNew nodeWeight storep95 queue time 400 ms, KV 92%preempt batch trafficactivateready (seconds)provision replacement spareload 140 GB weights~30 s from NVMe / peersCUDA graphs + warm-up requestshealthy, held as warm spare
Scaling up a 70B pool. Queue time rising is the trigger, not GPU utilization. A warm spare takes traffic within seconds while a new node loads weights from NVMe or peers, captures CUDA graphs and warms up. Batch work is preempted immediately to free capacity in the meantime.
  • Signals: scale up on interactive queue time and KV utilization (leading indicators), not GPU utilization (always high with batching).
  • Cold starts: a 70B replica takes ~1–2 minutes (node allocation, weight load, CUDA-graph capture, warm-up). Keep a small pool of warm spares, scale ahead of known daily peaks, and use batch traffic as the buffer: preempt it when interactive load rises.
  • Weight distribution: pre-stage popular model weights on node NVMe; stream weights from peers rather than object storage when many nodes scale at once.
  • Disaggregation (when prompts are long): for the 70B pool, separate prefill and decode replicas. Prefill nodes run large compute-bound batches; decode nodes run memory-bound batches tuned for time per token; KV caches transfer over the fast network. It decouples TTFT from time per token and lets each side scale on its own signal, at the cost of KV transfer bandwidth and complexity. Start without it; adopt it when chunked prefill can't hold both SLOs.
  • Quantization: fp8 weights for the 70B halve weight reads per step and raise per-node throughput, if evaluations show no regression on team-specific tasks.

Deep dive 4 · Rollouts and failures

  • New model or engine version: deploy to a canary replica set; mirror a sample of traffic for golden-output comparison (judge-based quality checks, JSON validity, refusal rate) and compare latency; then shift 5% → 25% → 100% with automatic rollback on SLO or quality regressions. Clients that pin versions are unaffected.
  • Replica failure mid-stream: a stream that has already sent tokens can't be transparently retried without duplicating text. Return an error event with the partial usage, and let clients retry; for non-streaming requests the gateway retries on another replica.
  • Node health: GPU errors (ECC, Xid), NCCL timeouts and engine hangs trigger draining and replacement; a watchdog checks per-replica token progress.
  • Overload: shed batch traffic first, then lower-priority teams per policy, and return 429s with Retry-After rather than letting latency collapse for everyone.

32–37 min Trade-offs

Decision Chosen Alternative Why
Replica layout (70B) TP = 8 on one node TP = 4 × 2 replicas per node in fp8 TP = 8 gives lower per-token latency; TP = 4 in fp8 gives better throughput per GPU. Revisit once fp8 quality is validated
Routing Prefix affinity with bounded load Least-loaded only Large TTFT and compute savings from cache hits; bounded load prevents hot spots
Prefill handling Chunked prefill first Disaggregated pools Simpler; adopt disaggregation when long prompts break SLOs
Batch work Same fleet, preemptible low priority Separate batch fleet Raises utilization; preemption keeps interactive SLOs
Overload Early 429s Unbounded queues Fast failure is better than timeouts for everyone
API OpenAI-compatible Custom API Lower adoption friction; extensions via extra fields

Build first: one pool per model with continuous batching, the gateway with quotas and metering, and least-loaded routing. Then prefix-affinity routing, batch backfill, autoscaling with warm spares, and canary rollouts.

37–40 min Wrap-up and likely follow-ups

Close with: "An OpenAI-compatible gateway with per-team quotas, a router that keeps prefix-cache affinity under bounded load, and engine pools doing continuous batching with chunked prefill, sized from the per-token latency target. Batch work backfills idle capacity and is preempted first. About fourteen 8-GPU nodes cover peak, autoscaled on queue time with warm spares, and every model change goes through a canary with quality and latency checks."

Likely follow-ups:

  • How do you serve fine-tuned variants? LoRA adapters on shared base pools with multi-adapter batching; full fine-tunes as their own pools.
  • How do you support 128k contexts? A separate long-context pool with larger KV budgets, fp8 KV and possibly context parallelism, so long requests don't reduce batch sizes for everyone.
  • How do you charge teams? Meter input, cached-input and output tokens separately, priced to reflect cost (output tokens are memory-bound and cost more).
  • How do you add speculative decoding? Enable it in low-load periods or for latency-sensitive teams; at high batch sizes it can reduce throughput.

What interviewers listen for

  • Separating TTFT from time per token and deriving batch limits from the SLO.
  • Capacity estimated from decode and prefill separately.
  • Cache-aware routing with load bounds.
  • Autoscaling that accounts for slow cold starts.
  • Safe rollouts and honest handling of mid-stream failures.

Red flags

  • Sizing the fleet from peak FLOPs alone.
  • Scaling on GPU utilization.
  • Retrying streams transparently and duplicating output.
  • No isolation between teams.