推理服务为什么用户都断开了 GPU 还在忙:从 cancel propagation 到幽灵解码清理的工程实战
🧠 用户都关页面了,为什么显卡还在持续发热
流式推理一旦进入生产,最让运维困惑的场景之一,就是前端早已超时、客户端连接也已经断开,GPU 利用率却没有立刻回落。很多团队会先怀疑continuous batching太激进,或者怀疑模型输出太长,但真正被忽略的,往往是取消信号根本没有穿透到调度和解码层。⚠️ 请求在网关上看似结束,在 Worker 内部却还保留着可运行状态,结果就是队列继续排、KV 槽位继续占、decode loop 继续吐 token。📉
更麻烦的是,这类浪费不会像 OOM 那样立刻炸出来,而是慢慢污染整池吞吐。🧩 少量幽灵请求混进批处理后,调度器仍会给它们分配 step,导致真实在线请求被迫共享时间片。表面上看,QPS没掉很多,TTFT和P99却开始持续抬升;如果系统还开启了重试,客户端断连造成的无效解码会和新请求同时争抢资源,尾延迟会被进一步放大。🚨
🔍 真正的问题,不是有没有 cancel,而是 cancel 停在哪一层
线上取消链路通常至少经过网关、调度器、Worker 和模型执行器四层。很多实现只在 HTTP 或 SSE 断开时打日志,却没有把取消状态写进共享任务表,结果调度器仍然认为这个请求“可继续推进”。📌 一旦任务已经拿到 KV page 或进入 decode micro-batch,后面的执行器如果缺少中断检查点,就会把整轮 token 生成跑完才释放资源。此时最浪费的不是一个请求,而是整批被拖住的 slot。⚙️
下面这组线上观测值很能说明问题。📊 当取消率超过 8% 但释放时延仍维持在秒级时,吞吐损失往往不是偶发噪声,而是服务治理缺口。
| 策略 | 取消到释放时延 | 无效 decode 占比 | P99 变化 | 主要症状 |
|---|---|---|---|---|
| 只在网关断连 | 2400 ms | 11.8% | +27% | Worker 仍继续跑 |
| 调度层感知取消 | 910 ms | 5.1% | +12% | 已入批次任务仍滞留 |
| Decode step 检查中断 | 260 ms | 1.7% | +4% | 残余浪费可控 |
| 心跳 + 令牌双门禁 | 140 ms | 0.8% | +1% | 最稳,适合生产 |
🛠️ 更稳的做法,是把取消做成可观测的资源回收协议
可靠的实现通常会同时做三件事。✅ 一是给每个请求下发独立cancel_token,让网关断连、上游超时和业务主动撤销都映射到同一个状态源;二是在调度器和 decode loop 都加轻量检查点,避免任务进了执行层就只能“跑完再说”;三是把 KV page、输出缓冲和 slot 占用挂到同一个回收流程里,而不是只停生成、不做资源归还。🛡️ 这样做的重点不是优雅取消,而是尽快把真实容量还给下一批请求。📦
defon_decode_step(req,runtime):ifruntime.cancelled(req.request_id):runtime.release_kv(req.request_id)runtime.release_slot(req.request_id)return"stopped"token=runtime.decode_one_token(req)runtime.flush_if_needed(req,token)return"running"📈 接下来 3 到 6 个月,取消治理会成为推理平台的基础能力
很多团队过去把取消当成“体验优化”,实际上它更接近容量治理。🔬 只要模型服务继续朝流式输出、长上下文和多租户方向发展,幽灵请求带来的资源损耗就会越来越像隐性税负。笔者认为,下一阶段更值得投入的,不是把取消写成更多异常分支,而是把cancel_backlog、zombie_decode_ratio、release_latency_ms做成上线门禁,让回收效率和吞吐、质量一样被持续审计。🚦
推理服务里最浪费的算力,常常不是慢请求本身,而是那些用户已经放弃、系统却还在继续计算的请求。💡 谁能先把取消信号做成端到端协议,谁就更可能在同样的 GPU 预算下拿到更稳定的吞吐和更低的尾延迟。你们线上更常见的问题,是取消不生效,还是资源释放总是慢半拍?欢迎在评论区交流。🧭
