opencode 工程化拆解:Monorepo 架构与双会话事件溯源内核设计
1. 项目缘起与整体定位
第一次接触 opencode 是在一个做智能体工具链的朋友推荐下,当时我正在给团队选一套能同时兼顾本地开发体验和云端协作的编码助手框架。市面上同类产品不少,但大多数要么是纯 IDE 插件形态,要么是纯 CLI 工具,真正把"工程化"三个字做透的并不多。opencode 吸引我的地方在于它的架构选择:TypeScript 全栈、Bun 作为运行时、Monorepo 组织代码、内核采用事件溯源模型。这几个关键词单独拎出来都不新鲜,但组合到一个编码智能体项目里,就值得好好拆一拆了。
这篇上篇主要聊三件事:整个工程的目录全景是怎么组织的、双会话内核到底解决了什么问题、事件溯源在这个场景下为什么是合理选择。下篇我会专门讲插件体系、模型接入和实际落地踩坑。如果你正在做智能体框架选型,或者单纯想看看一个现代 TypeScript 大型项目是怎么搭起来的,这篇应该能给你一些参考。阅读门槛不高,但需要对 TypeScript 和基本的 CLI 工具有一点概念,完全没接触过 Monorepo 的读者也能跟得上,我会在关键处补背景。
先给一个整体判断:opencode 的工程结构不是那种"能跑就行"的脚本堆砌,它在模块边界、依赖方向、状态管理上都有明确的设计意图。理解这些意图,比记住某个 API 怎么调用重要得多。下面从目录结构开始拆。
2. 工程全景:Monorepo 目录结构与依赖边界
2.1 为什么选 Monorepo 而不是多仓库
一个编码智能体项目天然会分裂成好几块:CLI 入口、核心内核、模型适配层、工具集、UI 组件、共享类型。如果拆成多个独立仓库,最直接的痛点就是类型共享。你在 core 里定义了一个SessionEvent类型,adapter 层想用,要么发个 npm 包等版本更新,要么复制一份然后慢慢漂移。Monorepo 把这个问题从"跨仓库版本管理"降级成"同仓库路径引用",改一个类型,所有包立刻感知。
opencode 用的是 Bun 自带的工作区能力,没有引入 pnpm 或 yarn 的 workspace。这个选择很务实:Bun 本身就是运行时加包管理器加打包器,工作区配置写在根package.json的workspaces字段里,一条bun install就把所有子包的依赖装好,符号链接自动建立。相比 pnpm 的 workspace 协议,Bun 的方案少了一层配置心智负担,代价是生态成熟度稍弱,但对一个自研为主的项目来说完全够用。
提示:Monorepo 不是银弹。如果团队规模小于三人,或者各模块发布节奏完全独立,多仓库反而更省心。Monorepo 的价值在"高频跨模块改动"这个场景下才真正体现。
2.2 目录分层与依赖方向
工程大致分成这么几层,从底往上:
packages/core:内核层,包含会话管理、事件定义、状态机。不依赖任何上层包。packages/protocol:协议层,定义事件 schema、消息格式、序列化规则。只依赖 core 的类型。packages/provider:模型适配层,把不同模型服务的接口统一成内部协议。依赖 protocol。packages/tools:工具集,文件读写、命令执行、搜索等。依赖 core 和 protocol。packages/cli:命令行入口,组装上面所有层。依赖全部。packages/ui:终端 UI 组件,被 cli 引用。
依赖方向是严格单向的,core 永远不知道 provider 的存在。这个约束看起来简单,实际执行起来需要纪律。我见过太多项目一开始分层清晰,几个月后 core 里就出现了import { OpenAIAdapter }这种反向依赖,然后整个架构开始腐化。opencode 用 ESLint 的no-restricted-imports规则把这条边界固化下来,CI 里跑 lint 就能拦住越界引用,这比口头约定靠谱得多。
2.3 共享类型的组织方式
TypeScript 项目里类型放哪儿是个老问题。opencode 的做法是:跨包共享的类型放protocol,包内私有的类型就近定义。protocol里又按领域拆成events.ts、messages.ts、tools.ts几个文件,每个文件导出的是纯类型和 zod schema,不含任何运行逻辑。这样做的直接好处是,任何包引用 protocol 都不会意外引入副作用代码,打包时 tree-shaking 也干净。
关于.d.ts声明文件,opencode 基本不手写,全部靠tsc --declaration从源码生成。手写声明文件只在对接没有类型定义的老库时才用,而且会放在types/目录下用declare module兜底。这个策略值得借鉴:声明文件是编译产物,不是源码,能自动生成就别手动维护,否则类型和实现迟早对不上。
3. 双会话内核:为什么要拆成两个会话
3.1 单会话模型的根本矛盾
最初我以为"会话"就是一个对话上下文,用户发消息、模型回消息,一条线走到底。但真正做智能体的时候会发现,这里混了两件本质不同的事:一件是"用户和助手的对话",另一件是"助手内部的任务执行"。前者是线性的、面向人的;后者是树状的、面向工具的,一个任务可能派生出子任务,子任务又可能失败重试。
如果硬塞进一个会话,会出现几个问题。第一,上下文窗口被内部执行细节挤爆,用户真正关心的对话历史反而被截断。第二,回滚困难,用户想撤销上一步操作,你得从一堆工具调用记录里分辨哪些是对话、哪些是执行。第三,并发受限,多个任务同时跑的时候,事件顺序会互相干扰。
3.2 双会话的职责划分
opencode 把这两件事拆成两个会话:对话会话和执行会话。对话会话只记录用户与助手的消息往来,是面向展示和持久化的。执行会话记录工具调用、中间状态、子任务派生,是面向调度和恢复的。两者通过事件关联,一个对话轮次可以触发多个执行会话,执行会话的结果再回流到对话会话。
这个拆法的精妙之处在于,它把"给人看的"和"给机器看的"彻底分开了。对话会话可以放心地做摘要、做压缩、做持久化,不用担心丢失执行细节。执行会话可以放心地做重试、做并发、做状态快照,不用担心污染对话历史。两者的生命周期也不同:对话会话跟着用户走,执行会话跟着任务走,任务结束执行会话就可以归档。
3.3 两个会话如何协同
协同靠的是事件。对话会话产生一个UserMessage事件,内核据此创建一个执行会话,执行会话内部产生ToolCall、ToolResult、SubTaskSpawn等事件,执行完成后产生一个ExecutionComplete事件,内核把这个事件转换成对话会话里的AssistantMessage。整个过程是单向的事件流,没有双向引用,所以两个会话可以独立演进。
我实测下来,这种拆分在长任务场景下优势特别明显。有一次让助手做一个涉及十几个文件的重构,执行会话里产生了上百个事件,但对话会话里只有三条消息:用户请求、助手确认、助手汇报结果。用户界面清爽,调试的时候又能下钻到执行会话看每一步细节。如果混在一起,那上百个事件会直接把对话历史冲垮。
注意:双会话不是没有代价。事件关联逻辑变复杂了,一个执行会话的失败要正确映射回对话会话的错误提示,需要仔细设计错误传播路径。opencode 用了一个
correlationId贯穿两个会话,所有相关事件都带这个 id,排查问题时按 id 过滤就能还原完整链路。
4. 事件溯源:状态管理的底层逻辑
4.1 事件溯源解决什么问题
传统做法是维护一个可变的状态对象,用户发消息就改state.messages.push(...),工具调用就改state.tools[i].status = 'done'。这种"当前状态"模型在简单场景下没问题,但在智能体场景下会暴露三个短板:无法回放、无法审计、无法时间旅行调试。
事件溯源换个思路:不存当前状态,只存导致状态变化的事件序列。当前状态是事件序列折叠出来的结果。想要某个时刻的状态,把那个时刻之前的事件重放一遍就行。这个模型天然支持回放、审计和时间旅行,代价是每次读状态都要重放,所以通常配合快照使用。
4.2 事件的定义与分类
opencode 的事件大致分四类:
| 事件类别 | 代表事件 | 作用 |
|---|---|---|
| 会话生命周期 | SessionCreated、SessionClosed | 管理会话边界 |
| 消息类 | UserMessage、AssistantMessage | 对话内容 |
| 执行类 | ToolCall、ToolResult、SubTaskSpawn | 任务执行 |
| 系统类 | ErrorOccurred、SnapshotTaken | 系统状态 |
每个事件都是不可变的,带时间戳和correlationId。事件一旦写入就不修改,需要修正就追加一个补偿事件。这个约束是事件溯源的根基,破坏它就退化成普通日志了。
4.3 快照与重放策略
纯事件溯源的问题是重放成本随事件数线性增长。一个跑了半天的会话可能有几千个事件,每次读状态都重放一遍不现实。opencode 的策略是定期打快照:每 N 个事件或每隔一段时间,把当前折叠出的状态序列化存下来,重放时从最近的快照开始,只重放快照之后的事件。
快照的粒度是个权衡。太频繁,存储和序列化开销大;太稀疏,重放慢。opencode 默认每 100 个事件打一次快照,这个值可以配置。我在本地开发时调成 20,方便快速调试;生产环境用默认值,因为事件写入本身有开销,快照太频繁会拖慢主流程。
4.4 事件存储的选型考量
事件存哪儿?opencode 默认用本地文件,每个会话一个 JSONL 文件,一行一个事件。这个选择很务实:JSONL 追加写性能好,崩溃时最多丢最后一行,而且人类可读,调试时直接tail -f就能看事件流。需要更强查询能力时,可以接 SQLite 或外部事件存储,但默认方案覆盖了绝大多数场景。
我踩过的一个坑是事件文件膨胀。一个长会话的 JSONL 可能到几十兆,加载慢、diff 难看。解决办法是定期归档:把已完成会话的事件文件压缩,或者只保留快照加最近的事件。opencode 提供了session archive命令做这件事,但需要手动触发,自动化归档还在路线图上。
5. 核心模块的实操拆解
5.1 内核初始化流程
内核启动大致走这几步:
- 加载配置,确定事件存储路径和快照策略。
- 初始化事件存储,扫描已有会话文件建立索引。
- 注册事件处理器,把各类事件映射到对应的状态变更函数。
- 恢复未完成会话,从最近快照重放事件,重建内存状态。
- 启动事件循环,等待新事件写入。
第 4 步是容易出问题的地方。如果快照和事件文件不一致(比如快照写了一半崩溃),恢复会失败。opencode 的做法是快照写入用临时文件加原子重命名,保证快照要么完整要么不存在。事件文件用追加写,天然原子。两者配合,恢复逻辑就简单了:有快照从快照起,没快照从头起。
5.2 事件写入的完整链路
一次工具调用的完整事件链路是这样的:
// 伪代码,展示事件流 const correlationId = generateId(); // 1. 对话会话记录用户请求 await eventStore.append({ type: 'UserMessage', correlationId, payload: { content: userInput }, }); // 2. 内核创建执行会话 await eventStore.append({ type: 'ExecutionStarted', correlationId, payload: { sessionId: execSessionId }, }); // 3. 执行会话记录工具调用 await eventStore.append({ type: 'ToolCall', correlationId, payload: { tool: 'readFile', args: { path: 'src/index.ts' } }, }); // 4. 工具返回结果 await eventStore.append({ type: 'ToolResult', correlationId, payload: { output: fileContent, status: 'success' }, }); // 5. 执行完成,回流到对话会话 await eventStore.append({ type: 'ExecutionComplete', correlationId, payload: { summary: 'Read file successfully' }, });每一步都是追加写,没有更新操作。想查这次调用的完整过程,按correlationId过滤事件文件即可。这个链路清晰得让人舒服,调试时不用在多个状态对象之间跳来跳去。
5.3 状态折叠函数的编写要点
状态折叠函数是事件溯源的核心,它接收当前状态和一个事件,返回新状态。写这个函数有几个要点:
- 纯函数:不能有副作用,不能读外部变量,同样的输入必须得到同样的输出。否则重放结果不可复现。
- 幂等:同一个事件重放两次,结果应该一致。这要求事件本身带唯一 id,折叠时去重。
- 防御性:事件可能乱序到达(并发场景),折叠函数要能处理"还没收到前置事件"的情况,通常做法是暂存或忽略。
我见过有人把网络请求写进折叠函数,结果重放时疯狂发请求。折叠函数必须是纯的,所有副作用都放在事件处理器之外。
5.4 双会话的关联查询
想查一个对话轮次对应的所有执行细节,按correlationId查:
# 假设事件存在 JSONL 文件里 grep '"correlationId":"abc123"' session.jsonl | jq '.type'输出会按时间顺序列出这个轮次的所有事件类型。这个查询在排查"为什么助手做了某个奇怪操作"时特别有用,能完整还原决策链路。opencode 的 CLI 里封装了session trace <correlationId>命令,不用手动 grep。
6. 常见问题与排查技巧实录
6.1 事件重放结果不一致
现象:同一个会话重放两次,得到的状态不同。
排查思路:先检查折叠函数有没有副作用,比如读了Date.now()或随机数。再检查事件顺序,如果事件存储的排序不稳定(比如按文件系统返回顺序),重放顺序就会变。最后检查有没有事件被重复写入。
解决:折叠函数纯化,事件按时间戳加序列号排序,写入时用唯一 id 去重。
6.2 快照恢复失败
现象:启动时报"snapshot corrupted"。
排查思路:看快照文件是不是完整 JSON,是不是写了一半。检查快照和事件文件的时间戳,如果快照比事件新,说明事件丢了。
解决:快照写入用原子重命名,恢复时校验快照完整性,失败就回退到上一个快照或从头重放。
6.3 长会话性能下降
现象:会话跑久了,每次操作越来越慢。
排查思路:看事件文件大小和事件数量。如果事件数过万,重放和查询都会变慢。
解决:调小快照间隔,定期归档旧事件,或者把事件存储换成带索引的 SQLite。
6.4 双会话关联丢失
现象:执行会话的事件找不到对应的对话轮次。
排查思路:检查correlationId是不是在某个环节没传下去。常见于异步任务里忘了透传上下文。
解决:把correlationId放进 AsyncLocalStorage,或者显式作为参数传递,别依赖全局变量。
| 问题 | 根因 | 快速修复 |
|---|---|---|
| 重放不一致 | 折叠函数有副作用 | 纯化折叠函数 |
| 快照损坏 | 非原子写入 | 临时文件加重命名 |
| 长会话变慢 | 事件数过多 | 调小快照间隔加归档 |
| 关联丢失 | correlationId 未透传 | 显式传递或上下文存储 |
6.5 独家避坑心得
第一个心得:事件 schema 要版本化。我一开始没做版本字段,后来加了个字段,老事件重放就报错。现在每个事件都带schemaVersion,折叠函数按版本分支处理,兼容性稳了。
第二个心得:别在事件里存大对象。有次把整个文件内容塞进ToolResult,事件文件瞬间膨胀到几百兆。正确做法是存引用或摘要,大对象放单独的 blob 存储,事件里只放指针。
第三个心得:快照要能反序列化成和重放一样的状态。我遇到过快照存的是 Map,反序列化变成普通对象,折叠函数假设是 Map 就崩了。序列化方案要统一,最好用同一套编解码。
7. 工程化细节与后续扩展方向
7.1 Bun 运行时的实际体验
用 Bun 跑这个项目,最直观的感受是启动快。冷启动到内核就绪,实测在 200 毫秒左右,Node 下同样逻辑要 800 毫秒往上。原因是 Bun 的模块加载和 TypeScript 转译是内置的,不用经过 tsc 编译再跑。开发时bun --watch的热重载也快,改完代码基本秒级生效。
但 Bun 不是没有坑。有些 Node 原生模块的兼容性还不完美,我遇到过一个依赖node:fs特定行为的库在 Bun 下表现不一致。解决办法是查 Bun 的兼容性文档,必要时用 polyfill 或换库。整体来说,纯 TypeScript 项目用 Bun 很舒服,涉及大量原生扩展的项目要谨慎。
7.2 TypeScript 类型系统的深度利用
opencode 把 TypeScript 的类型能力用得很足。事件类型用 discriminated union 定义,折叠函数里switch (event.type)能自动收窄类型,写错分支编译器直接报错。工具的参数用 zod schema 定义,运行时校验和编译时类型从同一份定义生成,不会漂移。
关于类型继承和重写,项目里用得比较克制。基类只定义接口,具体实现用组合而非继承。我见过一些项目滥用继承,基类改一个字段,十几个子类全崩。opencode 的选择是:能用组合就用组合,继承只用在真正有 is-a 关系的地方,比如BaseEvent和具体事件。
7.3 后续可以扩展的方向
事件溯源这套底子搭好之后,能扩展的东西很多。比如基于事件流做实时协作,多个客户端订阅同一会话的事件,天然支持多人同时看一个任务执行。再比如做事件回放式的测试,把生产环境的事件流录下来,在测试里重放,能复现绝大多数线上问题。还有基于事件的审计和合规,每个操作都有不可篡改的记录,这在企业场景下是刚需。
我个人最期待的是事件流的可视化。现在调试靠 grep 和 jq,如果能有个时间轴视图,把双会话的事件按 correlationId 分组展示,排查效率能再上一个台阶。这个方向社区里已经有人在做了,下篇我会聊聊插件体系怎么支持这类扩展。
7.4 给准备上手的人几句实在话
如果你打算基于 opencode 做二次开发,我的建议是先跑通最小闭环:一个会话、一次工具调用、一次事件重放。把这条链路走通,再去看双会话和快照,理解会顺很多。别一上来就啃全部源码,事件溯源的概念不难,难的是工程细节,而细节要在动手改的过程中才体会得到。
另外,Monorepo 的依赖边界一定要守住。改代码时如果发现要从 core 引上层包,先停下来想想是不是分层设计有问题,而不是直接加 import 绕过。架构腐化都是从第一个越界引用开始的,守住这条线,项目才能长期可维护。