过去这段时间,我一直在研究一类跑在终端里的 AI 编程工具,opencode 就是其中很有代表性的一个。它不是那种大而全的 IDE 插件,而是一个把重心放在可读性、可扩展性和状态可控性上的开源实现。最初我关注的是它的交互体验,后来看得越多越想搞清楚一件事:一个看起来简洁的 TUI 工具,凭什么能支撑起多模型、多会话、任务自动执行这些复杂功能?带着这个疑问,我顺着它的设计主线做了系统梳理,这篇先讲工程全景、双会话内核和事件溯源这三个最基础的部分。如果你也写或者准备写类似的 agent 类工具,这篇值得花几分钟看完。
1. 为什么要啃 opencode 的架构:工具内外是两套世界
1.1 从用户视角到工程视角的一次切换
普通用户打开 opencode,看到的是一套终端界面:左边是会话列表,右边是对话区域,底部是输入框。你在里面发一条指令,等一会儿,看到模型回复,有时候还会看到它"调用工具"的过程。整套体验看起来像是一个"对话框程序",很容易让人以为背后就是"把消息发给模型,再把结果渲染出来"这么简单。
但真正进入工程视角后,你会发现完全是另一套世界。用户看到的每一条消息、每一次工具执行、每一轮上下文压缩,背后都是一条条不可变的事件记录;用户以为的"一个会话",实际是两个分工明确的内核在协作;用户以为的"打字输出",实际是一连串带时序、带源头的状态变更。这个差距,就是我决定写这一系列文章的原因:很多工具的问题不在于模型不够强,而在于工程结构不够清晰。而 opencode 恰好提供了一个值得拆解的样本。
1.2 opencode 在 AI 编程工具里的生态位置
市面上的 AI 编程工具大致分成三类:第一类是 IDE 插件型,深度绑定编辑器的 UI 和数据模型;第二类是网页应用型,把上下文管理放在云端;第三类就是 opencode 这种终端原生的 agent 工具,它自己就是一个独立的交互运行时。它的特点在于:不依赖某个特定编辑器的内部接口,所有状态都可以落到磁盘,会话可以被序列化、被归档、被恢复,甚至能被脚本外部驱动。
这种定位带来的工程要求很高:它必须作为独立进程运行,自己管理配置、模型接入、工具权限、会话持久化。没有宿主应用帮它分担任何逻辑。所以 opencode 的架构注定要分层,而且每一层都要尽量独立。我梳理它的核心,发现可以用三个关键词概括全景:分层、双内核、事件溯源。后面所有内容都是围绕这三个词展开的。
2. 工程全景:一条命令启动后发生的五层协作
2.1 入口与配置:启动阶段的关键决策
一个 opencode 进程的启动,远不只是"渲染一个界面"。从执行命令到出现可交互界面,中间有一连串前置决策,每步都可能影响后续行为。
第一件事是合并配置来源。opencode 支持全局配置和项目级配置叠加,最终形成一份带有明确作用域的有效配置。这个阶段会决定很多重要参数:用哪个模型提供商、温度取多少、哪些工具默认启用、哪些目录被列入工作区白名单、是否需要审批确认。配置文件本身也支持多环境区分,甚至可以把一些敏感信息通过环境变量注入,而不是写死在文件里。这一步做得好不好,直接决定后面整个运行时的可预测性。
第二件事是初始化存储层。我之前强调过,opencode 不是一个无状态工具,它的会话是持久化的。启动时需要先确定数据目录是否可写、事件日志是否完整、是否需要进行快照恢复。如果有未完成的会话,工具可以选择恢复上次状态,而不是一切从零开始。这一步是事件溯源架构的入口,也是后面要重点展开的部分。
2.2 核心分层:四层各司其职
如果把 opencode 的工程结构摊开,我会把整个运行时划分为四个核心层:
- 交互渲染层:负责终端 UI 的状态呈现和键盘事件捕获。它本身不包含业务逻辑,只做"渲染当前状态"和"把用户意图转发给内核"两件事。由于 TUI 是单线程事件循环,这层必须非阻塞,任何耗时操作都要异步化。
- 会话内核层:这是整个系统的中枢。它接收用户输入、维护上下文窗口、管理 LLM 调用状态、调度工具执行。我在第三部分会说明它内部为什么拆成两个并列内核。
- 模型适配层:处理不同模型提供商的协议差异。所有模型能力经过统一接口暴露,差异被隔离在适配器内部。切换模型时,上面的内核逻辑不需要改动。
- 存储与工具层:存储端实现事件追加、快照持久化、历史归档;工具端实现命令运行、文件编辑、代码搜索等一系列真实操作。
每一层都只依赖自己下方的抽象接口,不跨层调用。这样做最大的好处是:渲染层可以替换成别的界面(比如网页或者富客户端),模型层可以替换成新的提供商,存储层可以替换成远端数据库,而核心会话逻辑保持稳定。这也就是"工程全景"想表达的核心:结构决定功能的边界。
2.3 依赖注入与插件扩展:工程可测试性的来源
opencode 不是把所有对象都做成全局单例,而是大量使用依赖注入。启动阶段创建好各项服务,再以显式方式注入到上层组件。比如会话管理器需要依赖模型适配器、工具注册表、事件存储,这些都在初始化阶段传入,而不是在内部直接 new 出来。
这个设计极大提升了可测试性。想在测试里模拟"模型返回超时",不需要真的去调用外部 API,只需要注入一个模拟适配器;想在测试里验证工具执行顺序,也不需要真实跑命令,只要把工具注册表替换成探测实现。对于 agent 类应用,这种可测试性几乎是必需品,因为 agent 的失败很多是发生在状态流转上的,而非模型本身。
插件扩展则建立在事件与工具两个抽象上。插件可以订阅特定类型的事件,也可以在工具注册表中新增能力。因为所有交互都被建模为事件流,插件不用侵入核心代码就能参与会话流程。这使得核心仓库能保持克制,而生态依然可以丰富。
3. 双会话内核:AI 工具里的"大脑"与"手脚"
3.1 为什么必须拆成两个会话内核
我在跟踪 opencode 的会话执行流程时发现,它的会话系统内部实际上由两个独立内核组成,一个是交互会话内核,一个是执行会话内核。第一次看到这种划分时我有点疑惑,后来在实际使用中理解了这个做法的必要性。
如果你把用户请求、模型回复、工具调用全部塞进一条直通链路,会出现一个非常严重的问题:模型在等待工具结果时,整个会话被锁住了,用户什么都不了,也没法发新消息。这对真实工作流是致命的,因为 agent 执行一个长任务常常要几十秒甚至几分钟,用户不可能干等。
于是 opencode 把"对话管理"和"行动执行"拆成两个内核。交互会话内核负责维护用户与模型之间的语义层:上下文窗口、消息顺序、模型回复的增量产出;执行会话内核负责处理工具调用:命令调度、输出解析、权限确认。两者不是上下级关系,而是通过事件流互相订阅。模型说"我要运行这个命令",交互内核发出工具调用事件,执行内核接收后执行并返回结果事件,交互内核再把它作为上下文的一部分继续推进。
3.2 交互会话:维护上下文与 LLM 调用的状态机
交互会话内核更像是一个有限状态机,它管理的核心概念是turn和context。一次完整的用户请求到最终回复,构成一个 turn;一个 turn 内又可能包含多轮"模型请求工具、工具返回结果"的子步骤。
它需要维护的不仅是消息列表,还有一个上下文预算。因为模型上下文窗口有限,对话一长就要压缩或裁剪。压缩策略必须考虑到哪些内容对后续执行依然重要,哪些已经可以被摘要替代。我在实际测试中的感受是,这个状态机做得好不好,直接决定对话超过一定轮数后模型是不是会"失忆"。opencode 用显式的事件记录来管理这些状态转换,而不是靠模型"记住"自己说过什么。
另一个交互内核的重要职责是处理"流式输出"。模型回复是增量到达的,每条增量都作为事件写入日志;UI 层订阅这些事件实时刷新渲染。即使是流式输出这种看似临时性的机制,也被纳入事件溯源体系,而不是放在外层临时变量里。这保证了进程崩溃后,可以按事件顺序重放得到完整的回复内容。
3.3 执行会话:工具调用与 agent 循环的运行时
执行会话内核更像一个"agent 运行时",它要解决的核心问题是:如何在真实环境里安全、有序地执行模型发起的操作。
每条工具调用进入执行内核后,会经历完整的生命周期:校验工具是否存在、检查参数是否匹配、评估权限策略、执行实际操作、捕获标准输出与错误、形成结构化结果、返回给交互内核。整个过程有明确的超时控制与重试策略。比如一个命令如果执行时间过长,执行内核可以选择终止或继续等待,取决于配置的策略。
这里有一个关键设计:执行内核不会直接信任 LLM 的输出。它把所有工具参数都做一层结构化解包和校验,而不是把模型生成的字符串直接拼进 shell 执行。这样做既是为了安全,也是为了稳定。我对这一点的评价是:真正的 agent 工程必须把"模型建议"和"系统执行"分开,否则模型的幻觉会直接变成对系统的破坏。
双内核之间的事件通信遵循固定的时序。一次 typical 的交互流程如下:
- 用户发送消息,交互内核追加
user_message_received事件,更新上下文。 - 交互内核请求模型回复,模型返回若干
assistant_message_delta事件。 - 如果回复里包含工具调用,交互内核发出
tool_call_requested事件。 - 执行内核接收事件,经过权限校验后执行工具,发出
tool_call_result事件。 - 交互内核收到结果事件,把它作为新上下文再次调用模型。
- 重复 2 到 5,直到模型不再请求工具,交互内核发出回合结束事件。
每一步的事件都带着父事件引用,保证能被回溯。这种设计在并发上也有天然优势:执行内核不需要等待模型回复,可以在后台持续处理多个命令,只要调度策略允许。
3.4 两个内核的事件通信与时序约束
既然两个内核是并行的,就必须处理深深的时序问题。opencode 的方式是:所有事件都进入同一条有序队列,但不同内核维护自己的游标。交互内核关心的事件顺序是围绕"对话推进"的,执行内核关心的是"工具执行完成"的,两者靠事件ID建立关联。
一个常见的复杂场景是:模型在同一个回复里请求了多个工具调用,其中一些可以并行执行,另一些有先后依赖。执行内核需要根据工具间的依赖声明决定执行顺序,而不是盲目并行。这种信息放在工具定义层面,由工具作者显式声明。事件溯源在这一步的价值体现得很明显:并行执行的结果最终会按完成顺序写回事件日志,重放时依然保持实际发生的顺序,不会因为并发导致状态错乱。
4. 事件溯源:会话状态为什么能被完整重放
4.1 append-only 事件日志与快照的关系
如果把 opencode 比作一个人,事件溯源就是它的长期记忆系统。普通的持久化方案会在每个轮次结束后把会话状态序列化保存,优点是简单,缺点是只能存储最终结果,过程细节全部丢失。一旦状态出现异常,无从排查;一旦你想回到之前的某个分支,无从下手。
事件溯源的核心思路是:状态可以被删除或修改,但事件永不改变。所有重要行为都以不可变事件的形式追加到日志中,想要得到当前状态,只需要从起点重放全部事件即可。这听起来很笨,但对 AI 会话特别合适,因为会话天然是线性的追加过程,很少有"修改历史"的需求,更多的是"从历史继续"。
为了避免事件日志无限增长,opencode 会定期生成快照。快照是某一时刻的完整状态,加上快照之后发生的事件,就构成一个可收缩的日志结构。恢复时先加载最近的快照,再重放快照后的增量事件,两段合并即得最新状态。快照本身也是一条特殊事件,被持久在同一个日志簇里,不破坏不可变性。
4.2 事件类型设计:哪些事件必须落盘
我梳理了一下事件类型的设计边界。不是所有 UI 抖动都需要落盘,落到事件日志里的,一定是对会话状态或审计链路有意义的事件。它们大致分四组:
- 消息事件:用户消息、助手回复增量、回复完成、上下文摘要生成。
- 工具事件:工具调用请求、参数校验通过或被拒、工具执行开始、执行结果返回、执行超时。
- 会话管理事件:会话创建、上下文压缩、会话恢复、会话分支、会话归档。
- 系统事件:模型配置变化、重试行为、致命错误、进程重启后的恢复标记。
每个事件包含最少三要素:唯一ID、产生时间、父事件引用。有些情况还需要记录模型请求ID或工具调用ID,便于把分散的事件串联成一条完整调用链。这样的事件设计看上去只有几个字段,却承担着对整个系统可观测性的支撑。
4.3 状态重建、回退与分支的实现逻辑
基于事件日志,可以实现三类很实用的能力。
第一类是回退。因为事件是不可变的,且每个回合之间都有明确的父引用,只要把游标移回某条事件之前,再对之后的重放做一次截断,系统就能回到任意历史时间点。这跟你编辑文档撤销不一样,它撤销的是整个会话状态,包括上下文、工具记录和 UI 焦点。在我实际测试恢复时,感觉它比"重新生成回复"稳定得多。
第二类是分支。会话可以从任意历史事件点创建出一个子会话,子会话共享祖先事件,但从分支点开始写入自己的新事件。这个机制让用户可以做并行探索:在主会话里保持一个稳妥方向,在分支会话里尝试激进方案,结果满意再合并或切换。分支之间互不污染,因为它们本质上是不同的事件流。
第三类是重放调试。开发 agent 工具时最头疼的问题就是"它上次明明可以,这次为什么不行"。事件溯源让这个问题有了确定性答案:读日志,重建当时的完整状态,一步步看到底是模型输出变了、工具规则变了,还是上下文顺序错了。这种调试体验,远超在断点处观察变量,因为你能看到的不是"那一刻的局部状态",而是"导致那一状态的完整因果链"。
4.4 事件溯源带来的调试与审计收益
在我自己折腾类似工具的过程中,最深的体会是:一个系统能不能被掌控,不取决于你写了多少测试,而取决于异常发生之后能不能低成本复现。事件溯源恰好把"复现"变成了一个基础操作。
另外,agent 工具还有一个特殊的审计需求:它会在真实环境里动文件、跑命令。哪怕做了权限确认,事后也需要有完整的行动记录。事件日志天然就是一份审计报告,什么时间、什么参数、谁批准、执行结果如何,全部有据可查。对安全敏感的工作流,这一点几乎是刚需。opencode 把这份收益作为底层机制免费送给所有上层功能,我认为这是它架构里最划算的一笔设计。
5. 这三个工程决策如何影响普通用户的实际体验
5.1 断点恢复:重启工具后会话还能接上
双会话内核配合事件溯源,最直接的用户体验就是:进程重启不丢会话。我可以把正在进行的任务中途停掉,关掉终端,第二天重新打开 opencode,看到的是同一个会话、同一个上下文,模型对前一天的讨论还有完整记忆。
我之前用过一些工具,重启后要么上下文全部丢失,要么依赖云端同步。opencode 把恢复逻辑做在本地存储层,所以即使在离线环境,只要磁盘数据还在,恢复就是可靠的。这个体验对一个喜欢长时间保留工作上下文的人来说,提升非常明显。
5.2 会话分支:多条探索路径互不污染
在没有分支能力前,探索一个问题的多种解法很痛苦。比如我正在让 agent 重构一个模块,一路走下去发现方向走偏了,想回到十分钟前重新试另一条路,普通工具没法干净地做到。opencode 的会话分支能力让这件事变得很自然:从当前会话分出两条新路线,各走各的,不满意就切回主会话,哪怕是昨天分出来的支线也随时能继续。
它背后的事件溯源机制保证了每个分支不会串数据。而对用户来说,这感觉就像是给 AI 工作加了一个"时间线管理器",一切有迹可循。
5.3 可追溯的 agent 行为:工具调用的每一步都能回看
很多 AI 编程工具会把 agent 执行过程折叠成几行摘要,用户看不到真实发生的操作。opencode 则倾向于把事件细节暴露出来:无论是模型请求了哪条命令、在哪个文件里做了替换、替换前的原始内容是什么,都能从事件日志里完整追溯。
你可能觉得这些细节平时没必要看,但一旦工具出现误操作,这些回看数据就是救命稻草。我可以快速确认"它改这个文件之前,原内容是什么",再决定要不要回退。对专业开发者的信任建立,靠的不是承诺,而是可验证的过程记录。
6. 上篇小结:从架构里学到的三件事(以及下篇预告)
6.1 架构品位的三个信号
我把 opencode 工程全景、双会话内核、事件溯源放在一起看,会提炼出三个我认为是"架构品位"的信号。
第一个信号是分层但不机械。它没有因为分层而把简单功能搞复杂,交互渲染层、会话内核层、适配层各管一段,边界清晰。第二个信号是并发但不失控。双会话内核并行运行时,所有状态变化都收敛到事件流里,每个内核只消费自己关心的事件,这样并发没有造成状态混乱。第三个信号是可回溯是一切高级功能的地基。断点恢复、分支探索、调试重放、行为审计都源自同一条事件日志,而不是东拼西凑的补丁式特性。
6.2 我踩过的坑和对读者的建议
在研究这套架构的过程中,我也踩过一些坑,说出来帮你避掉。
第一个坑是试图把所有信息都做成事件。什么事都落盘会立刻膨胀日志体积,反而让重放变慢。正确的取舍是只持久化对状态恢复有意义的业务事件,UI 渲染层的数据永远是临时状态,不该进事件流。
第二个坑是把快照做得太频繁。快照虽然在恢复时很快,但生成快照本身有成本,太频繁会拖慢主流程。我实测比较合理的节奏是每次上下文压缩或完成一次完整任务后打一次快照,而不是每轮对话都生成。
第三个坑是忽略事件的 schema 演进。事件是不可变的,但系统代码会升级,新代码在读老事件时会遇到字段格式不一致问题。设计事件类型时就要预留扩展字段,并在读取时做版本兼容。这个坑在长周期项目里几乎一定会遇到,越早设计越省事。
6.3 下篇打算写什么
上篇我把重点放在"全景与地基"上:工程分层、双会话内核对各自职责、事件溯源如何把一切状态变更变得可回放。下篇我打算深入更偏动态的部分,重点展开模型适配层和上下文工程:不同 provider 之间的协议差异怎么被抹平、token 预算怎么动态调整、上下文压缩用什么策略才能尽量少丢关键信息、以及工具的权限模型在真实场景里怎么权衡安全与效率。
如果你在做 agent 类工具,或者想找一个"结构工整的 AI 编程工具"做技术参考,上面的内容应该已经足够让你判断值不值得继续读下去了。我自己在分析完这套架构后,最大的收获并不是某个具体技巧,而是重新理解了"会话"这件事的真正边界:它不是一串数组,而是一棵可以被裁剪、被分支、被回放的事件树。顺着这个理解,很多后续功能设计都会自然走上更清晰的路。