凌晨 2 点值班手记:为什么我们的 vLLM 语义路由在高负载下丢弃了 40% 的请求

Dev.to ML 2026-10-21T21:41:31.051160

故障现象

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)

凌晨两点,流量高峰来了。我们那层包着 vllm-project/semantic-router 的 FastAPI 网关开始悄悄丢请求。没有 5xx 报错,没有 OOM 被系统杀掉,请求就是凭空消失了。Prometheus 上能看到路由决策延迟从 80ms 一路爬到 4.8 秒,然后请求干脆从日志里彻底不见了。

误导线索

semantic-router 的 README 里给的是干净利落的同步示例:

from semantic_router import Router

router = Router()
route = router("Which model is best for code generation?")

我们把它包进 FastAPI,用 async 处理函数,理所当然地以为路由库自己会处理好并发:

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

压测 200 个并发请求时,本地一切正常。可到了生产环境,遇上真实流量模式——10 秒内涌进 300 多个请求的突发——router() 里那段同步的向量计算直接把事件循环堵死了。AsyncIO 没有机会切换上下文,请求越堆越多,等内部那个 5 秒超时一到,它们就被静默丢弃了。

semantic-router 底层用 sentence-transformers 生成向量(embedding),默认在 CPU 上同步执行。并发一高,就成了典型的队头阻塞(head-of-line blocking)灾难:前面一个任务卡住,后面所有请求都跟着排队干等。

根因与可直接运行的修复

解决办法是把这段会阻塞的向量计算挪出主事件循环,交给 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):
    # 把同步的 embedding 计算交给线程池
    route = await asyncio.to_thread(router, prompt)
    return {"route": route.name}

这样,CPU 密集的向量计算被交给线程池执行,AsyncIO 的事件循环可以腾出手来继续接收新请求。

用于生产部署时,后端如果接的是 vLLM 或 Ollama,我们还会拿多供应商中继做一次路由延迟对比,验证故障切换是否生效:

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()

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

    # 打向被选中的后端(例如用 B-Lost 中继做多模型测试)
    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}

# 用 100 个并发请求压测
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")

上线线程池修复之后,同样的负载下 P95 路由延迟从 4800ms 降到 320ms,请求零丢失。

铁律:写成 async 不等于真的异步执行

我们团队现在的规矩是:凡是调用 sentence-transformers、tokenizers,或任何同步模型推理的库,只要在异步 handler 里用,就必须用 asyncio.to_thread() 包起来,或者丢到独立进程池里跑。

你的 handler 挂着 async def,不代表它调用的库就是非阻塞的。具体到 semantic-router:如果你在 CPU 上跑 embedding,又指望并发超过 50 QPS,那就老老实实把计算卸载到线程里。

如果瓶颈卡在 GPU 上,可以试着把请求攒批处理,或者单独拆出一个 embedding 服务,从根上避开阻塞。最难受的是那种“静默失败”:请求超时之后就凭空消失了,连个 5xx 错误都不留。调试异步瓶颈时,务必显式输出超时指标,并端到端追踪请求的完整生命周期。

查看原文