all_lessons/kubernetes_genai/09lesson 9 / 18

Part III - Production inference

Autoscaling and AI-Aware Routing

Lesson 08 taught you to measure a single replica — to find the batch size and concurrency where one GPU delivers the most tokens per second without blowing past your TTFT budget. But production runs many replicas behind a load balancer, and two new questions appear at once: when do you add a replica, and which replica should each request go to? A naive Kubernetes setup answers both badly — it scales on CPU and balances at random — and for LLM serving both defaults are actively harmful. This lesson replaces them with signals and routing that match how LLM inference actually consumes a GPU: tokens, queue time, cache state, and adapter locality.

Source coverage
PDF Chapter 4: autoscaling, LLM-aware routing, AI gateways, and the Gateway API Inference Extension.
Linear position
Prerequisite: Lesson 08 (Benchmarking and runtime tuning) — you can characterize one replica's throughput/latency curve and you know the runtime terms: TTFT, TPOT, the KV cache, prefill vs. decode, and prefix caching.
New capability: Scale and route a fleet of replicas on LLM-native signals — choosing which warm model, cache state, adapter, and queue absorbs the next request, instead of treating replicas as interchangeable CPU boxes.
The plan
Five moves. (1) Frame the router as a scheduler sitting above the Kubernetes scheduler, and say why that layer has to exist. (2) Show why a CPU-based HPA fails for LLMs and what to scale on instead — tokens, queue time, GPU saturation, KV pressure. (3) Make cache-aware routing concrete with a worked number: random load balancing destroys prefix-cache hit rate, and routing by prefix recovers it. (4) Add LoRA-aware routing and the AI-gateway boundary, then ground both in the Gateway API Inference Extension. (5) Failure modes, a checklist, and the hand-off into disaggregated serving.

1 · The router is a scheduler above the scheduler

Kubernetes already has a scheduler: it places pods onto nodes. But once your model server is running, a second scheduling decision happens on every single request — which running replica handles this prompt? By default a Kubernetes Service answers that question by spraying connections across endpoints roughly at random (round-robin / random L4 balancing). For a stateless web app that is correct: every pod is identical, so any pod is as good as any other.

LLM replicas are not identical, even when they run the same model image. Each replica carries hidden state that makes it better or worse for this specific request:

prefix cache state → a replica that already processed this prompt's prefix can skip recomputing it (cheap prefill, low TTFT)
loaded LoRA adapters → a replica with the requested LoRA already resident answers immediately; another must fetch and load it on the hot path
queue depth and KV pressure → a replica whose KV cache is near full will queue or preempt the request; an emptier one will not
readiness → a freshly scaled-up replica may be live on the network but still loading weights into HBM, so it serves errors or 30 s TTFTs

The mental model: an LLM router is a scheduler above the scheduler. The Kubernetes scheduler decides where pods live; the router decides which warm model, cache state, adapter, and queue should absorb the next request. It is the smallest piece of machinery that turns a flat pool of "identical" replicas into a fleet whose differences you can exploit. The cost is that routing becomes stateful and policy-heavy — fairness, cache locality, and latency can pull in different directions — which is exactly why the rest of this lesson is about choosing the right signal for each decision.

2 · Why a CPU HPA fails — scale on what binds

The default autoscaler is the HorizontalPodAutoscaler (HPA) driving on CPU utilization: when average pod CPU crosses, say, 70%, add a replica. This is the single most common mis-configuration in LLM serving, and it fails in both directions.

The reason is structural. LLM inference is bound by the GPU and by GPU memory bandwidth, not by the host CPU. A model server can have its GPU pinned at 100% — every streaming multiprocessor busy doing matrix multiplies, the KV cache full, requests piling up in the queue — while the host CPU, whose only job is to copy bytes and dispatch CUDA kernels, sits at 15%. The HPA looks at 15% CPU, concludes the pod is idle, and refuses to scale. Meanwhile TTFT is climbing because requests are waiting in the queue. Worse, on a quiet day the CPU can briefly spike on tokenization and trick the HPA into scaling up when the GPU is nearly idle, wasting an expensive accelerator. CPU is uncorrelated with the resource that actually binds.

