SevyDevy

Guide · AI and GenAI

LLM Inference System Design: KV Cache, Batching and the Gateway in Front

16 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • LLM inference has two phases: prefill processes the whole prompt in parallel and is compute-bound, while decode produces one token per step and is limited by GPU memory bandwidth.
  • The KV cache stores the key and value vectors of every past token so decode does not recompute them, and its size (2 x layers x KV heads x head dim x bytes x tokens) is what usually caps how many requests fit on one GPU.
  • PagedAttention stores the KV cache in fixed-size blocks like OS pages, and continuous batching adds and removes requests every decode step, which together keep the GPU full without wasting memory.
  • Measure an LLM serving system with time to first token (TTFT), time per output token (TPOT), throughput, and goodput: the request rate you can serve while still meeting your latency targets at p99.
  • The gateway in front handles auth, per-tenant rate limits counted in tokens, routing by model and prefix, provider fallbacks, SSE streaming, cancellation, and cost metering per million tokens.

Say you are asked in a system design round: "Design a service that lets 500 internal teams call open-weight LLMs, with streaming, fair usage and a sane bill." If your answer is "load balancer, some GPU servers, Redis cache", the interviewer will nod politely and then ask why your p99 time to first token is 9 seconds while the GPUs sit at 30% utilisation.

LLM serving breaks instincts from normal web services. A request is hundreds of tiny steps, memory decides how many users fit, and the cheapest request is one whose prompt you have already seen. Here is the whole stack as you would draw it: the GPU engine first, then the gateway in front.

How does LLM text generation actually work?

An LLM generates text in two phases: a prefill pass that reads the entire prompt at once, then a decode loop that produces one token per forward pass until a stop token or max_tokens. The two phases stress the hardware differently, and most serving optimisations exist because of that.

Prefill pushes all 2,000 prompt tokens through the model in parallel. That is big matrix multiplication, exactly what GPUs are built for, so prefill is compute-bound. It decides how long the user stares at a blank screen.

Decode produces one token per sequence per step, but the GPU must still read every weight from memory: about 16 GB for an 8B model in 16-bit precision. At a few terabytes per second, one sequence alone tops out at a few hundred tokens per second whatever your FLOPs. Decode is memory-bandwidth-bound. Decode 64 sequences in the same step and you read the weights once but get 64 tokens. Batching turns idle compute into throughput, and the KV cache limits how big the batch can get.

Prefill vs decode for one request

Prefill runs once over the full prompt and fills the KV cache. Decode then loops one token at a time, reading the whole cache each step, until it emits a stop token.

Diagram connections:

  • Prompt (2,000 tokens) to Prefill pass (tokenised input)
  • Prefill pass to KV cache in GPU memory (write K,V for prompt)
  • KV cache in GPU memory to Decode step (read all past K,V)
  • Decode step to Sampler (logits)
  • Sampler to Token stream to user (next token)
  • Prompt (2,000 tokens): System prompt + chat history + question
  • Prefill pass: All prompt tokens in parallel; compute-bound
  • KV cache in GPU memory: Keys and values for every token so far
  • Decode step: One new token per sequence; bandwidth-bound
  • Sampler: Temperature, top-p, stop checks
  • Token stream to user: Loop repeats until stop token or max_tokens

Which latency metrics matter for LLM serving?

The metrics that matter are time to first token, time per output token, throughput and goodput, each tracked at p50 and p99 rather than as an average. A single "latency" number hides the fact that users feel the first token and the streaming speed separately.

  • Time to first token (TTFT): arrival to first streamed token, so queueing plus prefill. For chat, users notice this most.
  • Time per output token (TPOT), also called inter-token latency (ITL): the gap between tokens during decode. Past reading speed it matters less for chat, but a lot for agents and code tools.
  • End-to-end latency: roughly TTFT + TPOT x (output tokens - 1). A 600-token answer at 30 ms per token adds 18 seconds, so output length is a design decision.
  • Throughput: total tokens per second across all requests. Your cost per million tokens is built on it.
  • Goodput: the request rate you can sustain while meeting both TTFT and TPOT targets. The DistServe paper made it the headline metric, and it is the honest one: throughput at a p99 TTFT of 20 seconds is not a product.

What is a KV cache?

A KV cache is GPU memory that holds the attention key and value vectors for every token already processed, so each decode step only computes keys and values for the one new token instead of the whole sequence again. Without it, generating token 1,000 would mean reprocessing 999 tokens, and generation cost would grow quadratically.

The cache grows by one entry per layer every decode step, and you do not know up front how long an answer will be, so you do not know how much memory a request will need.

