Home / AI & GenAI

AI & GenAIHow-toAdvanced

Load balancing self-hosted LLM replicas for prefix cache hits: session affinity, bounded consistent hashing and least-load trade-offs

Round-robin across vLLM replicas throws away the prefix cache on every turn. Route by session or prefix with bounded-load consistent hashing, measure the per-pod hit rate, and know when least-load should win.

Deepak Bhardwaj6 min read

Applies tovLLM V1 engine with prefix caching, HAProxy 2.x, NGINX, Kubernetes headless Services; ideas apply to SGLang and TensorRT-LLM

The problem

You scale a chat or agent workload from one vLLM replica to eight and time to first token (TTFT) gets worse per request, even though each GPU is less busy. Prefill compute goes up and the prefix cache hit rate drops from 70% to about 12%.

The cause is the load balancer. A Kubernetes Service spreads connections roughly at random, so turn 5 of a conversation lands on a replica that has never seen turns 1-4. That replica has to recompute the whole prompt: system prompt, tool definitions, retrieved documents and history. With long agent prompts, prefill is most of your TTFT.

Prefix caching only helps when requests that share a prefix land on the same replica.

Step 1: measure the per-pod hit rate

vLLM's V1 engine exposes prefix cache counters. Recent versions name them vllm:prefix_cache_queries and vllm:prefix_cache_hits (counted in tokens). Older V1 builds used vllm:gpu_prefix_cache_queries / vllm:gpu_prefix_cache_hits, counted in blocks. Older still, V0 had a vllm:gpu_prefix_cache_hit_rate gauge. Check yours:

kubectl exec -n llm deploy/vllm -- curl -s localhost:8000/metrics | grep -E '^vllm:.*prefix_cache'

PromQL, adjusting the names and _total suffix to match what you saw:

sum by (pod) (rate(vllm:prefix_cache_hits_total[5m]))
/
sum by (pod) (rate(vllm:prefix_cache_queries_total[5m]))

Record three things before you change anything: per-pod hit rate, p50/p95 TTFT, and per-pod request rate. You'll need all three to see whether affinity helped or just moved the load.

Step 2: pick a routing key

Key Good for Risk
Session or conversation ID header Multi-turn chat, agents Long sessions pin load to one pod
Tenant ID Per-tenant system prompts and RAG corpora One big tenant becomes a hot spot
Hash of the first N KB of the prompt Shared system prompts across users Gateway must read the body; costs CPU
None (least outstanding requests) Short, unrelated prompts No cache reuse

For most chat and agent apps, a session ID set by the application is the best starting point. Have the client send it as a header, e.g. X-Session-Id, so the gateway never has to parse the JSON body.

Step 3: route to pods, not to a Service VIP

The gateway has to see individual pod IPs. A ClusterIP Service hides them, so use a headless Service:

apiVersion: v1
kind: Service
metadata:
  name: vllm-headless
  namespace: llm
spec:
  clusterIP: None
  selector:
    app: vllm
  ports:
    - name: http
      port: 8000
      targetPort: 8000
kubectl get endpointslices -n llm -l kubernetes.io/service-name=vllm-headless

Step 4: consistent hashing with bounded load (HAProxy)

Plain hashing pins a long agent session, or a large tenant, to one replica no matter how busy it gets. HAProxy's hash-balance-factor caps each server at a percentage of the average load. When a server is over the cap, its keys spill to the next server on the ring. That keeps most cache hits and limits hot spots.

resolvers k8s
    parse-resolv-conf
    hold valid 5s

backend vllm
    balance hdr(X-Session-Id)
    hash-type consistent
    hash-balance-factor 150
    option httpchk GET /health
    timeout server 600s
    server-template vllm 16 vllm-headless.llm.svc.cluster.local:8000 check resolvers k8s init-addr none
  • hash-balance-factor 150 lets a server reach 1.5 times the average number of in-flight requests before keys move off it. Lower it for tighter balance and fewer cache hits.
  • Requests without the header fall back to round robin. Make sure the clients that matter actually send it.
  • timeout server must cover the longest streamed generation.

NGINX open source has consistent hashing but no bounded load:

upstream vllm {
    hash $http_x_session_id consistent;
    server 10.0.1.11:8000 max_fails=2 fail_timeout=10s;
    server 10.0.1.12:8000 max_fails=2 fail_timeout=10s;
}

It works for even, short sessions. It won't protect you from one heavy session or tenant.

Warning

Scaling events reshuffle part of the hash ring. With consistent hashing only about 1/N of keys move when a replica is added or removed, but those sessions pay a full prefill on their next turn. Expect a TTFT bump after each scale-out, and don't let the autoscaler flap.

Step 5: compare against least-load

Affinity is not always the right answer. Prefill saved by a cache hit is worth more than balance only when prompts are long and shared.

Workload Better choice
Long system prompt, many turns per session, agents with big tool lists Affinity with bounded load
Short independent prompts (classification, extraction) Least outstanding requests
Very skewed sessions (a few agent runs with hundreds of steps) Affinity with a low balance factor, or cap session length
Mixed: short and long Two pools, routed by app, each with its own strategy

Run the same recorded traffic both ways and compare p95 TTFT and tokens per second per GPU. Leave alone any strategy that improves hit rate but makes p95 worse.

Step 6: watch for imbalance

# Waiting requests per pod; one pod far above the rest = hot key
max by (pod) (vllm:num_requests_waiting)

# KV cache usage per pod (gauge name varies by version)
max by (pod) (vllm:kv_cache_usage_perc)

Alert when one pod's waiting queue stays well above the median for 10 minutes. The usual causes are a single huge session, a tenant ID used as the key, or a client that sends the same session ID for every user.

When to go further

Session affinity is an approximation: the gateway guesses where the cache lives. Newer components route on actual KV cache state. The Gateway API Inference Extension (InferencePool plus an endpoint picker) and projects built on it, such as llm-d, score pods using prefix-cache and queue signals reported by vLLM. They take more work to run and change quickly, so check the current docs and maturity of each before you adopt one. Header-based bounded hashing gets most of the benefit for chat and agents with far fewer moving parts.

Prevent it next time

  • Treat "how requests reach replicas" as part of the serving design, not a default Service.
  • Keep the static part of prompts (system prompt, tool schemas) byte-identical and at the start so prefixes actually match.
  • Dashboard per-pod hit rate, waiting requests and TTFT side by side.
  • Re-test routing after vLLM upgrades: block size, cache eviction and metric names change between releases.