ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AI编码工具的状态管理:双会话与事件溯源实战

AI编码工具的状态管理:双会话与事件溯源实战

维护编码辅助工具的人都知道,最怕的不是AI答错问题,而是AI“动过手”。我参与折腾的这个终端编码工具,代号 opencode,目标是让用户用自然语言驱动整个编码流程:读代码、改代码、跑测试、修错误。一开始实现得很简单,监听到用户消息就调模型,模型返回文本就把文件改了。用了一个月,问题全出来了:模型以为自己改了,实际上改了另一处;用户撤销了一次,结果连对话记录和文件状态一起乱掉;想查某个改动是谁在什么时候干的,只能翻日志里的零散输出。

后来我意识到,问题出在缺少一个明确的“状态事实来源”。于是按三层骨骼重写:外壳、内核、事件存储。内核里跑着两个并行会话,外部所有变更都写成事件流。这篇文章是上篇,聚焦工程全景、双会话内核和事件溯源这三个设计。下篇计划聊插件体系、补丁应用算法和跨设备同步。如果你正在做AI编码工具、编辑器,或者单纯好奇事件溯源怎么在务实项目里落地,这篇应该会给你一些有价值的参考。

1. opencode 工程全景:一个终端编码助手的三层骨骼

1.1 三层是什么,为什么必须三层

其实两层也可以:把事件存储直接内嵌到内核里。但那样的话,内核一旦崩溃,事件写入和业务逻辑容易在同一个进程里互相拖累。opencode 选择了独立的事件存储层,内核只负责计算和决策,持久化交给专门的事件日志模块。模块之间的边界很清楚:

  • 外壳层:和用户接触的部分,包括终端渲染、输入解析、补丁展示、交互确认。它不直接读写项目文件,也不保存会话历史。所有需要保存的东西都通过命令发给内核。
  • 内核层:双会话在这里跑。它接收外壳层转来的用户意图,调用模型生成补丁,再把补丁变成事件,更新投影。内核不感知终端,只对命令和事件负责。
  • 事件存储层:一个只追加的日志。可以本地磁盘文件实现,也可以换成远程对象存储。内核提交事件,查询时按事件ID读取。它不做业务判断,只保证两件事:写入顺序一致、读取可重放。

三层分离之后,最大变化是“出问题能问到底”。以前文件状态乱了只能拍脑袋;现在只要事件存储完整,任何状态都可以从某个事件序号开始重新计算出来。

1.2 内核的两个对外接口:命令入口和查询入口

内核对外只暴露两个入口。命令入口接收“希望达到的目标”,不是“帮我直接改文件第几行”。查询入口返回投影视图,比如当前文件树、当前候选补丁、当前会话上下文。

我特意把查询入口做成无副作用的接口,这是被坑过后才坚持的。最早版本里,为了显示文件树,外壳层直接调了fs.readdir,于是用户一刷新目录,外壳层就真的去扫磁盘。明明只是查看操作,却产生了一堆我们控制之外的状态变化,事件流里什么都查不到。现在所有查询都从投影走,投影由事件流构建,查看操作完全不落盘。只有当你确认要修改文件时,才会生成PatchApplied事件。

命令入口和查询入口分离还有一个好处:便于做权限校验。如果某个插件只想要读权限,那它只要接入查询入口;想要写权限,必须在启动时声明自己会发哪几类命令。执行会话会做白名单检查,避免插件乱发命令。

1.3 依赖方向:为什么插件只能订阅事件和发送命令

opencode 的插件系统一直很克制,插件只能做两件事:订阅事件、发送命令。插件不能直接调用内核内部函数,也不能绕过事件存储去改文件。依赖方向永远是:外壳层 -> 内核 -> 事件存储。反过来不行。