KV cache memory formula, worked for Llama 3.1 8B

KV cache bytes = 2 (one K, one V) x layers x KV heads x head dimension x bytes per value x tokens. Llama 3.1 8B's published config has 32 layers, 32 query heads but only 8 key-value heads (grouped-query attention, GQA), a head dimension of 128, and bfloat16 weights, so 2 bytes per value.

text
per token = 2 x 32 layers x 8 kv_heads x 128 head_dim x 2 bytes
          = 131,072 bytes = 128 KiB

4,096-token request   = 128 KiB x 4,096   = 512 MiB
8,192-token request   = 128 KiB x 8,192   = 1 GiB
131,072-token request = 128 KiB x 131,072 = 16 GiB  (full context window)

Without GQA (32 kv_heads) = 512 KiB per token, 4x more

Now put it on an 80 GB GPU. The weights take about 16 GB in bf16. Leave headroom for activations and runtime overhead and you might have around 55 to 60 GB for the cache. At 512 MiB per 4K-token request, that is roughly 110 to 120 requests in flight. One user pasting a 128K-token document eats the space of 32 normal chats. That is why the KV cache, not compute, usually caps your batch size, and why GQA (fewer KV heads) was such a big deal for serving cost.

What is PagedAttention and why did it matter?

PagedAttention stores each request's KV cache in small fixed-size blocks that can live anywhere in GPU memory, tracked by a block table, the same way an operating system maps virtual pages to physical frames. It came from the vLLM paper (Kwon et al., SOSP 2023).

Before it, many servers reserved a contiguous slab per request sized for the maximum output, so a request that stopped after 50 tokens still held memory for 2,048. The paper reports 2 to 4x higher throughput than earlier systems at similar latency by nearly eliminating that waste. Blocks also make sharing cheap: sequences sampled from the same prompt point at the same physical blocks and copy on write. When memory runs out, the scheduler preempts a request, frees its blocks and recomputes it later.

Prefix caching

Prefix caching keeps KV blocks around after a request finishes so a later request with the same leading tokens can reuse them and skip that part of prefill. vLLM does this by hashing each full block together with the hash of the blocks before it, so a block only matches if the entire prefix up to it is identical, and evicts unused blocks in LRU order.

It is a big win when every request starts with the same long system prompt or tool definitions, and for multi-turn chat where turn five resends turns one to four. So put stable content first and variable content last; a timestamp at the top of the system prompt kills your hit rate. For multi-tenant setups, vLLM can add a cache salt to the hash so tenants cannot probe each other's cached prompts.

Continuous batching vs static batching

Static batching waits for a group of requests, runs them together, and returns nothing until the longest one finishes. Continuous batching (iteration-level scheduling) re-forms the batch on every decode step, so a finished sequence leaves immediately and a waiting one joins on the next step.

The idea came from the Orca paper (OSDI 2022). Picture a static batch of eight: seven answers finish in 40 tokens, one rambles for 800, and the GPU spends 760 steps decoding a batch of one while requests queue. With continuous batching those seven slots refill within a step. Modern engines such as vLLM, SGLang and TensorRT-LLM all do this, so treat it as the default in an interview.

Chunked prefill

Continuous batching creates a new problem: when a 30,000-token prompt joins, its prefill can stall everyone else's decode for hundreds of milliseconds, and every streaming user sees a stutter. Chunked prefill splits a long prefill into pieces and mixes those pieces into steps alongside ongoing decodes, under a per-step token budget.

vLLM's V1 engine enables chunked prefill by default, schedules pending decodes before prefills, and exposes the budget as max_num_batched_tokens. Its docs describe the trade-off directly: a smaller budget (say 2,048) protects inter-token latency, a larger one (8,192 or more) gives better TTFT and throughput. There is no free setting, only the one that matches your SLO.

Other inference optimisations: speculative decoding, quantization, parallelism

Speculative decoding has a small draft model guess several tokens ahead, then the big model verifies all guesses in one pass. Since decode is bandwidth-bound, checking five tokens costs about the same as producing one. Leviathan et al. showed the output distribution is unchanged and reported 2 to 3x speedups on T5-XXL. The catch: gains depend on the draft's hit rate and shrink at large batch sizes, when the GPU is no longer idle.

Quantization stores weights in fewer bits: FP8 or INT8 halves memory versus bf16, 4-bit formats like AWQ or GPTQ quarter it. Fewer bytes per decode step and more room for KV cache improve both TPOT and batch size. The cost is quality, which you must measure on your own evals, especially for maths, code and non-English text. FP8 KV cache is a separate knob that doubles how many tokens fit.

