Token Pool 断流排查手册
这份文档说明:当 Token Pool 的流式响应出现“回答停住”“只吐了一半”“客户端看到 failed 或静默截断”时,应该先看哪些字段,如何区分是首包超时、上游中途断流、客户端断开,还是额度/配额类故障。
适用范围
- 适用于
Token Pool的流式请求链路,尤其是/openai/v1/responses和其他 SSE 风格返回。 - 当前口径基于以下真源实现:
E:\My Project\Token Pool\src\utils\common.jsE:\My Project\Token Pool\src\observability\request-context.jsE:\My Project\Token Pool\src\observability\debug-store.js
最短排查路径
- 先看最近请求调试面,定位目标请求。
- 看
finalStatus、streamCompleted、stopReason、streamLifecycle四组字段。 - 再结合 provider 健康状态和错误日志,判断是网关前失败,还是流开始后被截断。
如果只记一个规则,就记这个:
streamCompleted === false且finalStatus === "truncated",说明这次流在协议意义上没有正常收尾;即使 HTTP 状态还是200,也不能视为成功。
先看哪里
优先看请求调试快照,也就是最近请求视图。
建议先取:
GET /openai/debug/requests
当前这层快照已经直接暴露这些字段:
finalStatusstreamCompletedstreamLifecyclestopReason
其中:
finalStatus是最终归类值。streamCompleted表示这次流是否被系统判定为完整结束。stopReason表示最后是因为什么收尾。streamLifecycle表示流在生命周期里走到了哪一步。
四组关键字段怎么看
1. finalStatus
这是最先看的字段。
succeeded:请求成功完成。failed:请求失败,通常是还没进入“已发送部分数据”的阶段就失败了,或者是普通非流式失败。truncated:流被截断。这个状态比单纯看 HTTP 状态更重要。
当前实现里,streamCompleted === false 时,请求成本摘要和请求快照都会把它归成 truncated。
2. streamCompleted
true:流完整结束。false:流没有按预期完成。null:通常表示这次不是已归档的流式收尾结论,或者还没进入最终归档阶段。
这里的“完整结束”不是“连接断了就算结束”,而是“收到了协议级终止事件,或者明确走到了成功收尾逻辑”。
3. stopReason
这是判断“为什么停”的主字段。
最关键的几种:
stream_completed- 正常完成。
final_stream_error- 还没安全进入“已向客户端发送部分数据”的阶段,就以最终错误收尾。
- 常见于首包超时、上游直接报错、上游根本没产出任何 chunk。
partial_stream_sent- 已经向客户端写出过部分数据,但流没有正常完成。
- 这是“回答吐到一半停了”的核心标志。
client_disconnected- 客户端自己断开,通常不应误判成 provider 故障。
4. streamLifecycle
这是这次新增后最有用的一层。
当前结构:
{
"streamStarted": true,
"gotFirstChunk": true,
"gotTerminalEvent": false,
"endedNormally": false,
"truncated": true
}每个字段的语义:
streamStarted- 上游 iterator 已建立,网关已经真正进入流处理阶段。
gotFirstChunk- 首个 chunk 已收到。
gotTerminalEvent- 已收到协议级终止事件。
- 例如 Responses 协议里的
response.completed/response.failed/response.incomplete等。
endedNormally- 这次流被系统判定为正常收尾。
truncated- 这次流是在“已经开始但没有正常收尾”的情况下结束。
判定矩阵
场景 A:首包超时
常见表现:
- 客户端很快失败。
- 日志里出现
Upstream stream did not produce the first chunk within ... ms.
典型字段:
{
"finalStatus": "failed",
"streamCompleted": false,
"stopReason": "final_stream_error",
"streamLifecycle": {
"streamStarted": true,
"gotFirstChunk": false,
"gotTerminalEvent": false,
"endedNormally": false,
"truncated": false
}
}解释:
- 流已经启动。
- 但首个 chunk 没来。
- 这不是“中途断流”,而是“流根本没真正开始产出”。
场景 B:已经开始输出,但没有终止事件
这就是最典型的“断流”或“截断流”。
常见表现:
- 客户端已经看到部分正文。
- 最后又收到 failed,或者响应就停在那里。
- provider 会被标记为 unhealthy。
典型字段:
{
"finalStatus": "truncated",
"streamCompleted": false,
"stopReason": "partial_stream_sent",
"streamLifecycle": {
"streamStarted": true,
"gotFirstChunk": true,
"gotTerminalEvent": false,
"endedNormally": false,
"truncated": true
}
}解释:
- 流已经开始。
- 客户端已经收到内容。
- 但上游没有给出完整的协议终止事件。
- 这是当前系统里“回答吐了一半就死掉”的标准判定。
场景 C:正常完成
典型字段:
{
"finalStatus": "succeeded",
"streamCompleted": true,
"stopReason": "stream_completed",
"streamLifecycle": {
"streamStarted": true,
"gotFirstChunk": true,
"gotTerminalEvent": true,
"endedNormally": true,
"truncated": false
}
}解释:
- 首包到了。
- 终止事件到了。
- 收尾是完整的。
场景 D:客户端自己断开
典型字段:
{
"streamCompleted": false,
"stopReason": "client_disconnected"
}解释:
- 这类请求不要优先怀疑 provider。
- 应先判断是不是浏览器、CLI、代理层或下游 socket 主动断开。
场景 E:额度或配额类中途失败
常见表现:
- 已经返回了部分数据。
- 中途报
usage_limit_reached、429、quota exhausted 一类错误。 - provider 不只是 unhealthy,还可能带恢复时间。
典型观察点:
stopReason往往仍是partial_stream_sentstreamLifecycle.truncated === true- provider 健康管理层会记录 recovery time
这类场景的结论是:
- 从客户端体验看,它仍然是一次截断流。
- 从 provider 调度看,它是“可恢复时间已知”的配额故障,而不是普通瞬时错误。
真正定位“谁截断了流”的顺序
第一步:先确认是不是截断,不要先看 HTTP 状态
先看:
finalStatusstreamCompleted
如果看到:
finalStatus = truncatedstreamCompleted = false
那么就已经能确认:这不是正常完成。
第二步:再看流停在第几层
再看 streamLifecycle:
gotFirstChunk = false- 说明问题发生在首包前。
gotFirstChunk = true但gotTerminalEvent = false- 说明问题发生在中途。
gotTerminalEvent = true且endedNormally = true- 说明协议收尾是完整的,不属于断流。
第三步:用 stopReason 区分是谁触发的收尾
final_stream_error- 更偏向网关在“未完整开始输出”时就判失败。
partial_stream_sent- 更偏向上游在已经输出后没能完整结束,或者中途异常。
client_disconnected- 更偏向下游自己断开。
第四步:最后再看 provider 健康变化
如果是 partial_stream_sent,当前实现会:
- 标记 provider failure
- 必要时把 provider 标记为 unhealthy
- 如果是
usage_limit_reached一类错误,还会保留恢复时间
所以当你想知道“是不是某个 provider 自己坏了”,要把请求快照和 provider 健康/熔断信息一起看。
建议的最小排查模板
排查单次异常时,按这个模板抄就够了:
requestId:
providerType:
providerId:
model:
finalStatus:
streamCompleted:
stopReason:
streamLifecycle.streamStarted:
streamLifecycle.gotFirstChunk:
streamLifecycle.gotTerminalEvent:
streamLifecycle.endedNormally:
streamLifecycle.truncated:然后按下面规则给结论:
gotFirstChunk = false- 结论:首包前失败。
gotFirstChunk = true且gotTerminalEvent = false- 结论:中途截断。
stopReason = client_disconnected- 结论:优先查客户端/下游连接。
stopReason = partial_stream_sent且报 429/quota- 结论:中途配额类截断,优先查 provider 恢复时间。
当前实现里的关键真源位置
如果需要从代码继续追:
- 流生命周期状态机:
E:\My Project\Token Pool\src\utils\common.js
- 请求上下文字段定义:
E:\My Project\Token Pool\src\observability\request-context.js
- debug 快照与
finalStatus/streamLifecycle派生:E:\My Project\Token Pool\src\observability\debug-store.js
当前口径下的结论
在这版实现里,“如何找到断流原因”的核心不是去猜日志,而是按下面顺序读状态:
finalStatusstreamCompletedstopReasonstreamLifecycle- provider 健康状态 / recovery time
只要这五层一起看,通常就能很快回答三个问题:
- 这次是不是断流?
- 是首包前失败,还是中途截断?
- 更像是客户端断开、上游异常,还是额度类故障?