1. 从标题拆解 opencode 的工程骨架
第一次看到 "opencode" 这个名字,加上热搜里那一串 TypeScript、Bun、Monorepo、事件溯源的关键词,我基本能判断出这不是一个普通的 CLI 小工具,而是一个用现代前端工程体系搭起来的、带会话状态管理的终端应用。事实也确实如此。opencode 是一个跑在终端里的 AI 编程助手,用 TypeScript 写、用 Bun 跑、用 Monorepo 组织代码,内核里还塞了一套事件溯源(Event Sourcing)的会话机制。这几个词单拎出来都不新鲜,但把它们组合到一个终端工具里,就很有意思了。
我之所以想认真拆一遍它的工程全景,是因为大部分人对"AI 编程助手"的理解停留在"调个 API、拼个 prompt、流式输出"这个层面。真去看一个成熟项目的代码组织,你会发现真正难的不是调模型,而是会话怎么存、状态怎么同步、多轮上下文怎么不丢、工具调用和用户输入怎么在一个事件流里对齐。opencode 给出的答案就是双会话内核加事件溯源,这套设计思路值得单独拿出来讲。
这篇文章适合三类人看:一是想搞懂现代 TypeScript Monorepo 工程怎么落地的人;二是自己在写 AI Agent、想知道会话状态该怎么设计的人;三是单纯好奇 opencode 内部长什么样、想从源码里抄点架构思路的人。我会尽量把每个设计决策背后的"为什么"讲清楚,而不是只罗列目录结构。
2. 工程全景:Monorepo 是怎么切分的
2.1 为什么是 Monorepo 而不是单包
先聊一个最基础的问题:opencode 为什么用 Monorepo?很多人一看到 Monorepo 就觉得是"大厂炫技",其实不是。判断要不要上 Monorepo,核心看一件事——多个模块之间是否存在高频的、需要同步演进的共享代码。
opencode 的场景里,至少有这么几块东西是强耦合的:核心会话逻辑、终端 UI 渲染、工具(tool)定义、模型 provider 适配、配置管理。如果拆成独立 npm 包分开发布,每次改一个共享类型,你得先发 A 包、再升 B 包的依赖、再联调,光是版本对齐就能把人逼疯。Monorepo 把这些问题一次性解决:共享类型改完,所有包立刻能看到,类型检查在 CI 里一把过。
用 Bun 作为包管理器在这里是个关键选择。Bun 的 workspace 安装速度比传统方案快一个量级,而且它自带 TypeScript 运行时,不需要额外的 ts-node 或 tsx 来跑脚本。对于 opencode 这种"开发时频繁跑 CLI 验证"的项目,省掉编译步骤带来的体验提升是实打实的。
2.2 目录切分的逻辑
一个健康的 Monorepo,目录切分应该遵循"按职责分层,而不是按文件类型分层"。我见过太多项目把utils/、types/、constants/平铺在根目录,结果所有模块都往里面塞东西,最后变成垃圾场。opencode 的切法更接近按领域划分:
- 核心包负责会话内核、事件定义、状态机,这是整个系统的"心脏",不依赖任何 UI。
- CLI 包负责终端交互、命令解析、渲染,它依赖核心包,但核心包绝不反向依赖它。
- 工具包定义各种可被模型调用的能力(读文件、跑命令、搜索等),通过接口和核心包解耦。
- provider 包处理不同模型服务的适配,把"模型差异"收敛在一个边界内。
这种切法的好处是依赖方向单一。核心包永远不知道 UI 长什么样,也不知道用的是哪家模型。你想换一个终端渲染库,或者加一个新的模型服务,改动都被限制在对应的包里,不会像涟漪一样扩散到整个代码库。
提示:判断 Monorepo 切分是否合理,有个简单粗暴的标准——如果删掉某个包,其他包能不能独立编译通过?如果删掉 UI 包后核心包报了一堆错,说明依赖方向反了。
2.3 类型声明文件在工程里的位置
热搜里有人问 "typescript types 文件夹的声明文件怎么用",这个问题在 opencode 这种项目里特别典型。Monorepo 里跨包引用类型,最容易踩的坑就是类型解析路径。
常见做法有两种:一种是在每个包里放src/types/,通过tsconfig.json的paths做别名映射;另一种是单独抽一个types包,所有共享类型集中管理。opencode 更偏向后者——把跨包共享的领域类型(比如事件、会话、消息)集中定义,各包通过 workspace 引用。
这里有个实操细节值得说:.d.ts声明文件不要手写业务类型。手写声明文件只适合两种情况——给没有类型的第三方库补类型,或者给全局注入的东西(比如window上的扩展)做声明。业务类型一律用.ts写,让编译器自己生成.d.ts。我见过有人把接口全写在.d.ts里,结果类型和实现脱节,改了一处忘了另一处,编译能过但运行时炸。
3. 双会话内核:为什么要拆成两层
3.1 单会话模型的天花板
要理解"双会话内核",得先想清楚单会话模型会撞到什么墙。最朴素的 AI 助手实现是这样的:维护一个消息数组,用户发一条 push 进去,模型回一条 push 进去,每次请求把整个数组发给模型。这个模型在简单场景下能用,但一旦涉及工具调用、多轮任务、会话恢复,问题就来了。
第一个问题是状态和历史的混淆。消息数组既是"当前对话状态",又是"历史记录",还是"发给模型的上下文"。这三个角色其实需求不同:状态需要能快速查询当前在做什么,历史需要能持久化和回放,上下文需要能裁剪和压缩。用一个数组同时承担,迟早会互相打架。
第二个问题是工具调用的时序。模型说"我要读某个文件",然后工具执行、返回结果、模型继续。这个过程里,用户可能中途插话,可能取消,可能工具执行失败。单会话模型很难优雅地表达"一个正在进行的工具调用"这种中间态。
3.2 双会话的分工
opencode 的双会话内核,本质是把"持久化的对话历史"和"运行时的执行状态"分开管理。一层负责记录发生了什么(事件流),另一层负责当前正在发生什么(活动会话状态)。
打个比方,这就像记账。事件流是流水账,每一笔都记下来,只增不改;活动状态是当前余额和正在进行的交易,可以随时查询和变更。流水账保证你任何时候都能回溯到任意一个时间点,活动状态保证你查询当前情况时不用遍历整个历史。
这种分离带来的直接好处是可恢复性。程序崩溃了、终端关了、网络断了,重新启动时只要重放事件流,就能把活动状态重建出来。这在终端工具里尤其重要,因为终端会话本来就容易中断,用户不希望每次重开都从零开始。
3.3 两层之间怎么同步
双会话最大的难点在于两层之间的一致性。事件流记录了"用户发了消息",活动状态需要反映"当前在等模型回复"。如果同步逻辑写得不严谨,就会出现事件流里有记录但状态没更新,或者状态更新了但事件没落盘的情况。
opencode 的做法是以事件为唯一真相来源(single source of truth)。所有状态变更都必须先产生事件,再由事件驱动状态更新。也就是说,你不能直接改活动状态,只能"提交一个事件",让状态机根据事件推导出新状态。这个约束听起来麻烦,但它把"状态从哪来"这个问题彻底解决了——状态永远是事件流的函数。
注意:事件驱动状态更新时,一定要保证事件处理的幂等性。因为重放事件流时,同一个事件可能被处理多次,如果处理逻辑不幂等,状态就会错乱。比如"计数加一"这种操作,重放两次就变成加二了。
4. 事件溯源:会话状态的正确打开方式
4.1 事件溯源到底解决了什么
事件溯源(Event Sourcing)这个词听起来很唬人,但核心思想特别朴素:不存当前状态,只存导致状态变化的事件,状态通过重放事件算出来。
传统做法是存"当前状态"——比如会话表里存一条记录,字段是当前的消息列表、当前状态标记。事件溯源反过来,存的是"用户创建了会话""用户发送了消息""模型返回了内容""工具执行完成"这些事件。要看当前状态,就把这些事件按顺序跑一遍。
为什么这对 AI 会话特别合适?因为 AI 会话天然就是事件序列。用户输入是一个事件,模型响应是一个事件,工具调用是一个事件,工具结果又是一个事件。这些事件有严格的时序关系,而且一旦发生就不该被修改。事件溯源的数据模型和 AI 会话的语义模型是天然对齐的,用起来毫不别扭。
4.2 事件的数据结构设计
一个事件至少要包含这几个字段:事件类型、事件负载(payload)、时间戳、以及一个能唯一标识它的 ID。如果要支持多会话,还得带上会话 ID。如果要支持因果追踪,可以再加一个"父事件 ID",表示这个事件是由哪个事件触发的。
事件类型的设计有个经验:宁可多定义几个细粒度事件,也不要搞一个万能事件带一堆可选字段。比如不要设计一个MESSAGE事件,然后用type: 'user' | 'assistant' | 'tool'来区分。直接定义USER_MESSAGE、ASSISTANT_MESSAGE、TOOL_CALL、TOOL_RESULT四个事件,每个事件的负载结构清晰、类型明确。这样在 TypeScript 里可以用 discriminated union 做穷尽检查,漏处理一种事件编译器直接报错。
type SessionEvent = | { type: 'USER_MESSAGE'; payload: { content: string }; ts: number; id: string } | { type: 'ASSISTANT_MESSAGE'; payload: { content: string }; ts: number; id: string } | { type: 'TOOL_CALL'; payload: { name: string; args: unknown }; ts: number; id: string } | { type: 'TOOL_RESULT'; payload: { callId: string; result: unknown }; ts: number; id: string };这种写法配合switch语句,TypeScript 能帮你做穷尽性检查。我实测下来,这个模式能挡掉大量"新增了事件类型但忘了处理"的低级 bug。
4.3 重放与快照的权衡
纯事件溯源有个明显的性能问题:事件越积越多,每次重建状态都要从头重放,越来越慢。解决办法是快照(snapshot)——每隔 N 个事件存一次当前状态,重建时从最近的快照开始,只重放快照之后的事件。
快照的粒度需要权衡。存太频繁,写放大严重;存太稀疏,重放成本高。我的经验是,对于会话这种场景,可以按"每轮对话结束"或"每 N 个事件"来触发快照。opencode 这类工具单次会话的事件量通常不会爆炸,所以快照策略可以相对保守,甚至先不做快照,等真有性能问题再加。
这里有个容易忽略的点:快照必须带版本号。因为状态推导逻辑会随代码演进变化,旧快照可能和新代码不兼容。带上版本号后,遇到不兼容的快照可以丢弃并从头重放,保证正确性优先。
5. 核心实操:从零理解一次会话的完整生命周期
5.1 会话创建与初始化
一次会话的起点是创建。用户敲下命令,系统生成一个会话 ID,写入一个SESSION_CREATED事件。这个事件本身可能不带什么业务负载,但它的存在标记了时间线的起点。
初始化阶段要做几件事:加载配置(用哪个模型、什么参数)、准备工具集、建立事件存储的写入通道。这里的关键是初始化必须是幂等的——如果同一个会话 ID 被初始化两次,不能产生两套状态。做法是在初始化前先检查事件流里有没有SESSION_CREATED,有就直接重放恢复,没有才创建。
5.2 用户输入到模型响应的完整链路
用户敲入一句话,到看到模型回复,中间发生了一串事件。我把这条链路拆开讲:
- 用户输入被捕获,产生
USER_MESSAGE事件,写入事件流。 - 状态机收到事件,把用户消息加入当前上下文,标记状态为"等待模型"。
- 系统组装请求,把上下文发给模型服务。
- 模型开始流式返回,每收到一段内容,可以产生增量事件(或者等完整回复后产生一个
ASSISTANT_MESSAGE事件,看设计取舍)。 - 如果模型要求调用工具,产生
TOOL_CALL事件,状态机切换到"执行工具"。 - 工具执行完,产生
TOOL_RESULT事件,结果回填到上下文。 - 模型基于工具结果继续生成,直到给出最终回复。
这条链路里,每一步都是一个事件,任何一步失败都能从事件流里看出卡在哪。这就是事件溯源在调试上的巨大优势——出问题时你不是面对一个黑盒状态,而是面对一条清晰的事件时间线。
5.3 工具调用的状态管理
工具调用是 AI 会话里最容易出乱子的地方。模型可能一次要求调多个工具,可能工具执行超时,可能用户中途取消。用事件溯源管理这些情况,思路是把工具调用建模成一个有生命周期的小状态机。
TOOL_CALL事件标记调用开始,TOOL_RESULT标记调用结束。中间如果超时或取消,产生对应的TOOL_ERROR或TOOL_CANCELLED事件。状态机根据这些事件维护"当前有哪些工具调用在进行中"。
提示:工具调用的 ID 一定要和模型的调用请求 ID 对应上。模型返回的 tool call 通常带一个 ID,工具结果必须带着同一个 ID 回来,否则多工具并发时结果会串。我踩过这个坑,两个工具同时跑,结果张冠李戴,排查了半天。
5.4 会话的持久化与恢复
事件流的持久化,最简单的做法是追加写文件(JSONL 格式,一行一个事件)。这种格式的好处是写入快、追加方便、出错了容易人工检查。缺点是查询慢,但会话场景下查询需求不强,够用。
恢复时,读取事件文件,逐行解析,重放到状态机。如果事件文件损坏(比如最后一行写了一半),要能优雅处理——丢弃不完整的最后一行,保留之前的事件。这个容错细节很关键,因为程序崩溃时正在写的事件很可能就是半截的。
function loadSession(path: string): SessionState { const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean); let state = initialState(); for (const line of lines) { try { const event = JSON.parse(line) as SessionEvent; state = reduce(state, event); } catch { // 跳过损坏的行,通常是崩溃时写了一半 continue; } } return state; }6. 常见问题与排查技巧实录
6.1 事件顺序错乱怎么办
事件溯源最怕的就是顺序错乱。如果两个事件的时间戳相同,或者写入顺序和逻辑顺序不一致,重放出来的状态就可能不对。排查这类问题的第一步是给每个事件加单调递增的序号,不要只依赖时间戳。时间戳精度可能不够,而且系统时钟还可能回拨。
如果发现顺序确实错了,先别急着改数据,先查写入路径。常见原因是并发写入没有加锁,两个异步操作同时往事件流里写。解决办法是给事件写入加一个串行队列,保证同一会话的事件严格按提交顺序落盘。
6.2 状态重放结果不一致
同一个事件流重放两次,结果不一样,这基本可以断定是状态推导函数有副作用或非确定性。比如用了Date.now()、Math.random(),或者依赖了外部可变状态。状态推导函数必须是纯函数——给定相同的事件序列,永远产出相同的状态。
排查方法很直接:写个测试,把同一批事件重放两遍,断言两次结果深度相等。如果不等,逐步缩小事件范围,定位到具体哪个事件的处理引入了非确定性。
6.3 类型报错:跨包引用找不到类型
Monorepo 里跨包引用类型报错,八成是tsconfig.json的references或paths没配好。我的排查顺序是:先确认被引用的包有没有正确导出类型(package.json的types字段指向对不对),再确认引用方的tsconfig有没有把对方加进references,最后确认构建顺序——被依赖的包要先构建出.d.ts。
如果用的是 Bun 的 workspace,还要注意 Bun 对exports字段的解析规则和 Node 略有差异。遇到诡异的解析失败,先跑一遍bun install确保 workspace 链接是最新的,很多问题会自己消失。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 重放后状态错乱 | 事件顺序错乱 | 检查事件序号是否单调递增 |
| 重放结果不稳定 | 推导函数有副作用 | 检查是否用了时间、随机数 |
| 跨包类型找不到 | tsconfig 配置问题 | 检查 references 和构建顺序 |
| 工具结果串位 | 调用 ID 未对应 | 核对 tool call ID 传递链路 |
| 会话恢复失败 | 事件文件损坏 | 检查最后一行是否完整 |
| 状态更新丢失 | 事件未落盘 | 检查写入是否 await 完成 |
6.5 几个我踩过的坑
第一个坑是过早优化快照。我一开始就设计了复杂的快照机制,结果发现单次会话事件量根本不大,快照带来的复杂度远超收益。后来砍掉快照,代码清爽了一大截。教训是:先跑通纯事件溯源,等真有性能瓶颈再加快照。
第二个坑是事件负载塞了太多东西。我一度把整个上下文都塞进事件负载里,导致事件文件巨大、重放巨慢。正确做法是事件只记录"变化",上下文通过重放推导出来,而不是每次都全量存。
第三个坑是忽略了事件的向后兼容。上线后改了事件结构,老的事件文件解析不了。解决办法是给事件加 schema 版本,解析时按版本走不同的处理逻辑。这个成本不高,但能救命。
7. 从工程视角看这套设计的取舍
7.1 事件溯源不是银弹
说了这么多事件溯源的好话,也得泼点冷水。事件溯源不是所有场景都适合。它的优势在于可追溯、可恢复、可审计,代价是实现复杂度高、查询不直观、需要处理版本兼容。
如果你的 AI 助手就是个一次性的问答工具,不需要多轮、不需要恢复、不需要调试历史,那用个简单的消息数组就够了,上事件溯源纯属自找麻烦。opencode 之所以值得用这套,是因为它要做的是一个长期运行、可中断可恢复、需要精细调试的编程助手,这些需求正好落在事件溯源的优势区间里。
7.2 双会话内核的边界
双会话内核的边界要划清楚:事件流是只增不改的真相来源,活动状态是可重建的派生数据。任何时候,如果活动状态和事件流对不上,以事件流为准,重建状态。
这个原则听起来简单,但实操中很容易被破坏。比如有人图省事,直接改活动状态而不产生事件,短期看没问题,长期看状态和事件流就分家了。要守住这条线,最好在代码层面做约束——活动状态的修改接口只接受事件,不暴露直接改字段的方法。
7.3 这套架构能怎么扩展
理解了这套设计,扩展方向就很清晰了。想支持多会话并行,事件流按会话 ID 分文件即可。想做会话回滚,从事件流里截断到某个时间点重放就行。想做会话分享,把事件流导出,对方重放就能看到完整过程。想做审计,事件流本身就是审计日志。
我个人觉得最有价值的扩展是基于事件流的会话分析。因为所有交互都以结构化事件存下来了,你可以统计用户最常用哪些工具、模型在哪些环节容易卡住、平均一轮对话产生多少事件。这些数据反过来能指导产品优化,而传统的不存事件的实现根本拿不到这些信息。
最后分享一个我在实际项目里的小技巧:事件类型定义好之后,写一个assertNever辅助函数,在switch的default分支里调用它。这样只要新增了事件类型而忘了处理,TypeScript 编译就会报错,把"漏处理事件"这类 bug 挡在编译期。这个习惯帮我省了无数次运行时调试。