When a model does not fit on one GPU, you split it. Tensor parallelism shards each layer across GPUs and lowers per-token latency, but needs NVLink-class links because GPUs exchange results inside every layer. Pipeline parallelism puts different layers on different GPUs, tolerates slower links, but adds bubbles and does not speed up a single token. Tensor parallel within a node first, pipeline across nodes when you must.

Prefill/decode disaggregation

Prefill/decode disaggregation runs prefill and decode on separate GPU pools and ships the KV cache from one to the other. The DistServe paper (2024) argued that co-locating the phases forces them to interfere, and that separating them lets you size and parallelise each pool for its own SLO. vLLM supports it as an experimental feature, and its docs are blunt that it does not improve throughput; the point is independent tuning of TTFT and ITL and tighter control of tail inter-token latency.

Disaggregated prefill and decode

Prefill and decode run on separate GPU pools. The price is moving the KV cache between them, so it pays off only with fast interconnects and strict tail-latency targets.

Diagram connections:

  • Inference router to Prefill GPU pool (new prompt)
  • Prefill GPU pool to KV transfer (filled KV blocks)
  • KV transfer to Decode GPU pool (load KV, start decode)
  • Decode GPU pool to Streaming client (tokens over SSE)
  • Inference router: Picks a prefill and a decode instance
  • Prefill GPU pool: Tuned for TTFT; compute-heavy
  • KV transfer: NVLink, RDMA or a shared KV store
  • Decode GPU pool: Tuned for TPOT; large batches
LLM serving optimisations: what each one improves and what it costs
TechniqueWhat it improvesWhat it costs
Static batchingSimple throughput gain over one-at-a-timeShort requests wait for the longest one; poor TTFT and GPU idle time
Continuous batchingThroughput and TTFT; slots refill every stepMore complex scheduler; needs paged memory to work well
PagedAttentionBatch size, via near-zero KV fragmentationCustom attention kernels; block table bookkeeping
Prefix cachingTTFT and prefill cost for repeated prefixesMemory held for cached blocks; prompt order discipline; tenant isolation care
Chunked prefillSmooth inter-token latency during long promptsSlightly worse TTFT for the long prompt; budget tuning
Speculative decodingTPOT at low to medium batch sizesDraft model memory; gains shrink at high load
Weight quantization (FP8, INT4)Memory, TPOT, room for more KV cachePossible quality loss; needs per-task evals
FP8 KV cacheAbout 2x tokens in the same memoryAccuracy risk on long contexts
Tensor parallelismFits big models; lower per-token latencyNeeds NVLink-class links; communication every layer
Pipeline parallelismFits very big models across nodesPipeline bubbles; no single-token speedup
Prefill/decode disaggregationIndependent TTFT and TPOT tuning; tail ITLKV transfer cost; more pools to operate; not a throughput win

End-to-end LLM serving architecture

Put the pieces together and an LLM serving system has three layers: a gateway that deals with tenants and money, a router that picks the best replica for this prompt, and an engine per GPU replica whose scheduler forms a new batch every step and manages the paged KV cache.

LLM serving system, client to KV cache

The gateway handles tenants, the router picks a replica using prefix and load signals, and each engine scheduler re-forms the batch every decode step against its own paged KV cache.

Diagram connections:

  • Client app to LLM gateway (HTTPS, SSE stream back)
  • LLM gateway to Key and tenant store (verify key, load quota)
  • LLM gateway to KV-aware router (model + prompt)
  • KV-aware router to Engine scheduler A (prefix match)
  • KV-aware router to Engine scheduler B (least loaded)
  • Engine scheduler A to GPU worker A (batch per step)
  • Engine scheduler B to GPU worker B (batch per step)
  • GPU worker A to Paged KV cache (read/write blocks)
  • GPU worker A to Metrics and traces (engine metrics)
  • LLM gateway to Metrics and traces (usage per tenant)
  • Client app: Chat UI, agent, batch job
  • LLM gateway: Auth, token limits, streaming, metering
  • KV-aware router: Prefix hits, queue depth, KV usage
  • Engine scheduler A: Continuous batching, chunked prefill
  • GPU worker A: Model weights + attention kernels
  • Paged KV cache: Blocks + prefix cache, per replica
  • Metrics and traces: TTFT, TPOT, KV %, queue, cost

