
1. 项目缘起为什么我要啃 opencode 这块硬骨头第一次听说 opencode 是在一个深夜的技术群里有人甩了张截图说“这玩意儿把终端里的 AI 编程助手做成了正经工程”。我当时没太在意毕竟市面上各种 AI 编程工具已经多到让人麻木。直到后来连续看到好几个独立开发者都在讨论它而且讨论的角度很不一样——不是“哪个模型更聪明”而是“它的会话内核怎么设计的”“事件溯源在客户端怎么落地”。这就勾起了我的兴趣。opencode 是一个用 TypeScript 写的、跑在终端里的 AI 编程助手。它最特别的地方在于它不是简单地把请求转发给某个大模型 API 然后打印结果而是构建了一套完整的双会话内核和事件溯源机制。你可以把它理解成一个“有记忆、有状态、可回放”的编程对话系统。它解决的核心问题是当 AI 编程助手从玩具变成日常工具时如何保证对话的连续性、状态的可恢复性、以及多轮交互中上下文的一致性。这篇文章适合谁看如果你只是想知道怎么装一个 AI 编程助手网上教程一抓一大把不用往下读。但如果你想搞清楚一个生产级的 AI 编程工具在工程上是怎么组织的——Monorepo 怎么划分、双会话内核到底解决什么问题、事件溯源在客户端场景下怎么落地——那这篇内容应该能给你不少参考。我会尽量把每个设计决策背后的“为什么”讲清楚而不是只罗列“是什么”。2. 工程全景Monorepo 下的模块划分与依赖治理2.1 为什么选择 Monorepo 而不是多仓库opencode 的代码组织方式是我第一个想聊的点。它采用了典型的 Monorepo 结构用 Bun 作为运行时和包管理器。你可能会问一个终端工具而已至于上 Monorepo 吗我一开始也有这个疑问但看完它的模块划分之后我觉得这个选择是合理的。核心原因在于opencode 不是一个单一功能的 CLI 工具它同时包含了核心会话引擎、TUI 渲染层、Provider 适配层、工具调用系统、配置管理等多个相对独立但又高度耦合的模块。如果拆成多个仓库每次改动核心接口都要同时发多个包、协调版本号开发效率会急剧下降。而 Monorepo 允许你在一个提交里同时修改接口定义和所有调用方CI 也能一次性验证所有模块的兼容性。具体来说它的包结构大致是这样的packages/opencode主入口包含 CLI 命令解析和 TUI 启动逻辑packages/core会话内核双会话机制和事件溯源的核心实现packages/provider模型提供商适配层统一不同 API 的调用接口packages/tool工具调用系统文件读写、命令执行等能力的封装packages/shared共享类型定义和工具函数这种划分的逻辑是按变更频率和依赖方向分层。shared最稳定几乎不变core承载核心业务逻辑变更需要谨慎provider和tool是外围适配层经常需要新增支持opencode是组装层负责把下面几层串起来。2.2 Bun 作为运行时和包管理器的实际体验选择 Bun 而不是 Node.js pnpm这个决策在社区里争议不小。我实际跑下来Bun 的优势主要体现在三个方面第一是启动速度。终端工具的启动延迟直接决定使用体验。Bun 的冷启动比 Node.js 快不少对于 opencode 这种需要频繁启动的场景来说感知很明显。第二是内置 TypeScript 支持。不需要额外的编译步骤直接跑.ts文件开发时的反馈循环更短。第三是包安装速度Monorepo 下依赖关系复杂Bun 的安装速度确实省时间。但坑也不是没有。Bun 的某些 Node.js API 兼容性还在完善中如果你在tool包里用了比较冷门的 Node 内置模块可能会遇到行为差异。我的建议是核心逻辑尽量用 Web 标准 APIfetch、Stream、AbortController 这些把对 Node 特定 API 的依赖降到最低。这样即使将来要换回 Node.js 运行时迁移成本也可控。2.3 TypeScript 类型系统的工程化用法opencode 对 TypeScript 的使用不是“加个类型注解就完事”那种。它在几个关键位置用了比较高级的类型技巧值得单独拿出来说。首先是声明文件的组织。项目里有一个types目录存放全局类型声明和模块扩展。这里有个细节它没有把所有类型都塞进一个.d.ts文件而是按领域拆分——provider.d.ts管模型提供商的类型session.d.ts管会话相关的类型event.d.ts管事件溯源的类型。这样做的好处是当你只关心会话逻辑时不需要加载整个项目的类型定义编辑器的类型检查速度会快很多。其次是静态继承和重写的用法。在 Provider 适配层它定义了一个抽象基类各个模型提供商继承这个基类并重写特定的方法。TypeScript 的abstract关键字和override修饰符在这里发挥了作用——编译器会强制检查你是否正确重写了父类方法避免因为拼写错误导致方法没被覆盖却浑然不知。这个在多人协作时特别有价值。还有一个值得学的点是类型收窄在事件处理中的应用。事件溯源系统里事件类型很多每个事件携带的数据结构不同。opencode 用了可辨识联合类型Discriminated Union来建模事件配合switch语句做穷尽性检查。如果你新增了一个事件类型但忘了在某个处理分支里处理它TypeScript 会直接报错。这种“让编译器帮你找 bug”的思路在事件驱动系统里非常实用。3. 双会话内核一个被低估的架构决策3.1 什么是双会话为什么需要两个会话“双会话内核”这个说法第一次看到的时候我有点懵。会话就是会话为什么是两个看完实现之后我理解了它把用户可见的对话会话和系统内部的执行会话分开了。用户可见的会话就是你看到的那个对话界面——你发一条消息AI 回一条消息历史记录按时间排列。这个会话的核心诉求是可读性和连续性用户需要能回看之前的对话需要能理解上下文。系统内部的执行会话是 AI 在处理你的请求时实际发生的事情——它可能调用了文件读取工具、执行了命令、发起了多次模型请求、做了中间推理。这个会话的核心诉求是可追溯性和可恢复性当执行中断时系统需要知道进行到了哪一步能不能从断点继续。为什么要分开因为这两个会话的生命周期和状态管理需求完全不同。用户会话是线性的、面向展示的执行会话是树状的、面向状态机的。如果混在一起你会面临一个两难要么用户界面被内部执行细节污染要么内部状态管理被用户界面的展示逻辑束缚。3.2 双会话之间的同步机制两个会话之间需要同步但这个同步不是简单的“镜像复制”。opencode 的做法是用户会话是执行会话的投影。执行会话产生事件事件经过过滤和转换后投影到用户会话中展示。举个例子AI 在执行过程中调用了三次文件读取工具。在执行会话里这是三个独立的工具调用事件每个事件包含文件路径、读取内容、耗时等信息。投影到用户会话时可能只展示为一条“正在读取文件...”的状态提示或者干脆合并成一条“已读取 3 个文件”的摘要。具体怎么投影取决于用户界面的展示策略。这种设计的精妙之处在于展示逻辑和状态逻辑解耦了。你想改变用户界面的展示方式只需要改投影规则不需要动执行会话的状态机。你想增加一种新的执行能力只需要在执行会话里加事件类型投影层可以选择展示或不展示。3.3 会话状态机的设计要点执行会话本质上是一个状态机。opencode 的状态机设计有几个点我觉得很值得借鉴状态转换必须是显式的。每个状态转换都由一个事件触发不存在“隐式状态变更”。这意味着你可以在任何时刻通过回放事件来重建状态这对于调试和恢复来说太重要了。状态转换必须是可中断的。AI 执行过程中用户可能随时取消。状态机需要支持从任意中间状态安全地回退到空闲状态而不是留下一个“半死不活”的会话。状态转换必须是可序列化的。整个会话状态需要能存到磁盘、能从磁盘恢复。这就要求状态里不能有函数、不能有循环引用、不能有平台相关的对象。opencode 用了纯数据结构来表示状态序列化就是JSON.stringify简单可靠。实操心得如果你也在设计类似的会话系统我强烈建议把状态定义成纯数据把状态转换定义成纯函数。这样测试起来极其方便——给一个输入状态和一个事件断言输出状态就行不需要 mock 任何东西。4. 事件溯源客户端场景下的落地实践4.1 事件溯源的核心思想与适用场景事件溯源Event Sourcing这个概念在后端领域已经比较成熟了核心思想是不存储当前状态而是存储导致状态变更的所有事件。当前状态通过回放事件计算得出。这个模式在 opencode 这种客户端工具里落地有几个独特的挑战。后端的事件溯源通常有数据库支持事件存储是持久化的、有序的、可查询的。客户端没有数据库事件存在内存里或者文件里需要考虑内存占用、文件读写性能、事件数量增长等问题。opencode 选择事件溯源我认为核心动机是可恢复性。AI 编程助手的使用场景里一次对话可能持续很长时间中间可能因为网络问题、进程崩溃、用户主动关闭等原因中断。如果没有事件溯源中断就意味着丢失所有上下文用户得从头开始。有了事件溯源重启后回放事件会话状态就恢复了。另一个动机是可调试性。AI 的行为有时候很诡异你需要知道它为什么做出了某个决策。如果只存最终状态你只能看到结果如果存了事件流你可以看到每一步的输入和输出定位问题容易得多。4.2 事件的设计类型、载荷与元数据opencode 的事件设计遵循了一个原则事件是不可变的、自描述的、携带完整上下文的。每个事件包含三部分类型标识用可辨识联合类型的type字段来区分比如user_message、assistant_message、tool_call、tool_result、session_start等载荷数据事件的具体内容比如用户消息的文本、工具调用的参数、工具返回的结果元数据时间戳、事件 ID、父事件 ID用于构建事件树、会话 ID这里有个细节值得说父事件 ID 的设计。因为执行会话是树状的一个用户消息可能触发多个并行的工具调用每个工具调用又可能触发子调用。通过父事件 ID你可以把扁平的事件列表重建成树状结构这对于理解执行流程非常关键。事件载荷的设计也有讲究。以工具调用事件为例载荷里不仅包含工具名称和参数还包含调用意图的文本描述。这个描述是 AI 生成的用自然语言说明“我为什么要调用这个工具”。这个设计在调试时特别有用——你一眼就能看出 AI 的意图而不需要去猜参数的含义。4.3 事件存储与回放内存与磁盘的权衡事件存哪里opencode 的做法是内存 磁盘双写。内存里保留最近的事件用于快速回放磁盘上持久化完整的事件流用于恢复。内存存储用的是一个追加式的数组新事件 push 进去回放时从头遍历。这个方案在事件数量少的时候没问题但事件多了之后每次回放都要遍历整个数组性能会下降。opencode 的优化是定期做快照。每 N 个事件做一次状态快照回放时从最近的快照开始只需要回放快照之后的事件。磁盘存储用的是 JSONL 格式每行一个 JSON 对象。这个格式的好处是追加写很方便不需要读取整个文件再重写解析也简单逐行读取逐行解析而且人类可读调试时直接cat就能看。回放逻辑是事件溯源的核心。opencode 的回放是纯函数式的reduce(events, initialState, applyEvent)。applyEvent是一个纯函数接收当前状态和事件返回新状态。这个设计让回放逻辑极其容易测试——你不需要启动整个应用只需要构造事件序列调用 reduce断言最终状态。注意事项事件回放必须是确定性的。如果你的applyEvent里用了Date.now()或者随机数回放结果就会不一致。所有非确定性因素都应该在事件生成时固化到事件载荷里回放时直接读取。4.4 事件溯源带来的调试优势用了事件溯源之后调试体验的提升是质的飞跃。以前排查 AI 行为问题你只能看日志日志是线性的、信息有限的。现在你可以精确回放拿到用户的事件流文件在本地回放完全复现用户遇到的问题事件注入在回放的某个节点注入一个自定义事件观察系统如何响应状态对比回放到某一步导出当前状态和预期状态做 diff时间旅行跳到事件流的任意位置查看当时的状态这些能力在传统的“存最终状态”的系统里是很难实现的。我觉得这是事件溯源在 AI 工具场景下最大的价值——AI 的行为本身就难以预测你需要强大的调试工具来理解它。5. 核心流程拆解从用户输入到 AI 响应5.1 一次完整对话的生命周期让我把一次完整对话的流程串一遍这样你能更清楚地看到双会话和事件溯源是怎么协同工作的。用户在 TUI 里输入一条消息按下回车。CLI 层捕获输入生成一个user_message事件写入执行会话的事件流。事件溯源系统应用这个事件更新执行会话状态为“等待 AI 响应”。接着会话内核从执行会话状态里提取上下文历史消息、工具定义、系统提示词等组装成模型请求。这个请求通过 Provider 适配层发送给模型 API。模型返回响应。如果响应里包含工具调用会话内核会生成tool_call事件然后执行对应的工具。工具执行结果生成tool_result事件。如果工具执行又触发了新的模型请求比如需要根据工具结果继续推理就循环这个过程。最终模型返回纯文本响应生成assistant_message事件。执行会话状态更新为“空闲”。投影层把执行会话的事件投影到用户会话TUI 渲染出用户可见的对话内容。整个过程中每个事件都被追加到事件流内存和磁盘双写。如果中途进程崩溃重启后从磁盘加载事件流回放到最后一个事件会话状态就恢复了。5.2 工具调用系统的实现细节工具调用是 AI 编程助手的核心能力。opencode 的工具系统设计有几个点值得展开说。工具定义是声明式的。每个工具用一个对象描述名称、描述、参数 schema用 JSON Schema 或 Zod 定义、执行函数。声明式的定义让工具可以被模型理解描述和 schema 会发给模型也可以被系统调度执行函数会被调用。工具执行是沙箱化的。文件读写工具会限制在项目目录内命令执行工具有超时和输出大小限制。这些限制不是可选的是强制的。因为 AI 生成的工具调用参数不可完全信任必须有安全边界。工具结果是结构化的。工具执行函数返回的不是字符串而是一个结构化对象包含成功/失败标识、输出内容、错误信息、执行耗时等。结构化结果让投影层可以灵活展示也让事件载荷更丰富。5.3 模型 Provider 适配层的抽象opencode 支持多个模型提供商每个提供商的 API 格式、认证方式、流式响应格式都不一样。Provider 适配层的职责就是把这些差异屏蔽掉给上层提供一个统一的接口。适配层的核心接口大概长这样createCompletion(options): AsyncIterableCompletionChunk。上层只需要调用这个方法拿到一个异步迭代器逐块消费响应。至于底层是哪个提供商、用的什么协议上层不关心。这个抽象的关键在于流式响应的统一。不同提供商的流式格式差异很大有的用 SSE有的用 JSON Lines有的用自定义协议。适配层需要把这些都转换成统一的CompletionChunk格式。这个转换逻辑是适配层最复杂的部分也是最容易出 bug 的地方。实操心得写 Provider 适配层的时候一定要把原始响应先记下来。我踩过的坑是某个提供商的流式响应在特定情况下会发送空块或者重复块如果不记录原始响应你根本不知道是适配层转换错了还是提供商本身的问题。6. 常见问题与排查技巧实录6.1 安装与运行时的典型问题问题一Bun 版本不兼容。opencode 对 Bun 版本有最低要求版本太低会报奇怪的模块解析错误。排查方法bun --version确认版本对照项目package.json里的engines字段。问题二Monorepo 依赖没链接。在 Monorepo 里包之间的依赖是通过 workspace 协议链接的。如果你直接bun install之后跑某个子包可能会找不到依赖。解决方法是先跑bun install在根目录确保所有 workspace 链接建立好。问题三TypeScript 类型报错但运行正常。这通常是因为tsconfig.json的路径映射没配好编辑器找不到类型定义。检查paths配置确保opencode/core这类别名指向正确的源文件目录。6.2 会话状态异常的排查思路会话状态异常通常表现为AI 响应不符合预期、工具调用失败、上下文丢失。排查步骤找到会话的事件流文件通常在项目目录的.opencode文件夹下用cat或编辑器打开 JSONL 文件查看最后几个事件确认最后一个事件是什么类型是否处于“等待响应”状态如果是工具调用事件检查工具参数是否合法如果是模型响应事件检查响应内容是否完整如果事件流看起来正常但状态不对可能是回放逻辑有 bug。这时候可以写一个最小复现构造一个包含问题事件的最小事件序列跑回放看状态是否符合预期。6.3 事件流膨胀的处理长时间使用后事件流文件会越来越大。opencode 的处理方式是快照 截断。定期做状态快照快照之前的事件可以归档或删除。但要注意删除事件会丢失调试能力所以建议归档而不是直接删除。如果你自己实现类似系统我的建议是事件流文件按会话分文件每个文件有大小上限超过上限就滚动到新文件。同时维护一个快照文件记录每个事件流文件对应的起始状态。这样恢复时只需要加载最近的快照和之后的事件文件。6.4 常见问题速查表问题现象可能原因排查方法解决方案启动报模块找不到Bun 版本过低bun --version升级 Bun 到要求版本类型报错但能运行tsconfig paths 配置错误检查paths字段修正路径映射AI 响应中断网络问题或 API 限流查看事件流最后事件重试或检查 API 配额工具调用失败参数不合法或权限不足检查 tool_call 事件载荷修正工具参数或权限会话状态丢失事件流文件损坏检查 JSONL 文件完整性从快照恢复或重建会话回放结果不一致applyEvent 有副作用审查 applyEvent 实现移除副作用固化非确定性因素7. 从 opencode 学到的工程经验7.1 双会话设计的通用价值双会话这个模式不只适用于 AI 编程助手。任何需要展示层和状态层分离的系统都可以借鉴。比如协作编辑工具用户看到的文档是一个会话底层的操作变换OT/CRDT是另一个会话工作流引擎用户看到的任务进度是一个会话底层的状态机执行是另一个会话游戏回放系统玩家看到的画面是一个会话底层的输入事件流是另一个会话核心思想是一样的展示逻辑和状态逻辑的生命周期不同强行耦合会导致两边都做不好。分开之后各自可以独立演进。7.2 事件溯源在客户端的取舍事件溯源不是银弹。它在客户端场景下的优势是恢复性和可调试性代价是存储开销和回放性能。如果你的场景里会话状态很简单、中断恢复不重要、调试需求不强那用传统状态管理就够了没必要上事件溯源。判断标准很简单问自己“如果进程崩溃丢失当前状态代价大不大”。如果代价大事件溯源值得考虑如果代价小简单状态管理更划算。7.3 对 TypeScript 工程化的启发opencode 的 TypeScript 用法给我最大的启发是类型系统不只是用来防拼写错误的它是用来表达业务约束的。可辨识联合类型表达“事件类型和载荷的对应关系”抽象类和 override 表达“适配层的契约”类型收窄表达“状态机的合法转换”。当你把业务约束编码进类型系统编译器就成了你的第一道测试。这个思路在大型项目里尤其重要。当代码库大到一个人无法完全理解时类型系统是唯一可靠的全局约束。运行时测试只能覆盖你想到的场景类型检查能覆盖所有代码路径。7.4 后续可以深入的方向这篇主要聊了工程全景、双会话内核和事件溯源。opencode 还有几个方向值得继续挖TUI 渲染层的实现怎么在终端里做出流畅的交互体验、工具调用系统的安全边界怎么防止 AI 执行危险操作、多模型 Provider 的流式响应统一不同协议的适配细节。这些内容我会在后续的文章里展开。如果你正在做类似的 AI 工具或者对事件溯源在客户端的落地感兴趣我建议你直接去读 opencode 的源码。它的代码组织清晰注释也到位是一个很好的学习样本。我自己读下来的体会是好的架构不是设计出来的是演化出来的。opencode 的双会话和事件溯源也不是一开始就有的是在解决实际问题的过程中逐步形成的。理解这个演化过程比记住最终架构更有价值。