Scale on the signals that bind LLM serving instead. None is universally best; they trade responsiveness against stability:

CPU utilization (the default — avoid)
Cheap and built-in, but uncorrelated with GPU load. Idle-looking CPU while the GPU queue saturates; spurious scale-ups on tokenization spikes. Almost never the right primary signal for LLM serving.
Requests in flight / RPS
Better than CPU, but an LLM request is not a unit of work — a 50-token prompt and a 6,000-token prompt count the same. Underscales on long contexts, overscales on bursts of trivial requests.
Token throughput (prompt + output tokens/s)
The honest unit of GPU work. Captures that long prompts and long generations cost more. The recommended primary scaling signal; needs the runtime to export per-replica token counters.
Queue time / pending requests
A direct proxy for "users are waiting." Rising queue time is the earliest SLO-relevant symptom. Excellent scale-up trigger; pair with throughput so you do not thrash on momentary bursts.
GPU saturation + KV-cache utilization
Closest to the physical bottleneck: GPU SM busy-% and how full the KV cache is. When KV is >90% the replica is about to queue or preempt. Best paired with a queue-time trigger to act before SLOs break.

In practice a robust policy scales up on queue time or KV-cache pressure (early warning) and uses token throughput to size the steady-state replica count, while never trusting CPU. Custom and external metrics reach the HPA through the Kubernetes custom.metrics.k8s.io API, fed by an adapter such as Prometheus Adapter or KEDA scraping the runtime's /metrics endpoint.

Scale-up is not instant — the cold-replica trap
Adding a replica is not adding a web pod. A new LLM replica must be scheduled onto a GPU node (possibly triggering a cluster-autoscaler node that takes minutes to boot), pull a multi-GB image, then load tens of GB of weights from storage into HBM. From "HPA decided to scale" to "replica can serve a token" is routinely 60 s to several minutes. A replica that is network-reachable but not finished loading is worse than absent: if it accepts traffic it returns errors or multi-second TTFTs precisely during the overload that triggered the scale-up. The fix is readiness gating — the model server must fail its Kubernetes readiness probe until weights are fully loaded, so the Service and router exclude it until it can actually serve.

3 · Cache-aware routing — the worked number

Here is where random load balancing does real damage. Recall from lesson 08 that prefill (processing the prompt) is compute-heavy, and that a runtime keeps a prefix cache: if a new prompt shares a leading prefix with one it already processed — a shared system prompt, a few-shot preamble, a conversation history — the replica reuses the cached KV state for that prefix and skips recomputing it. A prefix-cache hit can cut prefill compute and TTFT for that request to near zero.

But the prefix cache is local to each replica. Replica A's cache does not help a request that lands on replica B. So the routing decision directly determines your hit rate. Random balancing scatters requests that could have hit a warm prefix across the whole fleet, where most land on a replica that never saw that prefix — a miss. Cache-aware routing instead sends requests that share a prefix to the same replica, concentrating hits.

Worked number — random vs. cache-aware across 8 replicas
Suppose 70% of your traffic is prefix-shareable — those requests fall into a handful of common prefixes (a shared system prompt, a popular few-shot template). You run a pool of R = 8 replicas.

Random routing. A shareable request only hits the cache if it happens to land on the replica that last served its prefix. With random spray across 8 replicas, the chance the matching prefix is warm on the chosen replica is roughly 1/R = 1/8 ≈ 12.5%. So the effective prefix-cache hit rate on shareable traffic is about 12.5%, and the overall hit rate is about 0.70 × 12.5% ≈ 9%. (This 1/R figure is a conservative floor: because random spray eventually warms a popular prefix on several replicas, the real random hit rate sits somewhere above 1/R but well below cache-aware — the point is the gap, not the third decimal.) Roughly nine-tenths of the prefill work you could have skipped, you redo.

Cache-aware routing. Now route by prefix: all requests sharing a given prefix go to the replica that owns it. Shareable requests now hit a warm cache nearly every time — say ~80% after accounting for evictions and the first (cold) request that warms each prefix. Overall hit rate jumps to about 0.70 × 80% ≈ 56%, and on the shareable slice specifically it is ~80% vs. ~12%.

