记录几次 LLM 服务里遇到的静默失败问题。
线上出问题时,最容易处理的反而是明确失败:进程 crash、HTTP 500、显存 OOM,或者日志里有异常栈。
麻烦的是另一类问题:请求不返回,但服务没挂;客户端已经断开,GPU
还在跑;流式响应发到一半,没有
[DONE];服务端返回了错误,SDK 却看不到错误原因。
这些问题表面上属于不同模块:vLLM 调度、FastAPI 流式接口、Java SDK、多进程工具调用、训练集群健康检查。放在一起看,其实都和“请求状态没有说清楚”有关。
我把这类问题叫做“大模型服务的静默失败”。
它和分布式系统里的 gray failure 有点像:系统自检看起来还健康,业务侧已经观察到异常。放到 LLM 服务里,最典型的形态就是 zombie request:客户端已经走了,服务端还在生成。
请求生命周期
传统 HTTP 服务里,请求状态通常比较简单:成功、失败、超时、取消。大模型服务复杂得多,因为它天然有更长的生命周期:
1 | 进入服务 -> 等待队列 -> 分配显存/调度 -> 逐 token 生成 -> 流式返回 -> 结束信号 -> 日志落盘 |
每一步都可能产生中间状态:
- 等待队列里还没开始生成;
- 已经开始生成,但客户端断开;
- 已经返回一部分 token,但没有结束信号;
- 服务端认为结束了,但调用方认为没结束;
- 生成结果被丢弃,但 GPU 时间已经消耗;
- 错误响应返回了,但 SDK 没有正确解析。
这些状态如果没有被显式记录,就会变成静默失败。
先用一个脱敏请求作为例子。假设业务方发起了一次长文本总结请求,请求参数大概是这样:
1 | { |
它看起来只是一个普通请求,但不同阶段都可能出问题:入口没有检查上下文长度时,它可能进队列后不返回;流式输出已经吐出几段内容时,它可能收不到
[DONE];客户端 60
秒超时后,它可能已经断开,但服务端还在继续生成;服务端返回错误时,SDK
可能只告诉业务方“失败了”,却不告诉它为什么失败。
下面按问题分别记录。
请求卡住不返回
最直接的静默失败:请求卡住,不报错,也不返回。
有一次直接使用 vLLM
时,遇到过这样的问题:当外部输入长度加上模型生成长度大于模型可接受长度时,请求有概率卡住。把
max_tokens 设置得较小能正常输出,设置到 1024
或更长时就可能挂住。
比如上面的请求,promptTokens 是
3800,maxTokens 是 512。如果模型上下文窗口是
4096,这个请求在入口就应该被拒绝。入口没有做这层校验,它就会带着一个已经不可能满足的参数进入调度队列。
排查时第一步不是直接看 vLLM 源码,而是先把变量收窄:同一个
prompt,调小 max_tokens 可以正常返回;调大
max_tokens 后卡住。这个现象说明问题大概率不在网络、不在业务
SDK,也不在模型“生成慢”,而是在“输入长度 +
生成长度”触发了某个调度边界。
下一步看服务端状态。如果进程没挂、GPU 也没有明显 OOM,但请求一直没有完成,就要看请求有没有进入 running,还是卡在 waiting。这个时候,问题从“模型不返回”变成了“调度队列里的请求有没有状态转移”。
排查到 vLLM
调度逻辑后,问题集中在等待队列上。生成调度时,系统会不断检查
self.waiting 队列中的请求。如果请求能被分配显存,就会从
waiting 中 pop 出来,进入 running;如果不能分配,就会 break。
问题是某个异常 case 下,can_allocate(seq_group)
返回失败后直接 break,但 waiting
队列里的元素没有被消费,也没有被标记成失败。于是这个请求进入了一种尴尬状态:没有成功调度,没有失败返回,也没有被取消。
等待队列里的元素,最终都应该走向下面几种状态之一:
- 开始运行;
- 被前置拒绝;
- 被取消;
- 明确失败并返回错误。
如果某个分支只是 break,而没有改变请求状态,就要警惕它会不会制造静默失败。
这次处理上,最终更倾向于外部控制 prompt 和 token 长度,而不是直接改底层源码。这个选择也很有代表性:当底层框架行为不完全确定时,前置拒绝往往比事后补救更可靠。
一个公开版复现可以简化成这样:选一个最大上下文为 4096 的模型,构造
3800 token 左右的 prompt,再把 max_tokens 设置成
512。此时请求总长度已经超过模型可接受窗口。如果服务没有在入口做校验,就会把这个请求继续交给底层调度。
更好的处理方式是在进入队列前直接拒绝:
1 | prompt_tokens + max_tokens > model_context_length |
命中这个条件时,服务应该返回明确错误,例如
context_length_exceeded。这里的重点不是具体阈值,而是“不可调度请求不能进入调度系统”。一旦进入,就必须有明确失败状态。
流式响应缺少 DONE
第二类更隐蔽:服务不是完全不返回,而是返回了一半。
流式接口里,调用方通常不是只看 HTTP
状态码,而是依赖协议结束信号。例如 SSE 场景下,调用方会等到
data: [DONE] 才认为响应完整结束。
曾经有线上请求出现不正常超时。排查时发现,服务端已经开始流式返回,客户端也收到了一部分内容,但最后一直没收到
[DONE]。从业务效果看,模型输出语义上是中断的;从 HTTP
层看,状态码可能已经是
200;从调用方看,请求一直没有完整结束,只能等到超时。
业务方看到的现象会很怪:它已经拿到了前几十个 token,甚至能拼出半段总结,但 SDK 一直不返回最终结果。服务端日志里也许看不到 500,因为 HTTP 连接早就开始写出数据了。缺的是一个终态。
这个 case 一开始很容易被误判成“模型生成太慢”或者“客户端超时设置太短”。但流式接口要分两段看:第一段是 HTTP 是否成功建立并开始返回,第二段是流式协议是否完整结束。
如果客户端已经收到 chunk,说明第一段是通的;如果一直没有
[DONE],问题就不在“请求有没有开始”,而在“生成器退出时有没有走到结束分支”。这时再去看服务端异常栈会发现一个麻烦点:普通
Exception 捕获不一定覆盖所有异步取消和生成器退出场景。
这里有几个容易踩的点。
第一,流式接口只要成功返回第一段内容,HTTP 状态码就很难再表达后续失败。HTTP 200 在流式场景里不等于成功。
第二,生成器内部的异常不一定会被普通 Exception
捕获。某些异步取消、生成器退出、客户端断开相关的问题,可能落在更宽泛的异常类别里。结果就是生成逻辑被中断,但外层没有机会发送
[DONE],也没有发出一个明确错误事件。
第三,调用方和服务端对“结束”的定义不一致。服务端可能觉得请求流程结束了,调用方却还在等协议结束信号。
对调用方来说,没有 [DONE]
就不是完整响应。服务端要么发送完整结束信号,要么发送可被识别的错误事件。不能让调用方在“可能还会继续返回”和“其实已经失败”之间猜。
这类问题可以用一个简单指标刻画:stream_without_done_count。它统计已经发送过至少一个
chunk、但最终没有发送 [DONE] 或错误事件的请求数。
例如,修复前可以看到这样的分布:
1 | total_stream_requests = 100000 |
这个数字未必大,但影响很烦。因为它不是一开始失败,而是在调用方已经收到部分内容后失败。修复后,目标不是让所有请求都成功,而是让每个流式请求都有明确终态:[DONE]、error、cancelled
三者之一。
客户端断开后服务端继续生成
第三类问题是客户端已经放弃了,但服务端还在消耗资源。
一个典型场景是客户端设置了 2 秒超时。如果服务器 2 秒内没有返回,客户端抛出超时异常并断开连接。按直觉,服务端应该感知到断开,然后取消生成。但实际排查时,服务端日志显示这个请求仍然处理了 3.6 秒。把客户端超时设置成 0.1 秒,服务端依旧会继续处理。
这意味着:客户端认为请求已经结束,服务端却还在跑。对于普通接口,这可能只是多消耗一点 CPU;对于 LLM 服务,这可能意味着 GPU 继续生成无用 token。
客户端超时后已经不再等结果,但服务端可能还在继续生成第 300 个、第 400 个 token。这个时候,它对业务已经没有价值,对 GPU 却仍然是一个真实负载。
这个问题的分析顺序也很重要。最开始不能只看客户端日志,因为客户端超时只能说明“调用方不等了”,不能说明服务端停了。要把同一个 request id 在客户端、网关、服务端生成日志里串起来看。
如果客户端 0.1
秒就断开,但服务端仍然完整跑完生成,说明断连信号没有传到生成任务。此时再看代码里的
is_disconnected,它存在不代表它生效。框架版本、Middleware、请求体读取方式,都可能影响这个判断。
问题最后定位到断连判断上。代码里确实有类似逻辑:
1 | if await raw_request.is_disconnected(): |
但由于框架和 Middleware 行为,is_disconnected
的判断并不符合预期。代码看起来已经处理了断连,实际上取消信号没有可靠传到生成任务。
这里要注意的是,取消不只是日志里记录一个状态。客户端断开、网关断开、业务超时,都应该传播到生成任务。否则系统会出现大量“已经没有接收方”的生成请求。
后续还有一个相关问题:当请求返回 499 时,日志里缺少关键字段,比如输入 token 数、排队时间、生成时间。这样即使我们知道客户端断开,也不好判断它到底是在排队时断开,还是生成时断开。
所以取消治理至少要包含两件事:
- 真的 abort 生成任务;
- 断开时保留足够完整的请求上下文。
可以把“断连后继续生成”单独做成资源浪费指标。例如一条请求日志里同时记录:
1 | { |
如果 tokensAfterDisconnect 长期不为
0,就说明取消信号没有及时传播。即使整体错误率不高,这类请求也会悄悄吃掉
GPU 时间。对推理服务来说,这比普通 499 更值得单独监控。
SDK 没有解析错误信息
有些静默失败不发生在服务端,而发生在 SDK。
Python 服务端在请求参数错误时,会返回结构化错误,例如:
1 | { |
但 Java SDK 如果固定按 ChatCompletionResult
去反序列化,就可能把错误体当成正常响应结构解析。结果调用方看到的是一个
object=error 的对象,却看不到
message、type、code
等真正有用的信息。
从服务端视角看,“我已经返回错误原因了”;从调用方视角看,“我不知道错在哪”。两个都对,问题出在中间的 SDK 反序列化。
假如入口已经正确拒绝一个超长请求,并返回
context_length_exceeded,这本来是一个好结果:调用方可以缩短输入,或者把
maxTokens 调小。但如果 SDK 没有解析出这个
code,业务方只看到一个笼统异常,就很可能按“服务偶发失败”处理,然后重试一次同样的请求。
这类问题不是模型服务特有的,但在 LLM
服务里影响会被放大。因为调用方经常要根据错误类型决定是否重试。如果
context_length_exceeded
被吞掉,就会把一个不可重试的参数错误变成可重试的服务异常。
很多系统会认真定义成功响应,却随意处理错误响应。对 LLM 服务来说,这尤其危险。服务端错误体、SDK 解析结构、调用方异常处理,三者必须共同定义。
一个最小错误契约可以先分成两类:调用方可修正的错误,以及服务端临时错误。
| 错误类型 | 示例 code | 是否建议重试 | 调用方动作 |
|---|---|---|---|
| 参数错误 | invalid_request_error |
否 | 修改参数或 prompt |
| 上下文超长 | context_length_exceeded |
否 | 缩短输入或降低 max_tokens |
| 鉴权失败 | unauthorized |
否 | 检查 token / 权限 |
| 队列满 | queue_overloaded |
可延迟重试 | 退避重试或降级 |
| 服务内部错误 | internal_server_error |
可有限重试 | 重试并上报 request_id |
| 客户端断开 | client_disconnected |
否 | 由调用方决定是否重新发起 |
这张表的作用不是穷举所有错误,而是让 SDK 能把错误翻译成稳定异常类型。调用方拿到异常后,至少知道“该改参数”还是“可以重试”。
生成结束后进程退不出去
还有一类问题看起来不像线上事故:结果已经生成完了,但程序无法退出。
在评测系统里,为了更快,会把每张卡作为一个单独进程处理;而模型服务内部的工具调用也可能使用进程池。于是出现外层评测进程池嵌套内层工具进程池的情况。
曾经遇到过文本生成完成后,评测程序无法退出。最初怀疑是工具进程池没有销毁,于是尝试手动
close、使用 __del__、显式
del。后来发现实际项目里还有 lru_cache 保存了
self,导致进程池对象无法正常释放。
另一个类似问题是 future.result()
遇到异常后,中间变量和异常 frame
引用没有及时释放,导致对象引用计数异常,程序无法退出。
线上排查完之后,通常会把同类请求拿到离线评测或压测环境里复放。如果评测脚本批量复放几千条请求,生成结果都正常,但程序最后卡着不退出,问题就从“请求结果是否正确”变成了“整条执行链是否能收尾”。
这个 case 的分析过程和线上请求不太一样。线上请求通常先看响应和日志;进程不退出要先看存活对象和引用关系。一个简单的判断方式是:生成任务已经结束,但主进程还在等谁?是进程池没有 close,还是 future 没有释放,还是某个缓存对象把执行器引用住了?
这类问题最后经常落到生命周期管理:谁创建进程池,谁负责销毁;异常路径和正常路径是否都能释放;缓存里到底能不能放带资源句柄的对象。
输出正确不代表生命周期正确。评测脚本和线上服务复用同一套能力时,生命周期模型可能完全不同。线上服务是长进程,评测脚本希望跑完退出;线上服务里常驻进程池可以接受,评测脚本里没有释放就会 hang。
所以测试不能只测“输出是否正确”,还要测:
- 请求是否结束;
- 进程是否退出;
- 子进程是否释放;
- GPU/CPU 资源是否释放;
- 异常路径是否能退出。
这类问题不一定直接影响单次线上请求,但会影响评测、批处理、自动化训练等系统的可靠性。
排队超时带来的资源空转
排队超时看起来是普通超时,但在 LLM 服务里,它经常意味着资源浪费。
一次排队报警里,早上某个时段业务 QPS 增大,服务端出现排队。以 5 分钟超时为例,请求可能有两种路径:
- 排队 5 分钟,刚要进入模型时客户端已经断开;
- 排队 4 分钟,开始生成 1 分钟后客户端断开。
第一种浪费了排队资源和调度资源;第二种还额外浪费了 GPU
生成时间。排查时发现,大部分请求最终都是 client disconnected,正常
stop
的请求很少。也就是说,这段时间服务看似很忙,但大量工作没有产生业务价值。
这个 case 最容易只看入口 QPS 和平均耗时,但这两个指标都不够。排队场景下,一个请求的总耗时至少要拆成两段:queue time 和 generation time。客户端超时也要放进来一起看。
如果请求排队 4 分钟、生成 1 分钟、客户端 5 分钟超时,那么服务端看到的是“我确实生成了一段时间”,业务方看到的是“我还是超时了”。这时只优化生成速度不一定能解决问题,真正的问题可能是排队策略、准入控制和客户端 timeout 没有对齐。
一个请求如果最终被客户端放弃,那么它消耗的生成时间就是浪费。系统需要能回答:
- 多少请求进入了队列?
- 多少请求真正开始生成?
- 多少请求成功结束?
- 多少请求在排队时断开?
- 多少请求在生成中断开?
- 这些请求消耗了多少 GPU 时间?
这里还有一个常见放大器:业务方重试。如果业务方 60 秒超时后不断重试,而服务端或中间层仍在处理旧请求,系统就会积累越来越多没有意义的请求,进一步挤占队列。
一旦进入这种模式,就不再是一个请求的问题。第一次请求超时,业务方发起第一次重试;第二次还超时,又发起第二次重试。旧请求没有及时取消,新请求继续进入队列,排队时间变长,更多请求开始超时。静默失败在这里会自我放大。
所以对 LLM 服务来说,并发控制通常比单纯 QPS 控制更贴近资源模型。稀缺的是“同时占用模型实例的请求数”和“GPU 生成时间”,不是入口请求数。
这里可以把“浪费请求”定义得更明确一些:
1 | wasted_request = |
如果想更严格,也可以只统计断连后继续生成的部分:
1 | wasted_tokens = tokens_after_disconnect |
并发控制上线前后,不一定只看 QPS,而要看有效完成率。例如可以用这样一组脱敏口径表达:
1 | before: |
入口请求数下降,不一定是坏事。如果系统少接收了注定超时的请求,却提高了完成请求数和 GPU 有效利用率,整体服务质量反而更好。
训练任务 hang 但平台显示运行中
前面几类主要发生在在线推理链路。训练任务也有类似静默失败。
一次大规模训练任务中,任务启动后卡住 26 分钟。如果不开健康检查,平台上所有节点都显示运行中,直到 30 分钟后触发自动容错机制。打开健康检查后,可以发现有节点异常,但平台不会自动拉黑或替换问题节点,任务直接失败。
更复杂的是,相同机器反复提交,错误节点有概率不一样。有时换节点能解决,有时换完仍然遇到问题。最后定位到 GDR 相关环境变量,临时方案是关闭 GDR、换用 ACCL,稳定性恢复,但通信速度有一定损失。
训练 hang 的分析更慢,因为“运行中”这个状态本身信息量很低。第一步通常是确认它是真的没有向前推进,还是只是日志没有刷新。比如看 step 是否增长、loss 是否更新、GPU 利用率和通信是否有变化。
确认没有进展后,才进入节点和通信栈排查:是不是某个节点掉队,是不是健康检查能发现,重试后坏节点是否固定,关掉某个通信优化项后是否稳定。这里的问题不只是“哪台机器坏了”,而是平台能不能把坏节点从后续任务里隔离出去。
大规模训练任务的“运行中”不等于健康。健康检查解决的是可见性的一部分,但不自动等于恢复能力。还需要:
- 健康检查能发现异常;
- 异常节点能被隔离或拉黑;
- 重试策略能避免反复命中坏节点;
- 性能优化项有稳定性回退方案;
- 和云厂商之间有明确的排查 SOP。
训练任务的静默失败通常更贵,因为它消耗的是几十张甚至上百张卡的时间。一个任务“看起来在跑”,但实际没有向前推进,这本身就是事故。
放在一起看
这些问题根因不同,但放一起看,大概暴露了几类缺口。
一个请求应该在入口被拒绝,或者进入队列后被调度,或者客户端断开后被取消,或者流式结束时给出
[DONE]。它不应该停在中间某个没人负责的状态里。
状态没有命名
系统里存在太多没有被命名的中间状态:
- 等待中;
- 生成中;
- 客户端断开;
- 服务端继续;
- 流式半结束;
- SDK 收到但不可理解;
- 任务运行中但无进展。
没有被命名的状态,就很难被监控、报警和处理。
取消没有传下去
客户端取消、网关断开、业务超时、队列超时,都应该传播到生成任务和资源调度层。否则请求虽然在业务上已经死亡,但在系统里仍然占资源。
取消传播要跨越多层:SDK、HTTP 框架、服务逻辑、vLLM pipeline、队列调度、GPU 生成。任何一层断掉,都会变成资源空转。
日志字段不够
日志不能只记录“请求失败”。对 LLM 服务来说,日志至少要能回答:
- 排队多久?
- 生成多久?
- 输入多少 token?
- 输出多少 token?
- 是否客户端断开?
- 断开时已经生成多少?
- 是等待超时还是生成超时?
- SDK 看到的错误和 server 记录的错误是否一致?
没有这些字段,复盘时就只能靠猜。
结束和错误协议不清楚
很多失败其实是契约没有定义清楚:
- 流式结束信号是契约;
- 错误响应体是契约;
finish_reason是契约;- 499 日志字段是契约;
- SDK 解析结构是契约;
- 队列超时和客户端超时的关系也是契约。
这些契约如果不稳定,业务就无法正确处理失败。
换句话说,请求需要一条完整的生命线:什么时候进来,为什么被拒绝,排了多久,生成了多少,客户端是否还在,最后是
[DONE]、error 还是
cancelled。只要这条线断了,静默失败就有空间出现。
最后整理一下
最后整理成一份检查清单:
- 请求进入队列前做 token、
max_tokens、timeout 前置校验。 - 服务端记录
queue_time、generation_time、prompt_tokens、completion_tokens、finish_reason、client_disconnected。 - 明确区分排队超时、生成超时、客户端主动断开、服务端异常、网关异常。
- 流式接口以
[DONE]或明确错误事件作为结束契约。 - 非流式接口同样要检查客户端断连,并 abort 生成。
- SDK 必须能解析错误响应,不能吞掉
message、type、code。 - 499 也要记录足够完整的上下文。
- 对资源浪费请求建立指标,而不是只看错误率。
- 大任务健康检查要配合节点隔离和厂商协作 SOP。
- 把“请求是否退出、进程是否退出、资源是否释放”纳入回归测试。
这类问题最后看的不是单个模块有没有报错,而是请求有没有明确状态:能不能被前置校验拦住,进入队列后能不能记录排队时间,客户端断开后能不能停止生成,流式结束时有没有明确终态,返回错误时 SDK 能不能读懂,离线复放后进程能不能退出。