文章目录
-
- [1. 开场:为什么我要扒开 opencode 看看里面长啥样](#1. 开场:为什么我要扒开 opencode 看看里面长啥样)
-
- [1.1 几个数字先感受一下](#1.1 几个数字先感受一下)
- [1.2 它跟别家有啥不一样](#1.2 它跟别家有啥不一样)
- [1.3 三个信号说明 Agent 正在"基础设施化"](#1.3 三个信号说明 Agent 正在"基础设施化")
- [2. 工程全景:一个 30 多包的 TypeScript 大仓长啥样](#2. 工程全景:一个 30 多包的 TypeScript 大仓长啥样)
-
- [2.1 仓库结构一览](#2.1 仓库结构一览)
- [2.2 依赖方向:一条写进宪法的规则](#2.2 依赖方向:一条写进宪法的规则)
- [2.3 Effect 是什么?为什么全仓都在用](#2.3 Effect 是什么?为什么全仓都在用)
- [2.4 持久化层:Drizzle + SQLite](#2.4 持久化层:Drizzle + SQLite)
- [2.5 元文档:给 AI 看的仓库宪法](#2.5 元文档:给 AI 看的仓库宪法)
- [2.6 代码规范里的几条狠规矩](#2.6 代码规范里的几条狠规矩)
- [3. 从启动开始:一条命令背后的三级跳](#3. 从启动开始:一条命令背后的三级跳)
-
- [3.1 bun run dev:desktop 到底干了啥](#3.1 bun run dev:desktop 到底干了啥)
- [3.2 渠道(Channel):dev / beta / prod](#3.2 渠道(Channel):dev / beta / prod)
- [3.3 models.dev 快照:模型目录的本地化](#3.3 models.dev 快照:模型目录的本地化)
- [3.4 CLI 二进制下载:桌面壳怎么拿到"大脑"](#3.4 CLI 二进制下载:桌面壳怎么拿到"大脑")
- [3.5 构建失败对照表](#3.5 构建失败对照表)
- [4. 双会话内核:V1 和 V2 为什么同时存在](#4. 双会话内核:V1 和 V2 为什么同时存在)
-
- [4.1 为什么要搞两套](#4.1 为什么要搞两套)
- [4.2 V1:一棵长满功能的树](#4.2 V1:一棵长满功能的树)
- [4.3 V2:事件溯源的内核](#4.3 V2:事件溯源的内核)
- [4.4 双 API:/session 和 /api/session](#4.4 双 API:/session 和 /api/session)
- [4.5 双内核并存的启示](#4.5 双内核并存的启示)
- [5. 消息与事件:从 parts 到 EventV2 事件溯源](#5. 消息与事件:从 parts 到 EventV2 事件溯源)
-
- [5.1 V1 的 parts:一种"扁平部件"模型](#5.1 V1 的 parts:一种"扁平部件"模型)
- [5.2 V2 的 SessionMessage:类型化的 tagged union](#5.2 V2 的 SessionMessage:类型化的 tagged union)
- [5.3 EventV2:事件溯源引擎](#5.3 EventV2:事件溯源引擎)
- [5.4 客户端怎么消费:两条通道的区别](#5.4 客户端怎么消费:两条通道的区别)
- [5.5 序号怎么保证不丢不重](#5.5 序号怎么保证不丢不重)
- [6. System Context:取代 System Prompt 的代数系统](#6. System Context:取代 System Prompt 的代数系统)
-
- [6.1 所有 Agent 都头疼的问题](#6.1 所有 Agent 都头疼的问题)
- [6.2 先统一词汇表](#6.2 先统一词汇表)
- [6.3 三个核心操作](#6.3 三个核心操作)
- [6.4 一个安全边界的完整时序](#6.4 一个安全边界的完整时序)
- [6.5 内置的 Context Source 有哪些](#6.5 内置的 Context Source 有哪些)
- [6.6 指令源:AGENTS.md 怎么进入上下文](#6.6 指令源:AGENTS.md 怎么进入上下文)
- [6.7 跟压缩的关系:记忆和人格要分开](#6.7 跟压缩的关系:记忆和人格要分开)
- [6.8 为什么要做"确定性渲染"](#6.8 为什么要做"确定性渲染")
- [6.9 快照与恢复:上下文的重建能力](#6.9 快照与恢复:上下文的重建能力)
- [7. 收尾:这篇文章到底讲了啥](#7. 收尾:这篇文章到底讲了啥)
P.S. 推荐一个大神的教程给想要了解或者学习人工智能知识的读者,这个教程里内容讲解通俗易懂且风趣幽默,对我帮助很大。我想与大家分享这个宝藏教程,请点击下方链接查看, 传送门https://blog.csdn.net/qq_74013365
1. 开场:为什么我要扒开 opencode 看看里面长啥样
2025 年这一年,AI 编程工具卷得跟早高峰的地铁站似的。
你方唱罢我登场,Claude Code 刚出道,Cursor 就涨了一波价,Codex CLI 紧随其后。大家都在喊"我是你的第二双手",但仔细一看------手是假的,手套是真的。
我翻了一圈开源项目,发现 opencode 这个东西有点意思。
它不是又一个套壳 CLI,而是一个正经用 TypeScript 写的、跑在 Bun 上的、深度拥抱函数式编程的 monorepo。TUI、桌面端、HTTP 服务、多语言 SDK,全给你安排上了。
说白了,别人在做"AI 助手",它在做"AI 操作系统"。
我就好奇了,这玩意内核到底怎么设计的?于是 clone 下来一顿猛翻,翻了快一周,翻出一身颈椎病。
今天就把我看到的东西,跟大家唠唠。
1.1 几个数字先感受一下
GitHub + npm 合计下载量,2025 年 7 月 10 号是 11 万,8 月 24 号就干到 43 万了。
六周翻四倍。
这增速放在开源圈,相当于一个刚毕业的实习生,两个月后工资涨了四倍------不是因为他能力强,是因为大家终于发现"原来开源的 AI Agent 也能用"。
1.2 它跟别家有啥不一样
你对比一下就明白了:
| 维度 | Claude Code | Cursor | opencode |
|---|---|---|---|
| 形态 | 终端 CLI | IDE | 终端 + 桌面 + HTTP 服务 |
| 内核 | 闭源 | 闭源 | 全开源 |
| 模型绑定 | Anthropic 自家 | 多模型 | 随便接,本地模型也行 |
| 事件模型 | 私有 | 私有 | 事件溯源,可重放 |
看出来了吧?它的核心竞争力不是"聊天聊得多好"------那是模型厂商的战场。它卷的是开放性和工程深度。
别人把 Agent 当产品卖,它把 Agent 当基础设施卖。
1.3 三个信号说明 Agent 正在"基础设施化"
我为什么花这么多时间扒它?因为我觉得这代表了一个趋势。
信号一:从聊天框变成操作系统。
第一代 AI 编程工具是"对话框 + 补全",第二代是"能读写文件的 Agent",opencode 这种是第三代------有会话管理、有权限模型、有插件生态、有多端外壳。它不再只是帮你写代码的工具,而是替你执行任务的运行时。
信号二:协议层开始标准化。
MCP 把"模型能调用的外部能力"标准化了。你的程序可以是别人的工具,别人的程序也可以是你的工具。这就像当年 REST API 干的事,只不过这次对象是 AI。
信号三:内核和外壳开始分离。
同一个内核,能跑成终端 TUI,能跑成 Electron 桌面端,能跑成 HTTP 服务,还能嵌进你自己的进程里。"内核与外壳分离"以后会是 Agent 工程的必修课,opencode 的 monorepo 就是个标本。
2. 工程全景:一个 30 多包的 TypeScript 大仓长啥样
2.1 仓库结构一览
根目录是标准的 Bun monorepo,顶层 package.json 声明 workspace,bun.lock 锁依赖,turbo.json 编排任务。
真正的主体在 packages/ 下面,一共 30 多个包。
我第一次 ls packages/ 的时候,愣了三秒。
不是因为多,是因为我突然意识到------我平时写的项目,连一个包都搞不利索,人家直接开了 30 多个。这就跟你自己在家煮个泡面都能把厨房烧了,隔壁人家已经开了个中央厨房似的。
| 层 | 包 | 干啥的 |
|---|---|---|
| Schema 层 | @opencode-ai/schema |
所有领域模型的 Effect Schema,单一事实源 |
| 协议层 | @opencode-ai/protocol |
下一代 HTTP API 的端点定义,构建时生成 SDK |
| 核心层 | @opencode-ai/core |
V2 会话内核:SessionV2、EventV2、System Context |
| 服务端层 | @opencode-ai/server |
HTTP 服务实现,handlers、middleware、路由 |
| 应用层 | opencode |
传统 V1 会话、CLI、TUI、MCP 客户端 |
| 客户端层 | @opencode-ai/client、sdk、sdk-next |
自动生成的双形态客户端,sdk-next 是嵌入式的 |
| 前端层 | @opencode-ai/tui、ui、web |
终端 UI、Web 组件库、控制台 |
| 桌面层 | @opencode-ai/desktop |
Electron 壳 |
2.2 依赖方向:一条写进宪法的规则
仓库根目录有个 AGENTS.md,里面白纸黑字写着依赖方向:
Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server;
sdk-nextcomposes Client, Core, and Server.
翻译成人话就是:
Schema 是地基,Core 和 Protocol 站在 Schema 上面;Server 站在 Core 和 Protocol 上面;Client 只能踩 Schema 和 Protocol,脚绝不能伸到 Core 和 Server 那边去;只有 sdk-next 是唯一被允许把 Client、Core、Server 组装到一起的包。
这条规则狠到什么程度?
狠到你想在客户端包里 import 一下 Core 的东西,类型检查直接给你打回来。
我见过很多团队嘴上说"分层架构",实际代码里 import 飞得到处都是,分层分了个寂寞。opencode 这个是真的把边界焊死了------不是靠人自觉,是靠类型系统和构建规则强制约束。
这就跟小区养狗必须牵绳一样,靠自觉是不行的,得有狗绳(类型检查)拽着。
2.3 Effect 是什么?为什么全仓都在用
如果你之前没接触过 Effect,读 opencode 源码会遇到第一道坎。
传统 Node 项目里,你看到的是 try/catch、回调、class 单例。opencode 里,你看到的是 Effect.gen 生成器、Schema 类型守卫、Layer 组合、Stream 流。
第一次读的时候我脑子里只有一个想法:这是人写的代码吗?
但读多了你会发现,它把"依赖注入、并发控制、错误传播、资源作用域"全统一成了一种带类型的表达。代价是学习曲线陡得像泰山十八盘,收益是整个核心层的正确性可以从类型层面推出来。
我给你五个钥匙,读完你就能看懂 80% 的代码骨架:
第一把:Effect.gen 生成器。 Effect 程序用生成器函数写副作用,yield* effect 就是"执行并等它完"。它把异步、错误、依赖全收进类型里,替代了 async/await + try/catch + 服务定位 三件套。
第二把:Context.Service + Layer。 服务用 Context.Service<Service, Interface>()("@opencode/xxx") 声明,括号里那个全局唯一 ID 不能乱改,改了依赖注入直接错乱。Layer 负责装依赖图,Layer.provide 把依赖喂进去。
第三把:Schema 运行时校验。 Schema.Struct(...) 定义的数据模型既是 TypeScript 类型,又是运行时校验器。类型定义 = 校验规则 = 传输格式,三位一体。
第四把:Stream。 流式序列,SSE 事件、消息流都用它表达。Stream.Stream<DurableEvent, NotFoundError> 这种签名,同时告诉你"产出什么"和"可能怎么失败"。
第五把:Scope。 资源作用域。Effect.addFinalizer 在作用域结束时自动清理------MCP 连接、临时文件、注册的工具,作用域一结束就自动注销。
读完这五条,你已经比很多只会喊"函数式牛逼"的人强了。
2.4 持久化层:Drizzle + SQLite
持久化选型是 Drizzle ORM 加 SQLite,表定义集中在 packages/core/src/session/sql.ts 和 event/sql.ts。字段一律 snake_case:
ts
// 会话主表(简化示意,字段以源码为准)
const table = sqliteTable("session", {
id: text().primaryKey(),
project_id: text().notNull(),
worktree: text().notNull(),
vcs: text(),
sandboxes: text().notNull().$type<...>(),
})
注意那个 $type<T>()。它的意思是:数据库层保持 SQLite 原生类型,TypeScript 层声明领域类型。类型信息不丢,从 ORM 一路贯穿到业务代码。
这套设计跟 Effect Schema 的哲学是一致的------类型就是契约,契约不能在半路丢了。
2.5 元文档:给 AI 看的仓库宪法
根目录有个 AGENTS.md 和 CONTEXT.md。这两份东西特别有时代感。
它们不是给人读的 README,是给 AI 协作者读的工程宪法。
AGENTS.md 规定代码风格和架构约束,CONTEXT.md 用一百多条"关系条款"定义 V2 会话运行时的语义------每条都是一句可执行的规则。
这就引出一个很有意思的正反馈:用 AI 开发的仓库,文档精度会被倒逼提高。
因为 AI 不像人,人能靠上下文猜你"大概想干啥",AI 不行。你写一句"这里应该注意一下",它根本不知道"一下"是几下。任何模糊的描述,到了 AI 那里都是 bug。
所以 opencode 的文档风格特别值得借鉴:每条规则独立成句、明确 Avoid 什么、关系用条款枚举。你团队如果也在用 AI 写代码,照这个风格写文档,AI 的产出质量能上一个台阶。
2.6 代码规范里的几条狠规矩
AGENTS.md 里有一段工程规范,我挑几条有意思的说说:
避免过度抽取。 "Do not extract single-use helpers preemptively"------一次性逻辑就地内联,别没事就抽函数。
这条我太有共鸣了。很多人写代码有个毛病,写三行就想抽个函数,抽完自己都忘了那个函数是干啥的。opencode 直接把这条写进宪法了。
避免 else。 优先早返回(early return)。
什么意思?就是遇到错误先 return,别写 if (ok) { ... } else { ... } 这种嵌套地狱。代码平了,人生也顺了。
避免 any。 类型安全是硬约束。
优先 Bun API。 比如 Bun.file(),别再用 Node 的 fs 那套老古董。
函数式数组方法优先。 flatMap、filter、map 优先于 for 循环。
测试不用 mock。 "Avoid mocks as much as possible"------测真实实现,别把逻辑复制进测试里。
这条规矩我见过太多团队反着来。测试里 mock 了一堆东西,测了个寂寞------代码改了测试还能过,因为 mock 的是假的。opencode 说:不行,就得测真的。
这些规矩合在一起,塑造了 opencode 代码的两个气质:短 和硬。
3. 从启动开始:一条命令背后的三级跳
3.1 bun run dev:desktop 到底干了啥
在仓库根目录跑一句 bun run dev:desktop,你以为它就起个桌面端?
太天真了。
它实际上是这么展开的:
json
// 根 package.json
{ "dev:desktop": "bun --cwd packages/desktop dev" }
// packages/desktop/package.json
{
"dev": "electron-vite dev",
"predev": "bun ./scripts/predev.ts"
}
Bun 执行 dev 之前会自动先跑 predev,于是调用链变成了:
bun run dev:desktop
└─ bun --cwd packages/desktop dev
└─ predev: bun ./scripts/predev.ts(前置钩子)
├─ bun run install-electron
├─ bun ./scripts/copy-icons.ts <channel>
└─ cd ../opencode && bun script/build-node.ts
└─ dev: electron-vite dev(真正的开发服务器)
predev.ts 干三件事:装 Electron 二进制、按渠道拷图标、构建 packages/opencode 的 Node 侧产物。
为什么桌面主进程要构建 opencode 包?因为它要 import 那个服务端模块。桌面端不是把 TUI 塞进 Electron 壳里那么简单,它是内嵌了一个完整的服务端。
3.2 渠道(Channel):dev / beta / prod
opencode 的发布体系围绕"渠道"展开。渠道解析逻辑在 packages/script/src/index.ts:
ts
const CHANNEL = await (async () => {
if (env.OPENCODE_CHANNEL) return env.OPENCODE_CHANNEL
if (env.OPENCODE_BUMP) return "latest"
if (env.OPENCODE_VERSION && !env.OPENCODE_VERSION.startsWith("0.0.0-")) return "latest"
return await $`git branch --show-current`.text().then((x) => x.trim())
})()
优先级是:环境变量 OPENCODE_CHANNEL > OPENCODE_BUMP > 非 dev 版本号 > 当前 git 分支名。
也就是说,默认情况下你在哪个分支开发,产物就按哪个渠道发布。dev 分支出 dev 渠道,main 分支出正式版。
这个机制听着合理,但我第一次在 Windows 上跑的时候,直接踩了个坑。
坑一:不是 git 仓库。
我的工作副本是从别处复制来的,不带 .git 目录。git branch --show-current 直接退出码 128,fatal: not a git repository,整个 predev 挂了。
那一刻我盯着屏幕看了五秒,心想:我只是想跑个项目,你为什么要检查我有没有 git?
解决办法两个:要么显式设 OPENCODE_CHANNEL=dev,要么把目录 git init 一下。
这也提醒我们:这套构建系统把"git 存在"当成了默认前提。脱离 git 的源码副本,得靠环境变量补偿。
3.3 models.dev 快照:模型目录的本地化
predev 之后,桌面开发服务器还会去拉一份模型目录快照。opencode 的模型元数据------哪个 provider 有哪些模型、上下文窗口多大、价格多少------来自 models.dev 的公开 api.json。
ts
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
return yield* HttpClientRequest.get(`${source}/api.json`).pipe(...)
})
它会把快照写进本地缓存,受两个开关控制:
OPENCODE_DISABLE_MODELS_FETCH:完全禁止联网拉取。
MODELS_DEV_API_JSON:把源从远程 URL 换成本地文件。
坑二:无法访问 models.dev。
在没有外网的环境下,https://models.dev/api.json 直接 ConnectionRefused,predev 就挂了。
我当时的心情怎么说呢------就像你点外卖,骑手到门口了告诉你"你家小区进不来,你自己下来拿",然后你下楼发现小区门确实锁了。
修复方式是把官方测试夹具指向本地:
powershell
$env:MODELS_DEV_API_JSON="E:\E\opencode\packages\opencode\test\tool\fixtures\models-api.json"
设置完日志出现 Loaded models.dev snapshot,构建继续。
这暴露了构建链的一个设计权衡:模型目录是启动期强依赖。虽然支持禁用,但桌面开发流程默认会尝试联网拉,内网或离线环境必须显式干预。
3.4 CLI 二进制下载:桌面壳怎么拿到"大脑"
桌面端不是把 TUI 包进 Electron,而是下载一个独立的 CLI 服务端二进制 ,内嵌到 resources/ 里。下载逻辑在 packages/desktop/scripts/utils.ts:
ts
export const CLI_BINARIES = [
{ rustTarget: "aarch64-apple-darwin", package: "@opencode-ai/cli-darwin-arm64", os: "darwin", cpu: "arm64" },
{ rustTarget: "x86_64-apple-darwin", package: "@opencode-ai/cli-darwin-x64-baseline", os: "darwin", cpu: "x64" },
{ rustTarget: "aarch64-pc-windows-msvc", package: "@opencode-ai/cli-windows-arm64", os: "win32", cpu: "arm64" },
{ rustTarget: "x86_64-pc-windows-msvc", package: "@opencode-ai/cli-windows-x64-baseline", os: "win32", cpu: "x64" },
{ rustTarget: "x86_64-unknown-linux-gnu", package: "@opencode-ai/cli-linux-x64-baseline", os: "linux", cpu: "x64" },
{ rustTarget: "aarch64-unknown-linux-gnu", package: "@opencode-ai/cli-linux-arm64", os: "linux", cpu: "arm64" },
]
它用 bun install --no-save --cwd <临时目录> @opencode-ai/cli-<平台>@<版本> 从 npm 拉包,然后从包里的 bin/opencode2.exe 复制到 resources/opencode-cli.exe。
桌面主进程启动时,再把这个二进制以 serve 模式拉起来。
所以桌面端的本质是:一个负责安装、升级、拉起 CLI 的壳 + 一个纯客户端 UI。它自己几乎不维护 AI 逻辑,内核升级就是换个二进制文件。
这种设计的好处是安全隔离------桌面壳崩了不影响内核,内核升级不用重新发整个桌面包。坏处呢?就是版本号稍微对不上,启动就挂。
坑三:dev 渠道的版本错配。
dev 构建时 CLI_VERSION = "0.0.0",但某些历史 dev 二进制会去拉 0.0.0-next-16350 这种预发布版本,结果:
error: No version matching "0.0.0-next-16350" found for specifier
"@opencode-ai/cli-windows-x64-baseline" (but package exists)
然后就算装上了 0.0.0,又会因为包内实际文件名是 opencode2.exe 而不是预期的名字,报 ENOENT。
这类问题属于"dev 渠道版本漂移",是上游发布节奏和本地缓存错位造成的,跟你写的代码半毛钱关系没有。重装包、对齐版本号就好了。
我花在这几个坑上的时间,比读核心源码的时间还长。这就是为什么我要把它们写出来------后来者踩坑的时候,至少知道自己不是一个人在踩。
3.5 构建失败对照表
把我踩过的坑做成一张表,供后来者少走弯路:
| 症状 | 根因 | 对策 |
|---|---|---|
fatal: not a git repository |
目录不在 git 仓库内 | 设 OPENCODE_CHANNEL=dev,或 git init |
ConnectionRefused on models.dev |
网络受限 | 设 MODELS_DEV_API_JSON 指向本地快照 |
No version matching "0.0.0-next-..." |
仓库 next 版本与 npm 不同步 | 设 OPENCODE_CHANNEL=dev |
ENOENT copyfile |
bin 文件名与预期不符 / 临时目录残留 | 清临时目录后重试 |
pnpm-lock.yaml 兼容警告 |
bun 无法迁移 lockfileVersion 6 | 可忽略 |
4. 双会话内核:V1 和 V2 为什么同时存在
4.1 为什么要搞两套
"会话"是 Agent 的核心抽象:保存历史、管理上下文、协调工具执行、跟模型交互。
opencode 仓库里同时存在两套并行的会话实现。
第一次看到的时候我也懵了:你一个开源项目,怎么还搞两套?
仔细读完才明白,这不是混乱,是刻意为之的迁移策略。
V1 在 packages/opencode/src/session/,围绕 Session、SessionPrompt、SessionTools 这一组服务构建。特点是"功能完整、长得快",工具、MCP、权限、快照、汇总全长在这棵树上。
V2 在 packages/core/src/session/,围绕 SessionV2、SessionRunner、EventV2、System Context 构建。目标是"可持久化、可重放、可分布式",把会话历史从"状态"变成"事件流"。
从代码位置就能看出来方向:V2 放在核心层 @opencode-ai/core,V1 放在应用层。位置本身就说明了------V2 才是未来要沉淀为核心能力的实现。
这就跟公司里一样:老员工还在一线干活(V1),新架构已经在总部开始设计了(V2)。两边并行,新架构没成熟之前,老员工不能走。
4.2 V1:一棵长满功能的树
V1 的请求路径大概是这样的:
用户输入 → SessionPrompt.Service(prompt 受理、消息组装)
→ LLM 调用(llm.stream)
→ 工具执行(SessionTools.resolve 把工具集物化,含 MCP 工具合并)
→ 结果回写(消息、快照、汇总、状态)
用一次真实请求把时序铺开:
POST /session/{id}/prompt_async {parts:[...]} → 204(受理,立即返回)
GET /session/{id}/message → 轮询结果
第 1 条:user 消息(parts = 用户文本)
第 2 条:assistant 消息(parts 依次为
step-start → reasoning → tool(调用,state.status=completed)→ step-finish)
第 3 条:assistant 消息(step-start → text → step-finish reason=stop)
V1 的几个关键特征:
工具解析在会话层。 SessionTools.resolve 会把内置工具(bash、edit、read、write......)、注册工具和 MCP 工具合并成一份清单交给模型。这就是 V1 能看到 MCP 工具的根本原因。
消息模型是 parts。 V1 的消息用 parts 数组表达------TextPart、ToolPart、ReasoningPart、StepStartPart、StepFinishPart......自由是自由,但语义比较弱,一条 assistant 消息到底是什么,得靠部件序列去推断。
V1 的配套服务也很全:摘要、压缩、回滚、状态、任务清单、分享,一应俱全。
但 V1 的问题也很明显:会话历史就是一个追加的数组,缺少可重放的事件流抽象。崩溃恢复、多端同步、分布式执行,这些它都扛不住。
4.3 V2:事件溯源的内核
V2 的核心方法长这样:
ts
interface Interface {
readonly list / create / get / messages / message / context
readonly events: (input: { sessionID; after? }) => Stream<DurableEvent>
readonly history: (input: { sessionID; after?; limit }) => Effect<{ events; hasMore }>
readonly prompt: (input: {
id?; sessionID; prompt: PromptInput.Prompt
delivery?; resume?: boolean
}) => Effect<SessionInput.Admitted>
readonly active / resume / interrupt
readonly revert: { stage; clear; commit }
}
几个设计决策特别值得说说。
第一,prompt 是"受理"而不是"发送"。
SessionV2.prompt(...) 返回 SessionInput.Admitted------它先把输入持久化受理进会话的 inbox,然后用 resume 参数决定要不要立刻执行。
resume: true:受理后调度一次建议性唤醒。
resume: false:只受理不执行,等后面显式恢复。
这个"受理与执行分离"的模型,让每个输入都有持久身份。崩溃了可以从 inbox 重建,不靠内存里的 promise 链。
你想想平时写的代码:用户发个请求,你 await 一个 promise,服务挂了,promise 没了,用户的请求就石沉大海了。V2 这个设计相当于------用户的消息先存进数据库,然后再开始处理。处理到一半挂了,重启之后从数据库接着来。
这就是事件溯源的思路。
第二,执行有明确的运行协调器。
SessionExecution 是进程全局的、以 Session ID 为单位的调度器;SessionRunner 负责把受理的输入变成真实的模型调用;SessionRunCoordinator 合并同一会话的并发唤醒、为不同会话提供并行。
第三,事件是事件溯源。
会话的每一次变更------输入受理、消息产生、工具结算、模型切换------都会以持久化事件写入 EventTable,带 aggregateID 和单调 seq。events() 和 history() 从事件流重放,不是直接读"最新状态"。
4.4 双 API:/session 和 /api/session
跟双内核对应,HTTP 面上也有两条会话 API:
| 维度 | V1 /session |
V2 /api/session |
|---|---|---|
| 路由归属 | InstanceHttpApi | HttpApi.make("server") |
| 消息结构 | parts 数组 | content/type 结构化消息 |
| MCP 工具 | ✅ 可见 | ⚠️ 还没实现 |
我实测的时候印象特别深:同一个配置好的 MCP server,走 V1 会话发消息,工具被真实调用了;走 V2 会话发消息,模型根本看不到任何 MCP 工具。
源码里 SessionRunner 的待实现清单明明白白写着 [ ] MCP/插件/结构化输出工具定义。
这不是 bug,是迁移还没完成。
对集成者来说结论很务实:当前要用 MCP 工具,必须走 V1 会话 API。
V2 对"还没实现的功能"也不是静默忽略,而是显式报错 OperationUnavailableError。move、shell、skill、switchAgent、compact、wait------每一个都是"V1 有、V2 待迁移"的典型。
看到这个错误别慌,切回 V1 端点就行,或者去仓库跟进迁移进度。
4.5 双内核并存的启示
opencode 的 V1/V2 并存不是混乱,是一种工程策略:
新内核带着完整的设计文档先落地,旧内核持续提供能力;公共协议面先行定义,SDK 从协议生成,跟内核实现解耦;能力按优先级迁移,MCP 这种重功能排在后面,避免一步到位的爆炸式重构。
这对任何正在重构核心系统的团队都有参考价值:先定义协议与数据模型,再迁移执行引擎,最后补齐能力。
别上来就说"我们要重写整个系统"。重写系统这种事,十个项目九个死,剩下一个半死不活。
5. 消息与事件:从 parts 到 EventV2 事件溯源
5.1 V1 的 parts:一种"扁平部件"模型
V1 消息把内容拆成扁平部件数组(parts),每个 part 用 type 标记身份。类型有 TextPart、ToolPart、ReasoningPart、StepStartPart、StepFinishPart、AgentPart、SnapshotPart、PatchPart......
好处是自由,随便塞什么部件都行,前端渲染也灵活。
代价是语义弱。一条 assistant 消息到底是一个回答,还是一次工具调用序列,还是一个子任务?得靠你自己从部件序列里推断。跨端复现的时候特别容易失真。
还有一个坑:输入(Input)和输出(Output)用不同的 schema。
发消息的时候用 TextPartInput,只需要 type + text 两个字段;读消息的时候用 TextPart,带 id、sessionID、messageID、time 一堆完整字段。
我最初手动调 API 的时候就用错了------拿 Output 形态去发消息,直接被校验拒绝。
这就是 Schema 驱动开发的典型摩擦点:类型在 schema 里是双份的(Input/Output),调用方必须知道自己在用哪一份。
5.2 V2 的 SessionMessage:类型化的 tagged union
V2 重新设计了消息模型,核心是一个用 Schema.toTaggedUnion("type") 构造的联合类型:
| 消息类型 | 用途 |
|---|---|
user |
用户输入 |
synthetic |
系统合成消息 |
system |
系统通知 |
assistant |
模型回复 |
compaction |
上下文压缩 |
agent-switched |
切换 agent |
model-switched |
切换模型 |
assistant 消息的 content 是三种内容块的联合:
ts
const AssistantContent = Schema.Union([
AssistantText, // { type: "text", text }
AssistantReasoning, // { type: "reasoning", text, providerMetadata }
AssistantTool, // { type: "tool", name, state, provider, time }
])
AssistantTool 内部的状态机是这个模型最精彩的部分:
ts
const ToolState = Schema.Union([
ToolStatePending, // { status: "pending", input }
ToolStateRunning, // { status: "running", input, structured, content }
ToolStateCompleted, // { status: "completed", input, content, outputPaths, result }
ToolStateError, // { status: "error", input, content, error, result }
])
一次工具调用从 pending 进入 running,最终落到 completed 或 error,每个状态都带结构化的输入输出。
这个设计的意义在于:模型可见的"工具结果"和系统真实的"工具结果"被分开表达 。content 是给模型回放的投影,structured 是给系统消费的结构化值,两者并存。
5.3 EventV2:事件溯源引擎
事件溯源的核心思想是:存储"发生了什么",而不是"现在是什么"。
就像记账------你不是每天只看存折上的余额,你记的是每一笔收入和支出。余额可以随时从流水账算出来。
packages/core/src/event.ts 实现了这套引擎,核心类型:
ts
export type SerializedEvent = {
id: ID
type: string
seq: number // 聚合内单调序号
aggregateID: string // 聚合根 ID(如会话 ID)
data: Record<string, unknown>
}
**持久化事件清单(Durable Event Manifest)**是这套系统的宪法:@opencode-ai/schema/durable-event-manifest 定义了哪些事件类型是持久的、各自的版本号和数据 Schema。
解码的时候:
ts
const definition = Durable.get(event.type)
if (!definition?.durable) throw new InvalidDurableEventError(...)
只有清单里登记过的事件才能被重放。这保证了"旧版本读不懂的新事件不会悄悄坏掉"。
这套设计的直接收益:
**进程重启可重建。**会话的历史就是事件流,重放就行。
**任意点续传。**客户端记住 after 序号,断线后从那个序号继续,天然支持 SSE 续传。
**审计与迁移。**事件是唯一的事实源,投影可以随便重建而不污染事实。
为分布式铺路。 aggregateID 就是未来的分片键。
代价也很明显:读路径变重了,要重放或者维护投影。opencode 用 SessionProjector 把事件流投影成消息视图,就是为了缓解读路径的成本。
5.4 客户端怎么消费:两条通道的区别
V2 为消费者提供了两条通道,语义被严格区分:
第一条:sessions.events({ sessionID, after })------持久化会话事件流,可重放可续传。
先验证 Session 存在,再按 after 序号重放已提交的持久事件,之后持续推送新提交事件。断线后客户端记住最后一条的序号,用 after 重新订阅就能续传------不需要重放整个会话。
第二条:events.subscribe()------实例级实时流,不可重放。
面向整个实例的实时活动,无重放保证,断线后不能补课,消费者必须"刷新权威状态再重新订阅"。
对集成者的启示很直接:
要"可靠的会话历史同步",用 sessions.events + after 续传;要"实时 UI 驱动",用 events.subscribe,把断线重连设计成"先拉状态、再开流"。
这两条通道的 schema、重放保证、游标、失败行为都不一样。别搞混了------搞混了轻则丢消息,重则整个 UI 状态错乱。
5.5 序号怎么保证不丢不重
EventV2 的持久化模型里,event_sequence 表单独维护每个聚合的最新序号------latestSequence 查询走这张表,O(1)。
写入的时候取当前 seq + 1,事务内完成。SQLite 单写者保证了不并发冲突。
续传协议因此非常简洁:客户端记住 latestSequence,断线后 after=latestSequence 重订,服务端只重放序号更大的事件。
这跟 Kafka 的 offset 思想同构,但落在 SQLite 上。对小而可靠的系统,事件溯源不需要一个独立的消息中间件。
6. System Context:取代 System Prompt 的代数系统
6.1 所有 Agent 都头疼的问题
几乎所有 Agent 系统都会遇到同一个难题:怎么把"系统提示词"组织得既不臃肿、又可更新、还能被审计?
传统做法是每次请求前拼一大堆系统提示文本,然后祈祷模型记得住。
我见过最夸张的一个项目,系统提示词拼了 8000 多 token,其中一半是"你要友好、你要专业、你要注意安全"这种正确的废话。改一行提示词要翻三个文件,改完不敢测,怕改坏了别的东西。
opencode 在 V2 里给了一个相当激进的答案:把 System Prompt 拆成一套可组合、可版本化、可重放的代数系统------System Context。
6.2 先统一词汇表
CONTEXT.md 是这份设计的词汇表,几个核心概念先建立一下:
| 术语 | 含义 |
|---|---|
| System Context | 呈现给模型的"结构化上下文事实集合",初始指令 + 按时间追加的更新 |
| Session History | 经过投影的"对话历史",含压缩与 Context Epoch 截断 |
| Context Source | 一个独立观测的、带类型的上下文值:稳定 key + JSON codec + 无失败加载器 + 纯渲染器 |
| Context Epoch | 一个"不可变基线"的存续期,从首次渲染 Baseline 开始,到压缩/迁移/不兼容变更结束 |
| Context Snapshot | 模型不可见的 JSON 状态,用于比较每个 Source 与上次提交给模型的值 |
| Safe Provider-Turn Boundary | 每次模型调用前、输入受理与工具结算之后的那个安全边界,上下文变更在此被按序接纳 |
这套词汇表的潜台词是:"系统提示词"不是一个字符串,而是一个随时间演化的、可观测的值域。
6.3 三个核心操作
packages/core/src/system-context/index.ts 把 System Context 抽象成三个操作:
ts
SystemContext.initialize(...) // 观测一次组合后的 System Context,产出新的 Baseline + Snapshot
SystemContext.reconcile(...) // 观测一次组合后的 System Context,返回唯一动作:不变/已更新/可替换/替换被阻塞
SystemContext.replace(...) // 在压缩完成等"基线替换"场景渲染新的一代
关键设计约束有几条,特别有意思:
懒采样,绝不推。 Context 变更只在安全边界被采样和接纳,Source 变化时不会异步推送。空闲会话永远不会被上下文变更唤醒。
这条什么意思呢?就是你改了 AGENTS.md 文件,正在跑的会话不会立刻拿到新指令。它会等这一轮模型调用结束,到下一个安全边界的时候,才把新的指令合并进去。
为什么要这样?因为你不能在模型说到一半的时候,把它的"世界观"换了。那它就精神分裂了。
原子推进。 Context Snapshot 和对应的持久化 Mid-Conversation System Message 原子地一起前进。多个 Source 的变更在同一个安全边界合并成一条系统消息。
顺序固定。 新受理的用户输入、结算完的工具结果,先于合并后的系统消息进入模型请求。
确定性。 Registry 并发评估生产者,但按稳定的贡献 key 顺序组合,保证渲染结果确定。
这些约束合在一起,解决了经典的"系统提示飘移"问题:你永远知道模型在某一轮看到的系统上下文是哪个版本,因为它在进入请求前被快照冻结了。
6.4 一个安全边界的完整时序
把上面的约束拼成一条时间线,一个 provider turn 的"上下文接纳"是这样的:
[上一轮结束]
1. 新输入到达 → SessionV2.prompt 受理(持久化 inbox,不动模型历史)
2. 执行被唤醒 → SessionRunner 开始排干(drain)
3. 【安全边界】此时依次接纳:
a. 新提升的用户消息(promotion,进入 Session History)
b. 已结算的工具结果(settle 完成,写入历史)
c. 合并后的 Mid-Conversation System Message(如果有来源变化)
4. 基线就绪 → 首次轮渲染完整 Baseline System Context + 初始化 Context Snapshot
5. provider turn 开始 → 模型看到的是【冻结的】系统上下文 + 按序的历史
这条时序回答了实践中总有人问的问题:"模型上一轮看到的上下文和我现在改的配置对不上怎么办?"
答案是:本来就不该对得上。
上下文只在安全边界推进,且推进时原子地带上快照。改动在下一个安全边界生效,绝不中途漂移。
就像你看电影的时候,中途有人改了剧本。正确的做法不是把你从电影院拽出来告诉你"剧情变了",而是等这一场散场,下一场再换新剧本。
6.5 内置的 Context Source 有哪些
packages/core/src/system-context/builtins.ts 里注册了一批内置来源:
| stable key | 内容 | 何时变化 |
|---|---|---|
date |
当前日期/时间 | 每轮安全边界刷新 |
cwd |
工作目录 | 会话初始化/切换目录 |
instructions |
AGENTS.md 指令聚合 | 文件变化 |
agent |
当前 agent 的角色提示词 | 切换 agent |
skill-guidance |
技能目录引导 | 技能加载 |
todo |
任务清单状态 | 清单变化 |
project |
项目元信息 | 项目变化 |
这几个来源就是"模型知道自己是谁、在哪、几点了、有什么工具"的全部来源。它们被组合成一条稳定的基线文本。
这就是为什么换 agent、改 AGENTS.md 不需要重启会话------来源是动态观测的,只在安全边界做增量替换。
6.6 指令源:AGENTS.md 怎么进入上下文
opencode 的"项目指令"来源是 AGENTS.md 系列文件。指令服务在安全边界做一次聚合观测:
观测范围是全局 AGENTS.md + 项目目录向上逐级查找的 AGENTS.md。
聚合为一个有序集合,作为一个 Context Source(stable key:instructions)。
指令集变化时,产生的 Mid-Conversation System Message 包含完整的新指令集,并显式"取代"先前值。指令清空的时候,消息明确告诉模型"先前指令不再适用"。
这里有个很有意思的闭环:opencode 自己就在用 AGENTS.md 管理自己的开发。根目录那两份文档,对仓库内的一切 AI 协作者------包括它自己的 Agent------都生效。
这就是"用 Agent 开发 Agent"的意思。它自己写的规矩,它自己也得遵守。
6.7 跟压缩的关系:记忆和人格要分开
上下文压缩(compaction)和 System Context 的关系容易被忽略。两个原则:
**压缩发生在安全边界。**压缩会截断 Session History,此时基线系统上下文需要重新渲染------SystemContext.initialize 重新建基线,replace 交换新基线。
**Epoch 重启。**压缩创建一个新 Context Epoch,旧 Epoch 的快照作废。模型看到的系统提示词文本不变,但"历史"变短了。
换句话说:压缩只影响"记忆",不影响"人格"。
系统上下文是人格,历史是记忆。
调试长会话问题的时候这个区分特别有用:回答风格漂移了,去看 System Context;忘记早期事实了,去看 History 截断。
就像一个人------你不能因为他失忆了,就说他性格变了。性格是人格,失忆是记忆。两码事。
6.8 为什么要做"确定性渲染"
"确定性基线"有一个被低估的收益:provider-side prompt caching 命中率。
Anthropic 和 OpenAI 都提供按前缀计费的缓存,前缀越长、越稳定,越省钱越快。
System Context 的稳定渲染(同样状态 → 同样文本)保证:
基线的开头几万 token 长期不变,缓存几乎 100% 命中;每次安全边界只替换 delta(比如日期),主体不动,前缀缓存不被击穿;压缩或换 agent 才产生一次大更新,属于可接受的缓存失效点。
这不是玄学,是可量化成本。长会话里,缓存命中与否可能差 3 到 10 倍的 token 账单。
opencode 把"上下文渲染确定性"作为一等约束,与其说是洁癖,不如说是对模型 API 经济学的尊重。
毕竟,谁的钱也不是大风刮来的。
6.9 快照与恢复:上下文的重建能力
Context Snapshot 不只是为了缓存。它是崩溃恢复的关键。
事件溯源保证"发生过什么"不丢,快照保证"模型当时看到了什么"可重建。两者结合:
正常流程:安全边界推进 → 新快照冻结 → 模型以新基线继续。
崩溃恢复:从持久事件重放到最后安全边界 → 恢复当时快照 → 继续执行,不重跑已完成的 turn。
审计场景:任何人可以在任意时间点取出"那一刻模型的完整视野",复现模型决策的输入。
这比"记录日志"强在哪?日志记录的是输出,快照记录的是输入。
对需要"解释模型为什么这么干"的产品------合规、评测、调试------输入级重建是唯一可靠的答案来源。
7. 收尾:这篇文章到底讲了啥
拉个清单,把今天聊的东西串一遍:
工程上,opencode 是一个依赖方向被严格约束的 TypeScript/Bun monorepo,核心由 Effect 驱动,持久化由 Drizzle + SQLite 承担,前端从终端到桌面到 Web 一应俱全。
构建上,渠道即身份,dev/beta/prod 决定图标、版本、更新源;桌面壳通过"下载独立 CLI 二进制 + serve 进程"获得能力,跟内核松耦合。
架构上,V1/V2 双内核并行------新内核带着完整设计文档先落地,旧内核持续提供能力,协议面先行定义,能力按优先级迁移。
数据上,V2 用类型化的消息联合 + 事件溯源,持久化清单 + 单调序号 + 聚合 ID 这个三元组,让"会话可重放"成为可能。
上下文上,System Context 把"系统提示词"从一个自由文本升级为受控领域模型------可观测、可版本化、按序合并、原子推进、容错但不静默。
这些设计里,最值得偷师的两块是事件溯源 和System Context。无论你用什么语言实现自己的 Agent 底座,这两块的思想都可以直接搬走。
剩下的------工具系统、MCP、Provider 抽象、HTTP 服务面、TUI 与桌面壳、配置与权限------咱们下篇再聊。
源码这东西,读一遍是看故事,读两遍是看结构,读三遍才能看到设计取舍背后的为什么。
我这才读了一遍半,先把半篇笔记放出来,欢迎大家一起交流。
P.S. 推荐一个大神的教程给想要了解或者学习人工智能知识的读者,这个教程里内容讲解通俗易懂且风趣幽默,对我帮助很大。我想与大家分享这个宝藏教程,请点击下方链接查看,传送门https://blog.csdn.net/qq_74013365