BLOG

Token Pool SSE-切换链路复盘 2026-04-25

2026/04/25 14 min read BLOG TOKEN POOL SSE ROUTING POSTMORTEM 池子

Token Pool SSE-切换链路复盘 2026-04-25

一、结论先行

这次故障不是单点问题,而是一条完整请求链上连续叠加的几个结构性错误:

  1. openaiResponses-custom 早期存在 unary 先打一遍、stream 再打一遍的双发路径,放大了上游消耗。
  2. 流式 response.completed 在部分异常路径或尾包场景下丢失,客户端会报 stream closed before response.completed
  3. CRS key 未显式配置 maxCredentialSwitchRetries 时,被错误归一化成 0,把凭证切换预算静默关掉。
  4. 即使切换预算恢复后,同一请求内的 retry 选择器仍会反复选回同一个坏 uuid,导致“名义上在切,实际上原地撞墙”。
  5. 从用户视角看,故障表现会不断变形:先是报错、再是几秒后消失、最后像是完全没反应。

最终修复的核心不是单纯补一个 SSE 事件,而是把下面四个层级都补齐:

  • 协议终止层:保证 response.completed
  • 重试预算层:null 不再被误判为 0
  • 节点选择层:同一请求内排除已失败 uuid
  • 文档真相层:明确 canonical 入口、内部目标和验证顺序

二、真实访问链

当前 Token Pool 的真实 canonical 链如下:

客户端 / Codex / OpenAI-compatible consumer

https://key.tengokukk.com/openai/v1

Nginx on 170.106.179.226

http://127.0.0.1:3301/v1

aiclient-2-api-mock.service

openaiResponses-custom provider pool

具体上游节点 / credential

对应公开契约:

  • 公共 API base:https://key.tengokukk.com/openai/v1
  • 公共 health:https://key.tengokukk.com/openai/health
  • 公共 debug:
    • https://key.tengokukk.com/openai/debug/providers
    • https://key.tengokukk.com/openai/debug/routes
    • https://key.tengokukk.com/openai/debug/requests

对应内部运行目标:

  • runtime root:/srv/aiclient-2-api-mock
  • service:aiclient-2-api-mock.service
  • internal API base:http://127.0.0.1:3301/v1
  • internal health:http://127.0.0.1:3301/health

三、这次故障是怎么一步步暴露出来的

1. 第一阶段:客户端明确报协议错误

用户最初看到的是:

stream disconnected before completion: stream closed before response.completed

这说明客户端不是没收到任何东西,而是:

  • 已经建立了流
  • 但直到连接关闭都没等到合法的 response.completed

这一阶段的主要问题在协议终止层。

2. 第二阶段:修完 completion 后,症状变成“卡住后消失”

response.completed 合成和 SSE tail buffer flush 落地之后,现象发生了质变:

  • 不再稳定报原来的 completion 错误
  • 变成卡几秒后直接消失

这类现象很容易让人误判为:

  • Nginx 缓冲
  • SSE 被截断
  • 连接提前关闭

但线上日志表明,真实主因不是“请求根本没到”,而是“请求到了,开始重试,但切换逻辑本身有结构性错误”。

3. 第三阶段:发现 Key 缺省字段把切换预算关死

线上 crs_k_20260423_gateway 并没有显式配置 maxCredentialSwitchRetries

问题在于:

  • src/core/crs-key-store.js 早期使用 normalizeNumber
  • null/undefined 返回 0

这会把“未配置”错误地变成“明确禁止切换”。

修完第一层后,又发现主请求链里还有第二层相同问题:

  • src/utils/common.js
  • Number(routingPolicy?.maxCredentialSwitchRetries)
  • 在 JavaScript 中 Number(null) === 0

所以即使 key store 已经返回 null,主链还是会把它再次压回 0

4. 第四阶段:预算恢复后,发现选择器还在原地撞坏节点

预算修好后,日志第一次出现了真正的 retry:

  • retry 1/5
  • retry 2/5
  • retry 3/5

但更深的日志显示,每一轮 retry 都重新选回同一个坏节点:

  • be1d6074-3a71-4702-ab53-d07d52512559
  • soxio-responses-v1

这说明系统名义上“在切换”,实际上却在:

失败节点 A
  → 标记不健康
  → 等待随机 backoff
  → 重新选回 A
  → 再失败

这正是用户看到“卡几秒后直接消失”的直接原因。

四、最终落地的修复项

1. 协议层修复

  • openaiResponses-custom unary 改为单路径流式聚合,去掉先 unary 再 stream 重打的双发路径
  • 在上游有有效 Responses 事件但缺少终止事件时,合成 response.completed
  • SSE tail buffer flush 落地,避免尾包无换行导致 completion 被吞
  • 流式错误路径对 OpenAI Responses 客户端返回兼容的终止 completion,而不是裸错误事件

2. 预算层修复

  • src/core/crs-key-store.js:缺省 maxCredentialSwitchRetries 改为 null
  • src/utils/common.jsnull/undefined/'' 视为未配置,正确回退到全局默认 CREDENTIAL_SWITCH_MAX_RETRIES