原因很简单:如果插件可以绕过事件流,那事件溯源就失去意义——总有一些状态变化发生在记录之外。插件如果想查看当前文件内容,只能通过查询入口拿到投影;如果投影里没有它要的数据,它不能自己去读磁盘,只能发一个“同步文件”的命令,让内核去扫描并生成事件。虽然绕,但保证了所有文件系统访问都是受控制的。

有同学问,这样性能会不会很差?实际上大多数操作根本不需要访问磁盘,投影在内存里有缓存。需要真正读取文件时,内核走统一的“文件读取服务”,这个服务也会产生FileReadRequested事件,记录谁读了这个文件,对审计很有用。

1.4 一条命令在三层间的完整路径

拿最简单的命令“查看当前文件树”来走一遍:

  1. 用户在终端输入/tree,外壳层解析命令,发给内核的命令入口。
  2. 内核命令入口收到命令,判断这是查询命令,不是写命令,于是走查询通道。
  3. 查询通道从投影仓库读取当前文件树投影,直接返回给外壳层。
  4. 外壳层把文件树渲染在终端上。

整个过程没有事件写入,也没有磁盘扫描。那“扫描文件系统、构建文件树”什么时候发生?在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)。当补丁被确认,执行会话会先做三项检查:

  1. 检查目标文件当前哈希是否等于补丁生成时的基线哈希。如果不一致,说明文件在生成补丁后被改动过,直接拒绝应用,并发一条PatchConflictDetected事件。
  2. 检查补丁是否超出会话允许的文件白名单。
  3. 检查补丁格式是否可以解析。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下的全部状态。加载时,如果存在快照,就从快照开始,然后重放快照之后的新事件;没有快照,就从事件ID=0开始重放。快照默认每1000个事件生成一次,也可以手动触发。

3.3 事件链示例:一次自然语言改代码的完整记录

我直接给一段简化的事件序列,大家感受下事件溯源在双会话下长什么样。假设用户想重命名一个变量:

EventID 1001 | session=intents-1 | type=UserMessageReceived | payload={ "text": "把 parseUser 改成 parseAccount" } EventID 1002 | session=intents-1 | type=AssistantMessageCreated| payload={ "reply": "好的,我生成一个补丁。", "ref_event": 1001 } EventID 1003 | session=execution-1 | type=PatchProposed | payload={ "patch": "udiff...", "base_hash": "abc123" } EventID 1004 | session=intents-1 | type=IntentRevised | payload={ "revises": 1001, "new_text": "还是用 parseAccount 吧,注意所有引用都要改" } EventID 1005 | session=execution-1 | type=PatchApproved | payload={ "patch_id": "p-1003", "approver": "user" } EventID 1006 | session=execution-1 | type=PatchApplied | payload={ "file": "src/parse.ts", "old_hash": "abc123", "new_hash": "def456" }

注意到IntentRevised事件(1004)出现在PatchApproved(1005)之前,这看起来有点怪:用户先确认了补丁,然后又发了一句补充说明。实际上这是真实操作顺序:用户当时先点了“确认”,后来又发了一条补充。因为是只追加日志,顺序不会重排,我们保留了这个真实顺序。在投影层可以定义规则:如果IntentRevised发生在PatchApproved之后,会自动触发一个校验警告,提示“这个补丁的意图可能已经过时”。但事件本身不会改动。

这种“即使看起来矛盾的顺序也保留”的设计,是事件溯源和普通数据库最不一样的地方。普通数据库会直接把状态覆盖掉,事件流则忠实记录每一个决策时间点,让事后分析有据可查。

3.4 回放调试:状态错乱时的终极手段

事件溯源最大的红利是调试。遇到“文件内容跟用户预期不一致”时,我不需要猜。直接执行:

  1. 筛选该文件相关的所有事件,按EventID排序。
  2. 找一个早期的状态快照点,重新加载对应文件哈希。
  3. 逐个应用之后的事件,看哪一步产生了和预期不同的文件哈希。
  4. 定位到具体补丁后,再返回意图会话看那段时间的对话记录。

