1. 从一次看不懂源码结构的挫败说起第一次把 opencode 的仓库拉下来的时候我盯着根目录愣了大概两分钟。不是因为它有多复杂而是因为它太不像一个我熟悉的 TypeScript 项目了——没有铺天盖地的src/目录没有一眼望到头的package.json依赖清单取而代之的是一堆按职责切开的 workspace 包以及一个明显被当作一等公民来对待的事件系统。当时我的第一反应是这玩意儿到底是怎么跑起来的后来花了几个晚上把它的工程骨架、会话模型和事件流一层层扒开才慢慢意识到opencode 的设计思路和市面上大多数套壳式AI 编程工具完全不是一回事。它把双会话内核和事件溯源这两件事放在了架构的最中心而不是当成事后补丁。这个选择直接决定了它的代码组织方式、状态管理方式甚至决定了它为什么能在终端、编辑器插件、Web 界面之间保持行为一致。这篇是上篇聚焦三件事工程全景Monorepo 是怎么切的、为什么这么切、双会话内核两个会话到底指什么、它们如何协作、事件溯源为什么一个编程助手需要事件溯源它解决了什么真实问题。下篇我会专门讲工具调用、权限模型和扩展机制。如果你正在用 TypeScript 写偏 Agent 方向的项目或者单纯想搞清楚一个成熟 AI 编程工具的内部结构这篇应该能给你不少可以直接抄的架构思路。需要提前说明的是opencode 本身迭代很快我参考的是它相对稳定的一段时期的代码组织方式。具体文件路径可能随版本变化但架构层面的决策逻辑是相对稳定的这也是我更想跟你聊的部分。2. Monorepo 的切法为什么不是一个大 src 目录2.1 先看它到底切了哪些包opencode 用的是典型的 Bun TypeScript Monorepo 组合。根目录下你能看到packages/这个目录里面按职责拆成了若干个 workspace。我把它大致的切分逻辑整理成下面这张表方便你对照理解包/目录核心职责为什么单独拆出来核心运行时包会话管理、事件总线、Agent 循环被所有前端复用必须零 UI 依赖CLI/TUI 包终端交互界面终端是主入口但不应污染核心逻辑SDK/协议包对外暴露的类型与客户端第三方集成只依赖协议不依赖实现工具与插件包文件操作、命令执行等能力能力可插拔独立演进配置与共享工具包配置解析、通用工具函数避免循环依赖统一行为这个切法背后有一条很清晰的线凡是会被多个入口复用的逻辑一律下沉到独立包。终端要用会话内核编辑器插件也要用Web 界面还要用那会话内核就绝不能写在 CLI 包里。这一点听起来是常识但真正落地的时候很多人会把先能跑的逻辑随手塞进主入口等到第二个入口出现时才发现要大规模重构。2.2 Bun 在这里不只是跑得快很多人选 Bun 是因为启动快、内置打包器、原生 TS 支持。但 opencode 用 Bun 有一个更实际的理由它需要频繁地 spawn 子进程、读写文件、做流式 IO而这些操作在 Node 生态里往往要拼一堆依赖。Bun 把这些能力做进了运行时减少了依赖面也就减少了版本冲突和供应链风险。不过这里有个坑我得提醒你。Bun 的生态兼容性虽然这两年进步很大但如果你在项目里用了某些深度依赖 Node 内部 API 的库迁移时可能会遇到诡异问题。我的经验是核心运行时用 Bun 没问题但涉及原生模块或冷门库时先在隔离环境里验证一遍。opencode 之所以能比较放心地用是因为它的核心依赖大多是纯 TS 实现边界清晰。2.3 workspace 之间的依赖方向必须单向Monorepo 最容易翻车的地方不是拆分本身而是依赖方向失控。A 包依赖 BB 又反过来依赖 A最后打包和类型检查一起爆炸。opencode 的做法是维持一个近似单向的依赖图底层是共享工具和协议中间是核心运行时上层才是各种 UI 入口。你可以用一个很土但很有效的办法自查画一张包依赖图如果出现环就说明有职责没切干净。我自己的项目里就踩过这个坑——工具包为了图方便直接 import 了会话内核的类型结果内核想复用工具包的一个 helper 时就形成了环。最后的解法是把那个 helper 下沉到共享工具包两边都依赖它环就断了。提示Monorepo 里类型依赖和运行时依赖要分开看。只 import type 通常不会造成运行时环但会让类型检查变慢甚至报错最好也用import type显式声明。2.4 为什么这种切法对 AI 编程工具特别重要普通 Web 应用拆包更多是为了构建速度和团队协作。但 AI 编程工具不一样它有一个特殊约束同一个会话状态要在完全不同的宿主环境里保持一致。终端里发起的对话切到编辑器插件里要能接着看插件里触发的工具调用回到终端要能看到完整记录。如果会话逻辑和 UI 耦合在一起这个一致性根本没法保证。opencode 把会话内核独立成包本质上是在为多宿主一致性买单。这个决策的代价是前期结构更复杂但收益是后期每加一个入口成本都极低。我在做类似项目时的体会是如果你预判这个 Agent 会有超过一个交互入口那第一天就该把内核拆出来否则后面补的代价会高得多。3. 双会话内核两个会话到底在分工什么3.1 先破除一个误解双会话不是两个聊天窗口看到双会话内核这个词很多人第一反应是哦就是能同时开两个对话。不是的。opencode 里的双会话指的是两种职责完全不同的会话抽象它们服务于同一个用户任务但管理的东西不一样。我把它理解成面向用户的会话和面向执行的会话这两层。前者关心的是对话历史、消息顺序、用户可见的上下文后者关心的是 Agent 这一轮到底调了哪些工具、产生了哪些中间状态、下一步该干什么。两者通过事件流同步但生命周期和数据结构是分开的。这个区分非常关键。如果你把这两层揉在一起会立刻遇到几个问题工具调用的中间态会污染对话历史用户回滚一条消息时不知道要不要连带回滚工具副作用多个入口同时读会话时拿到的状态可能不一致。opencode 用双会话把这些关注点硬性隔开了。3.2 面向用户的会话稳定、可回放、面向展示用户会话这一层核心诉求是稳定和可回放。它记录的是用户看到了什么包括消息的增删改、分支切换、上下文压缩等。这一层的数据结构设计得比较保守因为它要支撑 UI 渲染任何抖动都会直接体现在界面上。这里有个设计细节值得学用户会话不直接存最终状态而是存导致状态变化的事件序列。这跟后面要讲的事件溯源是一套逻辑。好处是当你想实现回到某条消息之前这种功能时不需要做复杂的状态快照和回滚只要重放事件到那个点就行。我在自己的项目里试过直接存状态快照结果一遇到编辑历史消息就头大——你得手动算出哪些后续状态要失效。改成事件序列之后逻辑一下子清爽了编辑就是插入一个新事件重放自然得到新状态。3.3 面向执行的会话临时、可丢弃、面向工具执行会话这一层就野多了。它管理的是 Agent 单轮或跨轮的执行上下文当前有哪些工具可用、上一步工具返回了什么、是否需要继续调用、有没有触发权限确认。这一层的数据很多是临时的、可丢弃的因为一旦这一轮结束大部分中间态就没用了。把它独立出来的最大好处是隔离副作用。工具调用可能改文件、跑命令、发请求这些副作用不应该直接写进用户可见的对话历史。执行会话负责干活干完之后把结果以事件的形式汇报给用户会话由后者决定怎么展示。这样即使用户回滚了对话执行层的副作用也能被单独追踪和处理。3.4 两层之间靠什么同步事件而不是直接调用如果两层之间是直接方法调用那耦合会非常紧用户会话要 import 执行会话的接口执行会话又要回调用户会话更新 UI很快就成一团乱麻。opencode 的做法是两层都往同一个事件总线上发事件各自订阅自己关心的事件。这个模式的好处是解耦彻底。用户会话不需要知道执行会话内部怎么调工具它只关心收到了一条工具结果事件。执行会话也不需要知道 UI 怎么渲染它只管发事件。新增一个入口比如 Web 界面时它只要订阅事件总线就行完全不用改内核。注意事件总线用起来爽但要有节制。事件类型一旦膨胀到几百种维护成本会飙升。我的经验是给事件分大类会话类、执行类、工具类、系统类每类下再细分并且强制要求事件携带足够的上下文避免订阅方还要反查状态。3.5 双会话带来的一个真实收益并发安全多入口场景下并发是绕不开的。用户在终端发了一条消息同时编辑器插件里也在读会话状态如果没有清晰的会话边界很容易读到半更新状态。双会话 事件流的组合让读和写有了明确的时序所有状态变更都通过事件串行化订阅方拿到的事件顺序是一致的。这一点我在做多端同步功能时深有体会。当时没有事件流两个端各自维护状态结果经常出现这边显示已发送那边还显示发送中的诡异现象。后来引入事件溯源所有端都从同一个事件序列重建状态不一致的问题基本消失了。4. 事件溯源一个编程助手为什么需要它4.1 事件溯源不是记日志别搞混了很多人一听事件溯源就觉得不就是把操作记成日志吗。这是最大的误解。普通日志是旁路的记了也不影响主流程丢了也无所谓。事件溯源是主路的事件序列本身就是唯一真相来源当前状态是从事件重放出来的。这个区别决定了实现方式完全不同。日志可以异步写、可以丢、可以格式随意事件溯源的事件必须持久化、必须有序、必须能完整重建状态。opencode 把事件放在这么核心的位置是因为它需要几个普通日志给不了的能力可回放、可审计、可分支、可跨端同步。4.2 可回放调试 Agent 行为的救命稻草Agent 的行为经常是不可复现的——同样的输入因为模型采样、工具返回、时序差异结果可能完全不同。这时候如果没有事件序列你根本没法定位问题。有了事件溯源你可以把某次出问题的会话事件完整重放一遍逐步看是哪一步开始跑偏的。我实际排查过一个Agent 反复调用同一个工具的问题。光看最终对话历史完全看不出原因重放事件之后才发现是某次工具返回的错误信息被解析成了需要重试而重试逻辑又没有上限。这种问题没有事件序列基本只能靠猜。4.3 可审计谁在什么时候改了什么AI 编程工具会改你的文件、跑你的命令用户天然会问它到底干了什么。事件溯源天然提供了审计能力每一次文件修改、每一条命令执行都是一个带时间戳和上下文的事件。用户想追溯直接看事件序列就行。这一点在团队协作场景下更重要。如果多个人的 Agent 会话需要合并或对比事件序列提供了一个统一的比较基准。我在做类似功能时发现只要事件格式设计得足够自描述审计和回放几乎是免费的不需要额外写一套审计逻辑。4.4 可分支会话分叉的实现基础从这条消息开始换个方向继续——这个功能听起来简单实现起来如果没有事件溯源会非常痛苦。因为你要复制当前所有状态然后在新分支上继续。有了事件溯源分支就是从某个事件点开始接上一条新的事件链底层只是引用关系的变化。opencode 的会话分支能力本质上就是事件序列的分叉。这个设计让探索多个方案变得很自然你可以从同一个起点分出几条链分别尝试最后挑一条满意的合并回主线。4.5 事件的结构设计自描述是硬要求事件溯源能不能用好八成取决于事件结构设计。我的经验是一个合格的事件至少要包含这几样类型标识、时间戳、来源会话、关联 ID、以及足够的载荷。载荷要自描述不能只存一个 ID 让订阅方去反查否则重放时会因为状态缺失而失败。opencode 的事件设计基本遵循这个原则。它的事件不是简单的动作名 参数而是携带了重建状态所需的最小完整信息。这一点在跨端同步时尤其关键——接收端可能没有发送端的完整内存状态只能靠事件本身重建。提示设计事件时问自己一句如果只有这条事件我能重建出对应的状态变化吗。如果答案是不能说明载荷不够自描述。4.6 事件溯源的成本别盲目上说了这么多好处也得说代价。事件溯源会带来几个实际问题存储增长快事件只增不减、重放有性能开销长会话重放慢、版本演进麻烦事件格式改了要兼容旧事件。opencode 应对这些的方式是定期做快照、给事件加版本号、以及限制单会话的事件规模。我的建议是如果你的场景不需要回放、审计、分支、跨端同步中的任何一项就别上事件溯源。它是个重武器用在小场景里纯属自找麻烦。但如果你在做的是 Agent 类产品尤其是多入口的那它带来的收益会远超成本。5. 把这三块拼起来一次完整请求的生命周期5.1 从用户按下回车到看到回复光讲架构容易飘我们把它落到一次具体请求上。用户在终端输入一句话按下回车接下来发生的事大致是这样的CLI 入口把输入包装成一个用户消息事件发到事件总线。用户会话订阅到这个事件更新对话历史并触发执行会话开始一轮处理。执行会话根据当前上下文决定是否调用工具每次调用都产生对应的事件。工具执行结果以事件形式回流执行会话判断是否需要继续。模型输出以流式事件的形式发出用户会话订阅后推给 UI 渲染。整轮结束后所有事件都已持久化会话状态可随时重建。这个流程里没有任何一步是直接跨层调用全靠事件驱动。这就是为什么它能同时支撑多个入口——每个入口只是事件的不同消费者。5.2 为什么这个流程对扩展友好假设你现在想加一个新入口比如一个 Web 界面。按这个架构你只需要订阅事件总线拿到状态、把用户操作转成事件发出去。你完全不用碰会话内核、不用碰工具执行逻辑。这就是前面说的多宿主一致性带来的红利。再假设你想加一个新工具。你只要实现工具接口、注册到工具包、在需要时发出工具调用事件即可。用户会话和执行会话都不需要改。这种扩展性是前期在架构上多花的那点功夫换来的。5.3 我踩过的一个坑事件顺序不能想当然事件溯源最容易被忽视的是顺序保证。我早期实现时假设事件发出顺序就是处理顺序结果在并发场景下翻车了——两个异步操作几乎同时发事件到达订阅方时顺序反了导致状态重建出错。opencode 的处理方式是给事件带上明确的序列信息并在持久化层保证顺序。这个细节看起来小但它是事件溯源能成立的前提。如果你自己实现一定要在最早的时候就把顺序问题想清楚别等到出问题再补。6. 给想复刻这套架构的人几句实在话6.1 先想清楚你的双会话边界在哪不是所有项目都需要严格的双会话。如果你的 Agent 只有一个入口、不需要回滚、不需要审计那单会话加简单日志就够了。但只要你预判到多入口或可回放这两个需求中的任何一个就值得把执行层和展示层分开。分开的时机越早代价越小。6.2 事件设计宁细勿粗我见过太多项目把事件设计成一个大而全的 action 事件结果订阅方要写一堆 if-else 去区分。更好的做法是按语义拆细让每个事件只表达一件事。拆细的代价是事件类型变多但换来的是订阅逻辑清晰、重放逻辑简单。6.3 Monorepo 的包边界要经得起第二个入口的考验判断你的包切得对不对有个简单标准假设明天要加一个新入口你需要改几个包。如果答案是只加一个新包不改老的那说明切对了。如果答案是要动内核那说明边界没切干净。6.4 别为了架构而架构opencode 这套设计是为它的产品形态服务的多入口、强审计、可回放。如果你的项目没这些需求硬套只会增加负担。架构的价值在于匹配场景不在于看起来高级。我自己就吃过这个亏早期给一个小工具套了事件溯源结果维护成本比收益还高最后又拆回去了。这套工程全景、双会话内核和事件溯源的组合是 opencode 能在多个宿主环境里保持一致行为的底层原因。下篇我会接着聊它的工具调用机制、权限模型以及怎么基于它做扩展。如果你在跟着看源码建议先把事件总线和会话内核这两块吃透剩下的部分会顺很多。