AGENT

Token Pool 流式断流排查手册

2026/04/28 12 min read AGENT TOKEN POOL 流式断流排查手册

Token Pool 断流排查手册

这份文档说明:当 Token Pool 的流式响应出现“回答停住”“只吐了一半”“客户端看到 failed 或静默截断”时,应该先看哪些字段,如何区分是首包超时、上游中途断流、客户端断开,还是额度/配额类故障。

适用范围

  • 适用于 Token Pool 的流式请求链路,尤其是 /openai/v1/responses 和其他 SSE 风格返回。
  • 当前口径基于以下真源实现:
    • E:\My Project\Token Pool\src\utils\common.js
    • E:\My Project\Token Pool\src\observability\request-context.js
    • E:\My Project\Token Pool\src\observability\debug-store.js

最短排查路径

  1. 先看最近请求调试面,定位目标请求。
  2. finalStatusstreamCompletedstopReasonstreamLifecycle 四组字段。
  3. 再结合 provider 健康状态和错误日志,判断是网关前失败,还是流开始后被截断。

如果只记一个规则,就记这个:

streamCompleted === falsefinalStatus === "truncated",说明这次流在协议意义上没有正常收尾;即使 HTTP 状态还是 200,也不能视为成功。

先看哪里

优先看请求调试快照,也就是最近请求视图。

建议先取:

  • GET /openai/debug/requests

当前这层快照已经直接暴露这些字段:

  • finalStatus
  • streamCompleted
  • streamLifecycle
  • stopReason

其中:

  • 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_sent
  • streamLifecycle.truncated === true
  • provider 健康管理层会记录 recovery time

这类场景的结论是:

  • 从客户端体验看,它仍然是一次截断流。
  • 从 provider 调度看,它是“可恢复时间已知”的配额故障,而不是普通瞬时错误。

真正定位“谁截断了流”的顺序

第一步:先确认是不是截断,不要先看 HTTP 状态

先看:

  • finalStatus
  • streamCompleted

如果看到:

  • finalStatus = truncated
  • streamCompleted = false

那么就已经能确认:这不是正常完成。

第二步:再看流停在第几层

再看 streamLifecycle

  • gotFirstChunk = false
    • 说明问题发生在首包前。
  • gotFirstChunk = truegotTerminalEvent = false
    • 说明问题发生在中途。
  • gotTerminalEvent = trueendedNormally = 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 = truegotTerminalEvent = 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

当前口径下的结论

在这版实现里,“如何找到断流原因”的核心不是去猜日志,而是按下面顺序读状态:

  1. finalStatus
  2. streamCompleted
  3. stopReason
  4. streamLifecycle
  5. provider 健康状态 / recovery time

只要这五层一起看,通常就能很快回答三个问题:

  • 这次是不是断流?
  • 是首包前失败,还是中途截断?
  • 更像是客户端断开、上游异常,还是额度类故障?