streaming-error-recovery · ZH · 2026-10-09

流式中途断流:已经吐出 token 后报错该怎么续

本文讲解流式(SSE)对话在已经输出部分 token 后中途报错的成因、检测方式与恢复策略:如何用 finish_reason 和重试标记区分正常结束与异常中断,如何在客户端缓存已生成文本做断点续写,以及通过幂等、超时与重试预算让聚合 API 调用更稳。

流式接口为什么“吐一半会断”

用 SSE 调用 LLM 时,响应是一个持续推送的事件流。理想情况下你会在结束时收到明确的结束事件(如 finish_reason 或一个 [DONE] 标记),代表模型正常完成。

但真实链路很长:客户端 → 聚合站网关 → 上游供应商 → 模型。任何一环出问题,都会让流在“已经吐出一些 token 之后”断掉。常见原因包括:

  • 上游连接超时或空闲超时(长时间没有新 token)。
  • 上游返回中途错误事件,或直接关闭连接。
  • 网络抖动、代理重置、客户端读取超时。
  • 模型侧限流或过载导致流被中断。

关键点:HTTP 层面连接关闭 ≠ 请求失败。你可能已经拿到了一半有效内容,直接丢弃很浪费,尤其是在长文生成场景。

第一步:区分“正常结束”和“异常断流”

不要用“连接关闭”判断成功与否,要看你是否收到了结束信号。

  • 正常结束:收到 finish_reason(如 stop)或收到的 [DONE] 标记,说明这一轮生成完整。
  • 异常断流:连接关闭,但既没有 finish_reason,也没有完成标记。
  • 中途错误事件:部分实现会在流里推一个 error 事件,此时应立刻记录并停止累积。

一个稳妥的做法是在客户端维护状态机:

状态:streaming -> finished
             \-> interrupted(连接断/错误,且未 finished)

只有进入 finished 才认为这一轮成功。进入 interrupted 时,已累积的文本就是“部分结果”,可用于续写。

第二步:把已生成的文本当成可用资产

SSE 是增量推送,所以最自然的做法是边收边拼:

  • 每收到一个 delta,就 append 到本地 buffer。
  • buffer 建议持久化(内存 + 可选落盘),因为断流可能发生在任何时刻。
  • 记录断流时的 finish_reason=null 状态,并标记这条消息“未完成”。

这样即使请求中断,UI 上也能保留用户已经看到的内容,而不是整段消失。

第三步:断点续写的三种策略

续写的本质是:如何把“已生成的部分”告诉模型,让它接着写。 没有一种方法能保证与原轨迹完全一致,需要按场景取舍。

策略一:拼接式续写(最通用)

把“原始 prompt + 已生成的部分”作为上下文,再发一轮请求,并指示模型“从断开处继续,不要重复已有内容”。

  • 优点:实现简单,不依赖任何特殊接口。
  • 缺点:模型可能在衔接处轻微重复或风格微变;长内容会消耗更多上下文。

适合:长文、报告、代码等可接受轻微接缝的场景。

策略二:从安全断点重试

很多应用会遇到“结构化输出写一半断掉”的情况,比如 JSON。这时更稳的做法不是续写,而是从最近一个完整边界重新生成。

  • 只保留到最后一个能解析的完整块(如一个完整对象/数组元素)。
  • 丢弃不完整的尾部,重新请求补全。
  • 因为聚合站按 token 计费,重试会带来额外消耗,所以要在“重试成本”和“输出正确性”之间权衡。

适合:JSON、表格、逐条列表等对结构完整性要求高的输出。

策略三:分段生成

与其让模型一次输出很长内容,不如在业务层就把任务拆成多轮:每轮生成一个较小的段落,每段单独处理成功/失败。

  • 单段断流时,只重试该段,爆炸半径小。
  • 每段都有明确的 finish_reason,状态更清晰。

适合:可控长度的长文档、批量抽取等。

第四步:重试要用“预算”管住

断流后自动重试很常见,但盲目重试会放大成本和延迟,尤其是在按量计费的聚合站上。

建议给每次请求设置重试预算:

  • 限制最大重试次数(少量次数即可)。
  • 只对可重试错误重试:网络断开、超时、上游 5xx;对参数错误、鉴权失败不要重试。
  • 采用退避(backoff),避免瞬时打满上游。
  • 超过预算仍失败时,明确告诉用户“内容未完成”,并保留已生成部分供手动继续。

另外注意:流式请求本身不一定幂等。同一个 prompt 重试可能得到不同输出,所以续写通常比整轮重放更省。

在聚合 API 场景下的额外注意点

用“一个 key 调多个模型”的聚合站时,断流的来源可能在你和上游之间多一跳。可以做这些事让链路更可观测:

  • 记录每次请求用的是哪个模型、是否收到完成标记、断流发生在第几段。
  • 对同一任务做多模型对比时,把“断流率”当作一个监控指标,而不是只看单次结果。
  • 设置客户端读取超时(idle timeout),不要让一个卡住的流无限等待。
  • 保留 request id 之类的追踪标识,便于排查是网络问题还是上游问题。

一个可落地的处理流程

把上面的要点串起来:

  1. 发起 SSE 请求,进入 streaming 状态,逐块 append 到 buffer。
  2. 收到 finish_reason/完成标记 → 标记 finished,正常结束。
  3. 连接关闭但未 finished → 标记 interrupted,保留已生成文本。
  4. 判断输出类型:
  • 普通长文 → 拼接式续写;
  • 结构化输出 → 从安全断点重试;
  • 可拆分任务 → 按段生成并只重试失败段。
  1. 在重试预算内续写,超预算则提示用户并展示部分结果。
  2. 记录日志,便于观察断流分布与模型差异。

小结

流式断流不是罕见故障,而是需要被设计的正常路径。核心原则有两条:

  • 用结束信号判断成功,而不是用连接是否关闭。
  • 把已生成的 token 当作资产,先保留、再决定是续写、从断点重试还是分段重做。

把这两点做扎实,再配合重试预算和日志监控,就能在不稳定的网络与多跳链路下,给用户一个“断了也能接着用”的流式体验。