Why a KV-aware router instead of round robin? Prefix caching is per replica, so if turn five lands on a different GPU from turns one to four, you pay the full prefill again. A good router scores replicas on prefix overlap, queue depth and KV utilisation. The Kubernetes Gateway API Inference Extension formalises this as an endpoint picker that reads model server metrics such as prefix cache status. Watch for hot spots: if everyone shares one system prompt, pure prefix affinity sends all traffic to one replica, so cap affinity with a load threshold.

What does an LLM gateway do?

An LLM gateway is the single entry point in front of all your models and providers: it authenticates callers, enforces per-tenant token budgets, routes each request to the right model or provider, streams the response, and records usage and cost. It is where platform policy lives, so product teams never talk to GPUs or vendor APIs directly.

Rate limit tokens, not requests

Request-per-minute limits are nearly useless here: one 100K-token prompt costs more than a thousand "hi" messages. Limit tokens per minute per tenant, often input and output separately. Output length is unknown at admission, so reserve prompt tokens plus max_tokens up front and refund the unused part when the stream ends.

typescript
// Admission: reserve the worst case, settle the real usage later.
async function admit(tenant: string, promptTokens: number, maxTokens: number) {
  const reserve = promptTokens + maxTokens;
  const ok = await bucket.tryTake(`tpm:${tenant}`, reserve); // token bucket in Redis
  if (!ok) throw new HttpError(429, 'token budget exceeded', { retryAfterSec: 10 });
  return reserve;
}

async function settle(tenant: string, reserved: number, usage: { input: number; output: number }) {
  const unused = reserved - (usage.input + usage.output);
  if (unused > 0) await bucket.refund(`tpm:${tenant}`, unused);
  await meter.record(tenant, usage); // feeds billing and cost dashboards
}

Routing and fallbacks across providers

Route by need: a small cheap model for classification and short replies, a large one for hard reasoning, a long-context deployment only for long prompts. If the self-hosted pool is saturated or a provider returns 429s or 5xx errors, retry on another provider behind a circuit breaker with a retry budget, so an outage does not become a retry storm.

Fallbacks have sharp edges. Tokenizers differ, so token counts and context limits change, and tool-call formats differ, which can break parsers. You can only fail over cleanly before the first token streams; after that, switching providers means restarting the answer and telling the client.

LLM gateway with routing and fallback

Every request passes auth and a token budget check first. The router prefers the self-hosted pool and fails over to hosted providers only before streaming starts.

Diagram connections:

  • Team service to TLS and WAF (HTTPS)
  • TLS and WAF to LLM gateway
  • LLM gateway to API key and tenant (who, which tier)
  • LLM gateway to Token budget (Redis) (reserve tokens)
  • LLM gateway to Self-hosted GPU pool (primary route)
  • LLM gateway to Hosted provider A (fallback 1)
  • LLM gateway to Hosted provider B (fallback 2)
  • LLM gateway to Usage and cost meter (usage on stream end)
  • LLM gateway: OpenAI-compatible API, SSE out
  • API key and tenant: Tier, allowed models, max_tokens cap
  • Token budget (Redis): Tokens per minute per tenant
  • Self-hosted GPU pool: Primary; cheapest at steady load
  • Hosted provider A: Fallback on 429, 5xx or timeout
  • Hosted provider B: Second fallback, circuit breaker
  • Usage and cost meter: Tokens, TTFT, cost per tenant

Semantic caching: useful, but careful

A semantic cache embeds the prompt and returns a stored answer if a past prompt is "close enough". It saves money on FAQ-style traffic, but "cancel my order" and "don't cancel my order" can sit very close in embedding space. Never serve answers that depend on user data, time or tool results from a shared cache, and key by tenant, model and system prompt version. Exact-match caching of temperature-0 calls is much safer; make semantic caching an opt-in per use case.

Streaming with SSE and cancellation

Tokens go back over Server-Sent Events: one HTTP response with Content-Type text/event-stream, each chunk a data: line, ending with a done marker. SSE works through most proxies, but you must disable response buffering at every hop, or users get the whole answer in one lump after 20 seconds.

http
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache

data: {"choices":[{"delta":{"content":"Paged"}}]}

data: {"choices":[{"delta":{"content":"Attention"}}]}

data: {"usage":{"prompt_tokens":2048,"completion_tokens":2}}

data: [DONE]

Cancellation is the part people forget. When a user hits stop, the gateway must abort the upstream request all the way to the engine so its KV blocks are freed. Otherwise the GPU decodes 1,500 tokens nobody reads, and you pay for them. Propagate an abort signal through every hop and test it.

Observability and cost per 1M tokens

