Navigation
🏭 Platform & Infrastructure· 10 min read

Load Balancing for LLMs

Why standard load balancing fails for LLMs β€” queue-depth-aware, prefix-cache-affinity, and token-budget-aware routing strategies.

load-balancingllm-servingprefix-cachequeue-depthrouting

Standard load balancing assumes requests are roughly equal in cost and duration. LLM requests violate both assumptions: a 50-token query takes 200ms while a 100K-token context takes 30 seconds. Round-robin sends the 100K request to the same instance as three quick ones, creating a 30-second head-of-line block. LLM load balancing must account for variable request duration, KV cache state, and prefix sharing.


1. Why Standard Strategies Fail

StrategyAssumptionLLM realityProblem
Round-robinEqual request costCosts vary 100Γ—Unbalanced load
Least-connectionsEqual connection weightOne long stream β‰  one shortStill unbalanced
RandomEqual probability worksSame issues as round-robinSame problems
IP hashSticky sessions helpNo per-user state (typically)Wastes affinity

All these strategies ignore the two things that matter most: how busy each instance actually is (queue depth in tokens, not requests) and what KV cache state each instance has (prefix sharing).


2. Queue-Depth-Aware Routing

Route to the instance with the fewest pending tokens, not the fewest connections:

class QueueDepthRouter:
    """Route to instance with lowest token queue depth."""
    
    def __init__(self, backends: list[Backend]):
        self.backends = backends
    
    async def select(self, request: ChatRequest) -> Backend:
        # Estimate tokens for this request
        input_tokens = count_tokens(request.messages)
        estimated_output = request.max_tokens or 4096
        request_tokens = input_tokens + estimated_output
        
        # Get queue state from each backend
        backend_loads = []
        for backend in self.backends:
            status = await backend.get_status()  # Returns pending tokens, active requests
            backend_loads.append({
                "backend": backend,
                "pending_tokens": status.pending_tokens,
                "active_requests": status.active_requests,
                "estimated_completion": status.pending_tokens / status.throughput_tok_s,
            })
        
        # Select backend with lowest pending tokens
        best = min(backend_loads, key=lambda b: b["pending_tokens"])
        return best["backend"]

Impact: 20-40% improvement in P99 latency compared to round-robin, because long requests don’t block short ones on the same instance.


3. Prefix-Cache-Affinity Routing

When requests share common prefixes (system prompts, RAG context), routing them to the same instance enables KV cache reuse:

class PrefixAffinityRouter:
    """Route requests with shared prefixes to the same instance for KV cache reuse."""
    
    def __init__(self, backends: list[Backend], affinity_weight=0.6):
        self.backends = backends
        self.affinity_weight = affinity_weight  # Balance affinity vs load
    
    async def select(self, request: ChatRequest) -> Backend:
        # Extract prefix (system prompt + first N tokens)
        prefix = self._extract_prefix(request.messages)
        prefix_hash = hashlib.md5(prefix.encode()).hexdigest()
        
        # Score each backend
        scores = []
        for backend in self.backends:
            status = await backend.get_status()
            
            # Affinity score: does this backend have the prefix cached?
            has_prefix = prefix_hash in status.cached_prefixes
            affinity_score = 1.0 if has_prefix else 0.0
            
            # Load score: inverse of queue depth (lower is better)
            max_tokens = max(b.pending_tokens for b in self.backends) or 1
            load_score = 1.0 - (status.pending_tokens / max_tokens)
            
            # Combined score
            combined = (self.affinity_weight * affinity_score + 
                       (1 - self.affinity_weight) * load_score)
            scores.append((backend, combined))
        
        # Select highest-scoring backend
        return max(scores, key=lambda x: x[1])[0]
    
    def _extract_prefix(self, messages: list[dict]) -> str:
        """Extract cacheable prefix β€” typically system prompt."""
        system = next((m["content"] for m in messages if m["role"] == "system"), "")
        return system[:2000]  # First 2000 chars as prefix key

Impact: Prefix cache hit rate 40-80% (vs 0% with round-robin), saving significant prefill computation.

For how prefix caching works inside the engine, see Prefix Caching.


4. Token-Budget-Aware Routing

Each instance has a maximum token throughput. Route based on remaining capacity:

class TokenBudgetRouter:
    """Route based on remaining token-processing capacity per instance."""
    
    def __init__(self, backends: list[Backend]):
        self.backends = backends
    
    async def select(self, request: ChatRequest) -> Backend:
        estimated_tokens = count_tokens(request.messages) + (request.max_tokens or 4096)
        
        candidates = []
        for backend in self.backends:
            status = await backend.get_status()
            remaining_capacity = status.max_tokens_per_sec - status.current_tokens_per_sec
            
            # Can this backend handle the request without exceeding capacity?
            if remaining_capacity > estimated_tokens / 10:  # rough: need 10s budget
                candidates.append((backend, remaining_capacity))
        
        if not candidates:
            # All backends near capacity β€” pick least loaded
            return min(self.backends, key=lambda b: b.pending_tokens)
        
        # Pick backend with most remaining capacity
        return max(candidates, key=lambda x: x[1])[0]

