Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

模型: openai/gpt-5.4
生成日期: 2026-04-01
书名: Claude Code VS OpenCode:架构、设计与未来
章节: 第3章 — 核心循环:ReAct范式
Token用量: 约 18,300 input + 2,300 output

3.2 流式处理架构

ReAct 循环要想在产品里成立,前提不是“模型能调工具”,而是系统必须能把 LLM→运行时→UI 的数据流稳定地搬运出去。也因此,流式处理 architecture 是三套系统的第二层共同骨架。所谓“流式”,并不只是逐字显示文本,而是把 reasoning、text、tool_use、tool_result、progress、attachment、error 等异质事件在时间上连续送达,并在尚未结束时允许系统作出反应。真正困难的地方不在首屏速度,而在于这条流里混有可执行事件、部分结果、并发子流和中途中断。

OpenCode 的流式路径相对直接。session/processor.ts 调用 LLM 流接口后,按事件类型逐个消费 fullStream,然后把 reasoningtexttoolstep 片段更新到 MessageV2。这种设计的优点是“单流单账本”:模型给出什么,处理器就把什么落到结构化 part 中,UI 与存储读同一份状态。借助 Vercel AI SDK,OpenCode 天然获得统一的 streaming event 语义,因此文本增量、reasoning 增量和 tool call 可以走同一个分发器。它的优势是清晰;代价是复杂并发场景大多留给上层扩展去处理。

Claude Code 则显著更复杂。query.ts 里一边消费模型流,一边维护 assistantMessagestoolUseBlockstoolResultspendingToolUseSummary 等多个缓冲区;当 tool_use 产生时,可以选择传统的回合后工具执行,也可以启用 StreamingToolExecutor 边收边跑。后者的意义在于:模型还没完全结束输出,工具已经可以排队、并发、安全地启动。与此同时,它仍然要求结果按接收顺序回灌,避免 UI 与 transcript 出现错序。这里其实涉及一个经典系统问题:背压(backpressure)。背压不是教材里只属于网络协议的概念,在 agent 系统里,它指“上游 LLM 产出 tool_use 太快,而下游工具执行、权限确认、UI 渲染来不及消费”时,系统如何避免内存膨胀、顺序错乱与状态丢失。Claude Code 通过队列、并发上限、context modifier、abort controller 和 synthetic tool_result 共同吸收这类压力。

另一个挑战是 部分工具结果。很多工具不是瞬时返回,而是会先产生 progress,再给出最终结果;或者在 streaming fallback、用户中断、权限拒绝时留下“半拉子状态”。OpenCode 的做法偏保守:工具 part 从 pending→running→completed/error,状态机相对紧凑。Claude Code 则更强调“不留悬空块”:即使流中断,也会通过 yieldMissingToolResultBlocks()StreamingToolExecutor.getRemainingResults() 合成缺失的 tool_result,确保每个 tool_use 都有可追踪的闭环。这不是 UI 小修小补,而是协议一致性要求;否则后续 API 调用会因 thinking/tool block 不匹配而崩坏。

OMO 面对的新增难题是 多智能体并发流。它自己并不重写底层 streaming parser,但在 OpenCode 之上又叠加了后台子 agent、continuation、task notification 和 hook 注入。于是,从 UI 角度看,用户并不是只接收一个 assistant 的单线流,而是可能接收主线程输出、后台任务完成通知、Ralph Loop 续接提示、todo continuation 倒计时触发后的再注入消息。此时“流”不再只是字节序列,而是多来源事件总线。OMO 的工程重点因此变成:怎样在不破坏 OpenCode 主流的前提下,把额外的事件插到正确时机,比如在 tool.execute.after 后监控上下文,在 experimental.chat.messages.transform 前修补 thinking block,在 session.idle 事件上决定是否再续一轮。

还有一个常被低估的问题是 UI 一致性。文本 token 可以天然逐步显示,但 tool result、attachment、后台通知并不总是适合“边来边画”。OpenCode 因为 part 结构比较整齐,UI 可以按类型稳定渲染;Claude Code 则需要同时处理 tombstone、summary、interruption message、hook attachment 等更多中间态,因此它更强调 transcript 与 UI 双重一致——既要让用户看得懂,也要让后续恢复与重放不失真;OMO 再往上叠加多智能体通知后,UI 的职责进一步从“渲染内容”升级为“表达系统状态”。这说明流式架构的终点不是更炫的界面,而是让用户始终知道:现在是谁在输出、哪个工具正在运行、哪一段结果是暂态、哪一段已经成为历史事实。