Log per request: tenant, model, input, cached and output tokens, TTFT, TPOT, finish reason, and whether a fallback fired. Scrape per replica: queue length, waiting sequences, KV utilisation, preemptions and prefix hit rate. Rising preemptions with high KV usage means you need more replicas or tighter limits, not faster GPUs.

Self-hosted cost per 1M tokens is GPU cost per hour divided by tokens served per hour, times a million. A hypothetical $2.50-per-hour GPU sustaining 2,500 output tokens per second serves 9 million tokens an hour, about $0.28 per million, but only at that utilisation. At 10% load at 3 a.m., each token costs ten times more. That is why teams self-host steady base load and burst to per-token hosted APIs.

How to structure an LLM serving system design interview

  1. Clarify workload and SLOs (3 to 5 minutes)

    Chat, agents or batch? Typical prompt and output lengths, peak requests per second, context limits, open-weight or hosted models, and p99 targets for TTFT and TPOT.

  2. Back-of-envelope sizing

    Compute KV cache per token, weights per replica, concurrent sequences per GPU, and how many replicas the peak needs. Write the numbers down.

  3. Draw the high-level path

    Client, gateway, router, engine with scheduler and paged KV cache, GPU workers, metrics. Say where streaming and auth happen.

  4. Go deep on the engine

    Continuous batching, PagedAttention, prefix caching, chunked prefill. Mention speculative decoding, quantization and parallelism with their trade-offs.

  5. Go deep on the gateway

    Token-based limits, routing by model and prefix, fallbacks, caching rules, cancellation, and per-tenant metering.

  6. Failure modes and scaling

    Replica crash mid-stream, provider outage, long-prompt abuse, cache hot spots, autoscaling on queue depth and KV usage rather than CPU.

  • Mistake: autoscaling GPU replicas on CPU or GPU utilisation. A memory-bound decode can show modest utilisation while the queue explodes. Scale on queue depth, waiting requests and KV usage.
  • Mistake: treating an LLM request like a 50 ms API call with a 30-second timeout at the load balancer. Long generations get cut off; use streaming and per-route timeouts.
  • Mistake: one global queue with no priority. A batch job submitting 10,000 summaries will starve interactive chat unless you separate priorities or pools.
  • Mistake: ignoring output length. max_tokens is your biggest cost and latency lever, so set it per use case.

Questions people ask

Why is LLM decode memory-bandwidth-bound?
Decode is memory-bandwidth-bound because each step produces only one token per sequence but must read every model weight from GPU memory. The arithmetic per byte read is tiny, so the GPU waits on memory rather than compute. Batching many sequences into the same step reuses each weight read for many tokens, which is why batch size drives throughput.
How do you calculate KV cache size for an LLM?
Multiply 2 (keys and values) by the number of layers, KV heads, head dimension and bytes per value to get bytes per token, then multiply by tokens. For Llama 3.1 8B (32 layers, 8 KV heads, head dim 128, bf16) that is 128 KiB per token, so a 4,096-token request needs 512 MiB of KV cache.
What is the difference between continuous batching and static batching?
Static batching runs a fixed group of requests until the longest finishes, while continuous batching re-forms the batch every decode step. With continuous batching, finished requests leave and queued ones join immediately, so the GPU stays busy and short requests are not held hostage by long ones. It is the default in modern engines such as vLLM.
Should an LLM gateway rate limit requests or tokens?
Rate limit tokens. A single request can carry 100 tokens or 100,000, so request counts say little about load or cost. Reserve prompt tokens plus max_tokens at admission, refund the unused part when the response ends, and keep separate budgets for input and output tokens if they are priced differently.
Does prefill/decode disaggregation improve throughput?
Not by itself. vLLM's documentation states plainly that disaggregated prefill does not improve throughput. Its value is letting you tune TTFT and inter-token latency independently and keeping long prefills from causing decode stalls, which tightens tail latency. You pay for it with KV cache transfers between pools and more infrastructure to run.
Is semantic caching safe for LLM responses?
Only for narrow, non-personal, low-risk queries. Semantic caches match prompts by embedding similarity, so prompts that look alike but mean opposite things can return the wrong answer. Never serve user-specific, time-sensitive or tool-dependent answers from a shared cache, scope keys by tenant and model, and monitor answer quality on cache hits.

If you remember one thing: the engine is a memory manager that happens to do matrix maths, and the gateway is a billing and fairness system that happens to proxy HTTP. Get both right and the rest is sizing. As a next step, take the Llama 3.1 8B numbers above, pick your own SLOs, and work out how many GPUs a 200 requests-per-second chat product needs.

Sources and further reading

Practise this with Lala

Practise