1. 项目缘起:为什么我要啃 opencode 这块硬骨头
第一次接触 opencode 是在一个周末的深夜,当时我正在给团队找一个能跑在终端里、又能跟编辑器打通的 AI 编码助手。市面上的方案要么是纯 IDE 插件、要么是纯 CLI 工具,两者之间的状态同步总让我觉得别扭——在终端里聊到一半的上下文,切到编辑器里就断了。opencode 吸引我的地方在于它把这两件事揉到了一起:一个跑在终端里的 TUI 客户端,加上一个可以被编辑器调用的服务端,两边共享同一套会话状态。
但真正让我决定深入读它源码的,是它的技术栈组合:TypeScript + Bun + Monorepo + 事件溯源。这四个词单拎出来都不新鲜,可凑在一个 AI 编码工具里就很有意思了。Bun 作为运行时意味着它抛弃了 Node 生态里那套又重又慢的构建链路;Monorepo 说明它把客户端、服务端、共享协议拆成了独立包;而事件溯源这个通常出现在金融、协作编辑领域的架构模式,被用在一个编码助手上,背后一定有它的道理。
这篇上篇我想聊三件事:整个工程的目录全景是怎么组织的、双会话内核到底"双"在哪里、以及事件溯源这套机制在 opencode 里是怎么落地的。适合已经用过 opencode、想搞清楚它内部怎么运转的人,也适合正在做类似 AI 工具、想参考架构选型的开发者。我会尽量把每个设计决策背后的"为什么"讲透,而不是只罗列目录结构。
2. 工程全景:Monorepo 的目录布局与包职责划分
2.1 顶层目录一眼看懂
opencode 的仓库是一个典型的 Bun workspace 驱动的 Monorepo。第一次 clone 下来,根目录大概长这样:
opencode/ ├── packages/ │ ├── opencode/ # 核心:服务端 + CLI + TUI │ ├── sdk/ # 对外暴露的 TypeScript SDK │ ├── shared/ # 跨包共享的类型与工具 │ └── ... ├── package.json # workspace 根配置 ├── bun.lockb # Bun 的锁文件 └── tsconfig.json # 根 TS 配置,被各包继承这里有个细节值得说:根目录的package.json里用的是workspaces字段,而不是 pnpm 或 yarn 的那套。Bun 原生支持 workspace,安装依赖时会把所有包的依赖提升到根node_modules,同时通过软链接把包之间互相引用打通。这意味着你在packages/sdk里import一个packages/shared的类型,不需要先 build,Bun 直接就能解析到源码。
提示:如果你用 VSCode 打开这个仓库,记得把 TypeScript 的版本切到 workspace 版本,否则跨包的类型跳转可能会失效。这是 Monorepo 里最常见的"明明代码没错但编辑器报红"的坑。
2.2 为什么是 Bun 而不是 Node
选 Bun 这件事,我一开始以为是图快,读下来发现不止于此。opencode 的 CLI 启动速度要求很高——用户敲一个命令,期望的是"秒开",而不是等 Node 加载一堆模块。Bun 的冷启动比 Node 快一个数量级,这对 CLI 工具是刚需。
更关键的是 Bun 内置了 TypeScript 转译、打包器、测试运行器。opencode 里几乎看不到 webpack、esbuild、ts-node 这些配置,bun run直接跑.ts文件,bun build直接出产物。这省掉的不仅是配置文件,还有一整条构建链路的维护成本。我在自己项目里试过把 Node + tsx 的组合换成 Bun,构建配置从三个文件缩到一个,启动时间从 800ms 降到 120ms 左右。
当然代价也有:Bun 的生态兼容性还没到 Node 那种"什么库都能跑"的程度。opencode 里如果用到某些依赖 Node 原生 API 的库,就得小心。这也是为什么它的packages/shared里会有一层薄薄的适配代码,把可能踩坑的地方包起来。
2.3 包之间的依赖方向
Monorepo 最怕的就是循环依赖。opencode 的依赖方向是单向的,大致是:
sdk → shared opencode(core) → shared opencode(core) → sdk (仅类型层面)shared是最底层,只放纯类型和纯函数,不依赖任何其他包。sdk依赖shared,对外提供一套干净的客户端接口。核心包opencode依赖shared,并且在类型层面引用sdk,但运行时不会反向依赖。这种分层让每个包都能独立测试,也方便未来把sdk单独发到 npm 上给第三方用。
我在实际拆包时踩过一个坑:一开始把工具函数随手放在sdk里,结果核心包为了用一个字符串处理函数不得不依赖整个sdk,导致打包体积膨胀。后来把这些纯函数下沉到shared,问题才解决。opencode 的分层逻辑基本就是这个思路的产物。
3. 双会话内核:客户端与服务端到底怎么分工
3.1 "双会话"不是两个会话,而是两层会话管理
很多人看到"双会话内核"会误以为是同时开两个对话。其实不是。opencode 的"双"指的是会话状态同时存在于客户端和服务端两层,两边各有一套会话管理逻辑,通过协议同步。
- 服务端会话:真正持有对话历史、模型调用、工具执行结果的地方。它是权威数据源(source of truth)。
- 客户端会话:TUI 或编辑器里展示的那份状态,是服务端状态的投影(projection)。
这么设计的原因很直接:AI 编码助手的会话可能很长,一次对话里夹着几十次工具调用、文件读写、命令执行。如果客户端是权威源,一旦客户端崩溃或断连,整个会话就丢了。反过来,服务端持有权威状态,客户端只是个"显示器",断线重连后从服务端拉最新状态即可。
3.2 服务端会话的生命周期
服务端会话从创建到销毁,大致经历这几个阶段:
- 创建:客户端发起一个创建请求,服务端分配一个会话 ID,初始化空的事件流。
- 追加事件:用户消息、模型回复、工具调用、工具结果,全部以事件的形式追加到会话的事件流里。
- 状态派生:服务端不直接存"当前对话长什么样",而是存事件序列,需要时从事件序列重放出当前状态。
- 持久化:事件流落盘,保证进程重启后能恢复。
- 销毁:会话结束或超时,事件流归档。
这里第 3 步是理解整个架构的钥匙。服务端不维护一个可变的"消息数组",而是维护一个只追加的事件日志。当前对话状态是"重放事件日志算出来的"。这就是事件溯源的核心思想,下一节会展开。
3.3 客户端会话的投影逻辑
客户端拿到事件流后,要做的是把它"投影"成用户能看的东西。比如:
message.created事件 → 在界面上新增一条消息气泡tool.started事件 → 显示一个"正在执行"的加载态tool.completed事件 → 把加载态替换成结果message.updated事件 → 更新对应消息的内容(流式输出时高频出现)
投影逻辑的关键是幂等。因为客户端可能重连、可能收到重复事件,同一个事件投影两次不能产生两条消息。opencode 的做法是每个事件带唯一 ID,客户端维护一个已处理事件 ID 的集合,重复的直接丢弃。
注意:流式输出场景下,
message.updated事件可能每秒来几十条。如果每条都触发一次全量重渲染,TUI 会卡成幻灯片。opencode 在客户端做了节流和增量更新,只重绘变化的那部分。这个优化不做的话,长对话下体验会非常差。
3.4 两层之间用什么协议通信
客户端和服务端之间走的是一个基于事件的双向通道。服务端把事件推给客户端,客户端把用户操作(发消息、取消、切换模型)发给服务端。协议本身是类型化的,定义在shared包里,两边共用同一套类型定义,改一处两边都跟着变。
这种"共享类型 + 事件通道"的组合,好处是协议演进时不容易出现两边不一致。坏处是shared包一旦改动,所有包都得重新类型检查。所以 opencode 对shared的改动很克制,尽量只加不改。
4. 事件溯源:为什么一个编码助手要用这套架构
4.1 事件溯源的基本模型
事件溯源(Event Sourcing)说白了就一句话:不存当前状态,只存导致状态变化的事件。当前状态 = 从初始状态开始,按顺序重放所有事件得到的结果。
举个生活化的例子。传统做法像记账时只写"余额 500 元",事件溯源像记流水账:"存入 1000,支出 300,支出 200",余额是算出来的。前者简单但丢失了过程,后者啰嗦但完整保留了每一步。
在 opencode 里,事件大概长这样(简化):
type SessionEvent = | { type: "session.created"; sessionId: string; timestamp: number } | { type: "message.created"; messageId: string; role: "user" | "assistant"; content: string } | { type: "tool.started"; callId: string; toolName: string; args: unknown } | { type: "tool.completed"; callId: string; result: unknown } | { type: "message.updated"; messageId: string; delta: string };每个事件都是不可变的、只追加的。会话状态就是这些事件的折叠(fold)结果。
4.2 为什么编码助手需要事件溯源
你可能会问:一个聊天式的编码工具,用得着这么重的架构吗?我读下来觉得有三个实打实的理由。
第一,可回放。用户想复现一个 bug 或者回顾某次对话是怎么一步步走到结论的,事件流就是完整的录像。传统存"最终消息列表"的做法,中间过程全丢了。opencode 里可以做到"把这次会话从头重放一遍",看到每一步工具调用的输入输出。
第二,可审计。AI 编码助手会读写你的文件、执行命令。出了问题时,"它到底做了什么"必须能查清楚。事件流天然就是审计日志,每个工具调用、每次文件修改都有记录,且不可篡改(只追加)。
第三,可恢复。服务端崩溃重启后,从事件流重放就能恢复到崩溃前的状态。不需要额外的快照机制(虽然为了性能通常会加周期性快照)。这对一个可能跑很久的长会话很重要。
4.3 事件流的具体结构
opencode 的事件流不是一条全局的流,而是按会话分片的。每个会话一条独立的事件流,事件之间用会话 ID 关联。这样做的好处是并发会话之间互不干扰,也方便按会话做归档和清理。
事件本身带几个关键字段:
| 字段 | 作用 | 备注 |
|---|---|---|
id | 事件唯一标识 | 用于客户端去重 |
sessionId | 所属会话 | 分片依据 |
sequence | 会话内序号 | 保证顺序,检测丢事件 |
timestamp | 发生时间 | 用于排序和展示 |
type | 事件类型 | 决定如何投影 |
payload | 事件数据 | 类型随 type 变化 |
sequence这个字段很关键。客户端收到事件后检查序号是否连续,如果发现跳号,说明中间丢了事件,需要主动向服务端请求补发。这是保证客户端投影不残缺的兜底机制。
4.4 快照:事件溯源的性能补丁
纯事件溯源有个明显问题:会话很长时,每次都要从头重放所有事件才能算出当前状态,越来越慢。opencode 的解法是周期性快照——每积累 N 个事件,就把当前状态存一份快照。恢复时从最近的快照开始,只重放快照之后的事件。
快照的粒度是个权衡。太频繁,写快照的开销大;太稀疏,重放的事件多。opencode 里这个阈值是可配的,默认值我印象里是几百个事件一次。实际用下来,普通对话根本触发不了几次快照,只有那种跑了几百轮工具调用的长会话才会明显受益。
提示:如果你自己在做事件溯源系统,快照一定要做成"可丢弃"的——即删掉所有快照,系统依然能靠重放全部事件恢复正确状态。快照只是加速手段,不是数据源。opencode 就是这么设计的,快照文件损坏了直接删掉重建即可。
5. 核心细节:类型系统与跨包协作的实操要点
5.1 共享类型怎么组织
shared包里的类型不是一股脑塞进一个文件,而是按领域分文件:会话相关、消息相关、工具相关、协议相关各一个。每个文件导出自己的类型,再由index.ts统一 re-export。
这里有个 TypeScript 的实操技巧。opencode 大量使用了**判别联合(discriminated union)**来表示事件,就是上面SessionEvent那种写法。判别联合的好处是switch (event.type)时 TypeScript 能自动收窄类型,每个分支里event.payload的类型都是精确的,不需要手动断言。
function project(event: SessionEvent) { switch (event.type) { case "message.created": // 这里 event 自动收窄为 message.created 类型 return renderMessage(event.messageId, event.content); case "tool.started": // 这里 event 自动收窄为 tool.started 类型 return renderToolStart(event.callId, event.toolName); // ... } }如果你写 TypeScript 时经常用as断言,大概率是没用判别联合。把类型设计成带type字段的联合,断言能少一大半。
5.2 声明文件(.d.ts)在项目里的角色
opencode 里手写的.d.ts不多,因为大部分类型都是源码里直接定义的。但有两类地方必须用声明文件:
- 第三方库没有类型:某些小库不带类型,需要自己写一个
.d.ts补上。 - 全局类型扩展:比如给
Bun的全局对象加自定义属性。
写.d.ts的关键是只声明、不实现。里面只能有declare语句、类型别名、接口,不能有实际运行的代码。我见过有人把实现逻辑写进.d.ts,结果运行时直接报错——因为.d.ts在编译后是被完全擦除的。
5.3 跨包引用的路径配置
Monorepo 里跨包引用最容易出问题的就是路径。opencode 的做法是在每个包的tsconfig.json里配paths,把包名映射到源码目录:
{ "compilerOptions": { "paths": { "@opencode/shared": ["../shared/src/index.ts"], "@opencode/sdk": ["../sdk/src/index.ts"] } } }这样开发时 TypeScript 直接读源码,跳转、补全都正常。打包时再由 Bun 的打包器把依赖内联进去。注意paths只在类型检查阶段生效,运行时能不能解析到,取决于运行时的模块解析策略。Bun 原生支持 workspace 包名解析,所以运行时也没问题;如果你用 Node,就得靠exports字段或者构建时替换。
6. 实操过程:从零跑起来并观察事件流
6.1 环境准备与安装
先把仓库拉下来,确认 Bun 版本。opencode 对 Bun 版本有要求,太老的版本可能不支持某些 API。
# 检查 Bun 版本 bun --version # 安装依赖(在仓库根目录) bun install # 跑类型检查,确认环境没问题 bun run typecheckbun install在 Monorepo 里会把所有 workspace 包的依赖一次性装好,比逐个包npm install快很多。我第一次装大概花了十几秒,后续增量安装基本秒级。
6.2 启动服务端并观察事件
启动服务端后,最直观的观察事件流的方式是打开调试日志。opencode 在开发模式下会把每个事件的类型和 ID 打到日志里。
# 开发模式启动,带详细日志 DEBUG=opencode:* bun run dev启动后你会看到类似这样的输出:
[session] created session sess_abc123 [event] session.created seq=1 [event] message.created seq=2 role=user [event] message.created seq=3 role=assistant [event] message.updated seq=4 delta="你" [event] message.updated seq=5 delta="好"注意message.updated是流式输出产生的,一个字一个事件。这就是为什么前面强调客户端投影要节流——真实场景下这些事件来得非常密集。
6.3 手动重放一个会话
想验证事件溯源是否真的"只靠事件就能恢复状态",可以手动做一次重放。opencode 提供了导出事件流的命令,把某个会话的事件导成 JSON,然后写个小脚本重放:
import { project } from "@opencode/shared"; const events = JSON.parse(await Bun.file("session-events.json").text()); let state = initialState(); for (const event of events) { state = project(state, event); } console.log(state.messages);跑一遍你会发现,重放出来的消息列表和界面上看到的完全一致。这个实验能帮你真正理解"状态是派生出来的"这句话的含义。我第一次跑通的时候,对事件溯源的理解才算落地。
6.4 参数选择:快照阈值怎么定
如果你要调快照阈值,别拍脑袋。我的经验是按"重放耗时"来定,而不是按事件数量。先测一下重放 100 个事件要多久,如果只要几毫秒,那阈值可以设大一点(比如 1000);如果重放涉及大量 IO(比如要读文件内容),那阈值就得小。
opencode 默认值偏保守,宁可多写几次快照也不让重放变慢。因为对交互式工具来说,恢复速度直接影响用户体验,多占点磁盘无所谓。
7. 常见问题与排查技巧实录
7.1 事件顺序错乱怎么办
现象:客户端展示的消息顺序偶尔和实际不符,或者工具调用结果显示在了发起之前。
排查思路:先看事件的sequence字段是否连续。如果服务端并发写事件时没加锁,可能出现序号重复或跳号。opencode 在服务端对同一会话的事件追加做了串行化,保证序号严格递增。
解决:客户端投影时不要依赖事件到达顺序,而是按sequence排序后再投影。到达顺序受网络影响,不可靠;序号才是权威顺序。
7.2 客户端重连后状态不一致
现象:断线重连后,界面上少了中间几条消息。
排查思路:检查客户端重连时是否请求了"从某个 sequence 之后的所有事件"。如果只请求了最新状态快照,中间的事件就丢了。
解决:重连协议里带上客户端已处理的最大sequence,服务端从那个序号之后补发。opencode 的协议就是这么设计的,客户端维护一个lastSequence,重连时带上。
7.3 类型报错但代码看着没问题
现象:Monorepo 里改了shared的类型,其他包报错,但代码逻辑明明是对的。
排查思路:多半是 TypeScript 的增量编译缓存没更新。Monorepo 里跨包类型依赖复杂,缓存容易失效。
解决:删掉各包的tsconfig.tsbuildinfo,重新跑类型检查。如果还不行,检查paths配置是否指向了正确的源码路径。我踩过好几次这个坑,最后养成了改完shared就清缓存的习惯。
7.4 常见问题速查表
| 问题 | 可能原因 | 快速排查 |
|---|---|---|
| 事件跳号 | 并发写未串行化 | 检查服务端追加逻辑是否加锁 |
| 重连丢消息 | 未带 lastSequence | 检查重连协议字段 |
| 类型跳转失效 | TS 版本或 paths 配置 | 切 workspace TS 版本,核对 paths |
| 流式输出卡顿 | 投影未节流 | 检查客户端是否每条事件都重渲染 |
| 快照恢复错误 | 快照与事件不一致 | 删快照,纯重放验证 |
7.5 独家避坑心得
做事件溯源系统,我最想强调的一点是:事件一旦写入就永远不要修改。我见过有人为了"修正"一个错误事件,直接去改历史事件的内容,结果所有依赖该事件的快照全部失效,整个会话状态错乱。正确做法是追加一个"修正事件",让重放逻辑去处理它。事件流的不可变性是这套架构的根基,动了它,整套机制就塌了。
另一个心得是关于事件粒度的。事件太粗(比如一个事件包含整轮对话),重放时无法做细粒度投影;事件太细(比如每个字符一个事件),事件流会爆炸。opencode 的粒度是"消息级 + 流式增量级"混合,普通消息一个事件,流式输出按增量发。这个粒度我觉得是合理的,你可以参考。
8. 从这套架构里能抄走什么
读 opencode 的源码,对我自己后来做工具影响最大的不是某个具体实现,而是几个架构判断。第一,客户端永远不要做权威数据源,尤其是涉及 AI 这种长会话、多工具调用的场景,权威状态放服务端,客户端只做投影,断线重连的成本会低很多。第二,事件溯源不是银弹,但对"需要可回放、可审计"的场景是刚需,如果你的工具需要回答"它到底做了什么",事件流比任何日志方案都干净。第三,Monorepo 的分层要克制,shared只放纯类型和纯函数,别让它依赖任何东西,这条纪律能省掉后面无数的循环依赖麻烦。
至于双会话内核那部分,我个人的体会是:它的复杂度主要来自"两层状态同步",而不是会话本身。如果你做的工具不需要客户端和服务端分离,那单层会话管理就够了,别为了架构好看硬上双会话。opencode 之所以这么设计,是因为它同时要支持 TUI 和编辑器两种客户端,这是它的场景决定的,不是普适的。
下篇我打算聊它的工具调用机制和模型接入层,那部分涉及不少和外部服务打交道的细节,坑也不少,感兴趣的话可以接着看。