StreamChunk 流式协议与错误处理

约 20 分钟

描述 StreamChunk 协议如何承载流式输出,以及适配器如何处理分块、结束与错误重试。

上一课我们把 LLM 适配器定成 dsh 的模型调用接缝:它把 Agent 循环的统一消息与流式调用翻译成具体模型端点的协议,加载哪个适配器由模型提供方配置决定。现在打开 Web 控制台,你会看到回答是逐字出现的——先冒出几个字,再冒几个字,像有人在打字。这不是界面特效,是模型在流式生成,你的 LLM 适配器正在把结果一段段推给上游。

dsh 用一套统一的流式协议承载这些增量:StreamChunk。菜鸟教程专门有一页「DeepSeek Harness StreamChunk 协议与错误处理」讲它,官方 docs 的对应子系统叫 LLM streaming。架构文档把这块责任划给 llm/llm 包:消息与流词汇,加上适配器接缝,注册在共享上下文的 ctx.llm 上。

协议统一,适配器才能自由替换——这正是「一切皆插件」的落点。你写任何模型的适配器,都要按同一套协议发言;也正因如此,上一课说的「适配器本身是插件、可在配置层替换」才成立。你在界面看到的打字机效果,本质就是分块被实时追加的结果。

核心概念

StreamChunk:一次流式响应的最小单元

类比:看直播时,画面一帧一帧到达,字幕一行一行出现。你不会等整场直播下载完才看,而是边到边看。任何一帧单独看都意义有限,按顺序拼起来才是完整内容。

技术定义:StreamChunk 是 dsh 承载模型增量输出的最小数据单元。LLM 适配器把一次完整响应拆成多个 StreamChunk,按生成顺序逐个发出;消费方按到达顺序处理,不必等整段响应。一个流由一系列分块组成。

一套完整的流式协议至少要回答三个问题:内容分块如何表示、流何时结束、出错时如何上报。分块携带增量内容;结束块标记流结束;错误以结构化方式上送,而不是混进正文。结束块没有内容载荷,它的存在本身就是信息。

对比一次到位的整段消息,流式的好处是首字延迟低:第一个分块到达就能开始显示,不用等模型把整段生成完。代价是状态变多:消费方要跟踪流是否结束、是否出错。协议定义这些语义,适配器与界面都按同一套规则说话,换适配器时界面不用改。

一次流式响应的生命周期是:

  1. 模型开始生成,适配器发出第一个分块,它携带开头的内容。
  2. 后续分块依次到达。消费方按接收顺序把它们追加到界面,不重排、不合并。
  3. 最后一个分块是结束块。它不再携带新内容,只表示流已结束。

顺序是流式协议的生命线。先到的分块先显示,顺序一乱,读起来就是乱码。界面只做追加这一件事:按到达顺序显示,不做重排。这要求你发射分块的顺序与模型生成的顺序完全一致。

界面收到结束块后,才把这条回答标记为完成。没有结束块,消费方只能判断流被中断,不能当作正常回答。你想确认流是否正常结束,就看有没有收到结束块。

流式请求也会失败。网络断开、服务端超时、鉴权失败,都可能发生在已经输出几个分块之后。

错误要结构化上报。适配器把失败原因整理成结构化信息,通过协议上送,而不是把错误文本当作普通分块混进回答正文。混入的后果很直接:界面把报错当作回答内容显示,消费方可能拿它继续当模型的话处理。

结构化上报还让消费方能够区分错误类型。瞬时错误与配置类错误,后续处理完全不同:一个值得重试,一个重试也没用。因此,结构化错误是协议的一部分,不是异常情况下的临时补丁。你写适配器时,把错误路径当作正常路径一样实现。

错误上报之后,适配器还有一件事:对瞬时错误实现重试与退避。

先分类。网络抖动、服务端暂时不可用、限流,属于瞬时错误,值得重试。鉴权失败、请求参数错误,属于确定性错误,重试多少次结果都一样,直接结构化上报。

再退避。重试之间要等待,间隔逐次拉大:先等短一点,失败再等长一点。这样不会在服务端还没恢复时反复撞门。退避的具体间隔是调优话题,先保证有退避,再谈快慢。

重试策略放在适配器内部。适配器能读到错误的结构化信息,能判断要不要重试;同时要考虑幂等,同一段内容不要因为重试而重复提交。

记住目标:让消费方看到一条完整、顺序正确、失败可辨的流。

遇到问题:回答在中途停住,之后的分块不再出现;日志显示流已中断,但没有收到结束块。

可能原因

  • 网络连接在流式过程中断开,本地与模型服务失去联系。
  • 服务端在生成中途超时或崩溃,流被掐断。
  • 适配器在错误发生后静默终止流,没有结构化上报,消费方不知道发生了什么。

排查步骤

  1. 先确认网络:连接是否稳定,代理或网关有没有切断长连接。
  2. 查看适配器日志,有没有结构化错误信息;有就按错误类型分流处理。
  3. 瞬时错误:启用重试与退避,等待后重新发起流式请求。
  4. 确定性错误:不要重试,把结构化错误上送给消费方。
  5. 让适配器在出错时发送表示错误的分块再结束流,而不是直接掐断。
  6. 锁定 @deepseek-ai/dsh 版本并做回归测试,避免上游协议变化造成这类中断。

从适配器视角,一次合格的流式实现要过三关:

  • 发射顺序正确:分块按模型返回顺序逐个发出,不缓存、不打乱。
  • 结束块收尾:流结束时有明确的结束块,消费方据此标记完成。
  • 错误有交代:出错时结构化上报,瞬时错误配重试与退避。

对照这份清单检查你自己的适配器,缺哪一项就补哪一项。

自检:一次流式响应从首个分块到结束块的完整生命周期是怎样的?中途出错时,适配器应该做什么?

适配器按生成顺序逐个发出 StreamChunk;消费方按到达顺序把分块追加到界面,不重排、不合并。最后一个结束块不携带新内容,只标记流结束,界面收到它才把回答标记为完成。中途出错时,适配器把失败整理成结构化信息上送,不混进正文;对瞬时错误实施重试与退避,对确定性错误直接上报,不重试。