OpenCode 客户端消息流是如何确保按序输出的?
OpenCode 客户端保证消息有序,靠的不是排序算法,而是 "按 partID 分桶 + 桶内就地更新" 的状态合并模型。
核心设计 三 点:
- 每个 part 由服务端分配全局唯一
id,创建顺序 = 渲染顺 序 - 后续
part.updated事件按 id 就地覆盖,不动partOrder - 断线重连先拉快照、再续 SSE,幂等更新天然无缝衔接
反面教材:早期每次更新都 setMessages([...messages]) 全量复制,消息一长 CPU 直接飙满。Streaming UI 的性能瓶颈从来不在网络,而在前端的更新粒度。
为什么这事不像看起来那么简单
很多人第一反应是:"SSE 是 TCP 流,本来就有序,客户端按顺序 append 就行。"这个想法在单一 part 的纯文本流里成立,一旦进入 Agent 场景就崩了。
OpenCode 的一条 assistant message 通常包含多种 part:text、tool、reasoning、step-start、step-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 时只有两个分支:
parts里已有这个 id:直接覆盖内容,partOrder不动——处理增量更新,同一个 text part 永远在原位置扩展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-start 和 step-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 创建事件永远不会重发。
拉到快照后,客户端用最新数据完全覆盖 parts 和 partOrder,然后处理新到的 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 协议时,"创建即定序,更新即幂等"这个拆分思路值得借鉴。