5. Combined Strategy

Production systems combine multiple signals:

class CombinedLLMRouter:
    """Multi-signal routing for LLM serving."""
    
    WEIGHTS = {
        "queue_depth":     0.35,  # Avoid overloaded instances
        "prefix_affinity": 0.35,  # Maximize cache hits
        "token_budget":    0.20,  # Stay within throughput limits
        "health":          0.10,  # Prefer healthy instances
    }
    
    async def select(self, request: ChatRequest) -> Backend:
        prefix_hash = self._prefix_hash(request)
        est_tokens = self._estimate_tokens(request)
        
        scores = []
        for backend in self.backends:
            s = await backend.get_status()
            
            score = (
                self.WEIGHTS["queue_depth"] * self._queue_score(s) +
                self.WEIGHTS["prefix_affinity"] * self._affinity_score(s, prefix_hash) +
                self.WEIGHTS["token_budget"] * self._budget_score(s, est_tokens) +
                self.WEIGHTS["health"] * self._health_score(s)
            )
            scores.append((backend, score))
        
        return max(scores, key=lambda x: x[1])[0]
    
    def _queue_score(self, status):
        """0.0 = fully loaded, 1.0 = empty queue."""
        return max(0, 1 - status.pending_tokens / status.max_pending)
    
    def _affinity_score(self, status, prefix_hash):
        """1.0 if prefix cached, 0.0 if not."""
        return 1.0 if prefix_hash in status.cached_prefixes else 0.0
    
    def _budget_score(self, status, est_tokens):
        """1.0 = plenty of capacity, 0.0 = at limit."""
        remaining = status.max_tok_s - status.current_tok_s
        return max(0, min(1, remaining / (est_tokens / 5)))
    
    def _health_score(self, status):
        """1.0 = healthy, 0.0 = unhealthy."""
        if status.error_rate > 0.05:
            return 0.0
        return 1.0 - status.error_rate * 10

6. Handling Streaming Connections

LLM responses stream for seconds to minutes. The load balancer must handle long-lived connections:

# Nginx configuration for LLM streaming
upstream llm_backends {
    least_conn;  # Basic, but better than round-robin for LLMs
    server vllm-1:8000;
    server vllm-2:8000;
    server vllm-3:8000;
    keepalive 64;
}

server {
    listen 443 ssl;

    location /v1/chat/completions {
        proxy_pass http://llm_backends;
        
        # Critical for SSE streaming:
        proxy_buffering off;           # Don't buffer response
        proxy_cache off;               # Don't cache
        proxy_http_version 1.1;        # Required for chunked transfer
        proxy_set_header Connection ""; # Keep-alive
        
        # Long timeouts for generation:
        proxy_read_timeout 300s;       # 5 min for long generations
        proxy_send_timeout 300s;
        
        # Headers for SSE:
        add_header X-Accel-Buffering no;
    }
}

Connection Draining

When taking an instance offline (for updates or scaling down), drain gracefully:

async def drain_instance(backend: Backend, timeout=120):
    """Stop sending new requests, wait for active ones to complete."""
    backend.accepting_new = False  # Router stops sending new requests
    
    start = time.time()
    while backend.active_requests > 0:
        if time.time() - start > timeout:
            logger.warning(f"Drain timeout: {backend.active_requests} requests still active")
            break
        await asyncio.sleep(1)
    
    await backend.shutdown()

7. Health Checking

LLM backends can fail in subtle ways β€” running but producing garbage, or accepting requests but timing out:

class LLMHealthChecker:
    """Deep health check for LLM inference instances."""
    
    async def check(self, backend: Backend) -> HealthStatus:
        # Level 1: Is the process alive?
        try:
            status = await backend.get_status(timeout=2)
        except Exception:
            return HealthStatus.DOWN
        
        # Level 2: Is it accepting requests?
        if status.queue_depth > status.max_queue:
            return HealthStatus.OVERLOADED
        
        # Level 3: Is it producing valid output?
        # (Periodic canary request β€” every 30s)
        if time.time() - self.last_canary > 30:
            try:
                response = await backend.generate(
                    messages=[{"role": "user", "content": "Say hello"}],
                    max_tokens=5,
                    timeout=10,
                )
                if "hello" not in response.lower():
                    return HealthStatus.DEGRADED  # Running but output is wrong
                self.last_canary = time.time()
            except Exception:
                return HealthStatus.DEGRADED
        
        return HealthStatus.HEALTHY

Self-Check

  • Explain why round-robin and least-connections fail for LLM serving
  • Implement queue-depth-aware routing that accounts for token count
  • Design prefix-cache-affinity routing for workloads with shared system prompts
  • Configure Nginx for SSE streaming with proper timeouts and buffer settings
  • Build a deep health checker that validates model output quality

References