先丢个结论:opencode 这类终端 AI 编码工具,表面上是一套命令行交互界面,内里其实是一个“会自己动手改代码的智能体运行时”。我花了不少时间把它的源码框架捋了一遍,最吸引我的不是某个提示词模板或某个模型适配,而是它怎么用工程手段把“对话”和“执行”这两条生命线拧在一起,又怎么用事件溯源把整段人机协作过程变成一条可回放、可审计、可断点续传的事件流。这篇是上篇,先讲工程全景和这两个内核,下篇再落到具体的数据结构、运行循环和扩展机制上。
1. 工程全景:一条从终端到模型再到文件系统的流水线
1.1 三个层面:界面、大脑、手脚
opencode 给我的第一印象是“模块切得很干净”。从高层往下看,它大概可以切成三层。
第一层是终端交互层,也就是用户在命令行里看到的那个界面,包括消息列表、输入框、快捷键、状态提示。这一层不直接跟模型对话,也不直接改文件,它只负责两件事:把用户输入变成内部事件,把内部事件渲染成界面。
第二层是会话内核层,这是整个项目的大脑。它负责维护对话状态、调度模型请求、处理工具调用、管理上下文窗口。模型要调什么工具、工具返回了什么结果、下一步该继续问模型还是停下来等用户确认,都在这一层决定。
第三层是工具与执行层,相当于手脚。读写文件、执行终端命令、列出目录、做代码搜索,这些能力都以“工具”的形式暴露给会话内核。工具只管执行,执行完把结构化的结果回传,不关心这个结果会不会被展示、会不会被写进上下文。
这个三层划分并不稀奇,很多智能体框架都长这样。opencode 做得比较好的地方在于,每层之间不是函数直接调用,而是通过事件流来通信。界面不直接调用内核的方法,而是投递“用户输入”事件;内核不直接调用工具的函数,而是发出“执行工具”事件。层与层之间几乎没有强耦合,这给后文的双会话和事件溯源留了很大的设计空间。
1.2 技术选型:为什么是 TypeScript、Node 和终端 UI 框架
我第一次看到 opencode 的实现语言时并不意外,TypeScript 几乎是这类工具的主流选择。原因很实际:生态成熟,模型 SDK、文件系统、子进程管理都有非常稳定的库;类型系统对事件这类多形态数据结构很友好,能显著减少状态流转时的低级错误。项目以 npm workspace 的方式组织成多包仓库,核心引擎、CLI、扩展、界面分包管理,编译和发布都比较清晰。
终端界面部分没有用传统的 TUI 库,而是基于一个支持 React 编程模型的终端渲染方案,结合 Node 的能力控制终端光标、颜色、键盘事件。这个选型的意义在于:可以用组件化的方式维护复杂交互界面,同时保持终端应用的实时响应。换个角度说,如果全用底层 TUI 原语手写,消息列表、流式输出、光标定位这些逻辑会非常难维护;有了组件状态管理之后,界面的复杂度和背后的状态流可以对齐。
还有一个值得注意的选型细节是本地优先。opencode 的配置、会话记录、事件日志都存在本地目录里,模型请求直接由本机进程发起,目标项目文件也是直接由当前用户权限读写。这个设计保证了它能在任意目录下工作,不需要注册全局服务,也不用额外启动一个守护进程。要说缺点也有:本地状态的管理和迁移完全靠自己,没有服务端兜底,所以它对“事件溯源”这类可恢复机制的依赖特别强——这一点后面会展开。
1.3 目录与模块:先看哪里,能最快建立全局观
很多读者拿到源码后容易一头扎进某个文件,我的建议是先看顶层目录结构和核心包的入口。
典型的多包工程结构大致是这样的:一个基础类型包存放事件、消息、工具调用结果等公共模型;一个核心引擎包实现会话状态机、模型请求调度、上下文管理;一个 CLI 包负责启动入口、参数解析、终端渲染;一个扩展包定义第三方扩展的接口;还有一个配置包管理全局配置和项目配置。它们之间的引用方向是单向的——CLI 依赖核心引擎,核心引擎依赖基础类型,工具实现相对独立,不反向依赖界面。
我读这类项目时习惯先用二十分钟把核心类型定义扫一遍。opencode 里最有价值的类型不是某个工具的参数,而是“会话状态”“事件类型”“消息角色”三组定义。只要理解了这三组类型,再去看状态机流转和事件消费逻辑,基本就能建立全局观。具体的事件模型会在第三章详细说,这里先记一个结论:类型定义是这份源码的索引,而不是某个工具函数的实现。
2. 双会话内核:为什么一份代码里要维护两套会话
2.1 一个会话跑到底,会卡在哪里
如果只做一个简单的“对话式编码助手”,本质上维护一个消息列表就够了:用户发言、模型回复、必要时插入工具结果。这个模型很简单,但实际用起来会遇到三个让人头疼的问题。
第一个问题是上下文混乱。当工具执行了二十步之后,模型上下文里既有人类对话,又有系统消息、工具结果、执行日志,如果全混在一个列表里,上下文窗口的管控会变得非常麻烦。第二个问题是流程控制。用户可能希望在执行到某一步时喊停,修改一句提示词,再让它继续;如果会话只有一条线,暂停、恢复、重试、分支这些操作都需要对同一条消息流做复杂的回溯。第三个问题是回放和审计。出了 bug 之后,我想知道“到底执行了哪些工具、每一步的输入输出分别是什么”,混在一个对话流里同样很难分清。
这就引出了 opencode 里一个很有意思的设计:把会话拆成两层,一层面向人,一层面向任务。我用“双会话内核”来描述它:一个负责与用户对话,一个负责与执行环境交互。两者不是简单的父子关系,而是两条平行的事件流。
2.2 交互会话与任务会话各自的边界
第一条会话线是交互会话,核心对象是“人与 AI 的对话”。它维护用户消息、AI 回复、用户对回复的反馈、上下文分支等。它的时间尺度是秒到分钟级的,一条消息就是一个完整单元。交互会话不关心底层到底调用了多少次工具、读了多少个文件,只关心“这个回合的输入是什么、输出是什么”。
第二条会话线是任务会话,核心对象是“AI 为完成目标而执行的智能体任务”。它会维护工具调用计划、每一步的执行状态、工具参数和返回结果、模型下一步决策所需的临时信息。任务会话的时间尺度可以非常短——几秒内可能触发几次工具调用;也可以比较长——一次复杂的多文件重构可能运行十几个工具步骤。
边界清晰带来的直接收益是上下文窗口可以分开管理。交互会话的上下文偏重“对话的历史”,任务会话的上下文偏重“工具执行前后的事实”。如果两者混用,模型会面临一个非常尴尬的情况:为了知道现在改到哪一步了,它可能需要把整个历史对话重新塞进上下文。拆开之后,任务会话需要携带的只是“当前目标 + 最近若干步执行记录 + 当前文件状态摘要”,信息密度高得多。
2.3 双会话怎么协同:事件桥接与状态同步
拆成两套会话之后,最难的问题就是协同。opencode 的做法不是让两个会话互相调用内部方法,而是通过一个事件总线来做桥接。
用户输入进入交互会话,交互会话生成一个“用户消息已创建”的事件,同时向任务会话下发一个“启动处理”的指令。任务会话开始运行后,每执行一个工具、每收到一次模型回复,都会发布对应的事件。交互会话作为事件的消费者,决定哪些内容需要展示、哪些内容只在后台记录。模型需要用户确认时,任务会话会发出“等待用户确认”的事件,交互会话把这个状态渲染成界面上的提示;用户按键后,交互会话再发布“用户已确认”的事件,任务会话恢复执行。
这种事件桥接的模式让两个会话可以独立演进,但代价是调试成本增加——你无法用普通的断点去观察“两套会话之间”的状态,只能靠事件日志来还原当时发生了什么。这也是 opencode 能把事件溯源做成刚需的原因之一:没有事件日志,双会话的协作过程会变成一个不可见的黑盒;有了事件日志,任何一次异常都可以回放定位。
3. 事件溯源:把一次编码过程变成一条可回放的河流
3.1 事件溯源解决的是状态丢失问题
“事件溯源”不是一个新的概念,它的核心思想很简单:不保存系统的当前状态,而是保存产生这个状态的一串事件;当前状态随时可以通过重放事件推导出来。你可以把它类比成银行流水:银行不会只告诉你“你现在余额有多少”,每一笔存款和取款都有记录,余额只是流水加总的结果。只要有流水,随时可以算出任意时刻的余额,也能追溯每一笔钱是怎么来的。
在普通业务系统里,事件溯源更多是一种可选的架构风格;但在 AI 编码工具里,我倾向于认为它是不可避免的。原因是这类工具的状态变化太快、太杂:模型回复是流式的、工具输出是异步的、用户可以随时打断、执行结果会改动文件系统。如果每次状态变更只保留最终结果,一旦出错你会发现根本不知道中间发生了什么。而如果每个关键状态变化都以事件形式追加到一条日志里,那么无论是崩溃恢复、会话回放,还是审计和调试,都能从这条日志里找到答案。
opencode 在这点上做得比较彻底:它不把事件当成调试辅助,而是作为会话运行时的主干。所有跨模块的信息传递,几乎都通过事件完成;界面可以随时“订阅”事件来刷新;任务的执行进度可以被持久化;甚至恢复一个中断的会话,也只需要从事件流里重建状态。
3.2 核心事件模型:从消息到工具调用,一切皆事件
在这个模型里,事件不是简单的字符串日志,而是结构化数据。我梳理下来,核心事件大概可以分成几类。
消息类事件记录“谁说了什么”,包括用户消息、模型消息、系统提示。这类事件的 payload 通常包含会话 ID、角色、文本内容、时间戳。工具类事件记录“每一步执行”,包括工具调用计划、工具开始执行、工具执行完成、工具执行失败。上下文类事件记录“上下文窗口的变化”,比如哪些内容被压缩、哪些文件被摘要。生命周期类事件记录“会话的开始、暂停、恢复、结束”,以及用户确认、取消等操作。
一个典型的事件 payload 大概长这样:
{ "id": "evt_01HZ...", "type": "tool.executed", "sessionId": "sess_main", "timestamp": "2025-06-18T11:32:07.482Z", "payload": { "toolName": "fs.readFile", "targetPath": "src/core/session.ts", "status": "success", "resultTruncated": true } }事件一旦追加就不可修改。如果某个工具执行失败产生了一个“错误”事件,后续可以追加一个“重试”事件或“跳过”事件,但不会去修改之前那条“错误”事件。这个不可变性对回放非常重要——它保证历史事实不被篡改,也让恢复逻辑变得简单。
我刚开始读这段代码时以为事件只是为了日志输出,后来才意识到它在运行时承担了三个职责:作为状态机的输入、作为界面的数据源、作为持久化的最小单元。一个事件被发布后,不同的订阅者各取所需:状态机根据它推进状态,界面根据它渲染新内容,存储层把它追加到当前会话的事件文件里。
3.3 投影与恢复:怎么从零重建一个会话
事件溯源里有个关键词叫“投影”。简单来说,投影就是从事件流推出我们关心的那个视图。同一个事件流可以投影出多种视图:可以投影出“当前对话消息列表”,也可以投影出“当前文件修改摘要”,还可以投影出“刚刚执行过的工具序列”。
opencode 的界面本质上就是一系列事件投影出来的结果。用户看到的消息列表,不是某个模块直接改 UI 数组,而是订阅了消息类事件后,由投影逻辑把事件还原成消息渲染项。这样做的好处是,界面永远只是“下游”,不会反向污染上游状态。
恢复会话也用同样的思想。如果进程在任务执行到一半时崩溃,下一次启动时只需要读取事件文件,重放所有事件,把投影内存重建出来,就能恢复到崩溃前的状态。这一步看起来简单,真正实现时要处理不少细节:某些事件重放时会不会产生副作用?比如“文件写入成功”的事件,如果只需要恢复状态,就不用真的再写一次文件;但如果是“命令执行中”的事件,重放时你不可能让命令重新跑一遍。所以事件投影通常分为两类,一类是纯内存状态还原,一类是需要外部资源重新协商的恢复操作。opencode 的经验是把“执行类副作用”从事件重放中剥离出来,恢复时只还原计划、结果、用户消息这些内存状态,不试着重新执行工具。
这个设计带来的一个隐藏好处是可以做“步骤回退”。如果用户觉得某一步改坏了,理论上可以回到这个步骤之前的状态,把后续事件标记为“已撤销”,再走另一条分支。当然,文件系统层面的改动没法靠事件回滚,但会话状态层面的回退是成立的,这对调试和探索式开发非常有用。
3.4 回放式调试:事件日志就是源码执行的黑匣子
我在实操这类架构时最深的感受是:它把调试从“打日志找状态”提升到了“看事件流还原现场”。传统开发里,遇到 bug 往往要重新跑一遍,边跑边加日志;事件溯源架构里,日志本身就足够完整,甚至可以离线回放整个会话。
具体调试时,我最爱用三类查询。第一是查因果链:找出某条消息是由哪次工具调用引发的,某个错误是发生在哪一步。第二是查时间线:把整个会话的事件按时间排序,看模型在某个时刻到底收到了哪些上下文。第三是查分支差异:同一个会话里,用户中途修改过指令,事件流会留下两条分支,对比分支事件就能搞清楚为什么“同一句话改一个词,结果完全不同”。
这个查证能力在排查模型行为问题时尤其重要。很多时候你觉得模型“变笨了”,实际是上下文里被塞进了一段异常的工具输出;通过回放事件流,能很快定位到是哪一步引入了这个异常输出,进而优化工具结果的截断策略或摘要逻辑。
4. 阅读与实操过程中最需要注意的几个细节
4.1 事件序列化:跨语言、跨版本的兼容性陷阱
事件是持久化到文件里的,这就意味着如果你改了事件结构,旧事件文件依然要被新版本代码正确解析。opencode 在这块的处理方式是给每个事件类型一个稳定的类型名,并在 payload 里带上一个版本字段。新增字段时尽量保持向后兼容,删除字段时要做迁移逻辑。
给读者的建议是:如果你打算在自己项目里借鉴这套设计,一定要从第一天就约定好事件结构的兼容性规则,否则项目迭代两个版本之后,回放逻辑会变成一个到处打补丁的泥潭。
4.2 恢复不是重跑:副作用与纯投影要分开
前面已经提到,事件回放时不能盲目重放所有事件。我的实操体会是:在设计事件类型时,就要把“观察类事件”和“执行类事件”分清楚。观察类事件比如“读取到文件内容”“收到模型回复”,可以被安全投影;执行类事件比如“文件已写入”“命令已执行”,只用来更新记录,不能作为再次执行的信号。
如果你在恢复逻辑里看到“为了恢复状态,把命令再跑一遍”的设计,基本可以判定这个恢复是危险的。opencode 的做法是:恢复只还原“意图与结果”,不重放“动作本身”。
4.3 双会话与事件溯源结合后的心智负担
这个架构强大,但也不是免费的。最直接的代价是心智负担:每个功能都要考虑它属于哪个会话、会触发哪些事件、事件被哪些消费者订阅、恢复时怎么处理。新人上手时往往觉得“为什么改一个消息展示这么麻烦,直接 push 到一个数组不就行了”。
答案在于可维护性。直接 push 数组在两三个功能模块时确实快,但当模块数量增长到十几个时,互相之间的状态同步会变得非常脆弱。事件溯源把“状态怎么变”和“状态变成什么样”分离开,短期内更繁琐,长期看反而更稳定。如果你要在自己的项目里落地这套模式,我建议不要一步到位,先在一个子模块里试点事件化改造,跑通后再逐步推广到其他模块。
5. 上篇收个尾,也给下篇留个引子
到目前为止,我重点讲了 opencode 的工程分层,拆了解释了它为什么需要双会话内核,以及事件溯源在运行时和调试时的价值。这三块是理解整个项目的骨架,但不代表它的全部。实际操作中还有几个非常值得展开的地方:会话状态机到底用什么样的状态图,上下文窗口是怎么压缩和摘要的,工具扩展接口如何做到让第三方低成本接入。
这些内容我打算放在下篇里继续写。如果你正在自己写 AI 编码工具、智能体框架,或者只是好奇这类产品内部长什么样,我的建议是:先跑起来,然后用“事件日志回放”的方式去观察它一步一动,而不是直接去读全部源码。等你在回放中产生了一堆“这里为什么这样”的疑问,再带着问题去翻内核实现,效率会高很多。