What it buys. Prefill compute and TTFT for a cache hit are near zero, so a ~70-percentage-point lift in hit rate on the shareable slice cuts prefill work on that slice by roughly the same proportion. If prefill is half of your total GPU compute, you have freed a large fraction of it — meaning either lower TTFT at the same load or the same TTFT on fewer replicas. The arithmetic is just hit_rate ≈ shareable% × P(prefix is warm on the chosen replica), and routing is the term you control.

The widget below lets you drag the two knobs — number of replicas and the shareable fraction — and watch random vs. cache-aware hit rate diverge. The gap widens as you add replicas, because random's 1/R term shrinks while cache-aware does not depend on R at all.

Cache-aware vs. random routing — prefix-cache hit rate
Two bars: the green bar is the overall prefix-cache hit rate under cache-aware routing (send shared prefixes to the same replica); the red bar is the same fleet under random routing, where a shareable request only hits if it lands on the one warm replica (1/R). Drag the replica count and the shareable fraction. Notice random collapses as you add replicas, while cache-aware barely moves.
Cache-aware hit rate
Random hit rate
Prefill compute saved (cache-aware)
Show the core JS
// shareable fraction s, replica count R, warm-when-aware ~0.80
const awareHit  = s * 0.80;            // shared prefixes pinned to one replica
const randomHit = s * (1 / R);         // shareable only hits if it lands on the warm replica
// prefill on a hit is ~free; if prefill is ~half of GPU compute,
// compute saved ≈ 0.5 * hitRate.

4 · LoRA-aware routing, the AI gateway, and a K8s-native standard

LoRA-aware routing. The same locality logic applies to fine-tunes. A LoRA adapter is a small set of weight deltas (often tens to a few hundred MB) layered on a shared base model, so one server can serve many fine-tunes by swapping adapters. But swapping an adapter that is not resident means fetching and loading it — adding latency on the request's hot path and a burst of memory traffic. LoRA-aware routing keeps a live inventory of which adapters are warm on which replicas and routes a request for adapter X to a replica that already has X loaded. The trade-off mirrors cache-aware routing: you gain locality at the cost of maintaining adapter-residency state and accepting less perfectly even load.

The AI gateway. All of this — auth, rate limits, prompt-logging policy, model selection, traffic shaping during a rollout — wants a single boundary in front of the pools. That boundary is the AI gateway: the place where a request is authenticated, checked against a tenant's quota, tagged with the model/adapter it is asking for, logged according to policy, and then handed to the router. Keep the boundary honest: the gateway must not hide runtime metrics, and the model-server endpoints must remain directly observable during an incident — otherwise the gateway becomes a blindfold exactly when you need to see.

Gateway API Inference Extension. The emerging Kubernetes-native way to express this is the Gateway API Inference Extension, a SIG-Network project that extends the Gateway API for inference workloads. It is maturing rather than long-settled — APIs are still evolving and you should pin versions and read the project's status before betting on it — but the design is the right shape, which is why it is worth knowing. Its central idea is the InferencePool: instead of treating a Service as a flat set of interchangeable pods, the gateway treats an inference pool as a backend that has an endpoint picker (EPP). The picker looks at inference-specific signals — queue length, KV-cache utilization, which LoRA adapters are active on each replica, and model-server readiness — before choosing the backend. That is precisely the cache-aware / LoRA-aware / readiness logic of this lesson, lifted into a shared API rather than re-implemented per team.

The picker is now on the request path
Making routing smart introduces a new failure surface: the endpoint picker itself sits on (or beside) every request. So fail-open vs. fail-close becomes a product decision. Fail-open (route somewhere, anywhere, if the picker is unavailable) protects availability but may route blindly during overload. Fail-close (reject if the picker cannot decide) protects correctness and overload boundaries but drops requests when the picker is down. Either way, the picker belongs in your observability and incident drills, not just in the networking diagram.

5 · Failure modes and the implementation checklist

