MastertheMesh
Solo · agentgateway · Inference Extension · InferencePool · Endpoint Picker · vLLM · kind
Live · Runs on kind

Inference routing on agentgateway — KV-cache-aware routing to a self-hosted model pool

TO
Tom O'Rourke
EMEA Field CTO · Solo.io

Route LLM traffic to a model pool you host yourself, and let agentgateway pick which replica serves each request from the model servers' live load. The route's backend is an InferencePool, not a Service, so the Gateway API Inference Extension Endpoint Picker chooses the replica by KV-cache usage and queue depth. Route to the replica that already holds the prompt's prefix in its cache and it skips prefill; route away from a saturated one. It runs on one kind cluster with no GPU, driven by a Jupyter notebook.

agentgateway v2.3.4 InferencePool v1 Endpoint Picker InferenceObjective vLLM (llm-d sim) kind

The story: a customer runs their own model on GPUs in their cluster, several replicas of it, and wants requests to land on the right replica rather than being spread round-robin. LLM replicas are not interchangeable. Each holds a different slice of prompt prefixes in its KV cache, and each has its own queue. Send a request to a replica that already has the prefix cached and it skips the prefill step and answers fast. Send it to a cold or saturated replica and you pay to recompute prefill, or you sit behind a queue.

agentgateway solves this with the Gateway API Inference Extension (GIE). Instead of pointing an HTTPRoute at a Service, you point it at an InferencePool. agentgateway then hands the endpoint choice to the GIE Endpoint Picker, which scrapes each model server's vLLM metrics and scores replicas on KV-cache usage and queue depth. That is the caching story on the slide: KV-cache-aware routing is the gateway keeping requests on the replica that can serve them without recomputing.

The model servers here are the llm-d inference simulator. They speak the OpenAI API and expose the same Prometheus gauges a real vLLM does (vllm:gpu_cache_usage_perc, vllm:num_requests_waiting), with the values pinned from config. So the routing decision is deterministic and you can flip it while you watch, on a laptop, with no GPU. Everything runs on OSS agentgateway and on Solo Enterprise.

What you'll build

How the routing is wired

client ──/v1/chat/completions──▶ Gateway (agentgateway)
                                   │  HTTPRoute backendRef ─▶ InferencePool
                                   ▼                          (group inference.networking.k8s.io)
                             Endpoint Picker (EPP) ── scrapes ──▶ vllm:gpu_cache_usage_perc
                                   │                              vllm:num_requests_waiting
                       picks the replica with the best score
                         ┌─────────┴─────────┐
                    pool-a (cold)        pool-b (hot)
The backend is not a Service. It is the InferencePool. That one change is the whole point: agentgateway delegates endpoint selection to the Endpoint Picker, which follows the model servers' real load instead of round-robin.

Step 1: cluster, Gateway API, and Inference Extension CRDs

Create the kind cluster and install the upstream Gateway API CRDs plus the GIE bundle, which brings the InferencePool (served as v1) and InferenceObjective kinds.

bashscripts/01-cluster.sh
kind create cluster --config kind/inference.yaml

kubectl apply --server-side -f \
  https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.1/standard-install.yaml

kubectl apply -f \
  https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/download/v1.4.0/manifests.yaml

Step 2: install agentgateway with the Inference Extension on

inferenceExtension.enabled=true is what makes the controller watch InferencePool and delegate endpoint selection to the Endpoint Picker. The lab defaults to Solo Enterprise (needs a license); the OSS path is one env var away.

bashEnterprise (default)
helm upgrade --install agentgateway-crds \
  oci://us-docker.pkg.dev/solo-public/enterprise-agentgateway/charts/enterprise-agentgateway-crds \
  -n agentgateway-system --create-namespace --version v2.3.4

helm upgrade --install agentgateway \
  oci://us-docker.pkg.dev/solo-public/enterprise-agentgateway/charts/enterprise-agentgateway \
  -n agentgateway-system --version v2.3.4 \
  --set licensing.licenseKey="$AGENTGATEWAY_LICENSE_KEY" \
  --set inferenceExtension.enabled=true
bashOSS (AGW_EDITION=oss)
helm upgrade --install agentgateway-crds \
  oci://cr.agentgateway.dev/charts/agentgateway-crds \
  -n agentgateway-system --create-namespace --version v1.3.1

helm upgrade --install agentgateway \
  oci://cr.agentgateway.dev/charts/agentgateway \
  -n agentgateway-system --version v1.3.1 \
  --set inferenceExtension.enabled=true

Step 3: the self-hosted model pool

Two model-server replicas, both labelled app: vllm-sim so the InferencePool selects them. Each mounts a config that pins its cache and queue gauges. Out of the box pool-a is cold (10% cache used, empty queue) and pool-b is hot (90% used, eight waiting). Those pinned values are what let you drive the routing decision on cue.

