The Inference Gateway Pattern: KV-Cache-Aware Routing and Flow Control for Multi-Model GPU Fleets
Sources
- llm-d v1.0 announcement: State-of-the-art inference, ready for Day 2 (Oct 20, 2026)
- llm-d architecture documentation (Router, Flow Control, Scheduling)
- llm-d Router: Flow Control design (EPP queuing, priority bands, saturation detectors)
- kubernetes-sigs/gateway-api-inference-extension (InferencePool API, v1.6.2)
- IBM Research: How llm-d makes the most of the hardware you already have (544 H100s)
- llm-d/llm-d-router: Endpoint Picker (EPP) repository
- llm-d multi-model routing guide (IPP + per-model InferencePools)
- Google Cloud: Enhancing vLLM for distributed inference with llm-d (GKE Inference Gateway)
- AWS: Disaggregated inference on SageMaker HyperPod powered by llm-d
- Envoy external processing (ext_proc) filter documentation
On October 20, 2026, llm-d shipped v1.0 — a stable, versioned configuration API for distributed LLM inference on Kubernetes, with multi-tenant quality of service, zero-downtime upgrades, and agent-aware serving as the headline features. Two weeks of perspective later, the release holds up as something bigger than a version bump: it is the moment the inference gateway — the layer that routes and flow-controls requests inside your GPU fleet — stopped being a research proposal and became an operating dependency. Google Cloud builds the GKE Inference Gateway on the llm-d Endpoint Picker. AWS runs disaggregated inference on SageMaker HyperPod with it. IBM Research used it to serve a 753B-parameter model to 3,000 concurrent coding agents on 544 H100 GPUs, at 5–10× lower cost per token than commercial APIs.
This article is the architectural deep-dive on that layer: the Inference Gateway pattern — an L7 gateway coupled with a scheduling brain that picks the specific GPU pod for every request and queues traffic in front of the fleet when the fleet is full. We will cover the exact mechanics: the Gateway API Inference Extension contract, the llm-d Endpoint Picker's Filter–Score–Pick scheduling pipeline, the three-tier flow-control dispatch loop, the header protocol on the wire, and complete manifests for a multi-model fleet. We will also cover where this pattern breaks — and it does break, in specific, documented ways that the marketing material underplays.
Three Gateways, Three Problems
The term “AI gateway” is doing quadruple duty in the industry right now, and most of the confusion in this space comes from conflating three different layers with three different jobs:
NORTH-SOUTH: API MANAGEMENT SOUTH: FLEET SCHEDULING
┌────────────┐ ┌───────────────────────────────┐ ┌──────────────────────────────┐
│ Client │──▶│ AI GATEWAY (LiteLLM-class) │──▶│ INFERENCE GATEWAY (this │
│ apps, │ │ - API keys, budgets │ │ article) │
│ agents │ │ - Provider routing/fallback │ │ - Per-request pod selection │
│ │ │ - Guardrails, cost telemetry │ │ - KV-cache affinity routing │
└────────────┘ │ - North-south, multi-cloud │ │ - Queueing + flow control │
└───────────────────────────────┘ │ - South, one cluster │
└──────────────┬───────────────┘
│ per-pod, L7
┌──────────────▼───────────────┐
│ SERVING TOPOLOGY (LWS/P-D) │
│ - Multi-host model sharding │
│ - Prefill/decode disaggregation
│ - GPU placement (DRA, MIG) │
└───────────────────────────────┘
The AI gateway layer — LiteLLM, Portkey, Helicone — manages API keys, budgets, provider fallbacks, and cost attribution for traffic crossing your trust boundary. We covered it in a separate deep-dive. The serving topology layer — LeaderWorkerSet, prefill/decode disaggregation, cell-based fleets — decides how many hosts a model spans. Both assume someone else solves the question in between: given one fleet of GPUs and thousands of concurrent requests, which specific pod gets this request, and what happens to the requests that don't fit?
That is the inference gateway's job, and it is the layer this article is about. It is not an API management proxy. It is a scheduler at the request level, deployed as a standard Kubernetes Gateway API extension, with real-time telemetry from every model server: queue depth, KV-cache utilization, prefix-cache state, and per-session affinity. The GAIE project's own definition is precise: an Inference Gateway is “a proxy/load-balancer which has been coupled with an Endpoint Picker,” providing optimized routing and load balancing for self-hosted AI workloads on Kubernetes.
Why Round-Robin Wastes Your Fleet
A Kubernetes Service load-balances inference requests the same way it load-balances REST calls: roughly round-robin across ready endpoints. For LLM serving this is actively harmful, for reasons rooted in the mechanics of the workload:
- KV-cache locality is worth real money. Every model server keeps a prompt-block cache of tokens it has already processed. A request whose 195K-token context is 96% identical to the previous turn costs almost nothing on the pod that holds the cache — and a full prefill pass on any other pod. IBM Research's study of 219 real Claude Code sessions found the median request carried ~195,000 input tokens and produced only 317 output tokens, and that 96% of main-agent requests reused at least 90% of a previous request's input verbatim. Round-robin scatters those requests across the fleet and pays the prefill bill every time.
- Resource consumption is not uniform. Traditional gateways rate-limit by requests-per-second, assuming each request costs roughly the same. An LLM request's cost is driven by input length and an unpredictable autoregressive decode loop — one request can consume more compute and memory than hundreds of others. Static RPS limits either starve the pool or overload it.
- Dispatch is irreversible at the engine. Once a request lands in a vLLM pod's local queue, the gateway cannot move it. Premature routing locks requests into suboptimal endpoints — the llm-d project calls this scheduling regret.
- Priority inversion is the default. Model servers batch by arrival order and sequence length, not business priority. A latency-sensitive interactive request queues behind a giant offline batch that arrived first.
The measured consequences of fixing this are not subtle. Tesla and Red Hat's KServe case study measured 3× higher output throughput and 2× lower time-to-first-token from prefix-cache-aware routing versus round-robin on Llama 3.1 70B. Google's production numbers on GKE: time-to-first-token cut by more than 35% and prefix-cache hit rate doubled from 35% to 70% after switching from a plain Kubernetes Service to the llm-d Endpoint Picker. The IBM fleet study found precise prefix matching alone delivered 79% higher throughput and 67% lower TTFT than approximate matching on the same hardware.
None of these gains come from the GPUs. They come from not wasting the work the GPUs already did.
The Anatomy of an Inference Gateway
The pattern has four moving parts, standardized across the Gateway API Inference Extension (GAIE) and the llm-d implementation:
- A Gateway API proxy — Envoy, Istio, agentgateway, or a cloud-managed equivalent (GKE Application Load Balancer). It terminates client connections and speaks standard Gateway API (
GatewayClass,Gateway,HTTPRoute). - An Endpoint Picker (EPP) — the scheduling brain. A gRPC service attached to the proxy via Envoy's external processing (ext-proc) protocol. Every request is parked at the proxy while the EPP picks a pod; the EPP returns the pod address and the proxy forwards.
- The
InferencePoolcustom resource — the bridge object. It tells the EPP which pods are candidates (label selector + target ports) and tells the Gateway controller where the EPP listens. InferenceObjectiveresources — traffic classes. Each binds a priority to one or more pools, giving the flow-control layer its ordering semantics.
Note the organizational split, because it matters for what you deploy in 2026: the InferencePool API and the conformance test suite live in kubernetes-sigs (current release v1.6.2, September 2026), while the full-featured EPP — flow control, KV-aware scheduling, the latency predictor — and the InferenceObjective API moved to the llm-d/llm-d-router repository (llm-d Router v1.0.0). GAIE retains only a lightweight reference EPP (LWEPP) for conformance testing. The split was proposed and executed in GAIE issue #2430. In practice: install the InferencePool CRD from GAIE, install the router from the llm-d OCI charts, and the two meet at the CRD.
Client (OpenAI-compatible SDK)
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ GATEWAY (Envoy / Istio / GKE ALB) │
│ - terminates TLS, matches HTTPRoute rules │
│ - backendRef points at an InferencePool, NOT a Service │
│ - ext_proc filter: parks the request, opens gRPC stream to EPP │
└───────────────┬─────────────────────────────────────────────────────┘
│ ext-proc (FULL_DUPLEX_STREAMED gRPC)
▼
┌─────────────────────────────────────────────────────────────────────┐
│ ENDPOINT PICKER (EPP) llm-d.ai/v1 │
│ │
│ 1. Parse request (model, body, headers, objective) │
│ 2. FLOW CONTROL: classify by FlowKey = (fairness-id, priority) │
│ hold in per-flow queues while pool is saturated (TTL-bounded) │
│ 3. SCHEDULER: Filter -> Score -> Pick over candidate pods │
│ - prefix-cache affinity, KV utilization, queue depth, │
│ session affinity, predicted latency (TTFT/TPOT regression) │
│ 4. Return pod address + port to the proxy │
└───────────────┬─────────────────────────────────────────────────────┘
│ chosen pod IP:port
▼
┌─────────────────────────────────────────────────────────────────────┐
│ INFERENCE POOL (label-selected pods, e.g. 5x H100 vLLM replicas) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ vLLM pod │ │ vLLM pod │ │ vLLM pod │ │ vLLM pod │ │ vLLM pod │ │
│ │ :8000 │ │ :8000 │ │ :8000 │ │ :8000 │ │ :8000 │ │
│ │ /metrics │ │ /metrics │ │ /metrics │ │ /metrics │ │ /metrics │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ EPP data layer polls each pod's Prometheus metrics + watches │
│ the Kubernetes API for pool membership changes │
└─────────────────────────────────────────────────────────────────────┘
The Request Path, Wire by Wire
The only ext-proc body mode the EPP supports is FULL_DUPLEX_STREAMED: the proxy streams the request headers and body to the EPP over a long-lived gRPC stream, and the EPP's decision comes back as an ext-proc response carrying the selected endpoint. The EPP's request handler parses the payload into an internal structure — out-of-the-box parsers cover the OpenAI HTTP formats and vLLM's gRPC API — and the rest of the pipeline is protocol-agnostic.
Classification happens through HTTP headers, which upstream services (your API gateway, an identity provider, an Envoy authz filter) inject after stripping any client-supplied copies:
curl -X POST "http://${GATEWAY_IP}/v1/chat/completions" \
-H 'Content-Type: application/json' \
-H 'x-llm-d-inference-objective: premium-traffic' \
-H 'x-llm-d-inference-fairness-id: tenant-a' \
-H 'x-llm-d-slo-ttft-ms: 1200' \
-d '{"model": "Qwen/Qwen3-32B", "messages": [{"role": "user", "content": "Hello"}]}'The full header protocol (canonical names, llm-d Router v0.9.0+): x-llm-d-inference-objective names the InferenceObjective to apply; x-llm-d-inference-fairness-id is the tenant key for fairness (missing values fall into a shared default-flow bucket); x-llm-d-slo-ttft-ms and x-llm-d-slo-tpot-ms declare latency targets used by admission and ordering plugins; x-llm-d-model-name-rewrite overrides the target model server-side. Legacy x-gateway-* names are accepted as deprecated aliases — if both are sent, the canonical x-llm-d-* value wins deterministically.
Responses carry the EPP's error contract in x-llm-d-request-dropped-reason. The prefix convention is worth internalizing for client retry logic: rejected-* values mean no inference work was done (cheap to retry); evicted means GPU cycles were already burned (back off accordingly).
| Outcome | HTTP | x-llm-d-request-dropped-reason | Meaning |
|---|---|---|---|
| Band/global queue capacity exceeded | 429 | rejected-saturated | Queue limits (maxRequests/maxBytes) met — load shedding |
| In-flight eviction | 429 | evicted | Dispatched negative-priority request terminated to reclaim capacity |
| TTL expiry (pool has endpoints) | 429 | rejected-ttl-expired | Request queued past its TTL before dispatch |
| Empty pool / scale-to-zero | 503 | rejected-no-endpoints | No serving endpoints exist in the pool |
| Client disconnect while queued | 503 | rejected-context-cancelled | Caller went away; slot freed |
| Graceful drain (rollout) | 503 | rejected-shutting-down | EPP draining during shutdown — retryable, not a fault |
| Internal error | 500 | rejected-internal | Flow controller or scheduler bug — page on rate |
One caveat the docs are admirably honest about: this entire contract applies only while the EPP is reachable. If the EPP process dies, Envoy never gets a decision, and behavior is governed by the InferencePool's failureMode — covered below, and it is a bigger deal than it looks.
InferencePool: The Contract Between Gateway and Fleet
The InferencePool (group inference.networking.k8s.io, version v1 in GAIE 1.6.x) is deliberately small. It does two jobs: endpoint discovery for the EPP, and EPP wiring for the Gateway controller. One pool maps to one logical model deployment; the model servers scale independently of the EPP that schedules them.
apiVersion: inference.networking.k8s.io/v1
kind: InferencePool
metadata:
name: qwen32b-pool
namespace: llm-d-fleet
spec:
# Pods matching this selector (same namespace) are scheduling candidates.
# Scale the deployment; the EPP's candidate list follows automatically.
selector:
matchLabels:
llm-d.ai/inference-serving: "true"
app.kubernetes.io/name: qwen3-32b
# Which port on each pod serves inference traffic (vLLM default: 8000).
targetPorts:
- number: 8000
# Optional: "http" (default) or "kubernetes.io/h2c" for HTTP/2 cleartext.
appProtocol: http
# The Gateway controller uses this to configure the proxy's ext_proc
# filter so every pool-directed request consults the EPP first.
endpointPickerRef:
group: inference.networking.k8s.io
kind: Service
name: qwen32b-pool-epp
port: 9002
# FailClose (API default): proxy rejects requests when the EPP is down.
# FailOpen (llm-d recipe default): proxy bypasses the EPP and routes
# directly to a pod - availability over pool protection.
failureMode: FailClose
The failureMode choice is the most consequential one-liner in the whole pattern, so make it deliberately. With FailClose (the API default), an EPP outage fails requests at the proxy — your fleet is down with the router. With FailOpen (what the llm-d base recipe actually ships, for availability reasons), the proxy silently bypasses the dead EPP and routes requests directly to pods: traffic keeps flowing, but with no flow control, no fairness, no saturation gating — and requests can land on already-saturated backends during the outage window. A busy multi-tenant fleet that suddenly loses tenant fairness is usually a louder incident than a brief availability dip; a single-tenant internal fleet usually prefers FailOpen. llm-d v1.0 mitigates the middle case with router high availability — the EPP runs with a hot standby that takes over scheduling if the active instance fails — but the standby takes seconds to promote, and the failureMode governs what happens inside that window.
Endpoint Scheduling: Filter, Score, Pick
Once a request is admitted, the EPP's scheduler picks a pod through a plugin pipeline. Every scheduling cycle runs one or more SchedulerProfiles; each profile is a list of sequential filters, a set of weighted scorers, and a picker:
- Filters narrow candidates:
prefix-cache-affinity-filterkeeps only “sticky” endpoints with high prefix-cache scores (with a built-in TTFT load gate that breaks stickiness when sticky endpoints are significantly slower than the rest);slo-headroom-tier-filterdrops endpoints without latency headroom; role filters (prefill-filter,decode-filter,encode-filter) keep only pods whosellm-d.ai/rolelabel matches the phase. - Scorers assign each surviving endpoint a score in [0.0, 1.0]:
prefix-cache-scorer(length of prefix cache match),kv-cache-utilization-scorer(lower utilization wins, avoids fragmentation),queue-scorer(shorter local queue wins),session-affinity-scorer(the pod that served this session before gets max score),token-load-scorer,running-requests-size-scorer,lora-affinity-scorer,latency-scorer(predicted-latency headroom against the SLO), andno-hit-lru-scorerfor cold requests — zero-cache-hit requests go to the least-recently-used endpoint so the expensive prefill work spreads evenly instead of piling onto one pod. - Pickers make the final call:
max-score-picker(default),random-picker, orweighted-random-picker(lottery scheduling using scores as probabilities).
Scores combine by weighted sum. The docs' worked example: scorer A (weight 2.0) returns 0.8 and scorer B (weight 1.0) returns 0.5 → the endpoint's final score is 0.8×2.0 + 0.5×1.0 = 2.1. This is the exact config surface — note the real weights from llm-d's flow-control guide, which put 3× the weight on prefix-cache affinity over queue depth:
apiVersion: llm-d.ai/v1
kind: EndpointPickerConfig
plugins:
- type: queue-scorer
- type: kv-cache-utilization-scorer
- type: prefix-cache-scorer
# Optional: switch the scorer from approximate to precise prefix matching,
# backed by the EPP's event-driven KV-cache index:
# prefixMatchInfoProducerName: precise-prefix-cache-producer
# (without it, prefix-cache-scorer falls back to approx-prefix-cache-producer)
schedulingProfiles:
- name: default
plugins:
- pluginRef: queue-scorer
weight: 2
- pluginRef: kv-cache-utilization-scorer
weight: 2
- pluginRef: prefix-cache-scorer
weight: 3
Prefix routing has two fidelity modes, and the gap between them is a headline number. Approximate matching tracks prefix state from tokenized request bodies with bounded memory; precise matching uses an event-driven KV-cache index (the precise-prefix-cache-producer data producer) that tracks exact block-level cache state across every server. IBM's fleet measurements: switching approximate to precise delivered 79% higher throughput and 67% lower TTFT, with local prefix hit rate rising from 44.46% to 73.18%. Google's Vertex AI production numbers were in the same class — 35% to 70% hit rate after adopting the llm-d EPP. Precise indexing costs EPP memory and a metrics subscription per pod; it is the first knob to turn when context-heavy agentic traffic dominates.
For prefill/decode disaggregated fleets, the disagg-profile-handler runs two profiles — one over prefill-role pods, one over decode-role pods — and returns both decisions to the proxy: the decode endpoint becomes the primary destination, and the prefill endpoint is injected as a header so the decode engine can pull the KV blocks. The v1.0 release also consolidated a coordination layer into the router so a request flows through its encode, prefill, and decode stages as one managed flow rather than being stitched together by the caller.
Flow Control: The Queue in Front of the GPUs
Scheduling picks where; flow control decides whether and in what order requests may reach the fleet at all. In llm-d v1.0 this is the flowControl feature gate on the EPP, and the design idea is simple: move the queue out of the model servers and into the gateway, where it can see priorities, tenants, and pool saturation at once.
Every admitted request is classified into a FlowKey — a tuple of (FairnessID, Priority). The fairness ID comes from x-llm-d-inference-fairness-id (your tenant or workload identifier); the priority comes from the referenced InferenceObjective. Each unique FlowKey gets its own in-memory queue. Dispatch then walks three tiers in strict order:
┌──────────────────────────────────────────┐
Request ───────────▶ │ ADMISSION: assign FlowKey │
(headers) │ FlowKey = (fairness-id, priority) │
└───────────────┬──────────────────────────┘
│
TIER 1: PRIORITY BANDS (hardcoded, serviced in strict order)
┌────────────────────────────────────┼─────────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌────────────────────────┐ ┌─────────────────┐
│ Band 100 │ │ Band 0 │ │ Band -10 │
│ premium-traffic │ │ standard-traffic │ │ best-effort │
│ maxReq: 500 │ │ maxReq: 200 │ │ maxReq: 50 │
│ queue: tenant-a │ │ queues: tenant-a, -b │ │ queue: batch-1 │
└────────┬────────┘ └───────────┬────────────┘ └────────┬────────┘
│ TIER 2: FAIRNESS within band (round-robin policy) │
└──────────────┬──────────────┴────────────────────────────┘
│ TIER 3: ORDERING within flow (FCFS / EDF /
│ SLO-deadline)
▼
┌───────────────────────────┐
│ SATURATION GATE │
│ utilization-detector: │
│ queue depth + KV cache │
│ concurrency-detector: │
│ in-flight request count │
│ OPEN -> dispatch to │
│ scheduler │
│ CLOSED -> hold (HOL block)│
└───────────┬───────────────┘
▼
Filter -> Score -> Pick -> pod
Three details make this production-grade rather than a textbook exercise:
- It is work-conserving. The gate only closes at saturation; if GPUs have spare capacity, nothing is throttled. The defaults deliberately mimic plain first-come-first-served (global-strict fairness + FCFS ordering) so enabling the gate changes nothing until you opt into multi-tenant policy.
- It does no-regret scheduling. Under saturation, requests are held in the EPP instead of being committed to a backend's local queue where they become immovable. The explicit trade: you are choosing to protect TPOT (per-token decode pace) by letting TTFT accrue in the queue — controlling where and for whom wait time lands rather than letting context-thrash the GPUs.
- Queues are bounded in two currencies. Global and per-band limits on both request count (
maxRequests) and payload bytes (maxBytes). A lower-priority band cannot fill the memory reserved for a higher band — that is the memory-isolation property. Overflow returns HTTP 429 immediately.
The saturation gate itself is pluggable, and the choice between the two detectors is a real architecture decision:
utilization-detector(EPP default): closed-loop, reacting to real telemetry — per-pod queue depth and KV-cache utilization scraped from the model servers' Prometheus endpoints. Accurate about actual hardware memory pressure, but subject to telemetry lag under sudden bursts, and in heterogeneous pools it treats all endpoints equally (an unweighted average), so one small saturated pod can trigger fleet-wide backpressure.concurrency-detector: open-loop, counting in-flight requests admitted but not yet completed. Instantaneous, but blind to KV-cache pressure — with heavy prefix caching, many concurrent requests can be cheap cache hits, so high concurrency does not mean the pool is full. The llm-d flow-control guide setsmaxConcurrency: 132for its 4-GPU reference deployment and recommends production fleets prefer this detector to dodge the telemetry-lag failure mode.
Both tuning philosophies converge on the same goal — keep a small “healthy buffer” of requests queued locally on the model servers (just enough that continuous-batching engines never starve) while the bulk of waiting happens centrally, where policy can act on it.
Finally, the aggressive tool: in-flight eviction. Gated dispatch can only refuse new work; it cannot recover capacity already committed. With enableEviction: true, when a higher-priority band is blocked at its ceiling, the EPP terminates an already-dispatched negative-priority request (victim order: lowest priority first, most-recently-dispatched first, so the request with the most accrued work survives longest) and returns 429 with x-llm-d-request-dropped-reason: evicted. Capacity returns only when the model server aborts generation and frees its KV blocks — the EPP assumes a bounded engine reclaim time and does not observe it directly, so verify on your engine that aborted requests actually free capacity promptly before enabling this in production. The evicted caller owns retry and needs idempotency. Reserve eviction for batch/background work whose owner can safely re-run it.
The full flow-control configuration surface, with real values from the llm-d guide (adapted to a 5×H100 fleet):
apiVersion: llm-d.ai/v1
kind: EndpointPickerConfig
featureGates:
- flowControl
plugins:
- type: queue-scorer
- type: kv-cache-utilization-scorer
- type: prefix-cache-scorer
- type: round-robin-fairness-policy # tenant equity within a band
- type: fcfs-ordering-policy # swap for edf-/slo-deadline- if you
# carry per-request deadlines
- type: concurrency-detector
parameters:
maxConcurrency: 132 # tune just above effective batch size
concurrencyMode: requests
headroom: 0.0
schedulingProfiles:
- name: default
plugins:
- pluginRef: queue-scorer
weight: 2
- pluginRef: kv-cache-utilization-scorer
weight: 2
- pluginRef: prefix-cache-scorer
weight: 3
flowControl:
saturationDetector:
pluginRef: concurrency-detector
maxBytes: "10Gi" # global queue memory ceiling -> 429 on breach
maxRequests: "1k" # global request-count ceiling
defaultRequestTTL: "60s" # queued requests expire, never serve stale work
enableEviction: true # reclaim capacity from negative-priority work
priorityBands:
- priority: 100 # premium: interactive, latency-sensitive
maxRequests: "500"
fairnessPolicyRef: round-robin-fairness-policy
orderingPolicyRef: fcfs-ordering-policy
- priority: 0 # standard: most product traffic
maxRequests: "200"
fairnessPolicyRef: round-robin-fairness-policy
orderingPolicyRef: fcfs-ordering-policy
- priority: -10 # negative = SHEDDABLE: batch, background eval
maxRequests: "50"
fairnessPolicyRef: round-robin-fairness-policy
orderingPolicyRef: fcfs-ordering-policy
The bands bind to pools through InferenceObjective resources. Since Router v1.0.0 the stable API is llm-d.ai/v1, where single-pool spec.poolRef was replaced by spec.poolRefs (1–16 pools) and/or spec.poolSelector; the older v1alpha2 is deprecated with removal announced for v1.2:
apiVersion: llm-d.ai/v1
kind: InferenceObjective
metadata:
name: premium-traffic
spec:
priority: 100
poolRefs:
- name: qwen32b-pool
---
apiVersion: llm-d.ai/v1
kind: InferenceObjective
metadata:
name: standard-traffic
spec:
priority: 0
poolRefs:
- name: qwen32b-pool
---
apiVersion: llm-d.ai/v1
kind: InferenceObjective
metadata:
name: best-effort-traffic
spec:
priority: -10
poolRefs:
- name: qwen32b-pool
Operationally, flow control turns the queue into an honest demand signal. The EPP exports llm_d_epp_flow_control_queue_size, llm_d_epp_flow_control_queue_bytes, llm_d_epp_flow_control_pool_saturation, per-band llm_d_epp_flow_control_capacity_utilization_requests/_bytes, queue-duration histograms labeled by fairness_id, priority, and outcome, plus eviction counters (revocations_issued_total, revocations_total) and Jain's fairness index across programs. Two of these deserve alert rules from day one: revocations_total{outcome="timed_out"} sustained non-zero means the EPP is sizing reclamation against capacity that never came back, and a rising queue_duration_seconds p99 with flat pool saturation means your bands are admitting work the scheduler cannot place.
Multi-Model Fleets: One Gateway, Many Pools
Real fleets do not run one model. A chatbot wants a Qwen-class model, a reasoning path wants DeepSeek, and half a dozen LoRA adapters hang off each base model — all behind one OpenAI-compatible endpoint where the only routing signal is a "model" field in the JSON body. Path-based routing cannot see it.
The llm-d answer is the Inference Payload Processor (IPP), itself an ext-proc service: it extracts the model name from the request body, resolves it to a base model (following LoRA-adapter mappings), and stamps the X-Gateway-Base-Model-Name header. Standard Gateway API header matching then routes to the correct InferencePool, and each pool has its own EPP scoring its own pods:
Client: POST /v1/chat/completions {"model": "food-review-1", ...}
│
▼
┌────────────────────────────────────────────────────────────────┐
│ GATEWAY │
│ ext-proc #1: IPP (payload processor) │
│ body model "food-review-1" -> base "Qwen/Qwen3-32B" │
│ sets header X-Gateway-Base-Model-Name: Qwen/Qwen3-32B │
│ │
│ HTTPRoute rules (header match): │
│ Qwen/Qwen3-32B -> backendRef InferencePool qwen32b-pool │
│ deepseek/DeepSeek-r1 -> backendRef InferencePool r1-pool │
└───────────────┬───────────────────────────────┬────────────────┘
│ header match │ header match
▼ ▼
┌───────────────────────────┐ ┌───────────────────────────┐
│ qwen32b-pool + EPP #1 │ │ r1-pool + EPP #2 │
│ - Flow control (bands) │ │ - own bands/queues │
│ - prefix/session routing │ │ - own prefix index │
│ - LoRA adapter affinity │ │ - P/D disagg profiles │
│ vLLM pods xN (H100 x5) │ │ vLLM pods xN │
└───────────────────────────┘ └───────────────────────────┘
The model registry is just labeled ConfigMaps — IPP watches them and resolves both base models and adapters:
apiVersion: v1
kind: ConfigMap
metadata:
name: qwen-model-mapping
namespace: llm-d-fleet
labels:
inference.llm-d.ai/ipp-managed: "true"
data:
baseModel: "Qwen/Qwen3-32B"
# Adapter names must be globally unique across ALL ConfigMaps/pools.
adapters: |
- food-review-1
- travel-assistant
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: model-fleet-route
namespace: llm-d-fleet
spec:
parentRefs:
- name: llm-d-inference-gateway
rules:
- matches:
- headers:
- type: Exact
name: X-Gateway-Base-Model-Name
value: Qwen/Qwen3-32B
backendRefs:
- group: inference.networking.k8s.io
kind: InferencePool
name: qwen32b-pool
- matches:
- headers:
- type: Exact
name: X-Gateway-Base-Model-Name
value: deepseek/DeepSeek-r1
backendRefs:
- group: inference.networking.k8s.io
kind: InferencePool
name: r1-pool
One Gateway can mix InferencePool backends with ordinary Service backends on different routes, and a single route can carry multiple pool backends with weights — which is how canary rollouts of a new model revision work without a second fleet. Two constraints to respect: every model and adapter name must be globally unique across all pools (IPP resolves by name alone), and do not create pool-level catch-all routes while running header-based routes on the same gateway — the catch-all swallows the header matches.
The Reference Deployment: 5×H100, Two Models, Full Flow Control
Putting the whole pattern together as one deployable unit — a two-model fleet on five H100 nodes with premium/standard/sheddable traffic classes and demand-driven autoscaling. These commands mirror llm-d's own validated guides (GAIE pinned to v1.6.2, Router chart v1.0.0):
# 1. InferencePool CRD set (inference.networking.k8s.io/v1)
kubectl apply -f \
https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/download/v1.6.2/v1-manifests.yaml
# 2. Namespace + model pull secret
kubectl create namespace llm-d-fleet
kubectl create secret generic llm-d-hf-token --from-literal=HF_TOKEN=$HF_TOKEN \
-n llm-d-fleet
# 3. The router: EPP + Envoy sidecar + InferencePool, from OCI charts.
# Layer your values (flow control config from the section above) on top
# of the shared base values; provider.name selects the Gateway
# implementation wiring (istio / gke / envoy).
helm install fleet-router \
oci://ghcr.io/llm-d/charts/llm-d-router-gateway \
--version v1.0.0 \
-f base.values.yaml \
-f fleet-flow-control.values.yaml \
--set provider.name=istio \
--set httpRoute.create=false \
--set httpRoute.inferenceGatewayName=llm-d-inference-gateway \
-n llm-d-fleet
# 4. Model servers: your vLLM Deployments (or LeaderWorkerSets for
# multi-host tensor parallelism) with the pool selector labels.
kubectl apply -n llm-d-fleet -f model-servers.yaml
# 5. Objectives + routes + autoscaler
kubectl apply -n llm-d-fleet -f objectives.yaml routes.yaml scaledobject.yamlFor the router sizing, take the project's own base values seriously: the EPP request spec is 4 CPU / 8Gi memory (16Gi limit), and the Envoy sidecar gets the same — because flow-control queues hold request payloads in memory, the EPP is sized like a cache, not like a controller. The base recipe also pins Envoy to --concurrency 8 and --log-level warn: the chart's default trace-level logging burns a meaningful share of sidecar CPU at high QPS, and an unpinned Envoy spawns worker threads equal to hardware concurrency, oversubscribing the cgroup.
Autoscaling closes the loop. Because flow control surfaces unmet demand as a metric, KEDA can scale the model server Deployment on what is actually waiting rather than on GPU utilization — which, during decode-bound generation, cannot distinguish a lightly loaded server from a saturated one. The recommended shape is two triggers, unmet demand plus active concurrency:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: qwen32b-scaler
namespace: llm-d-fleet
spec:
scaleTargetRef:
name: qwen3-32b-vllm # the model server Deployment
minReplicaCount: 2 # warm floor; dampens flap
maxReplicaCount: 12
cooldownPeriod: 300
triggers:
# Unmet demand: requests held in flow-control queues (requires the
# flowControl gate; series absent otherwise). AverageValue = per-replica
# queued-request target; the HPA scales so the aggregate stays under it.
- type: prometheus
metadata:
serverAddress: http://prometheus.monitoring:9090
query: sum(llm_d_epp_flow_control_queue_size)
threshold: "1"
# Active concurrency: in-flight requests across the pool.
- type: prometheus
metadata:
serverAddress: http://prometheus.monitoring:9090
query: sum(llm_d_epp_request_running)
threshold: "16"
Both thresholds are per-replica AverageValue targets — the aggregate must exceed threshold × current replicas to justify more capacity — and both are deployment-specific: a larger model or a smaller GPU saturates at lower concurrency, so re-derive them under representative load rather than copying. The min/max bounds and cooldown damp the loop because model server cold starts (weight load) take minutes, not seconds. Below saturation, flow control queues stay empty and the concurrency trigger carries the scaling signal alone; only past the band ceiling does unmet demand surface as queue depth.
What 544 H100s Prove About the Pattern
The strongest public validation of this architecture is IBM Research and Red Hat's September 2026 deployment of GLM-5.2 — a 753B-parameter mixture-of-experts model (~39B active) — across 544 NVIDIA H100 GPUs in a prefill/decode disaggregated topology, serving real agentic coding traffic:
- 3,000 concurrent coding agents with zero preemptions; at the 2,500-agent comfort point: 7,612 requests/minute, 134.89M input tokens/minute, 6.05M output tokens/minute.
- 85.2% of input tokens served from cache — prefix-aware routing plus tiered KV offload and P2P cache sharing cut uncached prefill from 27.1% to 14.8% of traffic; NIXL moved 6.2M KV-cache transfers (avg 2.71 GiB, ~580 Gb/s at fleet p90) with zero failures.
- Queue time stayed honest under load: on the CyberGym benchmark (400 agents, 376K-character contexts, 10 turns), p90 TTFT was 17.59s but p90 flow-control queue time was 1.11s — the wait accumulated where policy could see it, not inside opaque engine queues.
- Cost: 5–10× cheaper per token than commercial API pricing at prevailing H100 rates; the v1.0 announcement cites ~$0.11 per million tokens for the coding-agent workload, roughly 11.5× below a proprietary stack.
The through-line: none of these numbers came from new hardware. They came from the inference gateway refusing to recompute work the fleet had already done — and from flow control that let the same fleet absorb agent bursts without evicting the interactive traffic sharing it.
Production Realities
Now the part the launch posts soft-pedal. Every one of these is documented in the project's own design docs — which is to the project's credit — but each will bite an operator who deploys the pattern as a black box:
- Queues are in-memory and die with the EPP. A graceful shutdown drains queued requests with retryable 503s; an abrupt crash drops them with hard connection resets. Router v1.0's hot-standby EPP narrows the window but does not eliminate it. If “the gateway ate my batch” is unacceptable, make clients idempotent and treat 503-with-
rejected-shutting-downas a first-class signal in retry logic. - FailOpen quietly deletes your guarantees. The llm-d recipe ships FailOpen for availability. During an EPP outage, traffic flows — with no fairness, no saturation gating, no priority. A premium tenant discovering that their protection evaporated during a router incident is a trust problem, not just an SLO problem. Decide per fleet, and monitor EPP health as rigorously as the model servers themselves.
- The trust boundary lives in headers.
x-llm-d-inference-fairness-idandx-llm-d-inference-objectiveare self-asserted. The docs warn plainly: strip them from external traffic and re-inject from an authenticated upstream (API gateway, identity provider, authz filter). Ship the default and any tenant can claim the premium band by setting a header. - Telemetry lag is a thundering-herd failure mode. The default utilization-detector reacts to polled metrics; a burst can outrun the poll interval and over-commit the fleet before the gate closes. The mitigation is the concurrency-detector (instant in-flight accounting, blind to KV pressure) — you trade one blind spot for another, and heterogeneous pools suffer both detectors differently (unweighted averaging punishes small endpoints; aggregate capacity models bias toward large ones).
- Flow control shifts TTFT, it does not remove it. The layer chooses where wait time accrues and for whom. Under sustained over-subscription, queues drain slowly and TTL expiry (429
rejected-ttl-expired) is the honest outcome. If you are chronically over capacity, no scheduler setting fixes that — the queue metrics are telling you to buy GPUs or shed demand. - Eviction assumes well-behaved engines. In-flight eviction credits capacity back only after the model server aborts generation and frees KV blocks; the EPP does not observe that reclaim. Verify your engine's abort path actually releases memory promptly, or revocations size themselves against capacity that never returns (watch
revocations_total{outcome="timed_out"}). - Version churn is a live operational tax.
llm-d.ai/v1alpha2deprecation (removal in v1.2), thepoolReftopoolRefsmigration, deprecatedx-gateway-*header aliases, and the GAIE/llm-d repository split (issue #2430) all landed within months. Pin chart versions, pin GAIE to the version your router guide pins (v1.6.2 at release), and read router release notes before upgrades — mixed-version fleets must coordinate the header migration during rolling upgrades. - The EPP costs real memory and CPU. 4 CPU / 8Gi requests for the EPP and the same for the Envoy sidecar — per pool. A ten-pool fleet carries ten of those pairs. Budget it like infrastructure, not like a webhook.
Who Should Skip This Pattern
- Single-model, single-tenant fleets under 10 replicas. If one model, one team, and consistent load describe you, a vLLM Deployment behind a Service with a readiness probe delivers the same tokens. The EPP's machinery pays off on locality and multi-tenancy pressure; with neither, you are running a scheduler to make round-robin look smart.
- Fleets still routing to external APIs. If 95% of your traffic terminates at OpenAI/Anthropic/Bedrock, you need the AI gateway layer, not this one. The inference gateway schedules pods you own; it has nothing to say about provider rate limits.
- Batch-only workloads. Nightly evals and bulk document processing want a batch queue (Kueue-class) and gang scheduling, not request-level flow control. Run the batch gateway path if you adopt llm-d at all.
- Teams without Prometheus observability. The saturation detectors, KEDA triggers, and every alert worth having ride on metrics infrastructure. If you cannot scrape per-pod vLLM metrics today, fix that first — the gateway without telemetry is a queue you cannot see into.
- Latency-purists with hard tail SLOs and no headroom. The pattern adds an ext-proc round trip and a queuing stage to the hot path. On a comfortable fleet this is noise; on a fleet already at the edge, the added p99 tail is real. Measure the baseline first.
The Decision Table
| Signal in your fleet | Pattern to reach for |
|---|---|
| Multi-turn/agent traffic, high context reuse, TTFT is the pain | Inference gateway with prefix-cache scoring (precise producer) + session affinity |
| Multiple tenants or traffic classes sharing one fleet | Inference gateway with flow-control priority bands + fairness policy |
| Several models / LoRA adapters behind one OpenAI endpoint | Inference gateway with IPP + per-model InferencePools and header-matched routes |
| Model bigger than one host (multi-host TP/EP) | Serving topology layer (LeaderWorkerSet / disaggregation) under an inference gateway |
| Mostly external providers, key/budget/cost control pain | AI gateway layer — not this pattern |
| Batch/offline only, no interactive traffic | Batch queue + gang scheduler — skip the gateway |
References & Further Reading
- llm-d v1.0: state-of-the-art inference, ready for Day 2 — release announcement; production adopter numbers (Google, AWS, IBM Research, Tesla/Red Hat).
- llm-d architecture documentation — Router, Model Servers, Disaggregation, KV Cache Management, Orchestration, Autoscaling.
- llm-d Router: Flow Control — FlowKey topology, 3-tier dispatch, saturation detectors, eviction, error contract, metrics.
- kubernetes-sigs/gateway-api-inference-extension — InferencePool API (v1.6.2), LWEPP, conformance suite, repo split in issue #2430.
- llm-d/llm-d-router — the Endpoint Picker, scheduling plugin framework, and router charts.
- IBM Research: How llm-d makes the most of the hardware you already have — GLM-5.2 on 544 H100s; agentic workload characteristics and fleet benchmark numbers.
- llm-d flow-control guide — validated multi-tenant walkthrough with the values used in this article.
- llm-d multi-model routing guide — IPP deployment, model-mapping ConfigMaps, header-matched HTTPRoutes.
- Google Cloud: enhancing vLLM for distributed inference with llm-d — GKE Inference Gateway production numbers.
- AWS: disaggregated inference on SageMaker HyperPod powered by llm-d — up to 70% tokens/sec improvement over standard vLLM.
- Envoy ext_proc filter reference — the external processing protocol the whole pattern rides on.