Token Pool SSE-切换链路复盘 2026-04-25
一、结论先行
这次故障不是单点问题,而是一条完整请求链上连续叠加的几个结构性错误:
openaiResponses-custom早期存在 unary 先打一遍、stream 再打一遍的双发路径,放大了上游消耗。- 流式
response.completed在部分异常路径或尾包场景下丢失,客户端会报stream closed before response.completed。 - CRS key 未显式配置
maxCredentialSwitchRetries时,被错误归一化成0,把凭证切换预算静默关掉。 - 即使切换预算恢复后,同一请求内的 retry 选择器仍会反复选回同一个坏 uuid,导致“名义上在切,实际上原地撞墙”。
- 从用户视角看,故障表现会不断变形:先是报错、再是几秒后消失、最后像是完全没反应。
最终修复的核心不是单纯补一个 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/providershttps://key.tengokukk.com/openai/debug/routeshttps://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/5retry 2/5retry 3/5
但更深的日志显示,每一轮 retry 都重新选回同一个坏节点:
be1d6074-3a71-4702-ab53-d07d52512559soxio-responses-v1
这说明系统名义上“在切换”,实际上却在:
失败节点 A
→ 标记不健康
→ 等待随机 backoff
→ 重新选回 A
→ 再失败这正是用户看到“卡几秒后直接消失”的直接原因。
四、最终落地的修复项
1. 协议层修复
openaiResponses-customunary 改为单路径流式聚合,去掉先 unary 再 stream 重打的双发路径- 在上游有有效 Responses 事件但缺少终止事件时,合成
response.completed - SSE tail buffer flush 落地,避免尾包无换行导致 completion 被吞
- 流式错误路径对 OpenAI Responses 客户端返回兼容的终止 completion,而不是裸错误事件
2. 预算层修复
src/core/crs-key-store.js:缺省maxCredentialSwitchRetries改为nullsrc/utils/common.js:null/undefined/''视为未配置,正确回退到全局默认CREDENTIAL_SWITCH_MAX_RETRIES
3. 选择层修复
- 同一请求内新增失败 uuid 排除逻辑
- 某个节点在本次请求里 402 后,后续 retry 不得再选回它
- 至少保证 retry 会尝试其他兼容节点,或者在候选耗尽后尽快失败返回
4. README / 控制面修复
- README 明确写死 canonical 公共入口
- README 明确内部目标和 health/debug 合约
- README 增补本次修复后的访问链和请求流转结构
五、这次最关键的经验
经验 1:不要把“未配置”归一化成 0
null 和 0 在控制平面里语义完全不同:
null= 未配置,应该回退默认值0= 显式关闭
任何把 null 直接 Number() 掉的代码,都要高度警惕。
经验 2:retry 是否发生,不能只看计数,要看它是否真的换了目标
日志里出现:
retry 1/5
retry 2/5并不代表系统真的在“切换”。
必须继续确认:
- 下一跳的
uuid有没有变化 selectedProviderId有没有变化- 是否只是“对同一个坏节点重复请求”
经验 3:SSE 故障排查必须分四层看
正确顺序应该是:
- 客户端层:有没有报
response.completed缺失 - 入口层:Nginx access/error 有没有请求
- runtime 层:Node 日志里有没有收到请求、有没有写 completion
- 路由层:是不是切换预算、节点排除、provider 选择出了问题
跳过其中任何一层,都很容易误判。
经验 4:不要把“本地修好了”误当作“线上生效了”
本次中间有一个关键转折是:
- 本地 mock / 单测已经通过
- 但 canonical 入口行为仍旧像旧版本
只有在远端看到新日志特征、并且通过真实请求链验证,才算真正生效。
经验 5:控制文档要把真相写死
如果 README 没明确以下内容,后续极易再次混乱:
- 唯一 canonical 入口
- 内部运行目标
- 公开 health/debug 契约
- 标准排障顺序
文档不只是说明书,它本身就是运行控制面的一部分。
六、后续仍值得继续加强的点
虽然这次已经把最关键的几刀补上了,但后续仍建议继续收敛:
- 给请求链增加显式
excludedProviderUuids/attemptedProviderUuids观测输出 - 对
402类错误区分“节点额度不足”与“账号级不可用” - 对 Responses 流式失败补更稳定的最终 completion 契约
- Nginx 的
/openai/v1location 显式固定 SSE 友好配置,避免后续环境漂移 - 给
provider selection和routing-runner增加更完整的跨节点 retry 单测
七、给未来排障的最小流程
如果以后又出现:
stream closed before response.completed- 卡住几秒后消失
- 明明还有节点却不切换
先按这条顺序查:
GET https://key.tengokukk.com/openai/healthGET https://key.tengokukk.com/openai/debug/requestsjournalctl -u aiclient-2-api-mock.service --since '3 minutes ago'- 看
routingPolicy.maxCredentialSwitchRetries - 看 retry 之后
selectedProviderId是否真的变了 - 看同一请求内是否仍然反复命中同一个 uuid
只要第 5 步和第 6 步没确认,就不能说“系统真的切换了”。
八、一句话总括
这次不是单纯的 SSE 修补,而是一次把“协议终止、切换预算、失败节点排除、canonical 文档真相”一起补齐的修复。
真正让系统恢复可用的,不是哪一刀单独神奇,而是:
completion 要能落地
+ null 不能误判为 0
+ retry 不能原地撞回同一坏节点
+ README 必须把真实链路写死