DEV Community

Cover image for Notes from a 2 AM On-Call: Why Our vLLM Semantic Router Dropped 40% of Requests Under Load
Dave Mille
Dave Mille

Posted on

Notes from a 2 AM On-Call: Why Our vLLM Semantic Router Dropped 40% of Requests Under Load

Notes from a 2 AM On-Call: Why Our vLLM Semantic Router Dropped 40% of Requests Under Load

The Failure Symptom

2024-03-15 02:17:43 WARNING: Route decision timeout after 5000ms
2024-03-15 02:17:48 ERROR: 127 requests dropped (no route assigned)
2024-03-15 02:18:12 INFO: QPS: 240 → 140 (41% degradation)
Enter fullscreen mode Exit fullscreen mode

Our FastAPI gateway wrapping vllm-project/semantic-router started silently dropping requests at 2 AM during a traffic spike. No 5xx errors, no OOM kills, just requests vanishing into the void. Prometheus showed route decision latency climbing from 80ms to 4.8 seconds, then requests just disappeared from logs entirely.

The Red Herring

The semantic-router README shows clean synchronous examples:

from semantic_router import Router

router = Router()
route = router("Which model is best for code generation?")
Enter fullscreen mode Exit fullscreen mode

We wrapped this in FastAPI with async handlers, assuming the router would handle concurrency gracefully:

@app.post("/route")
async def route_request(prompt: str):
    route = router(prompt)  # BLOCKING CALL
    return {"route": route.name}
Enter fullscreen mode Exit fullscreen mode

Under load testing with 200 concurrent requests, everything looked fine locally. But in production with real traffic patterns — bursts of 300+ requests over 10 seconds — the synchronous embedding computation inside router() was blocking the event loop. AsyncIO couldn't context-switch, requests piled up, and after the internal 5-second timeout, they were silently discarded.

The semantic-router library uses sentence-transformers for embedding generation, which runs synchronously on CPU by default. At high concurrency, this creates a head-of-line blocking disaster.

The Root Cause & Runnable Fix

The fix required moving the blocking embedding computation off the main event loop using asyncio.to_thread():

import asyncio
from semantic_router import Router
from fastapi import FastAPI

app = FastAPI()
router = Router()

@app.post("/route")
async def route_request(prompt: str):
    # Offload synchronous embedding to thread pool
    route = await asyncio.to_thread(router, prompt)
    return {"route": route.name}
Enter fullscreen mode Exit fullscreen mode

This delegates the CPU-bound embedding work to a thread pool while keeping the AsyncIO event loop free to accept new requests.

For production deployments with vLLM or Ollama backends, we benchmark routing latency against a multi-provider relay to validate failover behavior:

import aiohttp
import asyncio
import time

async def benchmark_route_with_fallback(prompts: list[str]):
    async with aiohttp.ClientSession() as session:
        tasks = []
        for prompt in prompts:
            tasks.append(route_with_timing(session, prompt))
        results = await asyncio.gather(*tasks)
        return results

async def route_with_timing(session, prompt):
    start = time.perf_counter()

    # Route decision
    async with session.post(
        "http://localhost:8000/route",
        json={"prompt": prompt}
    ) as resp:
        route_data = await resp.json()

    # Execute against routed backend (e.g., B-Lost relay for multi-model testing)
    async with session.post(
        "https://b-lost.com/v1/chat/completions",
        headers={"Authorization": f"Bearer {YOUR_API_KEY}"},
        json={
            "model": route_data["route"],
            "messages": [{"role": "user", "content": prompt}],
            "max_tokens": 50
        }
    ) as model_resp:
        await model_resp.json()

    elapsed = time.perf_counter() - start
    return {"prompt": prompt, "route": route_data["route"], "latency_ms": elapsed * 1000}

# Test with 100 concurrent requests
prompts = ["Explain quantum computing"] * 100
results = asyncio.run(benchmark_route_with_fallback(prompts))
print(f"P95 latency: {sorted([r['latency_ms'] for r in results])[94]:.1f}ms")
Enter fullscreen mode Exit fullscreen mode

After deploying the thread pool fix, P95 routing latency dropped from 4800ms to 320ms under the same load. Zero dropped requests.

Invariant Rule: Async Wrapping ≠ Async Execution

Our team rule now: Any library using sentence-transformers, tokenizers, or synchronous model inference must be wrapped in asyncio.to_thread() or run in a separate process pool when called from async handlers. Just because your handler is async def doesn't mean the libraries you call are non-blocking.

For semantic-router specifically, if you're running embeddings on CPU and expect concurrency above 50 QPS, offload to threads. If you're GPU-bound, consider batching requests or using a dedicated embedding service to avoid blocking entirely.

The silent failure mode — where requests time out and vanish without 5xx errors — was the worst part. Always emit explicit timeout metrics and trace request lifecycles end-to-end when debugging async bottlenecks.

Top comments (0)