
维护编码辅助工具的人都知道最怕的不是AI答错问题而是AI“动过手”。我参与折腾的这个终端编码工具代号 opencode目标是让用户用自然语言驱动整个编码流程读代码、改代码、跑测试、修错误。一开始实现得很简单监听到用户消息就调模型模型返回文本就把文件改了。用了一个月问题全出来了模型以为自己改了实际上改了另一处用户撤销了一次结果连对话记录和文件状态一起乱掉想查某个改动是谁在什么时候干的只能翻日志里的零散输出。后来我意识到问题出在缺少一个明确的“状态事实来源”。于是按三层骨骼重写外壳、内核、事件存储。内核里跑着两个并行会话外部所有变更都写成事件流。这篇文章是上篇聚焦工程全景、双会话内核和事件溯源这三个设计。下篇计划聊插件体系、补丁应用算法和跨设备同步。如果你正在做AI编码工具、编辑器或者单纯好奇事件溯源怎么在务实项目里落地这篇应该会给你一些有价值的参考。1. opencode 工程全景一个终端编码助手的三层骨骼1.1 三层是什么为什么必须三层其实两层也可以把事件存储直接内嵌到内核里。但那样的话内核一旦崩溃事件写入和业务逻辑容易在同一个进程里互相拖累。opencode 选择了独立的事件存储层内核只负责计算和决策持久化交给专门的事件日志模块。模块之间的边界很清楚外壳层和用户接触的部分包括终端渲染、输入解析、补丁展示、交互确认。它不直接读写项目文件也不保存会话历史。所有需要保存的东西都通过命令发给内核。内核层双会话在这里跑。它接收外壳层转来的用户意图调用模型生成补丁再把补丁变成事件更新投影。内核不感知终端只对命令和事件负责。事件存储层一个只追加的日志。可以本地磁盘文件实现也可以换成远程对象存储。内核提交事件查询时按事件ID读取。它不做业务判断只保证两件事写入顺序一致、读取可重放。三层分离之后最大变化是“出问题能问到底”。以前文件状态乱了只能拍脑袋现在只要事件存储完整任何状态都可以从某个事件序号开始重新计算出来。1.2 内核的两个对外接口命令入口和查询入口内核对外只暴露两个入口。命令入口接收“希望达到的目标”不是“帮我直接改文件第几行”。查询入口返回投影视图比如当前文件树、当前候选补丁、当前会话上下文。我特意把查询入口做成无副作用的接口这是被坑过后才坚持的。最早版本里为了显示文件树外壳层直接调了fs.readdir于是用户一刷新目录外壳层就真的去扫磁盘。明明只是查看操作却产生了一堆我们控制之外的状态变化事件流里什么都查不到。现在所有查询都从投影走投影由事件流构建查看操作完全不落盘。只有当你确认要修改文件时才会生成PatchApplied事件。命令入口和查询入口分离还有一个好处便于做权限校验。如果某个插件只想要读权限那它只要接入查询入口想要写权限必须在启动时声明自己会发哪几类命令。执行会话会做白名单检查避免插件乱发命令。1.3 依赖方向为什么插件只能订阅事件和发送命令opencode 的插件系统一直很克制插件只能做两件事订阅事件、发送命令。插件不能直接调用内核内部函数也不能绕过事件存储去改文件。依赖方向永远是外壳层 - 内核 - 事件存储。反过来不行。原因很简单如果插件可以绕过事件流那事件溯源就失去意义——总有一些状态变化发生在记录之外。插件如果想查看当前文件内容只能通过查询入口拿到投影如果投影里没有它要的数据它不能自己去读磁盘只能发一个“同步文件”的命令让内核去扫描并生成事件。虽然绕但保证了所有文件系统访问都是受控制的。有同学问这样性能会不会很差实际上大多数操作根本不需要访问磁盘投影在内存里有缓存。需要真正读取文件时内核走统一的“文件读取服务”这个服务也会产生FileReadRequested事件记录谁读了这个文件对审计很有用。1.4 一条命令在三层间的完整路径拿最简单的命令“查看当前文件树”来走一遍用户在终端输入/tree外壳层解析命令发给内核的命令入口。内核命令入口收到命令判断这是查询命令不是写命令于是走查询通道。查询通道从投影仓库读取当前文件树投影直接返回给外壳层。外壳层把文件树渲染在终端上。整个过程没有事件写入也没有磁盘扫描。那“扫描文件系统、构建文件树”什么时候发生在opencode启动时内核会发一个WorkspaceScanned事件事件里包含根目录下所有文件的相对路径和哈希。投影器收到这个事件后构建出“初始文件树”。之后如果有文件变更另一个WorkspaceUpdated事件会带着变更路径进来投影器增量更新。所以“查看文件树”只是一个纯展示操作真正的事实已经在事件流里了。这里我想特别强调一下事件不只是“改了什么东西”也包括“看到了什么”。把扫描结果和变更都记录成事件才能保证投影总是完整的。如果只记录“变更”初始状态没有来源重放也无从做起。2. 双会话内核让“意图”和“执行”各自独立成流2.1 双会话到底指的是哪两个opencode 里跑着两个完全独立的会话意图会话Intent Session负责和用户对话。它维护上下文的自然语言历史保存用户的问题、模型的回答、中间讨论。它不碰文件系统也不执行补丁。你可以把它理解成“会议室里的白板”。执行会话Execution Session负责把已经确认的意图变成现实。它维护候选补丁区、工作区文件状态、命令执行结果。相当于“施工队”。两个会话有各自的ID但不意味着物理上单独存两份事件流。意图会话产生的事件和执行会话产生的事件都追加到同一个全局事件流中但每个事件会带有session_id字段标识它属于哪个会话。用户想单独回放对话历史就筛选session_id 意图会话ID的事件想回放文件变更历史就筛选session_id 执行会话ID的事件。分隔开的是逻辑职责不是物理存储。为什么这样设计因为AI编码代理里对话和操作是互相纠缠的。用户可能在对话里改了主意也可能连续下达多个操作如果不加区分事件流里会同时混着“用户说了一段话”和“补丁被应用”两类事件回放时很难判断某个文件状态到底是因为哪句话变成这样的。有了双会话至少能先按会话切分再按关联ID聚合。2.2 意图会话只负责“想”不负责“做”意图会话的核心是上下文工程。每次用户输入消息意图会话会记录UserMessageReceived事件载荷包括消息文本、光标所在文件、当前选中的代码片段。模型生成回复后意图会话记录AssistantMessageCreated事件载荷包括回复文本、引用的事件ID列表、是否附带补丁。你可能会问模型生成的补丁存在哪里这里就是关键。意图会话不直接应用补丁而是把生成结果打包成一个“补丁提案”交给执行会话。在交互上用户会先看到模型说“我打算这样改”然后展示一个候选补丁。用户确认前项目文件没有任何变化。意图会话还负责处理“意图修订”。比如用户说“还是改成用 cookie 吧”意图会话会新增一条IntentRevised事件指向之前那条UserMessageReceived的ID。这样可以保留“最初想做什么后来又改了什么”的完整链条而不是干脆覆盖掉旧消息。这是后来复盘的时候特别有用的能力。2.3 执行会话把“确认过的意图”翻译成文件变更执行会话是一个偏向状态机的组件。它接收两类输入一是来自意图会话的“已确认补丁”二是来自外壳层的直接执行命令比如用户手动输入/apply xxx.patch。执行会话内部维护一张“候选补丁表”每一行包含补丁文件路径、目标文件哈希、补丁体、状态pending / applied / rejected。当补丁被确认执行会话会先做三项检查检查目标文件当前哈希是否等于补丁生成时的基线哈希。如果不一致说明文件在生成补丁后被改动过直接拒绝应用并发一条PatchConflictDetected事件。检查补丁是否超出会话允许的文件白名单。检查补丁格式是否可以解析。opencode 使用统一的行级补丁格式避免不同模型输出格式差异带来的问题。检查通过后执行会话应用补丁写文件生成PatchApplied事件事件里包含旧文件哈希、新文件哈希和完整的变更行集合。之后更新相关投影。如果用户选择拒绝则生成PatchRejected事件文件保持原样。2.4 为什么不用单会话把“对话”和“施工”搅在一起的结果用单会话会怎样相当于让施工队和客户在一个会议室里边聊天边拆墙。最直接的问题是上下文窗口失效。一个会话一旦积累了上千条操作事件留给对话历史的 token 就有限了。更麻烦的是回滚粒度——用户想撤销一句话和想撤销一次文件修改需要的操作完全不同。单会话里只有一长串混合事件撤销“最近一次修改”可能误伤对话中的消息记录。双会话还解决了并发问题。用户可以先让AI继续跑测试执行会话里同时自己在另一个输入框问问题意图会话里。两个会话可以有不同的上下文裁剪策略意图会话按消息轮次裁剪历史执行会话按文件变更数量压缩投影。如果只有一个会话任何操作都会互相阻塞。当然双会话不是没有代价。最明显的是“用户以为AI已经改了实际只在改候选补丁”这种错觉。所以我在外壳层加了一个很显眼的“待确认补丁数”角标任何用户切换会话时如果存在未确认补丁终端里都会持续提示。这个交互细节救了不少次。3. 事件溯源机制把每次变更都变成永久事实3.1 只追加日志事件存储的三大约定事件溯源说起来很玄落地就是三条约定所有状态变化都必须用一个事件来表达并且只能追加不能修改或删除。事件一旦写入就带上单调递增的事件ID和写入时间戳。事件ID的顺序就是全局事实顺序。当前状态不算事实事件流才是事实。任何时刻的界面状态、文件树、补丁列表都是从事件流投影出来的临时视图。opencode 的事件存储层主要就做了这三件事。写入时事件存储会验证基本格式必须有event_type、session_id、aggregate_id、payload、created_at。格式非法直接拒绝并返回InvalidEvent错误。读取时支持按事件ID范围、按aggregate_id、按session_id查询。为什么用“事件”而不是“数据库记录”因为数据库记录体现的是“当前状态”搞丢了历史而事件体现的是“状态变化的原因”。数据库里你可以查到“现在文件A是v3”但你查不到“v2变成v3是哪个补丁干的”。事件流可以。3.2 投影Projection从事件到可查询视图事件流本身不适合直接查询所以需要投影。投影本质上是一个从事件流计算出来的缓存视图。opencode 里有两个核心投影文件树投影保存当前工作区里每个文件的路径、大小、内容哈希。收到WorkspaceScanned事件时初始化收到WorkspaceUpdated、PatchApplied事件时增量更新。补丁列表投影保存当前所有候选补丁的状态。从PatchProposed、PatchApproved、PatchRejected、PatchApplied这些事件更新。投影的实现有个很重要的原则投影可以被丢弃但不能被直接写入。我们经常在调试时手动把某个投影清空然后从事件存储里重新加载。如果投影和事件流不一致那就是代码bug而不是投影的锅。为了性能opencode 会定期生成“快照投影”。快照记录了某事件ID下的全部状态。加载时如果存在快照就从快照开始然后重放快照之后的新事件没有快照就从事件ID0开始重放。快照默认每1000个事件生成一次也可以手动触发。3.3 事件链示例一次自然语言改代码的完整记录我直接给一段简化的事件序列大家感受下事件溯源在双会话下长什么样。假设用户想重命名一个变量EventID 1001 | sessionintents-1 | typeUserMessageReceived | payload{ text: 把 parseUser 改成 parseAccount } EventID 1002 | sessionintents-1 | typeAssistantMessageCreated| payload{ reply: 好的我生成一个补丁。, ref_event: 1001 } EventID 1003 | sessionexecution-1 | typePatchProposed | payload{ patch: udiff..., base_hash: abc123 } EventID 1004 | sessionintents-1 | typeIntentRevised | payload{ revises: 1001, new_text: 还是用 parseAccount 吧注意所有引用都要改 } EventID 1005 | sessionexecution-1 | typePatchApproved | payload{ patch_id: p-1003, approver: user } EventID 1006 | sessionexecution-1 | typePatchApplied | payload{ file: src/parse.ts, old_hash: abc123, new_hash: def456 }注意到IntentRevised事件1004出现在PatchApproved1005之前这看起来有点怪用户先确认了补丁然后又发了一句补充说明。实际上这是真实操作顺序用户当时先点了“确认”后来又发了一条补充。因为是只追加日志顺序不会重排我们保留了这个真实顺序。在投影层可以定义规则如果IntentRevised发生在PatchApproved之后会自动触发一个校验警告提示“这个补丁的意图可能已经过时”。但事件本身不会改动。这种“即使看起来矛盾的顺序也保留”的设计是事件溯源和普通数据库最不一样的地方。普通数据库会直接把状态覆盖掉事件流则忠实记录每一个决策时间点让事后分析有据可查。3.4 回放调试状态错乱时的终极手段事件溯源最大的红利是调试。遇到“文件内容跟用户预期不一致”时我不需要猜。直接执行筛选该文件相关的所有事件按EventID排序。找一个早期的状态快照点重新加载对应文件哈希。逐个应用之后的事件看哪一步产生了和预期不同的文件哈希。定位到具体补丁后再返回意图会话看那段时间的对话记录。这一套流程我用过好多次。最典型的一次是模型生成了一个错误的补丁把另一个文件的缩进全改乱了。当时就是靠回放定位到PatchApplied事件的new_hash和实际磁盘文件哈希不一致发现是应用补丁时用了错误的偏移量。没有事件流的话这个bug会潜伏很久。4. 事件存储的工程落地序列化、快照与并发控制4.1 事件格式带版本号的 JSON而不是语言对象初版我把事件直接序列化成内部对象存内存后来发现根本没法跨语言重放。于是改成统一的JSON结构。每个事件长这样{ event_id: 1006, occurred_at: 2025-01-15T10:23:45Z, session_id: execution-1, aggregate_id: file:src/parse.ts, event_type: PatchApplied, schema_version: 1, payload: { patch_id: p-1003, old_hash: abc123, new_hash: def456, change_range: {start: 12, lines: [1, 2, 3]} } }schema_version非常重要。事件一经写入就不会再改但业务可能变化。比如后来我想给PatchApplied增加change_range如果直接改事件结构老事件就没法解析了。做法是给每个事件类型维护多版本 schema读取时根据schema_version选择对应的解析器。这样旧事件永远可以兼容。序列化我最终选了无动态类型的简单库没用复杂的 ORM也是为了减少依赖。事件存储文件可以直接是追加写的JSONL文件生产环境可以换成分区日志。对于终端工具来说一个文件满足需求重要的是写入原子性。打开日志文件时用追加模式写一行后立刻 flush避免进程崩溃丢失最后一条事件。4.2 快照与压缩策略只追加日志会让文件无限变大。opencode 用两个策略控制周期性快照每1000个事件生成一个全量快照文件快照包含某个事件ID下的所有投影状态。快照文件命名类似snapshot-01000.json。启动时读取最新快照然后重放后续事件。旧事件归档如果某些事件已经确认不再需要回溯比如已经跨了多个快照可以迁移到冷存储。但我的建议是至少保留最近一个月的完整事件宁可占点磁盘也不要在需要调试时后悔。快照生成本身也可以作为一个事件写进去吗不行。快照不是业务事实它是优化用的临时状态。写入快照时会记录对应的last_event_id但不给快照本身分配事件ID。这样重放时不会把快照当成业务事件。4.3 并发冲突两个会话同时动同一个文件怎么办双会话内核带来了并发问题。比如意图会话还开着执行会话同时应用另一个补丁两个补丁都改了同一个函数。直接把两个补丁都应用文件就乱了。opencode 的解决方法是“补丁基线校验 事件序号乐观锁”。每个补丁生成时会把当时目标文件的内容哈希记在base_hash。应用补丁前执行会话比较当前文件哈希和base_hash。如果一致则应用并生成PatchApplied事件。如果不一致不应用生成PatchConflictDetected事件并把冲突信息反馈给意图会话让模型重新生成补丁。这个流程在事件存储层还有一个兜底每个PatchApplied事件都会携带prev_event_id指向影响同一文件的最近一次事件。写入时事件存储校验如果当前最新事件的ID不等于prev_event_id拒绝写入。这个兜底防止了极端情况下两个并发写操作都通过哈希校验——比如一个写操作同时改了文件后立刻哈希又被另一个改回了相同内容。虽然概率极低但兜底是必要的。我用一个表格总结一下冲突场景冲突场景检测手段处理方式补丁生成后文件被动过当前哈希 vs base_hash标记冲突让模型重新生成同一文件两个并发补丁同时应用prev_event_id 不匹配拒绝后写入的事件要求重放用户手动编辑和AI补丁交错文件哈希校验生成冲突事件打开编辑器手动 merge5. 上篇之外的思考这些设计在真实使用中的体感5.1 双会话让用户更安心但也需要更主动的反馈上线双会话后最大的用户反馈是“终于知道AI改哪里了”。原因很简单执行会话把每个待确认补丁都摆在明面上终端上能直接看到每个补丁影响的文件、行数和旧新哈希。但代价是如果意图会话已经生成了补丁而用户没有确认那个补丁会一直悬着。很多人以为已经生效了导致后面操作踩坑。我们后来在外壳层加了一条规则当补丁悬置超过10分钟自动在消息流里插入一条系统提示问用户“这个补丁还没确认需要我应用还是丢弃”这个提示本身也生成一条SystemPromptInserted事件所以后续回放也能看到。这是我觉得双会话落地时最该学的交互设计。5.2 事件溯源真正救场的一次经历有一次某开发者反馈说代码库突然多出一堆奇怪的空行。我们查了半天最终通过事件流筛选出影响目标目录的PatchApplied事件发现时间戳集中在某一次批量修改。从那往后每一条补丁都带有base_hash和prev_event_id我们就按时间顺序重放了那一段。结果是某个模型回复里意外带了一个隐藏的“格式化整个文件”指令执行会话执行了它产生了大量非预期的行变更。如果没有事件流光靠版本管理根本不知道是哪一步引入的。那次之后我对“事件即事实”这句话有了切肤的体会。事件流不是为审计准备的额外日志它本身就是系统的权威状态。所有投影、缓存、界面视图都只是它的派生品。5.3 一些设计取舍和接下来想做的事双会话和事件溯源并不是银弹。它们的成本在于状态复杂度和学习曲线明显上升。团队新人刚接触时很难理解“为什么文件还没改”和“为什么事件顺序里出现了矛盾的记录”。所以文档和调试工具变得非常重要。我在项目里加了一条命令/replay event_id可以直接从某个事件开始重放并输出每一步的投影变化。这个命令成了调试主力。上篇写到这里工程全景、双会话内核和事件溯源的基本骨架应该已经清楚了。后面还有几块比较有意思的内容比如补丁格式的兼容策略、投影仓库的增量更新算法、还有怎么把事件流同步到多台设备。下篇我会从这里接着讲尤其会把“从事件流恢复现场”的实际案例再展开。如果你在调试自己的状态管理或者打算把AI引入编辑器希望这篇能给你们一些启发。