0%

LLM 服务静默失败问题记录

记录几次 LLM 服务里遇到的静默失败问题。

线上出问题时,最容易处理的反而是明确失败:进程 crash、HTTP 500、显存 OOM,或者日志里有异常栈。

麻烦的是另一类问题:请求不返回,但服务没挂;客户端已经断开,GPU 还在跑;流式响应发到一半,没有 [DONE];服务端返回了错误,SDK 却看不到错误原因。

这些问题表面上属于不同模块:vLLM 调度、FastAPI 流式接口、Java SDK、多进程工具调用、训练集群健康检查。放在一起看,其实都和“请求状态没有说清楚”有关。

我把这类问题叫做“大模型服务的静默失败”。

它和分布式系统里的 gray failure 有点像:系统自检看起来还健康,业务侧已经观察到异常。放到 LLM 服务里,最典型的形态就是 zombie request:客户端已经走了,服务端还在生成。

请求生命周期

传统 HTTP 服务里,请求状态通常比较简单:成功、失败、超时、取消。大模型服务复杂得多,因为它天然有更长的生命周期:

1
进入服务 -> 等待队列 -> 分配显存/调度 -> 逐 token 生成 -> 流式返回 -> 结束信号 -> 日志落盘

每一步都可能产生中间状态:

  • 等待队列里还没开始生成;
  • 已经开始生成,但客户端断开;
  • 已经返回一部分 token,但没有结束信号;
  • 服务端认为结束了,但调用方认为没结束;
  • 生成结果被丢弃,但 GPU 时间已经消耗;
  • 错误响应返回了,但 SDK 没有正确解析。

这些状态如果没有被显式记录,就会变成静默失败。

先用一个脱敏请求作为例子。假设业务方发起了一次长文本总结请求,请求参数大概是这样:

1
2
3
4
5
6
7
{
"requestId": "long_summary_demo",
"stream": true,
"promptTokens": 3800,
"maxTokens": 512,
"clientTimeoutMs": 60000
}

