凌晨 2 点值班手记:为什么我们的 vLLM 语义路由在高负载下丢弃了 40% 的请求
故障现象
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 错误都不留。调试异步瓶颈时,务必显式输出超时指标,并端到端追踪请求的完整生命周期。