Failure modes

  • CPU HPA while the GPU queue is saturated. The classic. CPU reads 15%, the HPA refuses to scale, but TTFT is climbing because requests queue on a pinned GPU. Signal: rising queue time and KV-cache utilization with flat CPU and flat replica count. Scale on tokens/queue-time, not CPU.
  • Random load balancing destroys prefix-cache hit rate. Shareable requests scatter across the fleet and miss warm prefixes, redoing prefill that could have been free. Signal: per-replica prefix-cache hit rate near 1/R instead of the runtime's achievable hit rate; prefill compute far above what the shareable fraction predicts.
  • Cold replicas accept traffic before weights load. A scaled-up replica is network-reachable but still loading into HBM, so it serves errors or 30 s TTFTs during the very incident that triggered scale-up. Signal: an error/TTFT spike that starts the moment a new pod goes Ready and decays as it warms. Gate readiness on weight-load completion.
  • LoRA loaded on the hot path. A request for an adapter routes to a replica that does not have it; the adapter loads inline, spiking that request's latency. Signal: bimodal TTFT per adapter — fast on warm replicas, slow on cold ones. Route by adapter residency.
  • The gateway becomes a blindfold. All metrics flow through the gateway, so when it degrades you lose both routing and the ability to see the runtime behind it. Signal: an incident where you cannot tell whether the model servers or the gateway are at fault. Keep runtime endpoints directly observable.

Implementation checklist

  • Is scaling driven by token throughput, queue time, or GPU/KV saturation — never raw CPU or undifferentiated HTTP request count?
  • Does the model server fail its readiness probe until weights are fully loaded, so cold replicas are excluded from routing?
  • When the runtime can exploit a prefix cache, does the router send shared prefixes to the same replica rather than spraying at random?
  • Are LoRA adapters kept warm and requests routed by adapter residency so adapters never load on the hot path?
  • Is there an AI-gateway boundary owning auth, rate limits, prompt-logging policy, and model selection — without hiding runtime metrics?
  • Is the routing layer (e.g. an InferencePool endpoint picker) on a defined fail-open / fail-close policy and exercised in incident drills?
  • Does scale-up account for the minutes-long cold start (image pull + weight load + possible node boot) so the autoscaler triggers early enough to matter?

Checkpoint exercise

Try it
You serve one base model with three LoRA fine-tunes across 6 replicas; 60% of traffic shares a common system-prompt prefix and is split unevenly across the three adapters. Design the routing + scaling policy: (1) name the primary scale-up signal and a second signal that sizes steady-state replica count; (2) state how you keep prefix-cache hit rate high and avoid loading an adapter on the hot path, and where those two goals conflict; (3) define the readiness gate and the fail-open/fail-close choice for the endpoint picker. Then estimate the random vs. cache-aware hit rate for the shareable slice using s × (1/R) vs. s × 0.8.

Where this points next

Cache-aware and LoRA-aware routing both rest on a hidden assumption: that a single replica does both prefill (compute-bound) and decode (memory-bandwidth-bound), so "warm prefix on this replica" is a meaningful, stable property. But those two phases stress the GPU so differently that the next architectural move is to split them onto separate pools — a prefill tier and a decode tier — and stream the KV cache between them. That changes everything about routing and scaling: you now scale and route each tier on its own bottleneck, and the cache locality you just learned to exploit becomes a thing you ship across the network. Lesson 10, Disaggregated Serving, builds that prefill/decode split and the KV-transfer machinery it requires. (For a deeper look at how a runtime actually implements the cache-aware scheduling this lesson routes toward, see SGLang cache-aware scheduling.)

Takeaway
An LLM router is a scheduler above the Kubernetes scheduler: it chooses which warm model, cache state, adapter, and queue absorbs the next request, because LLM replicas are not interchangeable. Scale on what binds — token throughput, queue time, GPU/KV saturation — never CPU, which reads idle while the GPU queue saturates, and gate readiness so cold replicas (minutes to load weights) never take traffic. Route on locality: cache-aware routing sends shared prefixes to the same replica, lifting hit rate from ~1/R under random toward the runtime's achievable ~80% and cutting prefill compute proportionally; LoRA-aware routing keeps adapters off the hot path. An AI gateway owns auth, rate limits, prompt-logging, and model selection, and the Gateway API Inference Extension's InferencePool + endpoint picker is the emerging (still-maturing) K8s-native way to turn all of this into one shared API — at the price of a new failure surface that needs a fail-open/fail-close policy and a place in your incident drills.

Interview prompts