推理服务为什么一开超时熔断就开始误杀长输出:从 Token Budget 到 Partial Result Commit 的工程实战
很多团队给推理网关补上超时熔断后,看起来更稳了。⚠️ 短问答不再长期占连接,坏请求也能更快清理。可一旦业务里出现长摘要、代码解释或结构化 JSON 输出,系统又开始把本来还能完成的请求提前掐掉,用户看到的不是慢,而是“差一点就答完”。🧠
更麻烦的是,这类误杀常被误判成模型退化。🔍 实际上 GPU 还在稳定解码,只是服务层拿着统一的15 s或20 s静态闸刀,对不同长度和输出目标一刀切。结果是短请求受保护了,长请求却被当成异常。📌
静态超时为什么会把长输出错杀成故障
推理服务的耗时本来就由prefill、逐 token 解码、下游流式回传三段组成。🚨 对短回答来说,固定超时足够有效;可对长输出来说,真正该看的不是墙钟时间,而是“已经生成了多少 token,还剩多少预算”。如果服务层只看总耗时,就会把慢速前进和卡死混成一类。🧩
生产里最常见的误区,是把熔断阈值设成统一常量,再把所有超时都记成timeout error。🧪 这样会掩盖两个事实:一类请求已经吐出大半结果,只差最后收尾;另一类请求虽然耗时长,但 token 产出速率始终健康。真正该被杀掉的,是没有进度、没有回传、也没有恢复价值的请求。✅
一组回放把真实故障和误杀请求拆开看
这次回放了18万条线上请求,其中27%带长答案或结构化输出。📊 基线方案使用固定15 s熔断;方案二改成按prompt token + max_new_tokens估算deadline;方案三在预算熔断之外,再把已生成内容写入partial commit,供客户端恢复或重试复用。结果说明,很多“超时失败”本质上是进度被丢掉。⭐
| 方案 | P95 完成时延 | 超时误杀率 | 平均无效 GPU 占用 | 可恢复结果比例 |
|---|---|---|---|---|
| 固定 15 s 熔断 | 15.0 s | 12.4% | 3.8 s | 0% |
| Token Budget 动态阈值 | 17.6 s | 4.1% | 1.5 s | 0% |
| Budget + Partial Commit | 17.9 s | 1.9% | 1.2 s | 68% |
真正该看的不是“有没有超时”,而是“超时时系统丢掉了多少已算出的价值”。🛠️ 当网关先按 token 预算决定允许时长,再在连接断开、客户端取消或代理回收前落一次部分结果,很多原本只能算失败的请求,就能变成可恢复、可续写、可复盘的中间态。📈
defdeadline_ms(prompt_tokens,max_new_tokens,prefill_ms,decode_ms_per_token):estimate=prefill_ms+max_new_tokens*decode_ms_per_tokenreturnmin(45000,int(estimate*1.15))defshould_fuse(elapsed_ms,emitted_tokens,expected_tokens,stream_stalled_ms):ifstream_stalled_ms>4000:returnTrueprogress=emitted_tokens/max(expected_tokens,1)returnelapsed_ms>15000andprogress<0.35工程上真正该补的是预算模型和部分结果提交
更稳的做法,是把熔断从“时间开关”升级成“预算契约”。🛡️ 请求进入系统后先估算prefill成本、目标输出长度和流式链路质量,再把hard timeout、soft timeout和partial commit checkpoint分开管理。这样即便最终仍要中断,系统也知道该保留哪些 token、日志和计费信息,而不是把整次推理直接抹掉。📦
另一层不能省的是状态语义。⏱️ 如果客户端已经拿到前600个 token,服务端就不该把这次请求简单记成失败;如果代理层先断了连接,模型侧也不该继续盲跑到自然结束。笔者认为,未来稳定的推理网关会把“超时”拆成stall、slow but healthy和partial committed三类,再决定重试、续写还是结案。🔁
未来 3 到 6 个月 推理超时治理会从统一阈值转向进度感知
一句话总结:超时熔断保护的是资源,不是统计面板上的数字。📍 只要系统还在稳定吐 token,就不该被静态阈值粗暴打断;只要结果已经产生,就该让它可恢复、可续写、可审计。你们现在的推理网关,记录的是“总耗时”,还是“剩余预算与已提交进度”?