如果说 OpenCode 的 streaming 更像“模型流解析器”,Claude Code 更像“带流控的多阶段执行管线”,那么 OMO 则更接近“附着在主流上的事件编排层”。三者共同说明了一点:流式架构的本质不是前端动画,而是 状态传播。谁拥有更清晰的状态边界,谁就更能处理异常、并发与恢复。

从智能体设计角度看,未来更好的方案应当同时满足四点:第一,事件类型统一,文本、thinking、tool、attachment 都能进入同一抽象;第二,工具执行必须支持顺序与并发混合调度;第三,任何中断都要能补全协议闭环,不能留下 orphaned tool call;第四,多智能体事件必须与主会话流隔离但可桥接。只有做到这一点,流式传输才不只是“看起来更快”,而是真正成为 agent runtime 的主动脉。

深度解析:类型安全事件总线

如果说 streaming architecture 解决的是“信息怎样持续流动”,那么 event bus 解决的就是“系统内部到底谁该知道什么事”。在一个成熟的 agent 系统里,真正需要互相通知的模块非常多:UI 要知道新文本什么时候到达,tool executor 要广播工具开始与结束,MCP manager 要通知远端服务是否断开,session store 要把状态变化写入持久化层,日志与统计模块可能还要旁听全部过程。它们像一家公司里的多个部门,平时各做各的事,但一旦发生重要事件,就必须立刻互相同步。

可以把它想象成一家大型公司。前台收到用户投诉后,客服、财务、物流、法务都可能需要知道;仓库发货失败后,订单系统、消息通知系统、售后系统也都要收到消息。agent runtime 也是一样:一个“tool 执行完成”,可能同时影响 UI 展示、session 记录、日志、后续 reasoning,甚至权限系统。也就是说,事件不是点对点的小通知,而是整个组织内部的协作语言。

最原始、也最危险的设计,是把 event bus 做成一种“字符串广播器”。任何人都可以随便喊一个事件名,再塞一个对象进去:

bus.emit("tool-done", { result: "something" })

这看起来很自由,但自由过头就会变成混乱。接收方看到这条消息时,根本不知道 result 里到底有什么。有没有 toolName?有没有 success?有没有 duration?事件名到底应该叫 tool-donetool.donetool_complete,还是 tool-finished?如果有人把名字拼错了,系统很可能不会报错,只是静悄悄地没人响应。新同事加入项目时,也很难知道系统里到底有哪些事件存在。这就像 工地对讲机:谁都能喊,喊法也不统一,有时意思大概能懂,有时全靠猜。

在小玩具项目里,这种方式勉强还能活下去;但在 agent 系统里,它会慢慢把系统拖进“内部沟通失序”的状态。原因很简单:agent 的事件非常多,而且变化很快。

相反,类型安全的 event bus 更像 航空管制无线电。在航空场景里,飞行员和塔台不是随便聊天,而是按严格格式说话:谁在说、现在在哪里、飞行高度是多少、接下来要做什么。如果格式不对,塔台甚至可能不认可这条消息,因为在高速、复杂、风险高的系统中,模糊表达会直接带来事故。类型安全事件总线要达到的,就是这种效果:每种事件都有固定格式,字段要求写清楚,名字也不能乱来;说错了,系统会立刻报警。

OpenCode 的做法就非常接近这一点。它不是把事件当成“随手写的字符串”,而是用 Zod 来定义每一种事件的结构。可以把 Zod 理解成一种“结构说明书”:开发者先写清楚一个对象必须长什么样,TypeScript 会根据这份说明书推导类型,运行时也能用它检查数据是否合法。

例如,一个 tool 完成事件可以定义成这样:

const ToolCompleteEvent = z.object({
  type: z.literal("tool.complete"),
  toolName: z.string(),
  duration: z.number(),
  success: z.boolean(),
  result: z.unknown().optional(),
  error: z.string().optional(),
})

而 session 开始事件则可以是:

const SessionStartEvent = z.object({
  type: z.literal("session.start"),
  sessionId: z.string(),
  agent: z.string(),
  timestamp: z.number(),
})

这里最关键的字段是 type。它不是普通字符串,而是 z.literal(...),也就是“这个值必须精确等于某个固定常量”。type: "tool.complete" 就说明这条消息一定是工具完成事件;type: "session.start" 就说明它一定是会话开始事件。把很多这样的 schema 放在一起后,它们就会自动组成 TypeScript 里的 discriminated union