3. 选择层修复

  • 同一请求内新增失败 uuid 排除逻辑
  • 某个节点在本次请求里 402 后,后续 retry 不得再选回它
  • 至少保证 retry 会尝试其他兼容节点,或者在候选耗尽后尽快失败返回

4. README / 控制面修复

  • README 明确写死 canonical 公共入口
  • README 明确内部目标和 health/debug 合约
  • README 增补本次修复后的访问链和请求流转结构

五、这次最关键的经验

经验 1:不要把“未配置”归一化成 0

null0 在控制平面里语义完全不同:

  • null = 未配置,应该回退默认值
  • 0 = 显式关闭

任何把 null 直接 Number() 掉的代码,都要高度警惕。

经验 2:retry 是否发生,不能只看计数,要看它是否真的换了目标

日志里出现:

retry 1/5
retry 2/5

并不代表系统真的在“切换”。

必须继续确认:

  • 下一跳的 uuid 有没有变化
  • selectedProviderId 有没有变化
  • 是否只是“对同一个坏节点重复请求”

经验 3:SSE 故障排查必须分四层看

正确顺序应该是:

  1. 客户端层:有没有报 response.completed 缺失
  2. 入口层:Nginx access/error 有没有请求
  3. runtime 层:Node 日志里有没有收到请求、有没有写 completion
  4. 路由层:是不是切换预算、节点排除、provider 选择出了问题

跳过其中任何一层,都很容易误判。

经验 4:不要把“本地修好了”误当作“线上生效了”

本次中间有一个关键转折是:

  • 本地 mock / 单测已经通过
  • 但 canonical 入口行为仍旧像旧版本

只有在远端看到新日志特征、并且通过真实请求链验证,才算真正生效。

经验 5:控制文档要把真相写死

如果 README 没明确以下内容,后续极易再次混乱:

  • 唯一 canonical 入口
  • 内部运行目标
  • 公开 health/debug 契约
  • 标准排障顺序

文档不只是说明书,它本身就是运行控制面的一部分。

六、后续仍值得继续加强的点

虽然这次已经把最关键的几刀补上了,但后续仍建议继续收敛:

  1. 给请求链增加显式 excludedProviderUuids / attemptedProviderUuids 观测输出
  2. 402 类错误区分“节点额度不足”与“账号级不可用”
  3. 对 Responses 流式失败补更稳定的最终 completion 契约
  4. Nginx 的 /openai/v1 location 显式固定 SSE 友好配置,避免后续环境漂移
  5. provider selectionrouting-runner 增加更完整的跨节点 retry 单测

七、给未来排障的最小流程

如果以后又出现:

  • stream closed before response.completed
  • 卡住几秒后消失
  • 明明还有节点却不切换

先按这条顺序查:

  1. GET https://key.tengokukk.com/openai/health
  2. GET https://key.tengokukk.com/openai/debug/requests
  3. journalctl -u aiclient-2-api-mock.service --since '3 minutes ago'
  4. routingPolicy.maxCredentialSwitchRetries
  5. 看 retry 之后 selectedProviderId 是否真的变了
  6. 看同一请求内是否仍然反复命中同一个 uuid

只要第 5 步和第 6 步没确认,就不能说“系统真的切换了”。

八、一句话总括

这次不是单纯的 SSE 修补,而是一次把“协议终止、切换预算、失败节点排除、canonical 文档真相”一起补齐的修复。

真正让系统恢复可用的,不是哪一刀单独神奇,而是:

completion 要能落地
+ null 不能误判为 0
+ retry 不能原地撞回同一坏节点
+ README 必须把真实链路写死

超简单大白话,一秒懂 ## 1. 两个黄金端口 - 80 端口 = HTTP(不安全、明文、网址 http://) - 443 端口 = HTTPS(安全、加密、网址 https://) --- ## 2. 直接对应你现在的网站 你的: https://key.tengokukk.com默认走的就是 443 端口 只要浏览器输入 https:// 自动默认访问服务器 443 端口,不用你手动加端口号。 --- ## 3. 结合你服务器架构 外网用户 → 访问域名(https) ↓ 自动连你 VPS 的 **443 端口** ↓ Nginx 专门守在 443 门口 ↓ 解密HTTPS、校验证书 ↓ 转发内网 127.0.0.1:3301 ↓ 你的AI池子程序 --- ## 4. 核心区别 | 端口 | 协议 | 特点 | |------|------|------| | 80 | http | 裸奔,数据不加密,容易被劫持 | | 443 | https | 全程加密,带SSL证书,安全,浏览器不会报错 | --- ## 5. 最通俗总结 - 443 = 安全上网的专属大门 - 你域名带 https://,全靠 443 端口支撑 - Nginx 就是霸占了你服务器的 80、443 两个端口 - 你的 3301 是内网后门,外人永远看不到 --- 补充: 你平时不用写: https://key.tengokukk.com:443 浏览器会自动省略 443,默认自带。