这一套流程我用过好多次。最典型的一次是模型生成了一个错误的补丁,把另一个文件的缩进全改乱了。当时就是靠回放定位到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 的解决方法是“补丁基线校验 + 事件序号乐观锁”。

  1. 每个补丁生成时,会把当时目标文件的内容哈希记在base_hash。
  2. 应用补丁前,执行会话比较当前文件哈希和base_hash。如果一致,则应用,并生成PatchApplied事件。
  3. 如果不一致,不应用,生成PatchConflictDetected事件,并把冲突信息反馈给意图会话,让模型重新生成补丁。

这个流程在事件存储层还有一个兜底:每个PatchApplied事件都会携带prev_event_id,指向影响同一文件的最近一次事件。写入时事件存储校验:如果当前最新事件的ID不等于prev_event_id,拒绝写入。这个兜底防止了极端情况下两个并发写操作都通过哈希校验——比如一个写操作同时改了文件后立刻哈希又被另一个改回了相同内容。虽然概率极低,但兜底是必要的。

我用一个表格总结一下冲突场景:

冲突场景检测手段处理方式
补丁生成后文件被动过当前哈希 vs base_hash标记冲突,让模型重新生成
同一文件两个并发补丁同时应用prev_event_id 不匹配拒绝后写入的事件,要求重放
用户手动编辑和AI补丁交错文件哈希校验生成冲突事件,打开编辑器手动 merge

5. 上篇之外的思考:这些设计在真实使用中的体感

5.1 双会话让用户更安心,但也需要更主动的反馈

上线双会话后,最大的用户反馈是“终于知道AI改哪里了”。原因很简单,执行会话把每个待确认补丁都摆在明面上,终端上能直接看到每个补丁影响的文件、行数和旧新哈希。但代价是,如果意图会话已经生成了补丁,而用户没有确认,那个补丁会一直悬着。很多人以为已经生效了,导致后面操作踩坑。

我们后来在外壳层加了一条规则:当补丁悬置超过10分钟,自动在消息流里插入一条系统提示,问用户“这个补丁还没确认,需要我应用还是丢弃?”这个提示本身也生成一条SystemPromptInserted事件,所以后续回放也能看到。这是我觉得双会话落地时最该学的交互设计。

5.2 事件溯源真正救场的一次经历

有一次某开发者反馈说,代码库突然多出一堆奇怪的空行。我们查了半天,最终通过事件流筛选出影响目标目录的PatchApplied事件,发现时间戳集中在某一次批量修改。从那往后每一条补丁都带有base_hash和prev_event_id,我们就按时间顺序重放了那一段。结果是某个模型回复里意外带了一个隐藏的“格式化整个文件”指令,执行会话执行了它,产生了大量非预期的行变更。如果没有事件流,光靠版本管理根本不知道是哪一步引入的。

那次之后我对“事件即事实”这句话有了切肤的体会。事件流不是为审计准备的额外日志,它本身就是系统的权威状态。所有投影、缓存、界面视图都只是它的派生品。

5.3 一些设计取舍和接下来想做的事

双会话和事件溯源并不是银弹。它们的成本在于:状态复杂度和学习曲线明显上升。团队新人刚接触时,很难理解“为什么文件还没改”和“为什么事件顺序里出现了矛盾的记录”。所以文档和调试工具变得非常重要。我在项目里加了一条命令/replay <event_id>,可以直接从某个事件开始重放,并输出每一步的投影变化。这个命令成了调试主力。

上篇写到这里,工程全景、双会话内核和事件溯源的基本骨架应该已经清楚了。后面还有几块比较有意思的内容,比如补丁格式的兼容策略、投影仓库的增量更新算法、还有怎么把事件流同步到多台设备。下篇我会从这里接着讲,尤其会把“从事件流恢复现场”的实际案例再展开。如果你在调试自己的状态管理,或者打算把AI引入编辑器,希望这篇能给你们一些启发。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进