最近身边不少人开始从商业AI编辑器转向开源方案,opencode这个名字出现的频率越来越高。它是一个完全开源的AI编程Agent,不依赖任何特定厂商的模型服务,可以通过配置文件接入各家大模型,在终端里完成代码阅读、修改、命令执行、文件操作这一整套流程。我花了两周时间把它的核心源码过了一遍,结论是:这个项目不仅在功能上对标商业产品,在工程架构上也有很多值得借鉴的设计。尤其是它的双会话内核和事件溯源机制,解决了一类很普遍的Agent状态管理难题。
这篇是上篇,重点拆三个部分:工程全景、双会话内核、事件溯源。如果你正准备研究开源Agent的架构,或者想自己搭一套带持久化能力的会话系统,这篇应该能给你不少灵感。
1. 工程全景:先看openCode怎么搭骨架
1.1 定位与技术选型
opencode的定位非常清晰:一个以终端为交互界面的AI编程助手。它没有做图形界面,也没有捆绑自己的模型服务,而是把模型接入、工具调用、会话管理这些核心能力全部做成可配置、可扩展的模块。技术上基于Node.js/TypeScript生态,CLI部分用了一个比较轻量的终端UI库来渲染交互界面,核心逻辑则保持与UI解耦。
这种选型带来的直接好处是跨平台和低依赖。只要系统能跑Node.js,基本上就能跑opencode,不需要额外的运行时依赖,也不需要容器环境。对于频繁在 macOS、Linux、Windows 之间切换的开发者来说,这一点很友好。
选TypeScript而不是纯JavaScript也有讲究。AI Agent工程的复杂度在于类型不稳定的数据流:模型的流式输出、工具调用的参数校验、会话事件的类型区分,哪一个都容易出错。TypeScript的类型系统能在编译期拦截一大批低级错误,尤其在会话事件和消息结构这类核心数据类型上,收益特别明显。
1.2 模块边界与目录结构
我读源码的习惯是先看目录,再找入口,最后追链路。opencode的顶层模块大致可以分成五个部分:
- CLI层:负责终端交互,包括按键处理、界面渲染、命令解析,这一层尽量薄,不掺业务逻辑。
- 会话内核:负责维护会话状态、消息序列、事件生命周期,这是整个系统的心脏。
- 模型提供方适配层:定义统一的模型调用接口,不同模型服务通过配置文件映射进来,例如通过SDK调云端模型,也可以接本地模型。
- 工具执行层:实现具体的工具能力,比如读取文件、搜索代码、执行终端命令,每个工具都是一个独立模块。
- 事件存储层:以事件溯源的方式持久化会话过程,负责事件的写入、查询和重放。
这个分层最巧妙的地方在于:CLI层与会话内核靠接口通信,完全不感知底层是哪个模型、哪个工具在提供服务。后面接入新模型时,只动配置和适配层,核心引擎一行不用改。如果你在设计自己的Agent框架,建议直接照抄这个分层思路,它能帮你省掉大量后期重构的麻烦。
1.3 一次请求的全链路
为了把抽象的分层落实到具体代码,我追踪了一次最简单的用户请求:“帮我解释一下这个函数”。整体流程是这样的:
- 用户在终端输入文字,CLI层把输入封装为一条用户消息。
- 会话内核把用户消息追加到当前会话,并触发一次事件写入,调用模型提供方。
- 模型提供方通过流式接口把思考过程和回答文本逐步返回,每一步都触发消息增量事件。
- 如果模型决定调用工具,工具执行层被拉起,执行完毕后把结果回传给模型,整个过程也在会话中记录为事件。
- 最终会话结束,所有事件按顺序落盘。
这条链路看起来常规,但关键在于第2步和第4步之间:模型并不是直接拿着工具结果就结束,而是把执行结果再次拼入上下文,进行多轮的持续决策,直到它认为任务完成。会话内核在这里扮演的更像一个调度中枢,保证用户消息、模型消息、工具结果这三类信息,在同一个事件流里有严格、可回放的前后顺序。
2. 双会话内核:为什么把会话拆成两个
2.1 单会话模型的痛
很多自己写过Agent原型的人,大概率都遇到过这类问题:把用户消息、模型输出、工具调用结果全部塞进一个会话里,结构上只是一个长长的消息数组。早期确实简洁,可当任务变复杂,痛点就出来了:
- 工具执行到一半,用户插了一句新指令,整个上下文顺序被打乱,之后的模型回答逻辑变得混乱不堪。
- 会话上下文持续膨胀,每次请求都携带全部历史,成本越来越高,响应越来越慢。
- 会话一旦崩溃,要恢复现场只能全量重放所有消息,没有轻量级的恢复办法。
单会话模型本质上是在用一套状态管理交互、执行与存储,属于典型的键值堆叠。真正做项目时,这三个关注点的生命周期和故障模式完全不同,绑在一起只会互相拖累。
2.2 交互会话和执行会话的分工
opencode的解法是将会话拆成两条:交互会话(Chat Session)和执行会话(Action Session)。
交互会话是面向用户的。它保存的是对话上下文、用户意图、模型回应的完整语义历史,所有用户能看到的对话内容都在这一侧。它的生命周期从用户发起对话开始,到对话结束为止,关注的是上下文连续性和用户体验。
执行会话是面向工具的。它保存的是当前正在执行的任务、已调用的工具序列、临时产生的中间状态。每次模型决定调用一个工具时,都会在执行会话里新建一个执行单元,工具运行时的输出、错误、耗时都记录在这一层。任务彻底完成后,这个执行单元才会被归档。
这个分工的直接好处是:用户可以在工具执行期间继续输入新消息、修正指令,而不需要等上一个工具跑完。交互会话始终流畅,执行会话则在后台按自己的节奏推进。两者通过一个事件总线关联,只有在需要把任务结果合并回上下文时才会短暂同步。
2.3 会话状态机设计
双会话能稳定协作,背后依赖一套严格的状态机。交互会话有四个主要状态:
- idle:空闲状态,没有任务在执行,随时可以接收新输入。
- streaming:模型正在输出,界面显示流式内容。
- awaiting_tool:模型请求调用工具,等待执行会话返回结果。
- interrupted:新用户消息插入,中断当前流程,等待重新规划。
执行会话也有自己的状态集合:
- pending:任务已创建但还没执行。
- running:工具正在运行。
- failed:工具执行出错,待处理。
- archived:任务已被归档,结果已合并回交互会话。
两套状态机独立维护,但有明确的联动规则。例如,交互会话进入awaiting_tool,执行会话才会允许创建新的执行单元;执行会话进入failed,交互会话收到通知后可以询问用户是否继续或调整方案。状态机的好处是让并发变得可控,任何时刻两个会话都不会出现同时写同一块上下文的冲突。
2.4 两会话协同的流程
我用一个具体场景展示两条会话的协同过程。假设用户说:“帮我写一个脚本,统计当前目录下所有文件的行数总和,然后运行它。”
第一步,交互会话接收消息,解析出意图,状态变为streaming,模型开始生成回复。模型在回复中意识到需要执行“写文件”和“跑脚本”两个工具,于是发起工具调用意图,交互会话切换到awaiting_tool。
第二步,执行会话收到创建任务的请求,依次执行写文件脚本、运行脚本两个工具。每一步的输入参数会被序列化保存,工具返回后的输出也会写入执行会话的日志。
第三步,执行任务归档,结果封装成一个工具结果消息,推送回交互会话。交互会话收到结果,把内容拼接进上下文,恢复为streaming,模型基于工具结果继续生成最终回复。
这个过程里,交互会话的上下文始终保持简洁:用户消息加上模型回复,加上一条工具结果摘要;而执行会话记录的是详尽的原始输出。下次重放或者调试时,可以在两个会话中分别按需查找,而不需要在一条很长的消息列表里大海捞针。
3. 事件溯源:让状态流转有据可查
3.1 为什么Agent需要事件溯源
事件溯源,简单来说,就是不保存当前状态的“结果”,而是保存导致当前状态的所有“事件”。比如一个银行账户当前余额是1000元,传统做法是保存这个数字本身;事件溯源的做法是保存“存款500”“取款200”“存款700”这一串事件,余额随时可以重算出来。
这套思想放到Agent会话里,就是不对会话状态做快照式的持续覆盖,而是把所有变更动作作为不可变的事件追加到日志里。当需要恢复状态时,从第一条事件开始顺序重放,即可重建出任意时刻的会话现场。
为什么Agent特别需要事件溯源?因为Agent的执行过程有很强的不确定性。一个任务可能因为工具失败、模型空闲超时、用户中途改主意而中途折返,这些过程恰恰是后期排查问题、优化提示词时最需要的原始材料。如果只存最终状态,你会丢失“它是怎么走到这一步的”全部信息。事件溯源则天然保留全程痕迹。
3.2 事件模型的设计细节
opencode里的事件有几类高层设计原则。一是不可变性。事件一旦写入,只有追加,没有修改和删除。即使某条事件最后被判定为错误决策,它在日志里也是保留的,只是后续会追加一条修正事件。这保证日志的审计价值。
二是统一结构。所有事件都包含基础字段:会话ID、事件类型、时间戳、序列号、增强数据和父事件ID。父事件ID用来串联因果关系,比如一条模型消息事件后面挂着若干条工具调用事件,后续排查时可以直接构建出一棵事件树。
三是事件元数据与载荷分离。元数据包含会话ID、事件类型、时间戳这些系统字段,载荷则是具体的业务数据。这样,引擎在查询时可以直接按事件类型筛选,不需要解析每一条事件的完整内容,性能上有保障。
3.3 事件类型与核心字段
我梳理了会话过程中最核心的几类事件类型,这里列一个速查表,方便理解它们之间的关系。
| 事件类型 | 触发时机 | 核心载荷 |
|---|---|---|
| SessionStarted | 会话创建时 | 初始上下文、模型配置ID |
| UserMessageCreated | 用户发送新消息 | 消息内容、意图元数据 |
| AssistantMessageCreated | 模型生成一条完整回复 | 文本内容、模型名称 |
| DeltaProduced | 模型流式输出过程中的增量 | 增量文本片段 |
| ToolCallInvoked | 模型发起工具调用 | 工具名称、参数快照 |
| ToolCallFinished | 工具执行完成 | 输出结果、耗时、状态码 |
| ToolCallFailed | 工具执行异常 | 错误堆栈、错误码 |
| ContextTrimmed | 上下文裁剪触发 | 被裁剪的旧消息ID列表 |
| SessionClosed | 会话结束 | 关闭原因、总结摘要 |
有必要重点提一下ToolCallInvoked和ToolCallFinished,这两个事件是成对出现的。如果某次排查发现一个工具调用事件只有Invoked没有Finished,那基本可以断定工具在执行过程中发生了非正常退出或进程被杀。这种语义对排查问题非常有价值。
3.4 事件存储与会话快照的配合
事件溯源按设计本身可以直接从全量事件重建状态,但纯事件存储有一个绕不开的问题:会话越长,重放开销越大。一个连续跑了很长时间的会话,可能有上千条事件,每次重启都要重放全部事件,体验会很差。
opencode的取舍是事件日志配合定期快照。引擎每隔一段时间,或者在关键阶段(例如一个任务完整结束后),会基于当前事件流生成一份物化的会话快照,保存该时刻的完整状态。恢复时,加载最近一份快照,然后只重放快照之后的新事件。这样一来,重放事件的数量被限制在一个可控范围内,从设计上规避了事件溯源的最明显短板。
打个比方:这就像写一本书。事件日志是每天的草稿和修改记录,快照是每个月底出版的修订定稿。要找一个具体细节,翻草稿;要快速恢复全书内容,直接读定稿。两者互为补充,缺一不可。
3.5 事件重放的实际应用
事件重放除了做崩溃恢复,还能用在一些很实用的场景上。
最直接的是调试排错。当模型在一次工具调用后给出了完全不符合预期的回复,我只需要把事件日志导出,按序列号回放到ToolCallFinished那一步,就能看到模型当时拿到的工具结果到底长什么样。很多时候,问题不在模型,而是工具返回了一段格式混乱的内容,把模型的判断带偏了。
另一个应用是离线审计。团队里有人担心AI改坏了代码?不必靠印象,直接从会话事件里查看每一次文件修改的前后对比,改动原因一目了然。这种审计能力是企业环境落地AI编程助手时比较看重的能力。
还有一个潜力巨大的玩法:基于事件日志做提示词优化。同一批任务的不同会话,事件序列放在一起对比,立刻能看出哪个提示词版本引发的重试率更低、工具失败次数更少。这种优化方式有数据支撑,比凭感觉调提示词靠谱得多。
4. 阅读源码的路径与踩坑记录
4.1 建议的阅读顺序
直接对着源码从上往下读,效果通常不会好。因为模块之间的依赖关系是图状的,不是线状的。我建议按照这条路径来:
第一步读事件定义层。先弄明白整个系统里有哪些事件、事件结构长什么样。事件是整个架构的“共通语言”,理解了事件,就理解了系统里流动的信息形态。
第二步读会话内核。看它如何初始化会话、如何监听和分发事件、如何更新状态机。这是理解双会话协作的关键。
第三步读存储层。重点看事件日志怎么写入、快照怎么生成、恢复时如何选择快照并重放补齐。这一步能把前两步的知识串起来。
第四步读CLI界面层。这时你可以看到事件是如何被渲染成终端里的文字流和状态指示的,建议把界面上的状态提示与底层事件对应起来,会有豁然开朗的感觉。
最后再读工具层和模型适配层。这两部分的业务逻辑相对独立,可以根据你的兴趣和需求再决定要不要深入。
4.2 常用的调试手段
调试这类事件驱动的系统,第一原则是打开事件日志的可视化输出。opencode的终端UI在渲染界面时同时会往日志文件里写入事件流水。我平时调试时习惯开一个侧边终端窗口,用tail实时跟进事件输出,能很直观地看到系统当前处于哪个阶段。
如果你需要更精细的调试,可以给某个特定session写一段独立的重放脚本:读取事件日志,按照序列号顺序执行state transition函数,在某个事件节点打印出当时的完整上下文快照。这一步非常有用,相当于给整个会话做了“断点重放”。
我自己调试时最常用的组合是:在事件写入处加一个条件断点,按事件类型过滤,命中指定类型后自动暂停,然后检查该事件载荷中的关键字段。因为事件是纯数据,在每个阶段检查数据的完整性,基本可以快速定位是哪个环节出了问题。
4.3 常见问题速查表
| 症状 | 可能原因 | 排查手段 |
|---|---|---|
| 模型回复内容丢失 | 流式增量事件未正确合并 | 检查DeltaProduced事件的序列号是否连续 |
| 工具执行状态一直不结束 | 执行会话未收到ToolCallFinished | 检查是否发生了异常退出 |
| 会话恢复耗时过长 | 快照太久未更新 | 检查快照生成策略的时间间隔 |
| 上下文内容混乱、逻辑断裂 | 两条会话的事件顺序不一致 | 检查父事件ID是否正确关联 |
| 新模型接入后输入格式异常 | 适配层参数映射不完整 | 对比已接入模型的参数映射表 |
这里额外说一个坑:事件日志的时区处理。事件时间戳如果只用本地时间,会在跨时区协作或服务器迁移时产生严重的时间错乱。不少类似的Agent项目早期都吃过这个亏,opencode代码里默认统一用UTC存储,展示层再做本地化转换。这个习惯值得借鉴。
另外还想提醒一下在自定义工具时不要忽略超时事件。给每个工具调用都加一个Timeout事件,是比较稳妥的做法。因为模型偶尔会给出一个参数组合,让某个工具进入死循环或无限挂起,如果没有超时兜底,整个执行会话会被卡死。我见过好几个项目上线后碰到这种问题,排查了半天,最后发现只是少了一条超时事件。
写在最后
opencode的架构设计并不算复杂,但它在关键节点上的取舍很精准:用双会话分离交互和执行的关注点,用事件溯源保证全程可重放,用快照弥补纯事件存储的重放性能短板。这三个决策叠加在一起,让整个系统既能面对真实项目里各种不确定的、突发的场景,又能在出问题后快速定位原因。
我个人在实际阅读源码的过程中最大的体会是:事件溯源不是银弹,它真正的难点不在“把事件追加到日志”这个动作,而在于事件类型的粒度设计。粒度太粗,重放时丢失细节;粒度太细,日志会变得极其臃肿。opencode的处理方式是把“用户消息、模型流式增量、工具调用结果”这类核心生命周期事件做到最细,其他次要状态拿到快照维度去兜底。如果你要在自己的项目里复刻这套思路,建议先盯好核心事件链,再逐步补充外围事件,先让主链路跑通,再追求面面俱到。
这个项目的双会话和事件机制只是第一层内核,下篇我准备继续拆它的工具调用编排、上下文压缩策略和插件系统设计,这些都是把Agent从“能跑”推到“好用”的关键环节。欢迎感兴趣的朋友留言交流各自的源码阅读视角,这类项目不同人看会有不同收获。