yamlyaml/model-servers/deployment.yaml (config for pool-a)
apiVersion: v1
kind: ConfigMap
metadata:
  name: sim-pool-a
  namespace: inference
data:
  config.yaml: |
    model: base-model
    port: 8000
    fake-metrics:
      kv-cache-usage: 0.10      # pool-a: cold, lots of free cache
      waiting-requests: 0
      running-requests: 1
# ... a matching Deployment runs ghcr.io/llm-d/llm-d-inference-sim:v0.5.0
#     with --config /etc/sim/config.yaml. pool-b is identical but kv-cache 0.90.

Step 4: InferencePool, Endpoint Picker, and the route

The GIE inferencepool Helm chart creates the InferencePool (selecting app=vllm-sim) and the Endpoint Picker that scores replicas. Then apply an InferenceObjective for priority, and a Gateway and HTTPRoute whose backend is the pool.

bashscripts/04-inference-pool.sh
helm upgrade --install vllm-sim \
  oci://registry.k8s.io/gateway-api-inference-extension/charts/inferencepool \
  --version v1.4.0 -n inference \
  --set inferencePool.modelServers.matchLabels.app=vllm-sim \
  --set provider.name=none
yamlyaml/gateway/httproute.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: llm-route
  namespace: inference
spec:
  parentRefs:
    - group: gateway.networking.k8s.io
      kind: Gateway
      name: inference-gateway
  rules:
    - backendRefs:
        - group: inference.networking.k8s.io   # the InferencePool, not a Service
          kind: InferencePool
          name: vllm-sim
      matches:
        - path: { type: PathPrefix, value: / }
      timeouts: { request: 300s }

Run it with the notebook

The whole demo is driven by demo.ipynb (a Bash-kernel notebook). Register the kernel once with ./demo-scripts/notebook-kernel.sh, stand the lab up, then step through the cells. Or drive it from the shell:

bashone-shot
export SECRETS_FILE=/path/to/secrets-envs.sh   # exports AGENTGATEWAY_LICENSE_KEY
./scripts/quick.sh up
./scripts/quick.sh test        # fire requests, see which replica the picker chose

# OSS path, no license:
AGW_EDITION=oss ./scripts/quick.sh up

KV-cache-aware routing: flip it on cue

With pool-a cold and pool-b hot, every request lands on pool-a. Now saturate pool-a and free pool-b. Nothing about the route changes. The picker re-scores on the new metrics and traffic moves to pool-b, the replica that can actually serve fast.

bashthe flip
./demo-scripts/route-test.sh 8         # 8/8 -> pool-a (cold)

./demo-scripts/set-kv.sh a 0.95 9 6    # pool-a now HOT
./demo-scripts/set-kv.sh b 0.05 0 1    # pool-b now COLD
sleep 6                                # let the picker re-scrape
./demo-scripts/route-test.sh 8         # 8/8 -> pool-b (traffic moved)
Routing to the wrong replica means re-running prefill from scratch and paying the time to first token. Routing to the warm one reuses the KV cache. That is what the gateway is protecting when it routes to an InferencePool instead of round-robin.

Serving priority with InferenceObjective

InferenceObjective attaches a priority to requests against the pool. Under contention the scheduler drains higher-priority work first, so interactive traffic keeps flowing while batch work is held back. Two tiers here: interactive (priority 10) and batch (priority 0). Priority bites when the pool is saturated, not when it is idle.

What the picker sees

The picker scores replicas from the vLLM metrics each server exposes. Read them straight off the pods. The cache-usage gauge and the waiting-queue gauge are the two that drive every decision above.

bashmetrics
kubectl -n inference exec deploy/vllm-pool-a -- \
  sh -c 'wget -qO- localhost:8000/metrics' \
  | grep -E "gpu_cache_usage_perc|num_requests_waiting"
# vllm:gpu_cache_usage_perc{model_name="base-model"} 0.10
# vllm:num_requests_waiting{model_name="base-model"} 0

OSS and Enterprise

Every manifest in yaml/ is edition-neutral: it uses the shared agentgateway.dev and GIE APIs. The only per-edition difference is the GatewayClass name (injected at apply time) and the Helm chart, so the same yaml/ runs on both. That is why there is no separate yaml-oss/.

GatewayClasschartlicense
Enterprise (default)enterprise-agentgatewaySolo enterprise-agentgatewayyes
OSS (AGW_EDITION=oss)agentgatewaycr.agentgateway.dev/charts/agentgatewayno

See also

Versions

Built and verified on both editions:

OSS
agentgateway (OSS)v1.3.1
Gateway API Inference Extensionv1.4.0
Gateway APIv1.5.1
llm-d inference simulatorv0.5.0
Enterprise
Solo Enterprise for agentgatewayv2.3.4
Gateway API Inference Extensionv1.4.0
Gateway APIv1.5.1
llm-d inference simulatorv0.5.0