这个词翻译成中文叫 可辨识联合类型,听起来像大学课程名,但核心思想其实很像生活中的表格。你可以把它理解成一种“看第一栏决定后面必须填什么”的表单:如果第一栏写的是 type = "tool.complete",那后面就必须出现 toolNamedurationsuccess 这些字段;如果第一栏写的是 type = "session.start",那后面就必须有 sessionIdagent 等字段。也就是说,第一个标签决定整张表格的合法结构。这就是可辨识联合类型最直观的含义。

这种做法的第一个好处,是 发送时更安全。如果开发者发 tool.complete 事件时漏掉了 success,或者把 duration 拼成了 duraton,TypeScript 往往在代码还没运行前就能报错。事件名也不再是“随便写个字符串”,而是必须属于那一组被定义好的合法事件。换句话说,发送者必须按航空无线电的格式说话,不能像工地对讲机那样想到什么喊什么。

第二个好处,是 接收时更轻松。例如:

if (event.type === "tool.complete") {
  // IDE 会自动知道这里有 event.toolName、event.duration、event.success
}

在这个分支里,IDE 会自动补全正确字段,因为它已经知道当前事件一定是 tool.complete。如果你这时误写了 event.sessionId,编辑器就会立刻标红。这不仅仅是“写代码更方便”,更重要的是:代码本身变成了带约束的文档。开发者不用靠记忆猜字段,也不用翻半天仓库找旧例子。

第三个好处,是 整个系统的事件地图变清晰了。新开发者加入项目时,不需要在仓库里到处搜字符串,去猜“到底都有哪些事件”。Zod schema 本身就是总目录:事件有哪些、每种事件携带什么字段、谁该怎么用,都被明确写出来了。

为什么 agent 系统尤其需要这个?因为它的事件密度比普通应用高得多,而且种类非常杂。一个 user message 进来,可能同时触发 session record 更新、token count 统计、UI 刷新;LLM 调用 tool 时,可能触发 permission check、执行、日志记录、结果回填;MCP disconnect 时,可能要更新 tool registry、弹出 UI warning、写诊断日志。也就是说,事件像城市交通一样持续流动,而不是偶尔出现的少数通知。

如果没有类型安全,后果会非常典型。新增一种事件时,大家不知道谁应该订阅;修改事件格式时,发送方已经改了,接收方却可能悄悄失效;某些监听器还在等 tool 字段,另一些监听器已经改成读取 toolName,但系统表面上不一定立刻崩。最糟糕的地方就在这里:它不是立即炸掉,而是慢慢腐烂。功能似乎还能跑,但模块之间的契约已经开始松动。

有了 Zod-typed events,情况就完全不同。每个事件都有明确 contract。你一旦改字段,相关 sender 和 receiver 会直接出现 compile errors;IDE 自动补全会马上同步;即便数据来自插件、远端 MCP server、动态注入逻辑等“不完全可信”的边界,Zod 也能在 runtime 再检查一层,作为 safety net。前者负责尽早发现程序员自己的错误,后者负责拦住外部世界带来的脏数据。

这一点对 agent 特别关键,因为 agent 天生跨越很多边界:本地代码、插件、远程工具、模型输出、用户操作都会混在同一个系统里。只靠 TypeScript 这种“编译期规则”还不够,因为外部世界不会自动遵守你的类型;只靠 runtime 检查也不够,因为那意味着很多错误要等跑起来才发现。OpenCode 这种 Zod + TypeScript 的组合,本质上是“双保险”。

从架构设计角度看,这还说明一个常被忽视的事实:真正让 agent 稳定的,不只是模型更强、工具更多、prompt 更聪明,也包括内部沟通机制是否足够严谨。一个优秀的 agent 系统,不只是会“思考”的机器,也是一套高度协同的组织。而 event bus,正是这套组织的内部语言系统。

所以,类型安全事件总线的价值,绝不只是“减少几个 bug”。它让模块边界更清楚,让重构更安全,让新成员更容易上手,也让架构意图从“口头约定”变成“系统强制执行的协议”。对于变化很快、模块很多、并发很重的 agent runtime 来说,这种纪律性不是锦上添花,而是基础设施。

一句话总结:类型安全事件总线,会把各个子系统之间的“对话”变成像签过字的合同——谁发送什么、谁接收什么、格式是什么,全部有据可查;一旦改动,系统立刻拉响警报。