流自然结束不等于回答完整

**「流读完了」只说明连接结束,不说明回答生成完了 —— 判据必须是 `finish_reason`,不是「循环退出」。**

正常结束是 finish_reason: "stop",撞输出上限是 "length"。如果收到的是 null,表示流没走完就被外部关掉了。可直接用于定位的证据:

  • finish_reason: null(而非 stop/length);
  • 整个响应只有 1 个 chunk id,且多次复现都在同一耗时附近挂断 → 上游有固定超时;
  • 上游实际模型与配置里写的不一致 → 轮换/中转层在生效,故障点在中转而不在业务代码。

为什么用户看不到任何错误:中转「干净地关闭连接」(done)时不会抛错,服务端的 catch (stream) 分支不触发,于是顺手补了个 [DONE];前端收到 done 就正常退出消费循环,把残缺内容当完整答案渲染。整条链路没有一处认为「出错了」。

所以正确做法是把「缺少终止原因」当成一种失败信号显式传递:服务端发现「流自然结束但没见到 finish_reason」就下发 {truncated: true},前端据此在气泡尾部提示「回答被上游截断,可重试」,而不是静默补 [DONE]。

附带的取证陷阱:推理模型会先用思考 token 吃掉预算,只累加 delta.content 统计出的正文长度不能反推 max_tokens 是否被撞到(例如 1024 上限下正文只落 84 字,是因为 reasoning_content 占了大头)。统计输出长度时必须把思考内容一起算,否则会得出「84 字就触发 length」这种反直觉且错误的结论。

相关