它看起来只是一个普通请求,但不同阶段都可能出问题:入口没有检查上下文长度时,它可能进队列后不返回;流式输出已经吐出几段内容时,它可能收不到 [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
2
3
4
5
total_stream_requests = 100000
stream_without_done_count = 37
client_timeout_after_partial_response = 31
server_error_logged = 4
unknown = 2

这个数字未必大,但影响很烦。因为它不是一开始失败,而是在调用方已经收到部分内容后失败。修复后,目标不是让所有请求都成功,而是让每个流式请求都有明确终态:[DONE]errorcancelled 三者之一。

客户端断开后服务端继续生成

第三类问题是客户端已经放弃了,但服务端还在消耗资源。

一个典型场景是客户端设置了 2 秒超时。如果服务器 2 秒内没有返回,客户端抛出超时异常并断开连接。按直觉,服务端应该感知到断开,然后取消生成。但实际排查时,服务端日志显示这个请求仍然处理了 3.6 秒。把客户端超时设置成 0.1 秒,服务端依旧会继续处理。

这意味着:客户端认为请求已经结束,服务端却还在跑。对于普通接口,这可能只是多消耗一点 CPU;对于 LLM 服务,这可能意味着 GPU 继续生成无用 token。

客户端超时后已经不再等结果,但服务端可能还在继续生成第 300 个、第 400 个 token。这个时候,它对业务已经没有价值,对 GPU 却仍然是一个真实负载。

这个问题的分析顺序也很重要。最开始不能只看客户端日志,因为客户端超时只能说明“调用方不等了”,不能说明服务端停了。要把同一个 request id 在客户端、网关、服务端生成日志里串起来看。

如果客户端 0.1 秒就断开,但服务端仍然完整跑完生成,说明断连信号没有传到生成任务。此时再看代码里的 is_disconnected,它存在不代表它生效。框架版本、Middleware、请求体读取方式,都可能影响这个判断。

问题最后定位到断连判断上。代码里确实有类似逻辑:

1
2
3
if await raw_request.is_disconnected():
await abort_request(request_id)
raise CancelledError(...)

但由于框架和 Middleware 行为,is_disconnected 的判断并不符合预期。代码看起来已经处理了断连,实际上取消信号没有可靠传到生成任务。

这里要注意的是,取消不只是日志里记录一个状态。客户端断开、网关断开、业务超时,都应该传播到生成任务。否则系统会出现大量“已经没有接收方”的生成请求。

后续还有一个相关问题:当请求返回 499 时,日志里缺少关键字段,比如输入 token 数、排队时间、生成时间。这样即使我们知道客户端断开,也不好判断它到底是在排队时断开,还是生成时断开。

所以取消治理至少要包含两件事:

  • 真的 abort 生成任务;
  • 断开时保留足够完整的请求上下文。

可以把“断连后继续生成”单独做成资源浪费指标。例如一条请求日志里同时记录:

1
2
3
4
5
6
7
8
9
{
"requestId": "req_xxx",
"clientDisconnected": true,
"queueTimeMs": 120,
"generationTimeMs": 3600,
"generatedTokens": 428,
"tokensAfterDisconnect": 311,
"finishReason": "client_disconnected"
}

如果 tokensAfterDisconnect 长期不为 0,就说明取消信号没有及时传播。即使整体错误率不高,这类请求也会悄悄吃掉 GPU 时间。对推理服务来说,这比普通 499 更值得单独监控。

SDK 没有解析错误信息

有些静默失败不发生在服务端,而发生在 SDK。

Python 服务端在请求参数错误时,会返回结构化错误,例如:

1
2
3
4
5
{
"message": "...",
"type": "invalid_request_error",
"code": 400
}

但 Java SDK 如果固定按 ChatCompletionResult 去反序列化,就可能把错误体当成正常响应结构解析。结果调用方看到的是一个 object=error 的对象,却看不到 messagetypecode 等真正有用的信息。

从服务端视角看,“我已经返回错误原因了”;从调用方视角看,“我不知道错在哪”。两个都对,问题出在中间的 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
2
3
wasted_request =
client_disconnected == true
and generated_tokens > 0

如果想更严格,也可以只统计断连后继续生成的部分:

1
2
wasted_tokens = tokens_after_disconnect
wasted_generation_time = generation_time_after_disconnect_ms

并发控制上线前后,不一定只看 QPS,而要看有效完成率。例如可以用这样一组脱敏口径表达:

1
2
3
4
5
6
7
8
9
10
11
before:
admitted_requests = 10000
completed_requests = 6200
client_disconnected = 3100
wasted_tokens = 1.8M

after:
admitted_requests = 7600
completed_requests = 6900
client_disconnected = 420
wasted_tokens = 0.2M

入口请求数下降,不一定是坏事。如果系统少接收了注定超时的请求,却提高了完成请求数和 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。只要这条线断了,静默失败就有空间出现。

最后整理一下

最后整理成一份检查清单:

  1. 请求进入队列前做 token、max_tokens、timeout 前置校验。
  2. 服务端记录 queue_timegeneration_timeprompt_tokenscompletion_tokensfinish_reasonclient_disconnected
  3. 明确区分排队超时、生成超时、客户端主动断开、服务端异常、网关异常。
  4. 流式接口以 [DONE] 或明确错误事件作为结束契约。
  5. 非流式接口同样要检查客户端断连,并 abort 生成。
  6. SDK 必须能解析错误响应,不能吞掉 messagetypecode
  7. 499 也要记录足够完整的上下文。
  8. 对资源浪费请求建立指标,而不是只看错误率。
  9. 大任务健康检查要配合节点隔离和厂商协作 SOP。
  10. 把“请求是否退出、进程是否退出、资源是否释放”纳入回归测试。

这类问题最后看的不是单个模块有没有报错,而是请求有没有明确状态:能不能被前置校验拦住,进入队列后能不能记录排队时间,客户端断开后能不能停止生成,流式结束时有没有明确终态,返回错误时 SDK 能不能读懂,离线复放后进程能不能退出。