在本地模拟主模型端点饱和场景,测试备用模型和队列的failover能力,避免线上流量真来时出现双重超时(请求超时+重试并发雪崩)。
每次有新的开源模型发布,运维团队都会面临同样的问题:如果把流量切到它,结果端点饱和了,正在处理中的请求会怎样?大多数团队能回答无状态 Web 流量的场景。但能回答 LLM 服务场景的就要少得多——在 LLM 服务中,一个"失败"的请求可能已经烧掉了 40 秒的 deadline 缓冲。
本文是这个场景的本地演练:一条主模型端点、一条更便宜的备用端点、一个前置在两者之前的队列,以及一个主动触发的饱和事件。一切运行在一台笔记本或小型免费服务器上,下面的每个数字要么是实际命令输出,要么是明确标注过的预期输出。
一个被炒上天的新模型发布后,典型模式是这样的:
团队把一个预发服务指向新的端点。
延迟在 2 req/s 下看起来很棒。
有人把它上线,流量涌入,提供商侧开始限速,请求开始堆积。
"备用"配置是存在的,但从未在负载下演练过——所以重试/故障转移逻辑的第一次真实测试,就是故障本身。
我关心的故障模式不是"端点变慢"。而是重复支出(double-spend):一个请求在网关处超时,被重试到备用端点,同时主端点仍然完成了它——现在你为两次生成付了钱,还可能返回了重复的副作用。没有幂等性和 deadline 核算的备用路由,就是一个队列吞噬者。
┌────────┐ ┌─────────┐ ┌──────────────┐
│ loadgen│──▶│ gateway │──▶│ primary:8001 │ (saturating, artificial cap)
└────────┘ │ :8080 │ └──────────────┘
│ │ │ ┌──────────────┐
│ └──────▶│ fallback:8002│ (small, always healthy)
└─────────┘ └──────────────┘
│
queue depth, per-request deadline,
route decision log (stdout JSON)
两个极简的 mock 服务器来代替真实的模型端点。这是刻意的:演练测试的是你的路由和准入逻辑,而不是谁的模型质量。之后把同一个网关指向真实的免费套餐端点,机制是完全相同的。
保存为 gateway.py(Python 3.11+,仅标准库 + httpx):
import asyncio, json, time, uuid
import httpx
PRIMARY = "http://127.0.0.1:8001/v1/chat"
FALLBACK = "http://127.0.0.1:8002/v1/chat"
DEADLINE_MS = 8000 # end-to-end budget per request
PRIMARY_TIMEOUT_MS = 3000 # give up on primary after this
async def call(client, url, req_id, prompt, deadline):
remaining = deadline - time.monotonic()
if remaining <= 0:
return {"route": "shed", "reason": "deadline_exhausted"}
try:
r = await client.post(
url,
json={"prompt": prompt, "request_id": req_id}, # idempotency key
timeout=remaining,
)
return {"route": url, "status": r.status_code,
"body": r.json()}
except (httpx.TimeoutException, httpx.ConnectError) as e:
return {"route": url, "error": type(e).__name__}
async def handle(prompt: str):
req_id = str(uuid.uuid4())
deadline = time.monotonic() + DEADLINE_MS / 1000
async with httpx.AsyncClient() as client:
first = await asyncio.wait_for(
call(client, PRIMARY, req_id, prompt, deadline),
timeout=PRIMARY_TIMEOUT_MS / 1000,
) if True else None
# NOTE: asyncio.wait_for cancels the client call, but the
# primary server may STILL be processing. The request_id
# is what lets the primary dedupe a late retry.
log = {"request_id": req_id, "attempts": [first]}
if "error" in first or first.get("status", 500) >= 500:
second = await call(client, FALLBACK, req_id, prompt, deadline)
log["attempts"].append(second)
log["elapsed_ms"] = None # filled by caller
print(json.dumps(log), flush=True)
return log
Mock 端点(primary.py, fallback.py)—— 主端点强制执行 2 的硬并发上限,其余全部排队 6 秒,模拟提供商侧的饱和:
# primary.py
import asyncio, json
from aiohttp import web
sem = asyncio.Semaphore(2)
seen = set() # idempotency: request_ids already completed
async def chat(req):
body = await req.json()
rid = body["request_id"]
if rid in seen:
return web.json_response({"deduped": True, "id": rid})
async with sem:
await asyncio.sleep(6) # saturated: every request is slow
seen.add(rid)
return web.json_response({"model": "primary", "id": rid})
app = web.Application()
app.router.add_post("/v1/chat", chat)
web.run_app(app, port=8001)
备用端点结构相同,asyncio.sleep(0.4),端口 8002。
工作负载事先声明,以便结果可解读:
40 个请求,到达率 10 req/s(4 秒的负载供给)
端到端 deadline 预算 8000 ms,主站尝试上限 3000 ms
主站容量:2 并发 × 6 s 服务时间 ≈ 0.33 req/s 可持续
Little 定律告诉我们,主站会在时间窗口内吸收约 2 个请求,其余必须 fallback 或 shed。预期输出(标注了——在你的机器上重跑后再引用):约 2 个请求只记录一次主站尝试,约 36–38 个有两次尝试最终落在备用端点,0 个 shed,因为备用端点 0.4 s 的服务时间使总延迟保持在 8 s deadline 以内。
python gateway_load.py | jq -r '.attempts[-1].route' | sort | uniq -c
# expected: ~2 http://127.0.0.1:8001/v1/chat
# ~38 http://127.0.0.1:8002/v1/chat
以及关键的幂等性检查——没有任何 request_id 应该出现两次成功的生成:
python gateway_load.py | jq -r 'select(.attempts | length == 2) | .request_id' \
| sort | uniq -d | wc -l
# must be 0 (or every duplicate must show "deduped": true server-side)
基线(上述):验证愉快路径的故障转移。
备用端点也降级:把备用端点 sleep 设为 7 s。现在 deadline 数学开始起作用——预期结果是晚到的请求以 deadline_exhausted 拒绝,而不是双重排队。如果你看到请求在 14 s 完成,那说明你的 deadline 核算只是装饰性的。
主站挂起但不报错:让主站接受连接然后 sleep 30 s。这是现实世界中最难搞的情况(提供商过载但不拒绝)。你的 3 s wait_for 必须触发;如果网关反而等 TCP 超时,那备用端点永远不会介入,队列悄然老化。
不管你用的是什么真实技术栈,路由决策日志每条请求都需要:request_id、offered_at、first_route、first_outcome、fallback_outcome、elapsed_ms、deadline_ms 和 queue_wait_ms。人们最容易跳过的两个字段——deadline_ms 和 queue_wait_ms——恰恰是让你在故障期间知道应该丢弃负载还是仍有缓冲的那两个字段。
这个演练迫使你选一个数字并为它辩护。我的候选信号排序:
Deadline 缓冲(deadline 减去 elapsed,再减去 p95 备用延迟):最佳信号,因为它是以用户实际体验到的东西计量的。当缓冲 < 备用端点 p95 时故障转移。
最老请求的队列年龄:好的代理指标,不需要每个请求的 deadline 也能工作。
利用率/并发数:单独看是最弱的——快速端点 100% 并发没问题;饱和端点 100% 并发就是崩溃。
如果只能接入一个指标,就接入 deadline 缓冲。
mock 服务器和网关可以舒适地放在一台小型免费 VM 上。披露:本文是 MonkeyCode 产品推广的一部分。他们提供的免费模型访问和免费服务器选项是运行这个演练的网关加真实端点变体的合理场所——把 mock URL 换成实际模型端点,故障转移、deadline 和幂等性逻辑原样迁移。如果你愿意尝试,上述演练是一个自包含的起点;负载脚本不到 60 行。
免费套餐对于这个特定演练有一个诚实的限制:提供商侧的限速可能比你模拟的饱和更严格,这其实挺好——它免费锻炼了场景 2(两条路径都降级)。只是不要把免费套餐的吞吐量数字外推到生产容量规划。
请求不是幂等的、且无法在服务端添加幂等性 key 的团队。没有去重的故障转移会把延迟问题变成正确性问题。
期望在流式响应中间断 failover 的场景。这个演练覆盖的是请求级故障转移;流中恢复是另一个更难的问题。
任何用它来基准测试特定模型的人。mock 服务器存在的目的恰恰是让路由逻辑独立于模型行为进行测试。
如果做好三件事,演练不会留下任何状态:杀死 mock 服务器(pkill -f primary.py)、截断 seen-request-id 集合(它在内存中,重启即可),以及——在真实部署中——把备用路由放在一个配置 flag 后面,这样回滚就是 FALLBACK_ENABLED=false 加一次 reload,而不是一次部署。在这个场景的故障版本中,回滚意味着排空:停止向主站接入新请求,让在飞请求在其 deadline 上完成或被 shed,然后切换。练习同样的本地排空方式——在运行中间把到达率设为 0,然后观察日志中是否有超过 deadline 的掉队者。
演练花一个下午构建。它所取代的故障花一个周末。