opencode 这个名字最近在我的技术雷达里频繁出现。如果你也跟我一样不仅把 AI 编码工具当黑盒用还好奇它内部到底怎么运转那这篇文章值得你花十分钟读完。我花了整整两周时间把 opencode 的核心源码从头到尾啃了一遍又自己动手实现了一个兼容其设计思想的简化内核。这篇上篇我只讲三个最关键的东西工程全景、双会话内核、事件溯源。这三个骨架搞明白你就知道 opencode 为什么能在长任务里保持稳定为什么能恢复中断的会话也为什么值得被当作一个架构范例来研究。适合人群已经用过 AI 编程助手、想理解其内部机制的同学以及那些打算自己设计编码代理框架、不想靠拍脑袋堆功能的开发者。1. 工程全景先在地图上找到每个模块1.1 五层架构与模块职责我读代码的习惯是不急着看具体实现先抓模块边界。opencode 的工程内部不是单体的 main 函数一路调到黑而是非常清晰地分成了五层CLI 适配层负责接收终端输入、解析启动参数、处理用户中断信号是所有请求的入口。它只做翻译不做决策。会话内核层这是大脑负责意图识别、任务规划、会话状态管理。它不关心具体怎么改文件只关心下一步应该做什么。工具执行层这是手脚负责调用文件读写、代码检索、命令执行、测试运行等真实操作。每个工具都暴露统一的执行接口返回结构化结果。事件存储层负责把每一件已发生的事持久化下来提供快照与回放能力。它是整个系统的黑匣子。事件总线以上四层不直接互相引用全部通过事件总线异步通信。总线承担了路由、分发、背压控制。这个分层给我一个强烈的感觉opencode 本质上不是一个AI 应用而是一个围绕 AI 构建的事件驱动状态机。每层都只对自己的输入输出负责通讯靠事件契约状态靠事件推导。这样做带来的直接好处是任何一层都可以单独替换。比如你想把工具执行层从本地命令换成远程沙箱只要保持事件格式不变内核完全无感知。1.2 一次普通修复请求的完整数据流我挑了一个最典型的场景——用户说了一句帮我修一下登录接口的空指针异常把完整链路捋一遍。第一步CLI 层把这个自然语言指令封装成UserCommand事件丢进事件总线。事件里除了原始文本还会带上工作目录、用户 ID、会话关联 ID 等元数据。第二步会话内核订阅到UserCommand先把它交给上下文引擎拼装出当前会话的完整上下文快照包括历史偏好、项目结构摘要、相关代码索引然后调用模型生成一个规划草案。规划结果不是直接执行而是先变成一个PlanProposed事件。第三步内核内部的裁决器会判断这个规划是否需要用户确认。如果需要就通过 CLI 层向用户输出规划内容等待授权如果不需要直接发出PlanApproved事件。第四步工具执行层收到PlanApproved后按步骤逐个发起工具调用。每调用一个工具就发一个ToolStarted事件结束后发ToolSucceeded或ToolFailed。第五步工具结果事件回到会话内核内核把结果摘要合并进上下文再次调用模型决定是继续下一个步骤、还是向用户汇报结果。最后发出一个TaskCompleted事件一次任务闭环结束。整个链路看起来和普通的消息队列应用没什么区别但关键点在于每一步的中间产物都是事件而不是内存变量。这意味着只要事件流还在系统随时可以被复活到任意历史节点。1.3 为什么事件总线比函数调用更合适有人可能会问直接用 RPC 一层层调用不是更简单吗确实简单但短视。我举一个具体场景工具执行层正在跑一个耗时 30 秒的测试这时候用户在终端敲了一条不用跑了直接改配置文件。如果采用同步函数调用执行线程会被测试命令卡住用户输入根本进不来。但在事件驱动架构里用户的新输入会立刻变成UserInterrupt事件投递到总线上会话内核在收到工具结果之前可以先响应这个中断事件取消当前规划并转入新的处理分支。另一个差异体现在错误恢复上。同步调用链一旦中间某个环节崩溃整个栈的状态就没了而事件驱动架构的每个消费者都是无状态的它们只需要从事件流里重建状态。我曾在测试时故意 kill -9 杀进程重启后 opencode 直接从最近的事件快照恢复接着执行剩余步骤效果让我印象深刻。2. 双会话内核控制与工作两条上下文线2.1 两个会话各自维护什么opencode 最具辨识度的设计是同时维护Control Session控制会话和Work Session工作会话。一开始我以为是故弄玄虚读完源码才明白它的必要性。控制会话的核心使命是理解人。它保存这些内容用户的长期偏好比如不用考虑 Windows 兼容性、变量命名一律用下划线项目级目标与约束比如这是一个给内部使用的工具不需要做登录注册当前对话里已经确认过的决策和结论用户对已有方案提出的反馈与修正工作会话的核心使命是理解代码。它保存这些内容当前任务涉及的文件列表及其当前状态文件变更的 diff 记录、新增/删除/修改的具体内容工具执行的原始输出摘要比如测试结果、编译错误、命令返回值已经执行过的步骤序列以及每一步遗留的待办事项两者生命周期不同控制会话跨任务存活工作会话跟任务绑定。一个控制会话可以串联多个工作会话但它不会直接看到工作会话内部那些冗长的工具输出。同样工作会话也不关心用户上一周说过什么它只看当前任务的结构化指令。2.2 同步机制事件契约与摘要体两个会话不是各自独立的孤岛它们通过特定的翻译事件同步。我在复刻时定义了这样一套契约interface CommandIntent { intentId: string; controlSessionId: string; workSessionId: string; task: string; // 结构化任务描述 constraints: string[]; // 用户约束如 不要改公共API targetFiles?: string[]; // 相关文件路径 issuedAt: number; } interface WorkSummary { workSessionId: string; intentId: string; changedFiles: Array{ path: string; diff: string }; testResults?: Array{ name: string; status: string }; remainingIssues: string[]; summarizedAt: number; }控制会话发出CommandIntent把用户模糊的需求翻译成工作会话能执行的结构化指令工作会话执行完毕产出WorkSummary把一堆原始输出压缩成控制会话需要的高层摘要。这个设计很像产品经理和开发团队的协作方式——需求文档一条验收报告一条中间的过程噪音不传递。我踩过的第一个坑是尝试把工具输出也塞进WorkSummary结果控制会话的上下文里堆满了大段日志导致模型在后续推理时过度关注无关细节。后来严格只放摘要 关键路径引用效果立刻好起来。2.3 上下文窗口的隔离与回收双会话最大的实际收益是让模型上下文窗口的利用率变得可控。把模型上下文比作一张有限的草稿纸传统单会话方案会把所有历史对话、工具输出、文件内容全部写在上面写到第几轮就满了然后开始各种遗忘和错乱。opencode 的策略是分两张纸。控制会话的纸上只写高层语义内容密度高、时效长工作会话的纸上只写当前任务的短期事实任务一结束整张纸直接扔掉。我本地实测过一个连续重构任务用单会话方案跑到第六个子任务时模型已经开始混淆前两个任务的变更文件换成双会话方案后每个子任务的工作会话都是干净的初始状态质量曲线始终平稳。这不是玄学而是上下文隔离带来的必然结果。隔离的代价是工作会话每次启动都要重新读入相关文件内容。opencode 用了一个技巧项目级代码索引放在一个单独的仓库级缓存里工作会话启动时通过事件快速加载索引而不是把整个仓库重新 embedding。这个细节让我意识到双会话不是简单拆两个 context 对象背后还需要配套的缓存策略。3. 事件溯源把发生了什么变成一等公民3.1 事件定义与四组分类事件溯源在 opencode 里不是辅助功能而是整个系统的真相来源。它的核心思想是状态不是存在变量里的而是从事件流里推导出来的。我梳理了它的事件分类大致可以归为四组事件组典型事件作用用户事件UserCommand,UserInterrupt,UserConfirm记录用户的每一次输入和干预规划事件PlanProposed,PlanApproved,PlanStepCompleted记录 AI 的决策与计划推进工具事件ToolStarted,ToolSucceeded,ToolFailed记录外部工具的真实执行结果上下文事件ContextLoaded,ContextArchived记录上下文加载与归档每条事件都带上了统一的元数据结构。我参考 opencode 的设计简化如下interface Event { id: string; // 全局唯一 type: string; // 事件类型 schemaVersion: number; // 事件结构版本 timestamp: number; sessionId: string; // 所属会话 parentEventId?: string; // 因果链上的父事件 payload: Recordstring, unknown; // 结构化载荷 }parentEventId是因果链的关键。比如ToolSucceeded(事件5)的父事件是PlanStepCompleted(事件4)这样回放时系统可以还原出因为计划步骤完成所以执行了工具工具成功于是推进到下一步的完整因果链。3.2 快照、压缩与版本迁移只追加事件表会无限膨胀所以 opencode 做了两层处理。第一层是快照。每经过 N 个事件我见过默认 100 个就生成一个会话快照把当前状态直接存下来。恢复时加载最近快照然后只重放快照之后的事件避免从开天辟地开始算。快照通常存成 JSON 或序列化的 Protobuf体积远小于事件流。第二层是业务压缩。比如一次文件修改可能产生ToolStarted、ToolSucceeded、FileRead、FileWrite等好几个事件这些可以压缩成一个文件变更已应用事件原始细节转移到冷存储。压缩过程要保证因果链不被破坏压缩后的事件仍然保存原始事件 ID 的映射关系。版本迁移是我认为最容易被忽略、但最致命的一点。事件一旦持久化结构就可能随功能演进变化。我维护过的一个分支里ToolSucceeded最初只记录exitCode后来增加了cwd字段。升级时没写迁移逻辑回放旧事件时系统把所有文件路径都按当前工作目录解析导致技术执行层把命令跑到了错误目录。教训就是必须给事件加schemaVersion回放时根据版本做无损转换。3.3 回放与断点恢复的实际效果事件溯源给我的实践带来了三个直观收益。第一个是恢复能力。跑一个长任务到一半进程可能因为内存溢出或外部资源耗尽而崩溃。传统方案会丢掉整个会话的进度但 opencode 重启后只需要从最近快照恢复然后继续订阅事件流。我在本地模拟过代码执行器崩溃重新拉起后系统自动跳过了已完成步骤从崩溃点继续执行对用户来说几乎无感。第二个是可审计性。每次 AI 改过什么文件、执行过什么命令都能从事件流里完整拉出来。这对于准备把 AI 代理引入正式项目的人来说是刚需——出了问题你能回答它到底动了什么。第三个是调试能力。当模型产出的代码不符合预期我可以按事件流逐步回放是意图理解错了规划步骤漏了还是工具执行阶段改了错误的文件这种时间旅行式调试在传统架构里几乎不可能实现。4. 内核实现笔记从零复刻双会话和事件存储4.1 核心数据结构的代码骨架只看不练等于没看。我照着 opencode 的思路写了一个迷你内核只保留最核心的会话管理。先说两个关键类的骨架。class SessionManager { private controlSessions new Mapstring, ControlSession(); private workSessions new Mapstring, WorkSession(); createControlSession(userId: string): ControlSession { const session new ControlSession(userId); this.controlSessions.set(session.id, session); return session; } createWorkSession(controlSessionId: string): WorkSession { const control this.controlSessions.get(controlSessionId); if (!control) throw new Error(Control session not found); const work new WorkSession(control.id); this.workSessions.set(work.id, work); control.attachWorkSession(work.id); return work; } endWorkSession(workSessionId: string) { const work this.workSessions.get(workSessionId); if (work) { work.close(); this.workSessions.delete(workSessionId); } } }这里我订了一条规则工作会话只能由控制会话创建并且必须在任务结束时主动关闭。刚开始实习时我贪图复用让多个控制会话共享一个工作会话结果上下文互相污染改到后半段模型连文件路径都开始张冠李戴。所以生命周期管理一定要严格。4.2 事件总线的背压实现事件总线如果处理不过来内存会被瞬间打爆。我参考 opencode 采用了一个简单的背压机制有界队列 阻塞写。class EventBus { private queue: Event[] []; private readonly maxSize: number; constructor(maxSize 10_000) { this.maxSize maxSize; } async publish(event: Event): Promisevoid { while (this.queue.length this.maxSize) { await new Promise((r) setTimeout(r, 10)); // 反压等待 } this.queue.push(event); this.dispatch(event); } private dispatch(event: Event) { // 按事件类型路由到对应消费者 this.consumers .filter((c) c.handles(event.type)) .forEach((c) c.handle(event)); } }实际线上场景中等 10ms 可能不够更靠谱的做法是用一个Semaphore或p-limit控制消费速率同时允许消费者主动暂停订阅。但核心思想不变宁可让生产者等也不能让队列无上限增长。工具执行层产生大量输出时这个背压策略保住了主进程。4.3 会话状态机与归档我最后把会话生命周期建模成一个简单的状态机Active会话正在处理或等待用户输入Pending会话生成了PlanProposed等待用户确认Executing会话内的工作会话正在执行工具调用Suspended会话因中断或外部原因暂停Closed任务完成会话归档控制会话通常从 Active 进入 Pending用户确认后进入 Executing工作会话结束后回到 Active。任何一个状态都可以收到UserInterrupt事件强制进入 Suspended。我在实现里给每个状态的合法事件列表做了校验不合法的事件会被拒收并记录日志。这个校验看似繁琐但能在早期拦截很多因事件乱序导致的诡异 bug。事件归档我采用了热存储 冷存储双轨热存储保存当前会话的最近 1000 条事件和全部快照用于快速回放冷存储把已关闭会话的完整事件流压缩后存到本地文件。这样做让热存储体积可控冷存储则承担审计需求。5. 常见问题速查与经验清单5.1 故障现象与定位方法我在开发过程中整理了一张排查对照表对后来者应该很有帮助。现象可能原因排查方法会话恢复后状态错乱事件 schema 版本不匹配或快照损坏检查schemaVersion加载最近一次有效快照模型开始重复修改同一文件工作会话未在任务结束后清理上下文残留检查endWorkSession是否被调用工具执行超时但无日志事件总线背压导致ToolStarted晚于实际执行查看事件投递时间戳对比工具开始时间用户中断不生效UserInterrupt事件被过滤或优先级低确认中断事件是否被同意路由到控制会话事件存储磁盘暴涨未做快照和压缩或压缩规则没有命中统计事件类型分布检查压缩策略最让我头疼的一个问题发生在一次模拟崩溃后工作会话的上下文文件已经写到了磁盘但持久化的事件流里没有对应快照导致恢复时工作会话认为文件不存在重新执行了修改。后来我给WorkSummary事件加了hasDirtyContext标志强制在任务中断前生成快照才彻底解决。5.2 我给自己的三条军规第一任何跨模块的数据传递都要显式建模成事件禁止悄悄传递内存引用。不然你迟早会写出两个模块相互依赖的意大利面。第二会话生命周期宁严格勿宽松。工作会话只能由任务创建、由任务关闭绝不允许跨任务复用。宽松一时爽上下文漂移火葬场。第三事件结构永远向前兼容。每次新增字段都必须提供默认值每次修改语义都必须提升schemaVersion。旧事件是公司里最难缠的老员工你得为它做适配而不是让它来适应你。这套内核我前后迭代了三个版本最初的版本没有事件溯源只有双会话雏形第二次加了事件存储但没做版本兼容第三个版本才把快照、压缩、背压全部补齐。opencode 的架构不是一天堆出来的但核心思想非常值得借鉴。这篇上篇先写到这里工具执行层怎么设计、上下文引擎怎么做索引我会在后续篇幅里继续拆给你看。如果你也在折腾 AI 编码代理欢迎带着问题来找我交流。