1. 从一次代码评审说起:opencode 到底解决了什么问题
第一次接触 opencode 是在一个前端团队的内部工具评审会上。当时团队正在维护一个 TypeScript 写的 CLI 工具,代码量不算大,但每次改完都要手动跑一遍构建、再手动验证几个命令的输出,整个反馈链路特别长。有人提了一句"要不试试 opencode",于是就有了后面这一连串的折腾。
opencode 是一个用 TypeScript 编写、基于 Bun 运行时的开源 AI 编程助手,形态上是一个终端里的 CLI 工具,同时也能和 VS Code 这类编辑器配合使用。它做的事情说白了就是:把大模型的能力塞进你的开发工作流里,让你在终端里就能直接对话、改代码、跑命令、查文件。听起来和市面上其他 AI 编程工具差不多,但真正让我愿意花时间拆它源码的原因,是它的工程结构和会话内核设计——这两块东西决定了它能不能被二次开发、能不能接自己的模型、能不能在团队里长期用下去。
这篇是上篇,聚焦三件事:工程全景(Monorepo 怎么组织的)、双会话内核(为什么一个会话要拆成两层)、事件溯源(消息和状态到底怎么存的)。适合两类人看:一类是想把 opencode 接进自己工作流、甚至改源码的开发者;另一类是单纯想学一个成熟 TypeScript Monorepo 项目怎么搭的人。下篇会讲模型接入、工具调用和实际使用中的坑,这篇先把地基打牢。
我拆的是比较新的版本,目录结构和早期版本有差异,如果你看的是老版本,包名和路径可能对不上,这点先说明。
2. 工程全景:一个 TypeScript Monorepo 是怎么搭起来的
2.1 为什么是 Monorepo,而不是多个独立仓库
opencode 的代码组织方式是典型的 Monorepo,用 Bun 自带的 workspace 能力管理。你打开根目录,会看到一个packages/文件夹,里面平铺着好几个子包,每个子包有自己的package.json,但共享根目录的依赖锁文件和构建脚本。
为什么这么设计?我一开始也疑惑,一个 CLI 工具而已,至于拆这么多包吗。后来看懂了:opencode 的核心逻辑(会话、事件、模型调用)和它的对外形态(CLI、编辑器插件、SDK)是两拨人、两种节奏在维护的。核心逻辑要稳定、要能被多种前端复用;而 CLI 和插件迭代快、依赖具体运行环境。如果全塞一个包里,改一个 CLI 参数可能触发整个核心的重新构建和测试,耦合太重。
Monorepo 在这里的价值不是"代码放一起方便",而是依赖关系可以被工具显式约束。比如核心包不允许反向依赖 CLI 包,这种规则在 Monorepo 里可以用工具检查,在多仓库里只能靠人自觉。
2.2 目录结构与包职责拆解
我把主要目录和职责整理成一张表,方便你对照自己的项目:
| 路径 | 职责 | 依赖方向 |
|---|---|---|
packages/opencode | 核心包,会话、事件、模型抽象、工具系统 | 被其他包依赖 |
packages/sdk | 对外 SDK,封装核心能力供外部调用 | 依赖核心包 |
packages/cli或入口脚本 | 命令行入口,参数解析、TUI 渲染 | 依赖核心包和 SDK |
packages/*-plugin | 编辑器/平台插件适配层 | 依赖 SDK |
| 根目录配置 | workspace 定义、共享 tsconfig、构建脚本 | 无 |
这个分层的关键在于核心包不碰任何 UI。会话逻辑、事件存储、模型请求全在核心包里,它不知道自己是跑在终端还是编辑器里。这种"核心无 UI"的约束,是它能同时支持 CLI 和 VS Code 插件的根本原因。你在自己的项目里如果也想做多端复用,这条经验可以直接抄:把业务逻辑和渲染逻辑彻底分开,中间用事件或接口通信。
2.3 Bun 作为运行时和包管理器带来的实际差异
opencode 选 Bun 而不是 Node,这个选择影响挺大。Bun 同时是运行时、包管理器、打包器和测试运行器,一个工具顶四个。实际用下来有几个明显差异:
- 安装依赖快:Bun 的安装速度确实比 npm 快一截,Monorepo 里几十个包一起装,体感差距明显。
- 原生 TypeScript 支持:Bun 能直接跑
.ts文件,不需要先编译。这对开发调试很友好,改完直接bun run就行。 - 内置测试:
bun test开箱即用,不用额外配 Jest 或 Vitest,省了一堆配置。 - 兼容性坑:Bun 对 Node 生态的兼容不是 100%,某些依赖 Node 原生模块的库可能跑不起来。opencode 能选 Bun,说明它的依赖树比较干净,没有重度依赖 Node 特有 API。
提示:如果你打算把 opencode 的某些包抽出来放进自己的 Node 项目,先确认那些包有没有用到 Bun 特有的 API(比如
Bun.file、Bun.spawn),有的话得替换成 Node 等价实现。
2.4 TypeScript 配置的工程化细节
Monorepo 里 TypeScript 配置最容易乱。opencode 的做法是根目录放一个基础tsconfig.json,定义通用的编译选项(target、module、strict 等),每个子包再放一个自己的tsconfig.json,通过extends继承根配置,只覆盖自己需要的部分。
这种"基础配置 + 局部覆盖"的模式,好处是改一处全局生效,坏处是子包配置容易和根配置冲突。我踩过的坑是:根配置开了strict,某个子包因为历史原因想关掉,结果extends之后覆盖不彻底,编译报错排查了半天。经验是子包配置里显式声明所有和根配置不同的项,别指望继承能帮你省事。
另外,Monorepo 里跨包引用类型时,paths映射要配好,否则编辑器里跳转和类型提示会失效。opencode 用的是 workspace 协议(workspace:*)来声明内部依赖,配合 TypeScript 的 project references,能做到增量编译——只重新编译改动的包,大项目里这个能省不少时间。
3. 双会话内核:为什么一个会话要拆成两层
3.1 单层会话模型的问题在哪
大部分 AI 对话工具的实现很直接:一个会话对象,里面存一个消息数组,用户发一条就 push 进去,模型回一条再 push 进去。简单、直观,小规模用没问题。
但 opencode 面对的场景更复杂:它要支持多轮工具调用(模型让你读文件、跑命令、再基于结果继续推理)、要支持会话恢复(关掉终端再打开还能接着聊)、要支持多端同步(CLI 和编辑器看到的是同一个会话)。单层模型在这三个需求面前会崩:
- 工具调用会产生中间状态,这些状态不该混进用户可见的消息列表里。
- 会话恢复需要持久化,但持久化的粒度和内存里的结构不一定一致。
- 多端同步需要一套统一的状态变更通知机制,单层模型没有这个抽象。
3.2 双会话内核的分层设计
opencode 把会话拆成了两层,我把它叫做逻辑会话层和物理会话层(这是我自己的叫法,源码里不一定是这个词,但职责划分是清楚的)。
逻辑会话层面向用户和业务,管理的是"对话"这个概念:有哪些消息、当前在等谁回复、上下文窗口怎么裁剪。它关心的是语义。
物理会话层面向存储和同步,管理的是"状态变更":每一次消息新增、每一次工具调用开始和结束,都是一个事件。它关心的是顺序和一致性。
两层之间通过事件通信。逻辑层产生一个"用户发了消息"的意图,物理层把它变成一个事件记录下来,然后通知所有订阅者(CLI、编辑器、日志)。这种设计的好处是:逻辑层不需要知道有几个端在看,物理层不需要知道消息的语义。
3.3 两层之间怎么通信:事件驱动而非直接调用
如果逻辑层直接调用物理层的方法,比如storage.saveMessage(msg),那耦合就产生了——逻辑层得知道存储的接口长什么样。opencode 用的是事件驱动:逻辑层发出事件,物理层订阅并处理,处理完再发一个"已持久化"的事件回来。
这种模式在 TypeScript 里实现起来不复杂,一个简单的 EventEmitter 或者类型安全的发布订阅就够了。关键是事件要有类型定义,否则any满天飞,重构的时候根本不知道谁在监听什么。opencode 用 TypeScript 的联合类型来定义事件,每个事件有明确的type字段和对应的 payload 类型,switch 的时候能穷尽检查,漏了一个编译器会报错。
提示:自己实现事件系统时,别用字符串当事件名裸传。用
type Event = { type: 'message.added', payload: Message } | { type: 'tool.started', payload: ToolCall }这种可辨识联合,配合 switch 的穷尽检查,能挡掉大量低级错误。
3.4 双会话内核带来的实际收益
拆成两层之后,几个原本棘手的问题变得简单了:
- 会话恢复:物理层的事件流本身就是完整的操作日志,重放一遍就能恢复状态,不需要额外设计序列化格式。
- 多端同步:新端接入时,先拉一遍历史事件,再订阅后续事件,天然就是一致的。
- 调试:出问题时看事件流,能精确知道哪一步状态变了,比在消息数组里翻找高效得多。
- 测试:逻辑层可以脱离存储单独测,物理层可以脱离业务单独测,测试用例写起来清爽。
代价是代码量增加了,抽象层多了,新人理解成本高。这是个典型的用复杂度换可扩展性的取舍。如果你的项目就是个单人用的小工具,别学这套,过度设计。但如果你要做的是团队级、多端、长期维护的工具,这套分层值得参考。
4. 事件溯源:消息和状态到底怎么存的
4.1 事件溯源的核心思想
事件溯源(Event Sourcing)这个词听起来唬人,核心思想其实一句话:不存当前状态,只存导致状态变化的事件,当前状态由事件重放得出。
传统做法是存"账户余额 100 元",事件溯源存的是"开户 +100、消费 -50、充值 +50",余额是算出来的。opencode 的会话状态就是这么管理的:不存"当前消息列表",存的是"消息 A 被添加、工具 B 被调用、工具 B 返回结果"这一串事件。
为什么这么设计?因为 AI 对话的状态变化很频繁,而且中间过程有价值。用户可能想知道模型为什么调用了某个工具、工具返回了什么、模型基于什么继续推理。如果只存最终消息列表,这些中间信息就丢了。事件溯源天然保留了完整过程。
4.2 事件的数据结构设计
一个事件对象通常包含这几个字段:
interface BaseEvent { id: string; // 事件唯一标识 sessionId: string; // 所属会话 timestamp: number; // 发生时间 type: string; // 事件类型 payload: unknown; // 事件数据,按 type 收窄 }id和timestamp是基础,sessionId用于隔离不同会话的事件流,type决定payload的结构。实际实现里,payload会用可辨识联合来定义,比如:
type SessionEvent = | { type: 'message.added'; payload: { role: 'user' | 'assistant'; content: string } } | { type: 'tool.started'; payload: { toolName: string; args: Record<string, unknown> } } | { type: 'tool.finished'; payload: { toolName: string; result: string; error?: string } };这样在消费事件时,switch (event.type)能自动收窄payload类型,写起来安全。
4.3 事件如何持久化与重放
持久化方式取决于场景。opencode 支持本地文件存储,事件按会话分文件追加写入,格式通常是 JSON Lines(每行一个 JSON 事件)。这种格式的好处是追加写高效、读取可以流式处理、单行损坏不影响其他行。
重放就是按顺序读事件,逐个应用到状态机上。状态机维护一个内存中的会话状态,每来一个事件就更新一次。恢复会话时,从文件读全部事件,重放一遍,状态就回来了。
这里有个性能考量:如果会话很长,事件几千条,每次启动都全量重放会慢。常见优化是定期做快照——每隔 N 个事件存一次完整状态,恢复时先加载最近的快照,再重放快照之后的事件。opencode 是否做了快照我没细看,但这是事件溯源系统的标准优化手段,你自己实现时可以考虑。
4.4 事件溯源在 AI 对话场景的特殊价值
AI 对话有个特点:不确定性高。同样的输入,模型可能给出不同输出,可能调用不同工具。这种场景下,事件溯源的价值被放大了:
- 可回放:出了问题,重放事件流能精确复现当时的每一步,这对调试模型行为特别有用。
- 可审计:谁在什么时候让模型做了什么,全在事件流里,团队协作时能追溯。
- 可分支:理论上可以从某个事件点分叉出新的会话,做"如果当时换个问法会怎样"的实验。这个 opencode 不一定支持,但事件溯源的数据结构为这种能力留了空间。
注意:事件溯源不是银弹。它增加了存储量(存事件比存状态占空间),增加了读取复杂度(要重放),对事件 schema 的演进也有要求(老事件新代码要能读)。用之前想清楚你的场景是否真的需要完整历史。
5. 实操:从零跑起来并观察事件流
5.1 环境准备与安装
先把环境搭起来。你需要 Bun,安装方式按官方文档来就行。装完之后验证:
bun --version然后克隆 opencode 仓库,进根目录装依赖:
bun installMonorepo 的依赖安装会一次性把所有子包的依赖都装好,这是 workspace 的便利。装完可以跑一下构建:
bun run build如果构建报错,大概率是 Bun 版本和项目要求的不匹配,看根目录package.json里的engines字段确认版本要求。
5.2 启动并观察会话事件
跑起来之后,开一个会话,随便发几条消息,然后去看存储目录。事件文件通常在用户目录下的某个隐藏文件夹里,具体路径看配置。打开事件文件,你会看到类似这样的内容:
{"id":"evt_1","sessionId":"ses_abc","timestamp":1700000000000,"type":"message.added","payload":{"role":"user","content":"你好"}} {"id":"evt_2","sessionId":"ses_abc","timestamp":1700000001000,"type":"message.added","payload":{"role":"assistant","content":"你好,有什么可以帮你"}}对照这个文件,你能直观看到"双会话内核"和"事件溯源"是怎么落地的:每一条消息、每一次工具调用,都是一个独立事件,按时间顺序追加。
5.3 用事件流做一次手动重放
想验证事件溯源,可以写个小脚本手动重放:
import { readFileSync } from 'fs'; const lines = readFileSync('path/to/events.jsonl', 'utf-8').trim().split('\n'); const state = { messages: [] as Array<{ role: string; content: string }> }; for (const line of lines) { const event = JSON.parse(line); if (event.type === 'message.added') { state.messages.push(event.payload); } } console.log(state.messages);跑一下,输出的消息列表应该和你在 CLI 里看到的一致。这个练习能帮你彻底理解"状态由事件重放得出"这句话的含义。
5.4 修改事件类型做一次扩展实验
想更深入,可以试着加一个新事件类型。比如加一个message.reacted,记录用户对某条消息的反馈。步骤是:在事件类型定义里加一个分支,在产生反馈的地方发这个事件,在重放逻辑里处理它。改完跑一遍,看事件文件里有没有新类型,重放结果对不对。
这个实验能让你体会到事件溯源系统的扩展方式:加事件类型比改状态结构安全,因为老事件不受影响,新事件独立处理。
6. 常见问题与排查技巧实录
6.1 安装和构建阶段的典型问题
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
bun install卡住 | 网络或 registry 配置 | 检查 registry 是否可达,换镜像源 |
| 构建报类型错误 | 子包 tsconfig 冲突 | 检查 extends 链,显式覆盖冲突项 |
| 跨包引用找不到模块 | workspace 协议没配好 | 确认package.json里用了workspace:* |
| Bun 版本不兼容 | 版本低于要求 | 看engines字段,升级 Bun |
6.2 会话与事件相关的排查思路
会话恢复失败,先看事件文件是否完整——有没有被截断、有没有非法 JSON 行。事件溯源系统对事件流的完整性很敏感,中间缺一条,重放出来的状态就可能不对。
多端不同步,检查事件订阅是否在连接建立后才开始。如果先订阅后拉历史,可能漏掉订阅前的事件;正确顺序是先拉历史、再订阅增量,中间用事件 id 去重。
工具调用卡住不返回,看事件流里有没有tool.started但没有对应的tool.finished。有的话说明工具执行环节挂了,去查工具本身的实现或超时配置。
6.3 我踩过的几个坑
第一个坑是把事件当消息用。一开始我图省事,直接在事件 payload 里塞了渲染需要的字段,结果存储层和展示层耦合了,改个 UI 要动事件结构。正确做法是事件只存业务语义,展示层自己从事件推导要显示什么。
第二个坑是事件 schema 没有版本字段。后来加字段时,老事件没有这个字段,读取代码得写一堆兼容判断。如果一开始就在事件里加个version,演进会从容很多。
第三个坑是重放逻辑和实时逻辑写了两套。实时处理事件和重放处理事件的代码如果分开写,很容易行为不一致。经验是抽一个统一的事件处理函数,实时和重放都调它,保证行为一致。
6.4 性能相关的注意事项
事件文件会越来越大,读取和重放的成本随之上升。几个优化方向:定期快照、按会话分片、冷数据归档。另外,事件写入如果是同步的,高频工具调用场景下可能成为瓶颈,考虑批量写入或异步落盘。
内存里的状态机也要注意,如果会话很长,内存占用会持续增长。可以只保留最近 N 条消息在内存,更早的按需从事件流加载。
7. 关于工程结构的一点个人体会
拆完 opencode 的工程结构和会话内核,我最大的感受是:它的复杂度几乎全部来自"要支持多端和长期维护"这个目标。如果只是做个单人用的 CLI 助手,Monorepo、双会话、事件溯源这三样东西一个都不需要,一个文件几百行就能搞定。
但一旦目标变成"团队用、多端接、要能改",这些设计就都变得合理了。Monorepo 让多包协作有约束,双会话让逻辑和存储解耦,事件溯源让状态可追溯可恢复。它们不是炫技,是被需求逼出来的。
我自己后来在一个内部工具里借鉴了事件溯源这一层,只借鉴了这一层,Monorepo 和双会话都没上。效果是调试体验明显变好,出问题看事件流一目了然。所以我的建议是:别整套照搬,挑你真正需要的那一层。opencode 的价值不在于它用了多少高级模式,而在于它把每个模式的适用边界划得很清楚,你可以按需取用。
下篇我会讲模型接入层怎么抽象、工具系统怎么设计、以及实际接入自己的模型时会遇到哪些坑,感兴趣的话可以接着看。