1. 从一条报错说起:为什么我要啃 opencode 的源码
第一次接触 opencode 的人,大概率不是被它的功能吸引,而是被一条报错拦住:
error from provider (console): opencode's free tier can only be used from within opencode这句话看着像绕口令,实际含义是:免费额度只在 opencode 自己的运行环境里生效,你拿它的 key 去别的客户端调,会被直接拒绝。很多人卡在这一步,以为是安装问题,反复重装、换 Node 版本、翻文档,最后才发现是调用来源校验。我当初也是这么过来的,所以决定不再停留在“能用就行”的层面,直接把它的工程结构、会话模型和事件机制拆开看一遍。
这篇是上篇,只聊三件事:工程全景、双会话内核、事件溯源。这三个词听起来抽象,但它们是理解 opencode 一切行为的底座。搞懂之后,你再看它的配置文件、插件机制、模型切换、额度计算,都会觉得顺理成章。适合谁看?写过 TypeScript、用过 Bun、对 Monorepo 有基本概念,并且想从“使用者”进阶到“能改、能调、能排查”的人。如果你只是想装个工具跑起来,那opencode安装那部分速查表也够你用;但如果你想真正吃透它,往下读。
opencode 是一个用 TypeScript 写的智能体框架,运行在 Bun 上,代码组织成 Monorepo。这三个技术选型不是随便定的,后面我会逐个解释它们各自解决了什么问题,以及为什么换成别的方案会难受。先把结论放前面:Bun 负责启动速度和内置工具链,TypeScript 负责类型安全和跨包契约,Monorepo 负责把 CLI、核心内核、插件、SDK 拆成可独立演进的单元。理解了这三点,你就理解了 opencode 的“骨架”。
2. 工程全景:Monorepo 里到底装了什么
2.1 为什么是 Monorepo,而不是一个大包
很多人第一次 clone opencode 会懵:根目录下packages/里一堆子包,每个都有自己的package.json。为什么不干脆写成一个包?因为 opencode 的边界天然就是分裂的。
- CLI 层:负责参数解析、终端交互、进程管理,它不该关心模型怎么调。
- 核心内核:会话管理、事件总线、工具调度,这是最重的一块。
- Provider 适配层:对接不同模型服务,每家的鉴权、流式协议、错误码都不一样。
- 插件/SDK 层:给外部扩展用的稳定接口,不能跟着内核天天变。
如果全塞一个包里,改一个 provider 的错误处理,可能连带把 CLI 的构建产物也重新打一遍,测试范围失控。Monorepo 的价值就在这:每个包有独立的版本、独立的测试、独立的发布节奏,但共享一套 TypeScript 配置和构建脚本。这就是typescript types文件夹的声明文件 如何使用这类问题在 opencode 里特别常见的原因——跨包引用全靠类型声明文件撑着。
2.2 目录结构与职责划分
我按自己的理解把核心目录梳理成一张表,你对照着看会清晰很多:
| 目录/包 | 核心职责 | 改动频率 | 依赖方向 |
|---|---|---|---|
packages/cli | 命令入口、终端 UI、进程生命周期 | 中 | 依赖内核 |
packages/core | 会话、事件、工具调度 | 高 | 被所有包依赖 |
packages/provider-* | 各模型服务适配 | 中 | 依赖 core 的接口 |
packages/sdk | 对外稳定 API | 低 | 依赖 core |
packages/plugin-* | 官方插件示例 | 低 | 依赖 sdk |
这张表最关键的一列是“依赖方向”。core 永远不反向依赖 cli 或 provider,这是保证内核可测试的前提。你在读源码时如果发现某个 provider 的逻辑渗进了 core,那基本可以判定是设计走味了。
2.3 Bun 在这里扮演的角色
opencode安装之所以比很多同类工具简单,很大程度是因为 Bun。Bun 同时是运行时、包管理器、打包器、测试 runner。这意味着:
- 不需要
ts-node或tsx做 TS 转译,Bun 直接跑.ts。 - 不需要单独装 Jest/Vitest,
bun test内置。 - 不需要 esbuild/rollup 配一堆,
bun build直接出产物。
我实测过,同样的 Monorepo,用 Node + pnpm + tsc + vitest 的组合,冷启动跑测试要十几秒;换成 Bun,两三秒就起来了。对于 opencode 这种需要频繁跑集成测试的项目,这个差距是实打实的效率。但要注意一个坑:Bun 的某些 Node API 兼容层还在补齐,如果你在插件里用了冷门的node:模块,可能在 Bun 下行为和 Node 不一致。我的建议是插件里尽量用 Web 标准 API(fetch、ReadableStream、AbortController),少碰 Node 私有 API。
2.4 TypeScript 类型声明文件的正确用法
热词里反复出现typescript 类型声明文件(.d.ts) 怎样编写,这在 opencode 的 Monorepo 里是个高频痛点。跨包引用时,你有两种做法:
- 源码直引:
import { Session } from '@opencode/core',靠tsconfig的paths映射到源码。 - 声明文件:core 构建时产出
.d.ts,其他包引用声明。
opencode 走的是第一种为主、第二种为辅。开发时用paths直连源码,享受即时类型反馈;发布时产出.d.ts给外部消费者。这里有个容易踩的坑:paths映射和package.json的exports字段必须保持一致,否则会出现“本地能编译、发布后找不到类型”的诡异问题。我排查过一次,就是因为exports里漏了一个子路径,导致 SDK 用户拿不到类型提示。
{ "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }, "./session": { "types": "./dist/session.d.ts", "import": "./dist/session.js" } } }提示:每次新增子路径导出,务必同步更新
exports和tsconfig的paths,两边不一致是 Monorepo 里最隐蔽的 bug 来源之一。
3. 双会话内核:一个被低估的设计
3.1 什么是“双会话”
opencode 的会话模型不是单一的。它同时维护两类会话:交互会话(interactive session)和执行会话(execution session)。名字是我根据行为起的,方便理解。
- 交互会话:面向用户,负责接收输入、渲染输出、维护对话历史。它的生命周期长,可能横跨几十分钟甚至几小时。
- 执行会话:面向工具调用和模型请求,每次任务派发时创建,任务结束就销毁。它的生命周期短,通常几秒到几分钟。
为什么要拆成两个?因为它们的关注点完全不同。交互会话关心“用户看到什么”,执行会话关心“这次调用成不成功”。如果混在一起,会出现两个问题:一是长会话里累积的上下文会污染单次执行的判断;二是执行失败时很难干净地回滚,因为状态和用户历史缠在一起。
3.2 双会话如何协作
我用一个生活类比:交互会话像“前台接待”,执行会话像“后台工单”。你到前台说“帮我查下这个文件”,前台记录你的需求,生成一张工单丢给后台;后台干完活,把结果交回前台,前台再展示给你。前台不关心后台用了什么工具、调了哪个模型,后台也不关心前台怎么排版。
具体到代码层面,交互会话持有一个sessionId,执行会话持有executionId,两者通过事件关联。一次用户输入可能触发多个执行会话(比如并行调多个工具),但最终都汇聚回同一个交互会话。这种一对多的关系,是 opencode 能支持复杂任务编排的基础。
3.3 会话状态机的关键状态
执行会话的状态流转是排查问题的核心。我整理成表:
| 状态 | 含义 | 可转移至 | 常见触发 |
|---|---|---|---|
pending | 已创建未开始 | running,cancelled | 排队等待 |
running | 正在执行 | completed,failed,cancelled | 模型请求中 |
completed | 正常结束 | 终态 | 收到完整响应 |
failed | 异常结束 | 终态 | 网络/鉴权/解析错误 |
cancelled | 主动取消 | 终态 | 用户中断或超时 |
这张表的价值在于:当你看到error from provider时,先确认执行会话处于哪个状态。如果是failed,看错误码;如果是cancelled,看是谁触发的取消。很多人一看到报错就慌,其实先定位状态,排查范围立刻缩小一半。
3.4 为什么不用单会话加锁
有人会问:单会话加个互斥锁不就行了?我试过这种思路,在小规模下能跑,但一旦涉及并行工具调用就崩。因为锁的粒度很难把握:锁太粗,并行变串行,性能掉一半;锁太细,状态竞争防不住。双会话本质上是用结构隔离替代锁竞争,把并发问题在架构层面消解掉,而不是靠运行时同步。这是我认为 opencode 设计里最聪明的一处。
3.5 会话与额度的关系
回到开头那条报错。免费额度的校验发生在执行会话创建阶段,而不是交互会话。也就是说,校验的是“这次执行从哪来”,而不是“你是谁”。这解释了为什么opencode's free tier can only be used from within opencode——它检查的是调用来源的上下文标识,这个标识只在 opencode 自己的执行会话里才会被注入。你换个客户端,执行会话不是它创建的,标识缺失,自然被拒。理解这一点,你就不会再纠结“为什么我 key 是对的却用不了”。
4. 事件溯源:opencode 的记忆机制
4.1 事件溯源到底解决了什么
传统做法是“存最终状态”:会话表里存当前对话内容,工具表里存执行结果。问题是,一旦状态出错,你只能看到“现在是什么样”,看不到“怎么变成这样的”。事件溯源反过来:不存状态,只存导致状态变化的事件。当前状态由事件序列重放得出。
opencode 用这套机制,好处有三个:
- 可回放:任何一次会话都能从头重放,复现 bug 变得可行。
- 可审计:每个事件带时间戳和来源,谁在什么时候做了什么一目了然。
- 可扩展:新增一种事件类型,不影响已有逻辑,老事件照常重放。
4.2 事件的结构
一个典型事件长这样(我按常见实践补全了字段):
interface OpencodeEvent { id: string; // 事件唯一标识 type: string; // 事件类型,如 'session.created' sessionId: string; // 所属交互会话 executionId?: string; // 所属执行会话,可选 timestamp: number; // 发生时间 payload: unknown; // 事件负载,类型由 type 决定 version: number; // 事件版本,用于兼容旧事件 }version字段容易被忽略,但它是长期演进的关键。当事件结构变化时,靠版本号做迁移,而不是直接改老事件。我在实际项目里踩过坑:早期没加版本号,后来改字段导致历史事件全部解析失败,只能写一次性迁移脚本,很痛苦。
4.3 事件类型的分层
opencode 的事件大致分三层:
- 会话层:
session.created、session.updated、session.closed - 执行层:
execution.started、execution.completed、execution.failed - 工具层:
tool.invoked、tool.result、tool.error
分层的好处是订阅者可以按需监听。比如你只想做日志,订阅会话层就够;想做性能分析,订阅执行层;想调试工具,订阅工具层。这种“按层订阅”比“一个大事件总线全收”要清晰得多。
4.4 重放与快照的平衡
纯事件溯源有个致命问题:事件多了之后,重放一次要很久。opencode 的解法是定期打快照。每 N 个事件或每隔一段时间,把当前状态序列化存一份;重放时从最近的快照开始,而不是从第一个事件开始。
这里有个参数要调:快照间隔。太短,存储涨得快;太长,重放慢。我的经验值是每 100 到 200 个事件打一次快照,具体看事件产生速率。如果一次会话动辄上千事件,就取小一点;如果会话很短,取大一点甚至不打。
4.5 事件溯源与调试实战
我最喜欢事件溯源的一点是:排查问题时可以“时间旅行”。比如用户反馈“第三步结果不对”,我可以把事件序列拉到第三步之前,逐步重放,看是哪一步的 payload 出了问题。这比在代码里打断点高效得多,因为断点只能看当前,事件能看历史。
具体操作上,我会先把某次会话的事件导出成 JSON,然后用脚本过滤出关键类型:
# 假设事件已导出为 events.json cat events.json | jq '.[] | select(.type | startswith("execution"))'这样能快速定位执行层的异常。如果发现某个execution.failed的 payload 里有 provider 错误码,再顺着executionId找对应的工具事件,链路就清楚了。
5. 实操:从安装到跑通第一条链路
5.1 安装与环境准备
opencode安装在不同系统上略有差异。Ubuntu 下我一般这么走:
# 确认 Bun 已安装 bun --version # 全局安装 opencode bun install -g opencode # 验证 opencode --version如果bun没装,先装 Bun。这里有个细节:Bun 的安装脚本会改 shell 配置,装完记得source一下或者重开终端,否则opencode命令找不到。我见过不少人卡在这,以为是安装失败,其实是 PATH 没刷新。
5.2 与 VSCode 的配合
热词里vscode怎么和opencode工作问得很多。opencode 本身是 CLI 工具,和 VSCode 的配合主要靠终端集成。我的做法是:
- 在 VSCode 里开集成终端,直接跑
opencode。 - 把常用命令配成 VSCode task,一键触发。
- 如果需要看事件流,开第二个终端跑日志监听。
不建议一上来就折腾深度集成插件,先把 CLI 用顺,再考虑编辑器层面的优化。顺序反了容易本末倒置。
5.3 跑通第一条执行链路
安装完,跑一个最小任务验证链路:
opencode run "列出当前目录的文件"这条命令会触发:交互会话创建 → 执行会话创建 → 工具调用(列目录)→ 结果回传 → 交互会话渲染。如果中间任何一环断了,你会看到对应的错误。第一次跑通这条链路,比读十遍文档都有用,因为它把双会话和事件流都串起来了。
5.4 观察事件流
想直观看到事件,可以开调试模式(具体开关以实际版本为准,常见做法是设环境变量):
OPENCODE_DEBUG=1 opencode run "列出当前目录的文件"你会看到一串事件按时间顺序打印。对照第 4 节的事件分层,你能清楚看到会话层、执行层、工具层各自的事件。这一步做完,你对“事件溯源”就不再是抽象概念,而是亲眼见过的东西。
6. 常见问题与排查技巧实录
6.1 免费额度报错怎么定位
回到那条最烦人的报错。排查顺序我总结成表:
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
free tier can only be used from within opencode | 调用来源非 opencode 执行会话 | 确认是否用官方 CLI 发起 |
| 额度突然归零 | 多模型分开计额度 | 查各模型用量明细 |
| 换模型后报鉴权错 | provider 配置未同步 | 检查 provider 适配层配置 |
opencode go 套餐是每种模型分开计算额度吗这个问题,从双会话设计看,额度校验发生在执行会话创建时,而执行会话是绑定具体模型的,所以大概率是按模型分开算。具体以官方说明为准,但理解机制后,你至少知道该去哪查。
6.2 类型报错排查
Monorepo 里最常见的类型报错是“找不到模块声明”。排查三步:
- 确认
tsconfig的paths指向正确。 - 确认目标包的
exports字段包含该子路径。 - 确认目标包已构建(如果走声明文件模式)。
三步走完还不行,就bun install重装依赖,清掉可能的缓存。
6.3 事件重放对不上
如果重放结果和实际不符,先查快照。快照和事件序列不一致是重放错乱的头号原因。检查快照生成的时间点是否在事件写入之后,顺序反了就会丢事件。
6.4 实操心得三条
- 先跑通再优化:别一上来就研究插件机制,先把 CLI 跑顺。
- 事件日志常开:调试期把事件日志留着,出问题能回溯。
- 类型声明同步改:改
exports必改paths,养成肌肉记忆。
7. 双会话与事件溯源如何支撑扩展
理解了双会话和事件溯源,你再看 opencode 的扩展能力就通透了。插件本质上是在事件总线上挂监听器:工具执行前监听tool.invoked做参数校验,执行后监听tool.result做结果加工。因为事件是分层的,插件可以只订阅自己关心的层,不会干扰其他逻辑。
oh my opencode如何安装这类扩展,走的也是同一套机制。它不修改内核,而是通过 SDK 注册事件处理器。这种“内核稳定、扩展外挂”的模式,是 opencode 能持续演进又不失控的关键。你如果打算自己写扩展,记住一条:永远通过事件和 SDK 交互,不要直接改 core 的内部状态。前者是契约,后者是耦合。
至于opencode 与 deepseek hermes 哪个好这种对比,我的看法是:选型看的是你的使用场景和扩展需求,而不是单纯比功能列表。opencode 的优势在于工程结构清晰、事件机制完善,适合想深度定制的人;如果你只是要个开箱即用的对话工具,那可能不需要这么重的架构。工具没有绝对好坏,匹配需求才是关键。
下篇我会接着聊 provider 适配层的细节、流式响应的处理,以及插件开发的具体步骤。这篇先把骨架立住,骨架稳了,后面填肉才不会塌。