Skip to main content

OpenCode 客户端消息流是如何确保按序输出的?

· 6 min read

OpenCode 客户端保证消息有序,靠的不是排序算法,而是 "按 partID 分桶 + 桶内就地更新" 的状态合并模型。

核心设计 点:

  1. 每个 part 由服务端分配全局唯一 id创建顺序 = 渲染顺序
  2. 后续 part.updated 事件按 id 就地覆盖,不动 partOrder
  3. 断线重连先拉快照、再续 SSE,幂等更新天然无缝衔接

反面教材:早期每次更新都 setMessages([...messages]) 全量复制,消息一长 CPU 直接飙满。Streaming UI 的性能瓶颈从来不在网络,而在前端的更新粒度。


为什么这事不像看起来那么简单

很多人第一反应是:"SSE 是 TCP 流,本来就有序,客户端按顺序 append 就行。"这个想法在单一 part 的纯文本流里成立,一旦进入 Agent 场景就崩了。

OpenCode 的一条 assistant message 通常包含多种 part:texttoolreasoningstep-startstep-finish,而 tool 调用本身是异步的。模型先吐一段思考,再发起一个 tool call,tool 在后台跑着的时候模型可能又开始吐下一段文本。服务端为了不阻塞,每个 part 都是独立 stream,事件交错推送是常态。客户端如果"收到啥就 push 到末尾",渲染出来就是思考、文本、工具结果乱成一锅粥。

更麻烦的是 part 内容本身是增量的。一个 text part 会先发 content="",然后 content="你好",再 content="你好,世界"。把每个事件当成新消息 push,UI 上会出现三段重复内容。

解法:part 是状态,不是事件

关键设计是把 part 当作有 ID 的状态对象,而不是事件流里的一行 log。每个 part 在创建时由服务端分配全局唯一 id(比如 prt_xxx),后续所有更新事件都带着这个 id 回来。

客户端的核心数据结构:

type Message = {
id: string
parts: Map<string, Part> // key 是 part.id
partOrder: string[] // part.id 的有序数组
}

收到 message.part.updated 时只有两个分支:

  1. parts 里已有这个 id:直接覆盖内容,partOrder 不动——处理增量更新,同一个 text part 永远在原位置扩展
  2. parts 里没有:新 part,append 到 partOrder 末尾,同时写入 parts

精妙之处在于:到达顺序 = 创建顺序。服务端单线程顺序创建 part(即使内容生成是并发的),所以"第一次见到的 part id"出现的顺序,就是它在最终消息里应该出现的位置。后续乱序到达的更新事件只改内容、不改位置。

为什么不用 sequence number

理论上给每个事件加 seq、按 seq 排序也能工作,但在 Agent 场景下成本太高:

  • 缓冲延迟:按 seq 排序意味着要等空缺补齐,LLM 输出本来就慢,再加一层缓冲会明显劣化 streaming 体验
  • part 内顺序无意义:同一个 text part 的多次 updated,后到的一定覆盖先到的(内容是累积的),根本不需要排序
  • 跨 part 顺序由创建决定:而创建事件本身就是有序的,无须额外字段

"创建定序,更新就地"本质上是利用了 part 的"创建-更新"语义分离,把全局有序简化成了局部幂等。

step 边界 part

Agent 的一个 step 通常包含"思考 → 工具调用 → 工具结果"。OpenCode 用 step-startstep-finish 两个特殊 part 标记边界,UI 上可以渲染成分隔线或 step 计数。

这两种 part 有自己的 id,按上面的规则走没问题。需要注意的是 step-finish 一定在该 step 内所有 part 创建之后才发送——服务端调度时用 await 保证了这一点。所以客户端按到达顺序 append 是安全的。

tool part 的状态机

tool part 最容易出问题,因为它有明确的状态流转:pending → running → completed/error。一个 tool 可能跑几秒到几十秒,期间 input/output 都在变。

OpenCode 的做法是 tool part 也只是一个普通 part,状态机存在 state 字段里:

{
id: "prt_tool_xxx",
type: "tool",
state: { status: "running", input: {...}, output: null }
}

每次状态变化触发一次 part.updated,客户端就地覆盖 state 字段。React 层通过 useSyncExternalStore 订阅 message store,状态变化自动触发对应组件的重渲染,不会影响其它 part。

隐藏好处是乱序容忍。completed 事件如果因为网络原因比中间某个 running 先到,客户端覆盖一次再被晚到的覆盖回去——虽然 SSE 单连接传输层有序,这种情况几乎不会发生,但幂等设计提供了天然兜底。

断线重连

客户端断线重连后,会先拉一次 message 的完整快照(GET /session/:id/message/:msgId),再接上 SSE 流。这一步必须做,因为 SSE 默认不支持回放,断线期间错过的 part 创建事件永远不会重发。

拉到快照后,客户端用最新数据完全覆盖 partspartOrder,然后处理新到的 SSE 事件。因为更新是幂等的(同 id 覆盖、新 id 追加),"全量替换 + 增量更新"的衔接天然无缝。

一个反面教材

早期版本(git history 里能翻到)的做法是每个 part.updated 都触发 setMessages([...messages]) 这种整数组复制。结果消息一长就开始卡,CPU 飙到 100%。改成基于 part id 的局部更新 + 细粒度订阅后,性能问题立刻消失。

性能教训

Streaming UI 的性能瓶颈从来不在网络,而在前端的更新粒度。能更新一个 part 就别更新整条 message,能更新一个字段就别替换整个 part。

小结

OpenCode 保证消息按序输出,靠的不是排序、不是缓冲、不是 sequence number,而是一条朴素原则:

  • part 创建顺序 = part 渲染顺序(服务端保证)
  • part 更新事件按 id 就地覆盖(不影响顺序)
  • 重连用快照覆盖再续(保持幂等)

"按序输出"从分布式系统问题降级成了本地状态合并问题——复杂度低、性能好、容易调试。下次设计类似 streaming 协议时,"创建即定序,更新即幂等"这个拆分思路值得借鉴。