最近一直在折腾 opencode,越拆越觉得这个项目有点东西。很多人以为它只是"终端里跑了个 AI 聊天",但真正深入代码之后会发现,它把会话状态、事件流转、工具调用全部显式化了,整个架构像一套可观测、可回放、可扩展的编码代理框架。这篇是上篇,重点讲三件事:工程全景、双会话内核、事件溯源。适合两类人看:一类是每天泡在终端里、想用 AI 提效但又不满足于简单聊天窗口的开发者;另一类是自己正在做 AI 工具、想参考一个成熟事件架构的人。我会尽量把背后的"为什么"讲透,而不只是罗列功能。
1. opencode 的工程全景:不是一个聊天工具,而是一个可组合的代理框架
1.1 工程定位与核心能力
opencode 本质上是运行在终端里的 AI 编码代理。它不依赖 IDE 插件,不依赖网页后台,所有交互都可以在命令行和 TUI(文本用户界面)里完成。你让它读代码、改文件、跑命令、查日志、对接多个模型,它都通过一套统一的事件机制来响应。
我拆完代码之后,对它的定位有了新的理解:它不是一个"聊天软件套了终端壳",而是一个把"模型调用、工具执行、上下文管理、会话恢复"全都抽象成标准化模块的框架。这意味着你可以通过改配置和写扩展,把它变成适合自己工作流的工具,而不是被固定交互束缚。
它的核心能力可以从几个维度看:
- 多模型对接:同一会话里可以切换不同模型供应商,也可以按任务类型指定不同模型来处理。
- 工具调用体系:文件读写、目录搜索、命令执行等能力都通过工具接口暴露给模型,而不是把终端权限直接裸给模型。
- 会话管理:支持双会话结构(后面细说),可以随时恢复历史会话。
- 事件溯源:所有交互都记录为事件流,支持重放和审计。
注意:opencode 选的是"终端优先"路线,这意味着它天然适合 SSH 远程开发、tmux 组合使用、CI 环境集成。如果你只用 IDE 图形界面办公,它的很多优势你感受不到。
1.2 工程分层架构
我从源码角度把 opencode 的工程结构拆成了六层,每一层职责都很清晰,层与层之间靠事件总线串起来,而不是互相直接调用。这种设计让每个模块都能独立测试、独立替换。
| 分层 | 核心职责 | 关键模块 |
|---|---|---|
| 交互层 | 命令行解析、TUI 渲染、键盘绑定 | 命令行入口、界面渲染组件 |
| 会话层 | 双会话管理、上下文预算、摘要持久化 | 会话管理器、上下文压缩器 |
| 事件层 | 事件总线、事件持久化、回放引擎 | 事件存储、事件重放器 |
| 工具层 | 工具注册、权限校验、超时熔断 | 工具注册表、执行器 |
| 模型层 | 多供应商适配、流式解析、重试策略 | 模型适配器、协议解析 |
| 存储层 | 事件文件、检查点快照、配置持久化 | 文件存储、检查点管理器 |
每一层之间通过事件解耦:交互层产生用户输入事件,会话层更新元数据,工具层执行完毕后发布工具结果事件,模型层发布流式响应事件,渲染层订阅这些事件刷新 UI。这种"发布-订阅"模式让整个系统像一个流水线,而不是层层嵌套的函数调用堆栈。
1.3 终端优先的设计取舍
我在实际使用中最大的感受是,这个项目把"终端优先"贯彻得很彻底。它没有像很多同类工具那样先做一个网页版,再补一个 CLI 壳子,而是从底层就为终端场景设计。带来的好处非常实际:
- 远程开发友好:SSH 到服务器就能用,不需要在本地跑一个重型 IDE 插件,也不需要在远端维护 GUI 环境。
- 可脚本化:所有功能都有命令行入口,这意味着你能把它接进自动构建、批量代码审查、日志分析脚本里。
- 组合性强:配合 tmux、tui 文件管理器、git 工具,能拼出非常顺手的本地工作台。
代价也很明显:渲染能力远不如 Web 端,表格、富文本、复杂交互都得裁剪。但它做了很聪明的取舍——把复杂度下沉到事件层和数据层,而不是花在表现层。这也是我推荐做 AI 工具的人去读它源码的原因:它让你看到,在资源受限的界面里,如何通过"事件架构"来保持系统的表达力。
2. 双会话内核:长会话管记忆,短会话管任务
2.1 为什么需要两条会话
先做一个类比:上下文窗口不是"内存",而是"预算"。模型一次能看到的 token 是有限的,你塞进太多历史记录,留给当前任务的推理空间就少了。如果所有对话都堆在一个会话里,几十轮之后要么爆掉上下文窗口,要么被迫把最早的关键信息扔掉。
opencode 的做法是把会话分成两种:
- 长会话(持久会话):承载跨任务的"长期记忆",比如项目编码规范、用户偏好、团队约定、常用命令习惯。它不负责具体任务的上下文,而是以摘要的形式存在。
- 短会话(任务会话):每次任务创建一条,负责当前问题的完整上下文,比如"修复 A 模块的并发 bug"或"给 B 服务补充测试用例"。任务结束之后归档。
这种拆分的核心动机是隔离和复用。隔离,指不同任务之间不会串上下文,任务 A 的大量日志不会污染任务 B 的判断;复用,指长期记忆不需要每开一个任务就重新交代一遍。
我试过很多 AI 编码工具,最大的痛点就是"每次新对话都要重新教一遍项目背景"。双会话机制把这个问题彻底解决了:项目背景在长会话里沉淀,短会话启动时自动携带摘要,你只需要关心当前任务本身。
2.2 双会话的联动方式
两条会话不是孤立的,它们之间的关系可以概括为:长会话提供"元信息",短会话负责"具体执行"。
短会话创建时,会从长会话加载当前项目的摘要信息,包括:
- 项目结构概览:主要目录、入口文件、模块边界
- 编码偏好:缩进风格、命名规范、注释习惯
- 历史经验:之前踩过哪些坑、哪些方案被否决了、为什么被否决
短会话运行过程中产生的重要结论,会在任务结束时回写到长会话摘要里。比如"A 模块的并发控制方案已切换为异步队列,原因是不希望引入额外的锁依赖",这条信息会被长会话记住,下次新任务就不会再推荐加锁方案。
我自己的复现流程是这样的:
- 打开一个长会话,先花几分钟把当前项目的架构、技术栈、常见命令梳理一遍。
- 长会话生成结构化摘要,确认无误后保存。
- 针对每个具体任务,创建独立短会话。
- 任务完成后,把关键决策和结果标记到长会话里。
这样做最大的收益是:任务错开之后,每个短会话的上下文窗口都被当前任务占满,模型推理质量明显提升,而不是浪费在回忆陈年旧事上。
2.3 会话状态机与管理细节
opencode 的会话管理不是简单的"开与关",而是一个有完整状态流转的生命周期。我梳理出的核心状态包括:
- created:会话创建,尚未激活
- active:会话处于活动状态,可以接收输入
- paused:会话暂停,例如用户切换到另一个任务
- closed:任务会话完成,上下文归档
- archived:长会话被标记为归档,进入只读状态
- cleared:上下文被清空,仅保留事件日志
状态流转的核心逻辑是:任何状态切换都会发布一个会话事件,写入事件流。这意味着即使程序崩溃,你也能从事件流里恢复出"当时进行到哪一步"。
有一个细节值得提:paused 状态对内存管理很重要。多个短会话同时激活时,模型上下文会被挤占。opencode 会把非活跃会话的原始消息从热区卸载,只保留摘要和关键消息的索引,等切回时再按需加载。
2.4 上下文压缩:token 预算怎么管
这是整个双会话机制能长期跑下去的关键,工程上如果不做压缩,会话时间一长照样爆窗口。
我看到它采用了一种"三段式"上下文布局:
- 头部固定区:系统提示词、项目摘要、用户全局偏好。这部分始终保留,头部压缩时也不动。
- 滑动窗口区:最近 N 轮完整的对话消息,原始保留,供模型理解当前进展。
- 摘要压缩区:更早的历史消息,经过摘要模型压缩为要点列表,只在需要追溯时展开。
三段式里,预算分配很关键。我调过几次参数之后,常用的一个比例是:头部占 15%,滑动窗口占 60%,摘要区占 25%。窗口太小时模型会忘记刚才聊到哪,窗口太大时会压缩掉早期的重要约束。60% 这个比例对大部分编码任务是中等偏稳的选择。
压缩触发时机一般有两个:一个是消息总量超过阈值,另一个是估算 token 超过窗口限制。估算方式通常不是逐字符数,而是按消息类型加权——工具执行结果的体积最大,会被优先裁剪成"结果摘要 + 关键输出片段"。
3. 事件溯源:为什么历史会话能完整复现
3.1 事件溯源的核心思路
很多 AI 工具的会话恢复是"存一份最终结果快照"。结果快照的问题在于:你只知道最终状态,不知道它是怎么来的——哪条用户要求触发了哪个工具调用,哪个模型决策后续被否决了,完全无法追踪。
opencode 采用事件溯源(Event Sourcing)架构:不直接保存会话状态,而是保存一串不可变的事件,状态由事件按顺序重放得出。这就像银行账户不直接存余额,而是存每一笔流水,余额查询时累加一遍。这样做的直接收益是:会话的每一次变化都有完整记录,任何时刻的现场都能精确还原。
对 AI 会话来说,这个思路尤其合适,因为 AI 交互本身就是天然的事件流——用户发消息、模型回复、模型请求调用工具、工具返回结果,这些动作之间天然存在时间顺序和因果关联。
3.2 事件模型与核心字段
opencode 的事件模型有统一的骨架字段,我简单列一下:
| 字段 | 说明 | 示例值 |
|---|---|---|
| id | 事件唯一 ID | evt_01HXYZ... |
| type | 事件类型 | user_message / tool_call |
| ts | 发生时间戳 | 1730000000 |
| session_id | 所属会话 | sess_coder_001 |
| parent_id | 父事件 ID,用于串链路 | evt_01HXYZ... |
| payload | 事件具体内容 | 结构化 JSON |
事件之间通过 parent_id 串成一颗树:用户消息是根,模型回复挂在消息下,模型触发的工具调用又挂在回复下,工具结果再挂在调用下。这种父子链路我非常喜欢,因为它能把一次完整的"用户提问 -> 模型推理 -> 工具执行 -> 最终回复"串成一条可追踪的因果链。
我随便贴一条示意事件,方便理解结构:
{ "id": "evt_01HXYZ", "type": "tool_call", "ts": 1730000000, "session_id": "sess_coder_001", "parent_id": "evt_01HXYW", "payload": { "tool": "read_file", "input": { "path": "src/core/session.py", "offset": 120, "limit": 80 } } }命令解释:evt_01HXYZ是某次文件读取工具调用事件,它挂在一条模型回复事件evt_01HXYW下面。重放时只要按序读事件,就能复现出"当时模型决定读文件的哪个片段"。
3.3 事件存储与重放机制
事件存储我在源码里看到有几种后端:默认是 JSONL 文件,每行一条事件;数据量大时可换 SQLite,按 session_id 建索引。文件模式的优势是简单、人类可读、方便直接用命令行工具分析,这也是我日常调试时最依赖的模式。
重放机制分两种:
- 全量重放:从空状态开始,按事件时间顺序逐条 apply,得到指定时刻的会话状态。用于精确还原、审计。
- 增量重放:从最近的检查点快照开始,只应用快照之后的事件。用于快速恢复会话,不需要重新跑完整历史。
检查点就是定期把当前会话状态压缩成一份快照。比如每 50 条事件打一个点,恢复时加载快照再补 10 条事件,比从 0 重放 60 条快得多。这是一种用存储换时间的经典优化,对于长会话特别重要。
事件重放还有一个意外的好处:它能用来复现 bug。如果一个任务会话当时没跑出预期结果,你可以把事件流复制出来,在隔离环境里重新跑一遍,看看是哪个环节出了问题。我排查工具调用异常时,经常就是照着事件流一步步定位到具体的 tool_result 事件。
3.4 事件驱动的实时渲染
事件溯源架构不仅解决了"记录"问题,还顺带解决了"渲染"问题。opencode 的 TUI 界面并不是一个传统的前端状态树,而是订阅事件流的"渲染器"。
事件到达后,渲染层只做两件事:
- 根据事件类型更新界面模型(比如新来一条 assistant_message,就把这条内容追加到消息面板)。
- 触发局部重绘(只刷新受影响的行,而不是整个界面重画)。
流式响应体验就是靠这个实现的:模型每次吐出增量文本,就发布一条流式事件,渲染层把增量追加到当前行。因为是事件驱动的增量更新,所以即使模型回复很长,界面也能保持顺畅滚动,不会整块卡顿重建。
这个设计让我深刻体会到"事件溯源 + 事件驱动 UI"的组合威力:数据层、逻辑层、表现层全部通过事件解耦,修改任何一层都不需要牵动另外两层。
4. 实操与调优:双会话和事件流在工作流里的落法
4.1 初始化一个带长会话的工作区
我的实际操作流程,供参考:
- 初始化 opencode 工作区,为指定项目目录生成独立的元数据空间。
- 配置模型供应商信息和密钥,选择默认模型。
- 新建一个长会话,把项目背景交代清楚:技术栈、目录结构、常用命令、编码规范。
- 检查摘要生成结果,确认长会话能准确概括项目要点。
建议:长会话的头几条消息质量直接决定后续所有短会话的项目理解能力。不要急着写代码,先把背景交代完整,宁可多花两分钟,后面能省两个小时。
做完这步之后,每个短会话启动时都会自动加载项目摘要,你不用重复解释"我们这个项目是 xx 框架、入口在 xx"。
4.2 短会话并行处理多个任务
现在演示一个真实工作场景:假设手头有三个任务——修复登录接口的越权漏洞、优化列表查询的慢 SQL、补充单元测试。我会这样组织:
- 为每个任务各开一个短会话,任务之间用清晰的任务名做标识。
- 短会话之间完全隔离,各自占用独立的上下文窗口。
- 每个短会话完成后,把结论写回长会话,归档任务会话。
- 切换任务时,旧会话自动进入 paused 状态,不占用当前上下文。
这个模式跑起来之后,我最直接的感受是模型"变聪明了"——因为每个短会话里的上下文高度聚焦,模型不会因为历史消息干扰而给出似是而非的建议。
有一点要提醒:并行短会话的数量不是越多越好。每个活跃会话都会占用部分系统资源(事件缓冲、上下文槽位)。我一般控制在 3 个以内,超过的话就把旧任务会话先归档。
4.3 token 预算和压缩策略调优
实际调参的时候,有一个我反复踩过的坑:不要只调窗口大小,要调"窗口布局"。配置里我把头部固定区、滑动窗口区、摘要区的比例调成 15 / 60 / 25 之后,效果比单纯把窗口从 32k 调到 64k 好得多。
如果你的任务大多是长上下文分析(比如读大文件然后改代码),可以尝试把滑动窗口比例调高到 70%,但要警惕早期约束被挤掉。如果你的任务多是问答型(比如查文档、解释概念),可以适当扩大摘要区,保留更多早期信息。
另外,工具结果裁剪我强烈建议开启。默认情况下,工具调用(比如读文件、跑命令)返回的完整输出会直接塞进上下文。对于跑日志、扫目录这种高输出量操作,我会把工具结果裁剪为"前 200 字符 + 后 200 字符 + 中间省略提示",既保留关键信息,又不占满窗口。
4.4 事件调试现场实录
事件日志是我排查问题最得力的帮手。默认情况下,事件文件可以在工作区数据目录下找到,格式是 JSONL,一行一条。
我常用的操作是:
# 查看最近 20 条事件 tail -n 20 session.jsonl # 按类型过滤,只看工具调用 grep '"type": "tool_call"' session.jsonl # 按会话过滤,只看某个短会话 grep 'sess_coder_001' session.jsonl有一次模型反复尝试读取一个不存在的文件,界面里只能看到它"正在思考",但看不到底层原因。我把事件流过滤出来,很快发现工具调用事件的参数里文件名拼接错了。这个体验比在 GUI 工具里黑盒排查高到不知道哪里去了。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 短会话恢复后上下文变"失忆" | 长会话摘要未包含关键项目信息 | 在长会话里补充摘要,重新标记 |
| 事件文件体积增长过快 | 工具结果未裁剪 | 开启工具结果裁剪,限制输出大小 |
| 模型回复突然中断 | 上下文窗口被工具结果占满 | 调高滑动窗口比例,压缩历史 |
| 双会话出现串扰 | 同一任务创建了多个短会话且未归档 | 保持单任务单会话,做完立即归档 |
| 恢复历史会话特别慢 | 无检查点,全量重放事件 | 缩短检查点间隔,开启增量重放 |
| 工具调用超时但界面无提示 | 超时事件未可视化 | 检查事件流中的 tool_result 异常标记 |
这些坑我基本都踩过一遍,其中"上下文窗口被工具结果占满"是最最常见的。现象就是聊到一半模型开始重复之前的措辞,甚至把工具输出抄进回复里。这时候去看事件流,十有八九是某个 grep 或日志查询命令把几百行输出全塞进来了。
5.2 用事件流还原"事故现场"的方法
排查复杂问题时,我习惯按三步走:
- 先定位时间起点:从报错信息里找到出问题的时间戳,去事件流里定位到那一刻。
- 再梳理父子链路:从报错点向上找 parent_id,把"用户消息 -> 模型回复 -> 工具调用 -> 工具结果 -> 后续消息"串出来。
- 最后比对预期:看模型收到的工具结果和它之后的行为是否匹配,定位决策偏移点。
这种方法比盲目重试或随机调参高效得多。有一次排查"模型反复修改一个文件但每次都还原"的问题,本来以为是权限问题,结果从事件链里看到是模型先读到了旧的缓存内容,之后每次修改都基于缓存版本覆盖了磁盘。
5.3 防止双会话串扰的三个习惯
双会话机制虽然做了隔离,但使用习惯也会决定隔离效果。我总结三个习惯:
- 任务边界要清晰:别在"修复 bug"的短会话里顺手做"重构 API",混淆任务会让总结写回去时出问题。
- 关键结论要显式写回:短会话结束前,明说"请把以下结论写入长期记忆",不要指望模型自动提炼。
- 定期审视长会话摘要:长会话里的信息如果不正确,会持续误导后续所有会话。我每周都会清理一次过时摘要。
注意:双会话不是万能药。如果项目本身结构混乱,摘要再怎么记也是记一笔糊涂账。建议在使用 opencode 之前,先花点时间把项目 README、目录结构、调试入口整理清楚,这样长会话记下来的摘要才有价值。
我个人在实际操作中的体会是:opencode 最让我惊喜的不是它跑了多少个模型,而是它把"AI 编码会话"这个模糊的概念,拆成了双会话、事件溯源这些可工程化的模块。读完它的架构,再回去看那些把上下文存成一个大 JSON 的工具,你会明显感觉到设计代差。下一篇我打算继续拆它的工具系统与权限校验机制——现在的 AI 编码工具把终端权限交给模型之前,有没有一套完整的信任边界?这是我觉得特别值得聊的话题。