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 挡在编译期。这个习惯帮我省了无数次运行时调试。