ClaudeCode 客户端消息流是如何确保按序输出的?
ClaudeCode 客户端消息流的有序性,本质上是 单写者 + 显式序号 + 受控并发 换来的工程纪律,不是靠运行时去猜消息该怎么排。
要点拆解:
- 所有 stream 事件先汇入统一写入通道,由 序号 决定渲染顺序,不按到达时间。
- 工具并发 被框死在 读可并行、写必串行 的边界内。
- UI 层只信任已经定序的快照,从不直接消费裸 SSE,否则就是乱序事故的源头。
- 回合作为原子单位,失败整体丢弃,不修补半截回合。
理解这套规则,比追着 SDK 文档更有用。
为什么"有序"这件事在 Agent 里特别难
普通聊天客户端把 SSE 增量按到达顺序拼起来就行,Agent 不能这么干。
模型一次回合里会交错产出 text_delta、tool_use、thinking,工具回来又触发新的子回合,子回合还可能 fan-out。按"先到先渲染"的结果就是:工具结果穿插在半截句子中间、思考块漏在工具输出之后——典型的乱序事故。
ClaudeCode 的解法不复杂,但需要把每一层都掐死:模型层有序输出 → 客户端层有序消费 → UI 层有序渲染,三段都不能漏。
模型层:先把"块"切清楚
Anthropic 的 stream 协议本身已经做了关键一步——每个 content block 都有显式 index。
message_start 给出 message 骨架,之后所有事件都带 index 标明属于哪个块,content_block_start / delta / stop 配对出现。也就是说,到达顺序可能乱,但归属顺序不会乱。
ClaudeCode 客户端做的第一件事就是按 index 归桶,而不是按到达顺序追加。这一步看似多余(HTTP/2 单流本来就有序),但它保护了下游:哪天换成多路复用、或者中间加个乱序的代理,归桶逻辑也不会崩。工程上不依赖网络承诺有序,这是基本素养。
客户端层:单写者模型把并发夹死
回合内只有一个 stream,问题不大;麻烦的是 Agent 触发了 N 个工具并发执行。
ClaudeCode 的处理方式可以浓缩成一句话:读可以并行,写必须串行。所有要进入对话历史的事件,无论来自模型 stream 还是工具结果,都必须经过同一个写入通道。
工具调用层面,只读类工具(Read / Glob / Grep / WebFetch)允许 fan-out 并行——它们不改状态,谁先回来都不影响语义。但只要涉及写(Edit / Write / Bash),调度器就强制串行,每条写完成后才把结果挂回主流。这条规则在 Claude 官方的 "Tool use best practices" 里也明说了,本质是用工具的语义类别去界定并发边界。
事件汇聚层面,模型 stream 产出的 tool_use 块进入待执行队列,工具结果以 tool_result 形式回来,但不直接追加进消息列表,而是先写入一个带序号的中间结构(每个 tool_use_id 对应一个槽位 )。槽位填齐之后,按原 tool_use 在消息里的位置原地填回去。
核心就在这一步:显示顺序由调度时确定的槽位决定,不由完成时间决定。 慢工具不会让快工具的结果插队,更不会让后续模型回合提前开始。
UI 层:只渲染快照,不消费裸事件
写流式 UI 最常见的坑,是让组件直接 subscribe SSE,每来一帧就 setState。
ClaudeCode 走的是反方向:stream 处理器维护一个"当前回合快照"对象,UI 订阅的是这个快照的不可变版本。
这样做有三个直接收益:
- 乱序重传可恢复。某个 delta 因为重试乱序到达,快照层可以丢弃或重新合并,UI 完全无感。
- 可中断不脏数据。用户按 Esc 中断时,快照层标记本回合 aborted,在途 delta 直接丢弃,不会出现"中断后又冒出半句话"。
- 历史一致性。回合结束写入持久化历史的就是快照的最终态——和 UI 看到的最后一帧严格相等,不存在"屏幕上和 JSONL 里不一样"的玄学问题。
工具结果与文本的相对位置
模型一次回合里可能产出:"说一句 → 调工具 A → 再说 一句 → 调工具 B → 总结"。如果工具 A 比 B 慢,naive 实现会让 B 的结果先显示。
ClaudeCode 的处理是:tool_result 不出现在当前回合的 assistant message 里,而是作为 user message 出现在下一个回合的开头。
这是 Anthropic API 本身的约定,但 ClaudeCode 严格执行了它——当前回合的 assistant 内容一旦 message_stop,整块就 freeze,工具结果统一进入下一回合的 user role。
用户看到的顺序永远是:本回合 assistant 完整内容 → 下回合工具结果块(按 tool_use_id 顺序)→ 下回合 assistant 内容。不会出现"工具结果插在助手没说完的句子里",因为协议层就不允许。
失败和重试如何不破坏顺序
任何一步失败(模型断流、工具异常、网络抖动),整个回合的快照标记为失败态,已渲染的部分允许保留显示但不进入历史。
除此之外还有两 条配套规则:
- 工具级幂等假设:重试只发生在工具层,默认假设工具幂等。这也是为什么 Bash 这种非幂等工具必须走严格串行 + 用户确认。
- 历史只追加:历史记录从不回滚,只会追加新回合来纠正。任何并发读取历史的组件都不需要处理"消息消失"的边界条件。
写给做 Agent 客户端的人
如果你也在做类似客户端,这五条直接抄走:
- 永远不要按到达顺序渲染,按显式 index 归桶后再渲染。
- 建立单写者通道,所有进入历史的事件都走这一个口子。
- 按工具语义划分并发边界,读并行、写串行,不要让用户帮你判断。
- UI 订阅快照,不订阅事件流,让流处理和渲染解耦。
- 回合作为原子单位,失败整体丢弃,不要修补半截回合。
这五条做到位,Agent 客户端就不会再有"消息错乱"的玄学 bug。剩下的问题基本都是产品决策,不是工程问题。