opencode 这个工具,说实话我第一次用的时候没太当回事,以为又是一个把大模型塞进终端的玩具。直到后来有人拿它跑一个要改十几个文件的重构任务,中途终端崩了,重启之后任务居然能从断点接着跑,我才意识到它跟普通 AI 编程工具不是一回事。这次想聊的是它的两个核心设计:双会话内核和事件溯源。这篇是上篇,先把工程全景、双会话怎么协作、事件怎么驱动状态重建讲清楚,下篇再展开模型路由和工具调用协议。对看代码前先想搞懂设计的人,读源码时能少走不少弯路。
1. 项目定位与设计思路:为什么是“双会话+事件溯源”
1.1 先给 opencode 一个准确坐标
opencode 表面上看是一个命令行工具,装好后可以在终端里输入自然语言,让 AI 代理帮你读代码、改文件、跑命令。但如果你只把它当成一个“AI 终端”,就完全低估它了。它更像一个带状态的代理运行时:每一次用户输入、模型决策、工具调用、系统错误,都会被记录成结构化事件,追加到本地事件流中。换句话说,opencode 不只是“执行任务”,它把整个执行过程变成了一条可追溯、可重放、可恢复的数据流。
这个定位非常重要。传统的 AI 编程工具大多是“单会话 + 无状态”:你开一个终端,跟模型聊一轮,它返回一段代码,聊完就结束了。会话上下文要么存在内存里,要么存在一个临时文件里,进程一退全没了。opencode 把这两件事都改了,既让会话和任务分离,又让状态可以从事件流中重建。我个人觉得,理解这两点,基本就掌握了阅读这个项目源码的钥匙。
1.2 双会话解决了什么真实痛点
先聊双会话。很多人第一次听到“双会话”会以为就是“支持多个聊天窗口”,其实不是。opencode 把整个运行过程拆成两种完全不同的会话:交互会话和服务会话。交互会话绑定终端,负责接收用户输入、渲染输出、响应快捷键;服务会话则是后台常驻进程,负责真正干活——调度模型、执行工具、维护项目上下文。
为什么非要拆开?我举一个很常见的场景:你在终端里让 AI 代理做一个跨十几个文件的代码迁移,模型调用可能要持续几分钟。如果是传统单会话工具,你只能干瞪眼盯着屏幕,不敢关终端,一关任务就断。就算不关,中途网络抖动一下,整个任务可能全部作废,再来一次。用开放代码的双会话设计,交互会话只是“前台窗口”,你把任务交给服务会话后,完全可以把终端关掉去干别的事,服务会话在后台继续跑,时间到了再重新连上看结果。
这个设计用个生活类比特别好理解:交互会话是餐厅的前厅,负责接单、上菜、跟顾客沟通;服务会话是后厨,负责真正把菜炒出来。前厅可以随时打烊(关终端),后厨的灶台还在火上炖着菜。很多长任务工具之所以难用,就是把前厅和后厨绑在了一起,顾客一走,厨房就熄火了。
1.3 事件溯源:把“执行过程”变成“可重放资产”
双会话解决的是“任务断不了”,事件溯源解决的是“过程看得见、状态可重建”。事件溯源不是 opencode 发明的概念,它是一种成熟的架构模式,最早流行于金融和订单系统里。核心思想特别简单:不直接保存当前状态,而是保存产生状态的事件流,当前状态随时可以从事件流里重新算出来。
举一个记账的例子。普通做法是维护一个“余额”字段,收入加一笔、支出减一笔,余额就变了。事件溯源的做法是,把所有收入和支出都记成流水账,余额每次需要的时候,就把流水从头到尾加一遍。看着多花了点计算,但你获得了一个普通方案给不了的能力:你可以知道每一笔钱是怎么来的,还可以回到任何一个历史时点重算余额。
opencode 把这个模式用在了 AI 代理执行引擎上。用户输入、模型返回、工具调用结果、内部错误,全部作为事件追加写入本地存储。这样做有三个直接红利:第一,任何一次异常行为都能从事件流里找到“案发现场”;第二,进程崩溃、终端关闭后,可以从快照加事件流重建会话状态;第三,拿到一段事件流,就等于拿到了某一时刻完整的“任务回放录像”,调试和复现问题变得极其方便。如果你想深入理解 opencode 的源码,我建议先从事件模型入手,而不是从 UI 或命令行解析入手——事件模型是这根水管的主管,其他都是支流。
2. 工程全景:模块怎么分、核心链路怎么串
2.1 仓库布局与模块边界
我拿到一个开源项目,习惯先看目录结构,再顺着一条用户请求捋代码。opencode 的目录组织整体是清晰的,不同小版本会略有差异,但主线基本一致。核心模块大概长这样:
cmd/opencode # CLI 入口:启动、attach、events 子命令 internal/session # 双会话管理:交互会话、服务会话、attach 逻辑 internal/event # 事件模型、事件总线、事件存储 internal/agent # 代理引擎:上下文组装、模型调用、工具调度 internal/model # 消息结构、角色定义、工具协议 internal/ui # 终端 UI:流式渲染、快捷键、增量更新 pkg/store # 快照与 check point 存储读的时候我推荐的顺序是:入口 → 事件 → 会话 → 代理。先看 cmd/opencode 了解有哪些命令,再跳进 internal/event 搞清楚事件是什么,接着看 session 怎么把两个会话状态管起来,最后再看 agent 怎么消费事件去调用模型。按这个顺序走,你会发现 opencode 的每个模块边界都切在“事件”这个共同语言上,模块之间不直接调用彼此的内部方法,而是通过事件总线互相通信。
各模块的职责也很有讲究。internal/ui 只做一件事:订阅事件流,渲染增量。它不关心模型怎么调用、工具怎么执行,所以 UI 层可以任意替换,甚至可以做成网页版。internal/agent 则是纯执行者,它读取事件判断下一步做什么,调用模型并产生新事件,它也不需要知道用户在终端里按了什么键。这种边界划分的好处是,你单独测 UI、单独测 Agent、单独测事件存储,都可以互相不依赖。
2.2 一条用户指令的完整旅行
理解模块之后,最好的验证方式就是跟着一条用户指令走一圈。我在实际调试中把整个过程拆成了七个环节:
- 用户在终端输入
帮我看看 config 目录下哪些文件需要迁移,交互会话把这段文本包装成一个UserInput 事件。 - 事件进入总线,被路由到当前活跃的服务会话。
- 服务会话收到事件,通知代理引擎组装上下文:系统提示词、历史消息、已读取文件列表、当前工作目录。
- 模型返回一个AgentAction 事件,比如“读取 config 目录列表”。
- 代理引擎执行这个动作,拿到文件列表。
- 执行结果被包装成ToolResult 事件,写回事件流,同时更新会话上下文。
- UI 订阅到 ToolResult 事件,把文件列表渲染到终端上。
关键点在于第 6 步:所有动作都是先落事件、再更新状态。这个顺序保证了事件流就是事实源,内存状态只是事件的投影。一旦出现状态不一致,你不需要猜,直接看事件流就能知道哪一步出了问题。
2.3 让一切围绕“事件总线”转的工程折衷
为什么 opencode 要设计成事件驱动,而不是像普通 CLI 工具那样主流程直接调用各模块?我理解这是一种工程折衷,代价和收益都很明确。
收益方面,事件驱动带来三个好处。第一是松耦合:UI、Agent、存储之间不互相依赖,模块可以独立演进。第二是可观测性:任何行为都留有事件痕迹,排查问题不需要打断点。第三是可恢复性:状态可以从事件流重建,服务会话崩溃后能接续执行。
代价也客观存在:事件序列化本身有开销,写事件流需要进行磁盘 IO,高频工具调用会有性能损耗;事件模型的抽象需要学习成本,刚上手时容易绕晕;恢复状态时如果事件量很大,重放时间会变长。所以 opencode 用了快照加事件流的组合策略,把一部分事件压缩进 checkpoint,而不是每次都从零重放全部历史。
这种架构不是银弹。如果你的工具只是做一次性问答、临时处理任务,没必要上事件溯源。但当你的系统需要长会话、审计、回放、断点恢复这套能力时,事件总线的设计就非常值得借鉴。opencode 恰恰属于这种场景,所以它的架构选择是匹配的。
3. 双会话内核:前台交互与后台服务的分工与协作
3.1 交互会话:负责“人”这一侧
交互会话的设计目标很明确:让人的输入和 AI 的输出有一个实时舒适的交界面。它绑定 TTY(终端),提供行编辑、多行输入、流式渲染、快捷键中断等能力。你在键盘上敲的每一个指令、按的每一个快捷键,都被它封装成事件,而不是直接操作内部状态。
这里有一个很容易踩的坑:不要把太多业务逻辑堆在交互会话里。我看到过有些类似项目,为了快,把上下文组装、模型调用的逻辑直接写在 UI 层,结果 UI 一崩,任务全灭。opencode 的做法是把交互会话当作一个非常薄的“人体传感器”,它只负责捕获意图和渲染结果,所有决策都放到服务会话侧。交互会话崩溃,只要服务会话活着,任务就能继续。
实操中,交互会话还有一个容易忽略的职责:限流和中断。当模型开始长时间输出时,用户可能想打断,重新给一个指令。交互会话必须能快速捕获中断信号,把它转成事件送给服务会话,让代理引擎停止当前动作、收尾上下文。这件事处理得不好,用户会觉得“卡死了、关不掉”。
3.2 服务会话:负责“任务”这一侧
服务会话是真正干活的进程。它的生命周期跟交互会话完全独立,即使你关掉终端,它也会继续运行,直到任务完成或者被主动终止。在工程实现上,服务会话通常以守护进程的方式运行,监听本地端口或者 Unix socket,等待交互会话连接。
服务会话内部要管理的东西很多:项目根目录、会话上下文窗口、模型实例、工具执行器、checkpoint 定时器。这几个东西互相牵制,我印象最深的是上下文窗口管理。大模型的上下文长度有限,当事件流越来越长时,服务会话需要决定哪些历史消息保留、哪些可以压缩、哪些需要丢弃。如果只按“最近 N 条”滑窗截断,很容易丢掉关键信息,比如用户最早交代的约束条件。这是我后面第 5 节要演示的经典故障场景。
实际上,我建议你亲手做一个实验:启动服务会话,提交一个耗时任务,然后用Ctrl+C杀掉交互会话进程,再用 attach 重新连上。你会看到任务进度还在,上下文也没丢,因为服务会话一直把状态同步进快照和事件流里。这个体验,传统单会话工具做不到。
3.3 线程模型、消息路由与状态一致
双会话的难点不在“两个进程”,而在状态一致。opencode 内部对一致性的处理,我总结起来就是三条原则。
第一条,单写者原则。同一时刻只有一个事件写入者,通常是服务会话的主事件循环。这样事件流天然有序,不需要分布式锁。模型调用是 IO 密集型操作,可以在独立线程池里跑,但模型返回结果后,代理引擎必须回到主事件循环里把事件写下去。这种设计避免了对同一个事件文件并发写的脏数据问题。
第二条,消息按类型和会话路由。事件总线上挂着多个订阅者,UI 订阅渲染事件,Agent 订阅任务指令,存储模块订阅所有事件用来落盘。路由的 key 通常是session_id + event_type,这样同一会话的事件不会串台,不同会话之间的状态也天然隔离。
第三条,快照与事件流双写。服务会话会定期生成 checkpoint,把内存中当前状态打包落盘;同时所有事件继续追加写入事件流。恢复时,先加载最近的快照,再重放快照之后的事件,把状态推进到最新。这个过程有点像一个数据库既有全量备份、又有增量日志,崩溃恢复时两者配合使用。
配置方面,我这边常用的版本里,三个参数最重要:snapshot_interval(快照间隔)、event_retention(事件保留条数)、session_timeout(会话超时)。快照间隔设太短会增加磁盘压力,太长会导致恢复变慢,我一般设置在 5 到 10 分钟一次;事件保留条数我习惯保留最近 1000 条左右,再老的事件用归档压缩处理。
3.4 双会话之间的“上下文接力”
双会话能不能真正做到“无缝接力”,关键看上下文怎么在不同会话间传递。opencode 的实现是:交互会话本身不持有上下文,它只是一个窗口;真实上下文始终保存在服务会话中。当你重新 attach 时,服务会话把当前上下文的最新状态推给交互会话,交互会话渲染出来,看起来就像“什么都没发生”。
我遇到过一种情况:服务会话和交互会话因为某种原因失联,重连后界面显示了旧的上下文列表,导致我以为状态丢了。后来排查发现,是交互会话侧的内存缓存没有清掉,UI 渲染了旧快照。重启交互会话再 attach 就正常了。这里有个排查经验:出现“状态好像丢了”时,先重启交互会话而不是服务会话,大部分情况只是 UI 层缓存问题;如果重启交互会话还没恢复,才需要去查事件流和服务会话快照。
4. 事件溯源:从事件流到状态重建
4.1 事件不是日志
很多项目把“日志”和“事件”混为一谈,但在 opencode 里这是两回事。日志是人读的,通常是一行一行文本,写出去就完了,丢了也不影响系统状态;事件是机器读的,它必须以结构化格式写入,并且系统状态完全依赖事件流来重建。丢一条日志顶多少一条排查线索,丢一条事件,快照到某一点之后的状态就彻底断了。
我这个类比应该好记:日志相当于行车记录仪的视频,你能看到车开得怎么样,但视频丢了车照开;事件相当于汽车黑匣子里的传感器数据,记录了刹车、油门、转速,维修技师拿到这些数据能把事故发生过程完整还原。opencode 把事件设计为 append-only(只能追加,不可修改)、带时间戳、带版本号,就是为了保证可重放性和不可抵赖性。
4.2 核心事件类型与字段设计
opencode 里事件类型不算多,但每个都有明确语义。我梳理了平时最常用的六类:
| 事件类型 | 触发时机 | 关键字段 |
|---|---|---|
| UserInput | 用户在交互会话中输入指令 | content, session_id, timestamp |
| AgentAction | 代理决定执行某个工具调用或回复 | action_type, params, source_event_id |
| ToolResult | 工具执行完成,返回结果 | tool_name, status, output, error |
| SessionEvent | 会话生命周期变化,如创建、attach、detach、终止 | state, reason |
| Checkpoint | 定期快照生成完成 | snapshot_id, base_event_seq |
| SystemError | 内部异常,如模型调用失败、文件读写错误 | error_code, message, stack |
每条事件的核心字段里我有一个特别关注项:source_event_id。它表示这条事件是由哪条上游事件触发的,相当于事件链的“上一环”。有了它,你可以从任意一条事件往回追,把一整个因果链捋出来。没有这个字段的事件系统,本质上只是一串平铺的日志,谈不上真正的溯源。
一个简化版的 AgentAction 事件看起来是这样:
{ "event_id": "evt_0091", "event_type": "AgentAction", "session_id": "s1", "sequence": 91, "timestamp": 1719253000123, "source_event_id": "evt_0089", "payload": { "action_type": "read_file", "params": { "path": "config/app.yaml" } } }注意sequence字段,它是事件在会话内的单调递增序号。恢复状态时,依据 sequence 排序重放事件,顺序就有保证。
4.3 状态重建:快照 + 事件流
事件溯源最核心的操作就是状态重建。opencode 的恢复流程可以简化成三步:
- 找到该会话最近一个 Checkpoint 快照。
- 加载快照,得到事件流中某个时间点的完整状态。
- 依次重放快照之后的所有事件,每一条事件都调用对应的 apply 函数更新状态,直到最新一条。
这个过程用伪代码表示就是:
func rebuildSession(sessionID string) *SessionState { snap := store.LoadLatestCheckpoint(sessionID) state := snap.Restore() for _, evt := range store.EventsSince(sessionID, snap.BaseSeq()) { state.Apply(evt) } return state }实际使用中,重建不一定要等到崩溃才触发。你可以把这个能力做成“时光回溯”工具:指定某个 sequence 号,重建当时的状态,看看那个时刻上下文里到底有什么。这在排查“模型怎么突然忘记了我前面说的话”这类问题时,简直神器。
4.4 事件溯源带来的三个实战红利
第一个红利是调试。前阵子某团队用 opencode 在模拟项目 X 上做代码重构,agent 把一个文本文件的格式改乱了。从 UI 上根本看不出来为什么改错,但把事件流导出来,很快发现某一次 ToolResult 事件里,工具执行的当前目录指向了错误路径,agent 读错了文件。这个结论在传统日志方案下基本不可能快速得到。
第二个红利是审计。每次文件改动、命令执行都会被记录下来,相当于给 AI 代理装了一个行车记录仪。有一次我需要回滚 agent 在某个时间点之后做的所有改动,直接在事件流里筛选该时间点之后的 ToolResult 事件,逐个检查工作区文件就定位到了范围。
第三个红利是回放测试。把线上出问题时的原始事件流,放到测试环境里喂给代理引擎重放一遍,验证修复方案是否生效。这个流程帮我复现过一个只在特定项目下出现的偶发错误,当时不靠事件流回放,几乎无法稳定复现。
4.5 一致性与迁移:私藏的几处“硬骨头”
事件溯源看着美好,实际落地有几处硬骨头,文档里通常不会写。第一个是写事件的有序性。多线程环境里如果不加控制,事件乱序写入,恢复时状态就变“薛定谔的猫”。解决思路就是前面说的单写者,所有事件必须经过主事件循环写入。
第二个是路径位移。工具执行时记录的文件路径最好是相对于项目根目录的,而不是绝对路径。否则项目目录移动或者换机器,旧事件里的路径全部失效。我见过有人把绝对路径写进事件,迁移后回放直接崩,血泪教训。
第三个是事件格式演化。opencode 迭代很快,事件结构偶尔会变。如果你直接升级到新版本,新代码读旧事件可能会解析失败。稳妥做法是每个事件结构体带schema_version字段,解析器对未知字段采取“能读则读、读不了跳过”的宽容策略,同时升级前先手动导出一份 checkpoint 备份。
5. 实操:安装、启动双会话、用事件回放排查问题
5.1 安装与初始化
opencode 的安装分两种方式:一种是直接下载发布版二进制,另一种是源码构建。我这里用源码构建举例,因为可以顺手改代码调试:
git clone <你的仓库地址> opencode cd opencode go build -o opencode ./cmd/opencode ./opencode initinit会在你的用户目录下生成配置目录,包含配置文件、events 子目录、checkpoint 子目录。真实使用时注意权限问题,别把数据目录放在 /tmp 下面,搞不好一次重启就清空了。
初始化完成后,最好把配置目录纳入版本管理,方便回溯配置变更。我的习惯是给配置写个简短注释,标清每个参数的含义,不然隔一个月回来看自己都记不住。
5.2 启动“前台交互 + 后台服务”
双会话启动的核心动作,是先拉起服务会话,再通过 attach 接入交互会话。以我这边使用的版本为例,命令大致如下:
# 终端一:启动后台服务会话 opencode server start --port 8899 # 终端二:接入交互会话 opencode attach --port 8899启动服务会话后,可以用opencode ps查看会话状态,正常情况下能看到一个RUNNING状态的服务会话。交互会话接入后,你就可以正常输入指令。需要脱离时,不要直接关闭终端,使用 detach 快捷键(我这边默认是Ctrl+Q),交互会话会断开,服务会话继续跑。
这里有个实操细节:服务会话的日志默认打到标准输出,如果你用了 nohup 或者 systemd,要把标准输出重定向到文件,否则后台运行日志没地方看,排查问题会无从下手。
5.3 翻看事件流:像查流水一样查现场
事件流是排查问题的核心工具,我常用的三个子命令是 tail、query 和 dump:
# 实时滚动事件流,观察 agent 每步行动 opencode events tail -f # 按类型和时间范围过滤,比如最近 10 分钟的 AgentAction opencode events query --type AgentAction --since 10m # 导出某个会话的完整事件,用 jq 格式化 opencode events dump --session s1 --format json | jq '.event_type, .payload'我第一次跑tail -f的时候,看到事件一条条滚出来,最大感受是原来 agent 每一步都在有条不紊地留证据。UI 上可能只显示最终结果,但事件流里能看到它先读了哪个文件、调用了什么工具、拿到了什么返回,这些才是有价值的现场。
5.4 用事件回放定位一个“上下文丢失”的现场
下面这个案例是我实际排查过的,很有代表性。某团队用 opencode 跑一个任务:读取配置文件,生成迁移文件,再修改 prompt 模板。任务跑了一半,agent 突然忘了配置文件里的关键内容,后续操作完全变了样。
排查思路分四步。第一步,确定时间窗口,导出事件流。第二步,按 sequence 顺序看 UserInput 和 AgentAction,确认初始 prompt 里包含了配置文件路径。第三步,在事件流里看到 ConfigRead 成功后,紧跟着出现一个 SystemError 事件,原因是磁盘告警导致读操作超时。第四步,发现代理引擎在遇到 SystemError 后重建了上下文,而它的上下文重建策略是“滑窗保留最近三条事件”,正好把 ConfigRead 这条事件排挤掉了。
关键事件片段长这样:
{"sequence": 88, "event_type": "ToolResult", "payload": {"tool_name": "read_file", "status": "ok", "output": "server: 8080\n..."}} {"sequence": 89, "event_type": "SystemError", "payload": {"error_code": "disk_warning", "message": "read timeout"}} {"sequence": 90, "event_type": "AgentAction", "payload": {"action_type": "respond", "params": {"content": "我继续执行迁移任务,先扫描目录..."}}}看到第 90 条事件的输出就觉得不对,它没有引用第 88 条读到的配置内容。结论是上下文滑窗策略有缺陷,修复思路是让 AgentAction 携带source_event_id,重建上下文时如果有依赖引用,必须回溯到那条被引用的事件,不能仅按最近 N 条滑窗截断。
这个排查过程,如果不走事件流回放,靠肉眼盯模型输出很难定位到“哪一步把上下文丢了”。这也是事件溯源最值钱的场景之一。
5.5 事件文件增长与保留策略
事件流是只增不减的,跑几天之后事件文件会膨胀得很快。我见过一个同事的项目,跑了两个月,events 目录占了 6 个 GB。磁盘空间还在其次,重放时事件量太大,恢复速度也会被拖慢。
我的建议是三招组合:
- 按会话分目录存储事件,清理时可以直接删掉整个旧会话目录。
- 定期生成 checkpoint,生成后可以安全清理 checkpoint 之前的老事件,因为恢复时只需要最近一次 checkpoint 加增量事件。
- 旧事件压缩归档,把超过保留窗口的事件用压缩算法打包,需要回溯时解压即可。
清理顺序要注意:先做新 checkpoint,再删旧事件,顺序反了会导致状态断链。这个优先级我栽过跟头,提醒各位务必记住。
6. 常见问题速查与避坑经验
6.1 高频故障速查表
下面这些问题是社区里出现频率比较高的,整理成速查表供参考:
| 症状 | 可能原因 | 处理思路 |
|---|---|---|
| attach 时提示服务会话不存在 | 后台进程已退出或端口不对 | opencode ps查看状态;检查端口配置 |
| 事件流突然停止更新 | 事件存储目录无写入权限 | 检查 events 目录权限;确认磁盘剩余空间 |
| 恢复状态时出现“多了一段对话” | 快照序号与事件流序号不一致 | 按事件 sequence 与快照 base_seq 对齐重放 |
| 升级后旧事件解析失败 | 事件 schema 版本不兼容 | 先导出 checkpoint;升级解析器或做迁移 |
| 模型输出突然丢失前文 | 上下文滑窗策略挤掉了关键事件 | 查找关键事件是否被滑出;检查 source_event_id 依赖 |
| UI 显示和历史上下文不一致 | 交互会话侧缓存未刷新 | 先重启交互会话重新 attach,不要动服务会话 |
6.2 升级后读不了旧事件,怎么办
事件格式迭代是避免不了的。我遇到过几次升级后旧事件读不了的情况,表现是新版本启动后事件解析大量报错,会话状态恢复不完全。深究原因,是旧事件缺少新代码需要的某个字段,而新代码对缺字段的默认值处理不对,解析直接失败,而不是合理降级。
几个实际经验:升级前先手动触发一次 checkpoint 导出,作为安全垫;升级后不要立刻删旧数据,确认新版本能正常读旧事件再清理;解析器要写成“宽容模式”,遇到未知字段跳过而不是报错,这样版本平滑升级的概率大得多。
6.3 “幽灵恢复”:快照与事件流不一致
这个坑比较隐蔽,值得单独说。我遇到过一种情况:服务会话崩溃后恢复,会话上下文里多出了一段我完全没见过的对话,像闹鬼一样。后来定位发现,问题出在快照和事件流的时序上。
恢复流程是“加载最近快照 + 重放快照之后的事件”。但如果快照生成时,记录的是某个时间点的状态,而这个时间点之后、快照生成之前,已经有事件写入了,就会导致快照本身已经包含了部分事件的效果,重放时又把它们重放了一遍,状态就被“叠加”错了。
解决办法是给每个快照打一个单调递增的base_sequence,等于“这个快照包含了哪些事件为止”,恢复时只重放sequence > base_sequence的事件,而不是简单按时间重放所有记录。这个细节,文档里极少有人讲。
6.4 不要把事件流当成百宝袋
最后一条避坑经验是关于事件设计的品位。事件流是事实源,不是垃圾桶。有些人为了让“什么都有记录”,把渲染帧、临时状态、中间计算结果都写成事件,导致事件文件越来越大,回放越来越慢,真正重要的信息反而淹没在海量事件里。
我判断一条状态变更该不该写成事件,只有一个标准:如果这条事件被删掉,系统状态还能不能正确重建?如果能,它就不应该出现在事件流里。比如渲染进度条,UI 上实时显示即可,不需要写进事件;但一次文件写入、一个模型决策,必须写,因为状态恢复依赖它们。保持事件流精简,回放效率才有保证,这个度需要在实战中慢慢拿捏。
写到这里,上篇的核心内容基本讲完了。我个人在实际使用中的体会是,opencode 最打动我的不是“AI 能写多少行代码”,而是它把 AI 代理的执行过程当作一等公民来对待,事件溯源让“这次任务为什么这么干”这个问题第一次有了可回答的路径。动手跑一遍比看十遍文档都管用,建议你找个模拟项目 X,用events tail盯着 agent 下一步要做什么,你会立刻理解双会话和事件流的价值。下篇我会聊模型路由、工具调用的协议设计,以及多 Agent 并发时的编排细节,到时见。