深入 opencode(上篇):工程全景、双会话内核与事件溯源
版本:基于 opencode dev 分支源码(2026 年)与 Windows 实测环境 建议阅读时长:45--60 分钟
关键词:AI Coding Agent、Effect、事件溯源、System Context、Bun、Monorepo、TypeScript
摘要 :本上篇聚焦 opencode 的"骨架与内核"。文章以本机源码仓库为唯一事实来源,从 bun run dev:desktop 的启动链出发,依次拆解:30+ 包的 monorepo 工程全景与依赖规则、构建链与发布渠道、V1/V2 双会话内核的设计之争、从 parts 到 EventV2 的消息与事件溯源、取代 System Prompt 的 System Context 代数系统。所有源码事实均来自对仓库的直接阅读与 Windows 实测复现,可作为二次开发与架构借鉴的直接参考。
目录
- [引言:AI 编程助手的浪潮与 opencode 的坐标](#引言:AI 编程助手的浪潮与 opencode 的坐标 "#1-%E5%BC%95%E8%A8%80ai-%E7%BC%96%E7%A8%8B%E5%8A%A9%E6%89%8B%E7%9A%84%E6%B5%AA%E6%BD%AE%E4%B8%8E-opencode-%E7%9A%84%E5%9D%90%E6%A0%87")
- [工程全景:一个 30+ 包的 TypeScript 大仓](#工程全景:一个 30+ 包的 TypeScript 大仓 "#2-%E5%B7%A5%E7%A8%8B%E5%85%A8%E6%99%AF%E4%B8%80%E4%B8%AA-30-%E5%8C%85%E7%9A%84-typescript-%E5%A4%A7%E4%BB%93")
- 从启动开始:开发脚本、构建链与发布渠道
- [双会话内核:V1 与 V2 的设计之争](#双会话内核:V1 与 V2 的设计之争 "#4-%E5%8F%8C%E4%BC%9A%E8%AF%9D%E5%86%85%E6%A0%B8v1-%E4%B8%8E-v2-%E7%9A%84%E8%AE%BE%E8%AE%A1%E4%B9%8B%E4%BA%89")
- [消息与事件:从 parts 到 EventV2 事件溯源](#消息与事件:从 parts 到 EventV2 事件溯源 "#5-%E6%B6%88%E6%81%AF%E4%B8%8E%E4%BA%8B%E4%BB%B6%E4%BB%8E-parts-%E5%88%B0-eventv2-%E4%BA%8B%E4%BB%B6%E6%BA%AF%E6%BA%90")
- [System Context:取代 System Prompt 的代数系统](#System Context:取代 System Prompt 的代数系统 "#6-system-context%E5%8F%96%E4%BB%A3-system-prompt-%E7%9A%84%E4%BB%A3%E6%95%B0%E7%B3%BB%E7%BB%9F")
1. 引言:AI 编程助手的浪潮与 opencode 的坐标
2025 年无疑是"AI 编程助手"从概念走向产品的一年。以 Claude Code、Codex CLI、Cursor 为代表的一批工具,把"在终端里对话式地写代码"变成了数千万开发者的日常。这些工具的共同特征是:一个能读取文件、执行命令、编辑代码、调用外部服务的长上下文 Agent,被封装成开发者的第二双手。它们的背后是同一套技术命题------如何可靠地把一个不完美的模型,变成一台在真实代码库上"可问责地干活"的机器。
在这条赛道里,opencode 是一个相当特殊的样本。它诞生于 SST(Serverless Stack)团队,定位是"开源的 AI 编程 Agent"(The open source AI coding agent)。但比"又一个 CLI Agent"更有价值的,是它的工程形态:这是一个完全用 TypeScript 编写的、基于 Bun 运行时的、深度拥抱函数式编程(Effect)的 monorepo,同时交付 TUI、桌面应用(Electron)、HTTP 服务与多语言 SDK。它试图用一套严谨的类型系统与运行时抽象,回答"Agent 内核应该长什么样"这个开放问题。
这篇文章不是一份使用教程,而是一次源码级解剖。我们会沿着仓库的骨架,依次拆解:
- 一个 30+ 包的 monorepo 如何组织依赖边界;
- V1/V2 两套会话内核为什么并存,它们各自解决了什么问题;
- 事件溯源(Event Sourcing)如何成为会话历史的底层存储模型;
- System Context 如何用代数化的方式取代脆弱的 System Prompt;
- 工具系统与 MCP(Model Context Protocol)如何构建 Agent 的能力边界;
- HTTP 服务面如何同时承载传统 API 与下一代协议;
- TUI 与桌面端如何共享同一套内核;
- 最后,用一份完整的实战案例,展示如何把 opencode 嵌入你自己的系统。
为了保证文章的准确性,所有引用均标注了仓库内源码路径(如 packages/core/src/session.ts),并附带了我在 Windows 环境下的实测结论。文中涉及的版本与行为,均对应 2026 年 dev 分支的状态;如果你阅读时仓库已经演进,请以最新源码为准。
1.1 为什么是 opencode
要理解 opencode 的价值,先看几组事实:
- 安装形态多样 。它同时通过
curl脚本、npm 包(opencode-ai)、Homebrew、Scoop、Chocolatey、Arch 仓库、Nix 分发,并发布独立的桌面应用(macOS dmg、Windows exe、Linux deb/rpm/AppImage)。 - 双内核迁移 。仓库里并存着传统的 V1 会话(
SessionPrompt+SessionTools)与全新的 V2 会话内核(SessionV2+SessionRunner+EventV2),两条 HTTP API(/session与/api/session)同时在线。这种"新内核未完成前旧内核不断供"的迁移策略,本身就是大型软件重构的教科书案例。 - 函数式先行 。整个核心层用 Effect 编写:可组合的
Effect类型、Schema驱动的运行时校验、Context.Service的依赖注入、Layer的模块组合、Stream的流式处理。这让 opencode 的并发、错误、取消、依赖管理都建立在同一套类型系统之上,代价是学习曲线陡峭。 - 事件溯源 。会话历史不是"追加写入的消息数组",而是一条可重放的持久化事件流(EventV2),每次落库都有聚合 ID 与单调序号。这意味着会话可以在进程重启后重建,可以在任意点重放,也为未来的分布式执行留下了空间。
- 生态位独特 。它不像 Cursor 那样是封闭产品,也不像 Continue 那样是 IDE 插件,而是一个"Agent 内核 + 多前端"的开放底座:TUI、桌面、HTTP、SDK 全部围绕同一套内核构建,外部系统可以通过
serve模式把它当作一个可编程的 AI 服务来调用。
1.2 竞品坐标:opencode 站在哪里
要理解 opencode 的取舍,先把它放进 AI 编程助手的坐标系里:
| 维度 | Claude Code | Codex CLI(OpenAI) | Cursor | opencode |
|---|---|---|---|---|
| 形态 | 终端 CLI + IDE 插件 | 终端 CLI | IDE(编辑器) | 终端 TUI + 桌面 + HTTP 服务 |
| 内核 | 闭源 | 闭源 | 闭源 | 开源(TypeScript/Bun) |
| 模型绑定 | Anthropic 系为主 | OpenAI 系为主 | 多模型 | 任意 Provider(含自托管/本地模型) |
| 可编程性 | 有 CLI/SDK,范围受限 | 有 SDK | API 有限 | 完整 HTTP API + SDK + 嵌入式内核 |
| MCP 支持 | 有 | 有 | 有 | 有(V1 会话完整) |
| 扩展机制 | 插件生态 | 有限 | 插件 | Skill + 插件 + 配置 + 权限 |
| 事件/历史模型 | 私有 | 私有 | 私有 | 事件溯源,可审计可重放 |
这张表想说明一点:opencode 的核心竞争力不在"对话体验"(那是各家模型的战场),而在开放性与工程深度------它是这组竞品里唯一把"Agent 内核"当作可审计、可嵌入、可编程基础设施来设计的。
1.3 一个数据注脚
仓库 STATS.md 记录了 2025 年中的下载趋势(GitHub + npm 合计):
| 日期 | GitHub 下载 | npm 下载 | 合计 |
|---|---|---|---|
| 2025-07-10 | 43,796 | 71,402 | 115,198 |
| 2025-08-01 | 123,539 | 146,680 | 270,219 |
| 2025-08-24 | 232,098 | 201,157 | 433,255 |
不到六周,累计下载从 11 万涨到 43 万------对于一个开源 CLI Agent,这个增速相当可观。它至少说明:"开源 + 多模型 + 可集成"这个定位,在开发者市场是真实存在的需求。
1.4 三个信号:Agent 基础设施化正在发生
在深入代码之前,值得先回答"为什么现在值得写这样一篇文章"。2025-2026 年,AI 编程工具领域出现了三个信号:
信号一:从"聊天框"到"操作系统"。 第一代 AI 编程工具是"对话框 + 代码补全",第二代是"能读写文件的 Agent",而 opencode 代表的是第三代------一个拥有会话管理、工具系统、权限模型、插件生态、多端外壳的完整运行时。它不再只是"帮我写代码"的工具,而是"替我执行任务"的执行环境。
信号二:协议层开始标准化。 MCP(Model Context Protocol)把"模型能调用的外部能力"标准化,OpenAPI/Schema 生成把"服务契约"标准化。opencode 同时是 MCP 的宿主(消费外部 MCP server)与生产者(可以把自己暴露为服务)------协议化让"AI 工具链"第一次具备组合性:你的程序可以是别人的工具,别人的程序也可以是你的工具。
信号三:Agent 的运行形态分化。 同一个内核,既可以跑成 TUI(终端)、Electron 桌面端(GUI)、serve(HTTP 服务)、嵌入式(进程内),还可以通过 sdk-next 被塞进任何宿主。"内核与外壳分离"成为 Agent 工程的必修课,而 opencode 的 monorepo 恰好是这一理念的标本。
这篇文章的路线图如下:
1.5 这篇文章的阅读路径
如果你时间有限,可以按三条路径选择性阅读:
- 架构关注者:重点看第 2、4、5、6 章,理解大仓组织、双内核与事件溯源;
- 集成开发者:重点看第 3、9、11、12 章,理解构建链、HTTP API、配置与实战接入;
- Agent 研究者:重点看第 6、7、8 章,理解上下文系统、工具系统与 Provider 抽象。
在开始之前,请确保你对以下概念有基本认识:TypeScript、HTTP/REST、SSE(Server-Sent Events)、MCP 的基本形态(stdio/HTTP 传输)、事件溯源的基本思路。如果你从未接触过 Effect,不必担心------文章会在用到的地方用通俗语言解释它做了什么,而不是陷入其类型体操。
接下来,我们从仓库的骨架开始。
2. 工程全景:一个 30+ 包的 TypeScript 大仓
2.1 仓库结构总览
opencode 的根目录是标准的 Bun monorepo:顶层 package.json 声明 workspace,bun.lock 锁定依赖,turbo.json 编排任务。根目录还包含一批"元文档"------AGENTS.md、CONTEXT.md 这类给 Agent 阅读的上下文文件,本身就是这个项目"用 Agent 开发 Agent"的一个佐证。STATS.md 记录了每日下载量,我们在引言中的部分数据即来源于此。
真正的主体在 packages/ 下,共 30 余个包。按职责可以分成几层:
| 层 | 包 | 职责 |
|---|---|---|
| Schema/类型层 | @opencode-ai/schema |
所有领域模型的 Effect Schema:Session、Message、Event、Permission、Skill、Provider、Model...... |
| 协议层 | @opencode-ai/protocol |
下一代 HTTP API(V2)的端点定义、中间件编排,构建时生成 SDK |
| 核心层 | @opencode-ai/core |
V2 会话内核:SessionV2、SessionExecution、EventV2、System Context、Tool Registry、Provider/Model/Catalog、配置系统 |
| 服务端层 | @opencode-ai/server |
HTTP 服务的具体实现:handlers、middleware、路由装配,承载 V2 协议面 |
| 应用层 | opencode(packages/opencode) |
传统 V1 会话、CLI(yargs)、TUI 逻辑、MCP 客户端、认证、LSP、快照等 |
| 客户端/SDK 层 | @opencode-ai/client、@opencode-ai/sdk、@opencode-ai/sdk-next |
由协议层生成的 Promise/Effect 双形态客户端,sdk-next 是嵌入式宿主 |
| 前端层 | @opencode-ai/tui、@opencode-ai/ui、@opencode-ai/web、@opencode-ai/console-*、@opencode-ai/session-ui、@opencode-ai/storybook |
终端 UI(SolidJS + OpenTUI)、Web 组件库、控制台应用 |
| 桌面层 | @opencode-ai/desktop |
Electron 桌面壳,内嵌 CLI 服务端 |
| 基础设施 | @opencode-ai/cli、@opencode-ai/llm、@opencode-ai/plugin、@opencode-ai/effect-drizzle-sqlite、@opencode-ai/httpapi-codegen 等 |
CLI 二进制打包、LLM 协议适配、插件 ABI、数据库、代码生成 |
依赖方向被严格约束(见根目录 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 组装在一起的包。这条规则保证了"客户端不背核心包袱",也让嵌入式场景(进程内直接起一个 opencode)成为可能------因为 sdk-next 可以复用同一套路由与 handler,而不必走网络。
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 组装在一起的包。这条规则保证了"客户端不背核心包袱",也让嵌入式场景(进程内直接起一个 opencode)成为可能------因为 sdk-next 可以复用同一套路由与 handler,而不必走网络。
依赖方向的直接后果是分层的包可以被独立替换 。比如你想换存储:Core 里的数据库层(core/database)是独立服务,可以替换为 Postgres 适配(项目里已有 @opencode-ai/effect-drizzle-sqlite 与 @opencode-ai/effect-sqlite-node 两个基础设施包,Drizzle 方言也是可拔插的)。这也是 monorepo 相比单体最大的工程红利:依赖边界即团队边界,即演进边界。
2.3 一个"全包"清单
为了让"30+ 包"不只是个数字,这里列一份完整盘点(以仓库 packages/ 为准):
| 包名 | 一句话职责 |
|---|---|
opencode |
应用层:CLI、TUI 逻辑、V1 会话、MCP 客户端、认证、LSP |
@opencode-ai/core |
V2 内核:SessionV2、EventV2、System Context、Tool Registry、Provider/Catalog |
@opencode-ai/schema |
全部领域模型的 Effect Schema(单一事实源) |
@opencode-ai/protocol |
下一代 HTTP 协议面(端点、中间件编排、SDK 契约) |
@opencode-ai/server |
HTTP 服务实现(handlers/middleware),承载 V2 协议面 |
@opencode-ai/client |
生成的 Promise/Effect 双形态网络客户端 |
@opencode-ai/sdk |
客户端包(迁移中) |
@opencode-ai/sdk-next |
嵌入式 OpenCode(进程内起内核) |
@opencode-ai/llm |
LLM 协议适配、工具调用/输出类型 |
@opencode-ai/plugin |
插件 ABI |
@opencode-ai/tui |
终端 UI(SolidJS + OpenTUI) |
@opencode-ai/ui |
Web 组件库 |
@opencode-ai/web |
Web 前端/落地页 |
@opencode-ai/desktop |
Electron 桌面壳 |
@opencode-ai/app |
应用壳 |
@opencode-ai/cli |
CLI 二进制构建 |
@opencode-ai/console-*(6 个) |
控制台应用(app/core/function/mail/resource/support) |
@opencode-ai/stats-*(3 个) |
统计站(app/core/server) |
@opencode-ai/session-ui |
会话 UI 组件 |
@opencode-ai/slack |
Slack 集成 |
@opencode-ai/storybook |
组件故事书 |
@opencode-ai/codemode |
代码模式工具 |
@opencode-ai/enterprise |
企业功能 |
@opencode-ai/function |
无服务器函数 |
@opencode-ai/identity |
身份 |
@opencode-ai/http-recorder |
HTTP 录制(测试) |
@opencode-ai/httpapi-codegen |
HttpApi 代码生成器 |
@opencode-ai/effect-drizzle-sqlite / effect-sqlite-node |
Effect + SQLite 基础设施 |
这张表同样来自对 packages/ 各 package.json name 字段的逐一核验------在 monorepo 里,"包名"本身就是地图。
2.4 技术栈清单
| 技术 | 用途 | 关键证据 |
|---|---|---|
| Bun | 运行时、包管理、脚本执行(bun ./scripts/xxx.ts) |
根 bunfig.toml、各包 scripts |
| TypeScript | 全仓语言 | 统一 tsconfig.json、bun typecheck |
Effect(含 effect/unstable/http、httpapi、socket) |
依赖注入、错误模型、Schema 校验、HTTP 路由、流处理 | packages/core/src/*.ts 大量 Effect.gen、Schema.Struct、HttpApi.make |
| Drizzle ORM + SQLite | 持久化(会话、消息、事件、快照) | packages/core/src/session/sql.ts、event/sql.ts |
| yargs | CLI 参数解析 | packages/opencode/src/index.ts |
| SolidJS + OpenTUI(@opentui/core/solid/keymap) | 终端 UI | packages/tui/package.json |
| Electron + electron-vite + electron-builder | 桌面壳 | packages/desktop/package.json |
| @modelcontextprotocol/sdk | MCP 客户端(stdio/SSE/streamable HTTP) | packages/opencode/src/mcp/index.ts |
| turbo | 任务编排 | turbo.json |
这套组合最引人注目的是 Effect 的全面铺开 。在传统 Node 项目里,你会看到 try/catch、回调、class 单例;在 opencode 里,你会看到 Effect.gen 生成器、Schema 类型守卫、Layer 组合、Stream。它把"依赖注入、并发控制、错误传播、资源作用域"统一成了一种带类型的表达。代价是代码初次阅读门槛高;收益是整个核心层的正确性可以从类型层面推理。
2.5 Effect 速成:读 opencode 源码必需的五个概念
如果你对 Effect 陌生,读 opencode 源码会遇到的第一道坎就是它。这里用最少的篇幅给你五把钥匙:
Effect.gen生成器 :Effect 程序用生成器函数表达副作用。yield* effect就是"执行并等待"。它把"异步 + 错误 + 依赖"都收进了类型里,替代了async/await+try/catch+ 服务定位。Context.Service+Layer:服务用Context.Service<Service, Interface>()("@opencode/xxx")声明(括号里是全局唯一标识符,这就是为什么第 4 章说"@opencode/xxx这类标识符不能乱改,改了依赖注入就错乱")。Layer负责装配服务的依赖图,Layer.provide把依赖喂进去。opencode 里大量xxx.node就是"这个服务的装配节点"。Schema运行时校验 :Schema.Struct(...)定义的数据模型既是 TypeScript 类型又是运行时校验器。Schema.decodeUnknownEffect(...)把不可信输入解码成可信类型,Schema.toTaggedUnion("type")构造可判别联合。opencode 的几乎所有领域模型都这么定义,所以"类型定义 = 校验规则 = 传输格式"三位一体。Stream:流式序列,SSE 事件、消息流都用它表达。Stream.Stream<DurableEvent, NotFoundError>这种签名同时声明了"产生什么"与"可能怎么失败"。Scope:资源作用域。Effect.addFinalizer在作用域结束时自动清理(比如 MCP 连接、临时文件、注册的工具)。第 7 章 ToolRegistry 的注册就带 Scope------作用域结束工具自动注销。
读完这五条,你已经可以读懂 opencode 核心层 80% 的代码骨架了。剩下的是 Effect 的类型体操细节,遇到时查文档即可。
2.6 持久化层:Drizzle 如何定义表
opencode 的持久化选型是 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<...>(),
})
Drizzle 的 $type<T>() 允许在数据库层保持 SQLite 原生类型,而在 TypeScript 层声明领域类型------这与 Effect Schema 的"类型定义 = 校验规则"哲学一致:类型信息不丢失地贯穿 ORM、Schema 与业务代码。
表之间的引用关系:session.project_id 指向 project 表;消息与事件按 session_id/aggregate_id 关联。event_sequence 表用"聚合 ID → 最新序号"的映射加速续传查询。整套 schema 由 data-migration.sql.ts 管理迁移,服务启动时自动迁移到最新版本。
2.7 任务编排与测试:turbo 与 bun typecheck
- turbo (
turbo.json):按包依赖图并行执行build/typecheck等任务,增量缓存。AGENTS.md明确要求类型检查从包目录跑bun typecheck,不裸用tsc------因为仓库统一走 tsgo(TypeScript Go 实现),速度更快。 - 测试纪律 :
AGENTS.md说"避免 mock,尽量测试真实实现"。仓库里大量.test.ts就是围绕真实服务层写的(如desktop/src/main下的*.test.ts:窗口注册、外部 URL、安装状态等),测试即文档。 - 根目录有防护 :
do-not-run-tests-from-root标记------测试必须在包目录运行,防止 workspace 级别的误执行。
2.8 元文档:Agent 时代的仓库宪法
根目录的 AGENTS.md 与 CONTEXT.md 是这个仓库最有"时代感"的部分:它们不是给人读的 README,而是给 AI 协作者读的工程宪法 。AGENTS.md 规定代码风格与架构约束,CONTEXT.md 用一百多条"关系条款"定义 V2 会话运行时的语义(每条都是一句可执行的规则,如"Context changes are sampled and admitted lazily at a Safe Provider-Turn Boundary, never pushed asynchronously")。
这带来一个有趣的正反馈:用 AI 开发的仓库,文档精度会被倒逼提高------因为 AI 不像人类能靠上下文猜意图,任何模糊的"应该"都会在执行时变成错误。opencode 的文档风格(每条规则独立成句、明确 Avoid 词、关系用条款枚举)值得所有 AI 辅助开发的团队借鉴。
2.9 代码规范:一份"如何写代码"的宪法
根目录 AGENTS.md 里有一段非常值得一读的工程规范,它不只是给人类看的,也是给"用 Agent 开发 Agent"的 AI 协作者看的:
- 避免过度抽取:"Do not extract single-use helpers preemptively"------一次性逻辑就地内联;
- 避免 else:优先早返回(early return);
- 避免
any:类型安全是硬约束; - 优先 Bun API :如
Bun.file(); - 函数式数组方法 :
flatMap/filter/map优先于 for 循环,filter 用类型守卫保持类型推导; - schema 字段用 snake_case(Drizzle 表定义直接映射列名);
- 测试不用 mock:"Avoid mocks as much as possible"------测真实实现,不复制逻辑进测试;
- 类型检查用
bun typecheck,从包目录运行,不用裸tsc。
这些规则合在一起,塑造了 opencode 代码的两个气质:短 (一次函数、内联、早返回)与硬(类型安全、无 mock、schema 驱动)。
2.10 数据与规模
packages/下 32 个独立包(含 console/stats 子包);- 仅
packages/opencode/src、core/src、protocol/src、server/src、schema/src、cli/src、tui/src七个源码目录,就有 558+ 个文件的正文包含 "opencode" 字符串------这还没算前端、桌面、文档与 CI; - 会话模型(
schema/src/session-message.ts)定义了 8 种消息类型、4 种工具状态、3 种助手内容块; - 事件系统(
core/src/event.ts)以"聚合 ID + 单调序号"存储所有持久化事件。
这些数字说明它不是一个小玩具,而是一个具备完整工程形态的产品级代码库。
2.11 monorepo 的取舍与工具选型
opencode 选择 monorepo + Bun + turbo,这一组合并非偶然:
- Bun 作为运行时与包管理器 :Bun 兼容 npm 的大部分工作流(
bun install、bun run),速度远超 npm/pnpm;Bun.file()、Bun Shell($模板标签执行命令)让脚本层干净利落。仓库里大量脚本用await $...`` 写,就是 Bun Shell 的风格。 - turbo 做任务编排 :按依赖图并行跑
build/typecheck/test,增量缓存避免重复构建。monorepo 最怕"全量重跑",turbo 的哈希缓存解决了这一点。 - pnpm-lock.yaml 的兼容代价 :实测中遇到过
bun install读 pnpm-lock 的警告("pnpm-lock.yaml is lockfileVersion 6.0, which bun cannot migrate"),Bun 会退回从 package.json 解析------大部分场景可用,但版本解析偶有偏差(第 3.8 节的 CLI 版本漂移就是其一)。这是 Bun 兼容层的已知边界,不是 bug。
选型的收益是:一套工具链覆盖"构建、测试、类型检查、发布、桌面打包",对 35 个包的仓库,这几乎是必需的基础设施。
2.12 核心层文件地图:packages/core/src 导游
packages/core/src 是 V2 内核的家,值得按目录做一次地图式遍历(帮你后续对照源码时快速定位):
| 目录/文件 | 装着什么 |
|---|---|
session.ts |
SessionV2 门面:prompt 受理、events/history/active/interrupt |
session/ |
运行器(runner/)、投影器(projector)、SQL(sql.ts)、SessionStore 实现 |
event.ts / event/ |
EventV2 事件溯源核心:SerializedEvent、Durable 事件清单、续传 |
system-context/ |
System Context 代数:index/registry/builtins |
tool/ |
ToolRegistry(register/materialize/settle)、工具模型 |
permission.ts |
权限求值(flat().findLast 规则合并) |
skill.ts |
SkillV2:技能加载/可用性/权限 |
provider.ts / models-dev.ts |
Provider/Model/Catalog 与 models.dev 快照 |
llm/ |
LLM 调用、事件发布、协议适配 |
project.ts / config/ |
项目模型与配置加载(jsonc 解析、目录源) |
global.ts |
全局目录、环境变量默认 |
database/ |
SQLite 数据库服务与迁移 |
markdown.ts |
markdown 解析/渲染(前端消息体用) |
agent/ |
Agent 模型(build/plan/custom、角色提示词) |
这张表是"阅读索引"而非"完整清单"------写这篇文章时,我几乎每一步都靠它定位源码。把它抄进你的笔记,读代码效率至少翻倍。
2.14 开源协作:一个"用 Agent 开发 Agent"的仓库
opencode 的团队名单与 CI 机器人(opencode-agent[bot])出现在脚本输出里,暗示了这个仓库的开发方式本身就很"Agent 化":
- AI 协作者直接参与:仓库配置了对 opencode 自身的调用链(开发流程文档里有"使用 opencode 开发 opencode"的内容),PR 由人类 + Agent 协作完成;
- 文档即契约 :
CONTEXT.md用可机器执行的条款描述运行时行为,AGENTS.md约束代码风格------这本质上是在给"AI 同事"写需求文档; - 发布自动化:渠道解析、版本号、更新元数据全部脚本化,人工介入点极少。
对读者来说,这个仓库是最好的"Agent 工程实践案例":它不只在讨论 Agent,而是真的用 Agent 开发 Agent。你可以在 commit 历史里看到 AI 生成的代码、AI 写的测试、AI 修自己的 bug------这对"AI 原生开发团队"是稀缺的参考样本。
2.16 目录速览:从头到尾的"仓库地图"
最后用一张总表把仓库主要目录串起来(我探查时的实际路径):
| 路径 | 内容 |
|---|---|
AGENTS.md / CONTEXT.md |
AI 协作者宪法 |
packages/opencode |
V1 应用层 + serve 实现 |
packages/core |
V2 内核(事件、系统上下文、工具、会话) |
packages/schema |
领域模型(Effect Schema 单一事实源) |
packages/protocol / packages/server |
V2 协议面与 HTTP 实现 |
packages/client / packages/sdk / packages/sdk-next |
客户端与嵌入式 |
packages/desktop |
Electron 壳 |
packages/tui |
终端 UI |
packages/cli |
CLI 二进制构建 |
docs/ |
开发文档 |
配合第 2.12 章的 core 地图,这份"仓库地图"足以支撑你独立读完整个项目。记住核心问题:想知道某功能在哪 → 先判断它属于 Schema(数据)/ Core(内核)/ Server(暴露)/ 外壳(形态)哪一层------定位即分层。
2.17 小结
这一章我们建立了对仓库的整体认知:一个依赖方向被严格约束的 TypeScript/Bun monorepo,核心由 Effect 驱动,持久化由 Drizzle + SQLite 承担,前端从终端到桌面到 Web 一应俱全。下一章,我们从"怎么跑起来"入手------顺着 bun run dev:desktop 的调用链,看构建与发布机制如何运作,这也是理解后续所有章节的入口。
3. 从启动开始:开发脚本、构建链与发布渠道
任何大型工程都有属于自己的"启动仪式"。opencode 的启动仪式里藏着它的构建哲学:一切用脚本自动化,一切按渠道(Channel)区分 。这一章我们以桌面端开发启动为线索,逐层拆解 bun run dev:desktop 背后发生的事情,并把我在 Windows 真实环境里踩过的坑一并记录下来------它们恰好是理解这套机制的最佳教材。
3.1 一条命令的三级跳
在仓库根目录执行:
bash
bun run dev:desktop
它实际上是这样展开的(根 package.json + packages/desktop/package.json):
jsonc
// 根 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,于是调用链变成:
bash
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 干三件事:
install-electron:确保 Electron 二进制就位;copy-icons.ts:把icons/<channel>/下的图标复制到resources/icons,并按渠道选图标------开发时是dev渠道,会打出 "Copied dev icons from ./icons/dev to resources/icons" 这行日志;build-node.ts:构建packages/opencode的 Node 侧产物(dist/),因为桌面主进程要import它的服务端模块(见desktop/src/main/env.d.ts里virtual:opencode-server的声明)。
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(升版本场景,强制 latest)> 非 dev 版本号 > 当前 git 分支名 。也就是说:默认情况下,你在哪个分支开发,产物就按哪个渠道发布------dev 分支出 dev 渠道,main/release 分支出正式渠道。
这个机制有一个非常现实的副作用,也是我第一次在 Windows 上跑它时踩到的第一个坑:
坑 1:不是 git 仓库。 我的工作副本是从别处复制来的、不带
.git的目录。git branch --show-current直接退出码 128(fatal: not a git repository),整个predev失败。解决办法有两个:显式设置$env:OPENCODE_CHANNEL="dev",或把目录初始化为 git 仓库。这也提醒我们:这套构建系统把"git 存在"当成了默认前提,脱离 git 的源码副本需要靠环境变量补偿。
3.3 models.dev 快照:模型目录的本地化
predev 之后,桌面开发服务器还会去获取一份模型目录快照 。opencode 的模型元数据(哪个 provider 有哪些模型、上下文窗口、价格等)来自 models.dev 的公开 api.json,核心层在 packages/core/src/models-dev.ts 实现:
ts
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
return yield* HttpClientRequest.get(`${source}/api.json`).pipe(...)
})
它会把快照写进本地缓存(fetchAndWrite),并受两个开关控制:
OPENCODE_DISABLE_MODELS_FETCH:完全禁止联网拉取;MODELS_DEV_API_JSON:把"源"从远程 URL 替换成本地文件------这也是我第二次踩坑的修复方式。
坑 2:无法访问 models.dev。 在没有外网的 Windows 环境下(或代理未生效时),
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,构建继续。这暴露了构建链的一个设计权衡:模型目录是"启动期强依赖" ,虽然支持禁用(OPENCODE_DISABLE_MODELS_FETCH),但桌面开发流程默认会尝试获取,内网/离线环境必须显式干预。
3.4 CLI 二进制下载:桌面壳如何获得"大脑"
桌面端不是把 TUI 包进 Electron,而是下载一个独立的 CLI 服务端二进制 并内嵌到 resources/。下载逻辑在 packages/desktop/scripts/utils.ts:
ts
const CLI_VERSION = "0.0.0" // dev 构建时的占位版本
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-<平台>@<CLI_VERSION> --os=... --cpu=... 从 npm 拉包,然后从包里的 bin/opencode2(.exe) 复制到 resources/opencode-cli(.exe)。桌面主进程启动时再把这个二进制以 serve 模式拉起(我们会在第 10 章细讲)。
坑 3:dev 渠道的版本错配。 dev 构建时
CLI_VERSION = "0.0.0",但某些历史 dev 二进制会尝试拉取0.0.0-next-<build号>这样的预发布版本,结果:
perlerror: 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 渠道版本漂移",是上游发布节奏与本地缓存的错位造成的,与你的代码无关 ;重装包、对齐版本号即可。对一般读者,理解机制比记住修复步骤更重要:桌面壳与 CLI 服务端的耦合点是"npm 包 + 版本号 + bin 文件名"三方约定,任何一个对不上,启动就会失败。
3.5 构建期环境变量一览
顺着构建链,我们其实已经收集到一批对开发者有用的环境变量,汇总如下(全部来自源码实测):
| 环境变量 | 作用 | 出现位置 |
|---|---|---|
OPENCODE_CHANNEL |
强制指定渠道(dev/beta/prod) | packages/script/src/index.ts |
OPENCODE_BUMP |
升版本场景,强制 latest 渠道 | 同上 |
OPENCODE_VERSION |
显式版本号(非 0.0.0- 前缀时走 latest) | 同上 |
MODELS_DEV_API_JSON |
本地 models.dev 快照文件路径 | packages/core/src/models-dev.ts |
OPENCODE_DISABLE_MODELS_FETCH |
禁用联网拉取模型目录 | 同上 |
OPENCODE_MODELS_DEV |
静态注入 Provider/模型数据 | 同上 |
OPENCODE_SERVER_PASSWORD / OPENCODE_SERVER_USERNAME |
serve 认证 | packages/opencode/src/server/auth.ts |
OPENCODE_CONFIG_DIR |
覆盖全局配置目录 | packages/core/src/global.ts |
OPENCODE_TEST_HOME |
测试用 home 目录 | 同上 |
OPENCODE_DISABLE_PROJECT_CONFIG |
禁用项目级配置发现(指令读取仍可用全局) | System Context 指令源 |
OPENCODE_TERMINAL / TERM |
PTY 终端不变量 | PTY 环境(CONTEXT.md) |
OPENCODE_INSTALL_DIR / XDG_BIN_DIR |
安装目录优先级 | 安装脚本 |
这套变量体系说明:opencode 把"环境内可调"当作一等设计------离线、测试、定制、认证,都能通过环境变量解决,而不用改代码。对集成者这是极大的便利。
3.6 从 dev 到发布:electron-builder 与渠道产物
predev 只是开发态;正式打包走 package:win/mac/linux 三个脚本,由 electron-builder --config electron-builder.config.ts 完成。配置文件里针对每个渠道(dev/beta/prod)区分了产物命名、图标、更新通道(electron-updater 依赖更新 feed)。scripts/ 下还有一批"收尾"脚本:finalize-latest-json.ts、finalize-latest-yml.ts 负责更新 latest 元数据,copy-metainfo.ts 负责 Linux 的 AppStream 元数据------这些都是发布流水线的一部分。
3.7 electron-builder 渠道配置一瞥
packages/desktop/electron-builder.config.ts 是"渠道差异"落地的现场。它大致按渠道区分:
- 产物名 :
opencode-desktop-<platform>-<arch>(正式)/ 带渠道后缀的 dev/beta 名; - 图标 :
icons/<channel>/下的整套尺寸(icns/ico/png 多分辨率,Windows 还有 StoreLogo 等); - 更新源 :
electron-updater的 publish feed 按渠道指向不同 latest 文件; - 协议注册 :
oc://自定义协议与文件关联在打包配置里声明,安装后系统可唤起桌面应用。
配套脚本 finalize-latest-json.ts / finalize-latest-yml.ts 在发布后重写 latest 元数据(供自动更新器发现新版本),copy-metainfo.ts 生成 Linux AppStream 元数据。这三件套合在一起,构成了"一键发布三平台 + 自动更新"的完整链路。
3.8 CLI 二进制下载的内部原理
第 3.4 节的 downloadCliToResources 背后,CLI 二进制发布走的是 npm 平台包分发 。桌面壳按"当前平台"选择一个平台包安装到临时目录,再把 bin/<name> 拷到 resources/:
ts
const CLI_BINARIES: Record<Platform, { package: string; file: string; os: string; cpu: string }> = {
win32: { package: "@opencode-ai/cli-windows-x64", file: "bin/opencode2.exe", os: "windows", cpu: "x64" },
darwin: { package: "@opencode-ai/cli-darwin-arm64", file: "bin/opencode", os: "darwin", cpu: "arm64" },
// ... 六平台
}
getCurrentCli() 按 process.platform + process.arch 选包;CLI_VERSION 与渠道强相关(dev 渠道解析到仓库当前的 0.0.0-dev-* 或固定 next 版本)。实测中遇到的两个坑都源自这里:
- 版本漂移 :
0.0.0-next-16350在 npm 上不存在(仓库的 next 版本与 npm 发布不同步)时安装失败------这是开发期常见现象,不是代码 bug; - bin 文件名不符 :下载成功但 bin 目录里没有预期文件名(例如包结构变化)时报
ENOENT copyfile------临时目录残留也会导致二次运行踩到旧内容。
对策:设 OPENCODE_CHANNEL=dev 让版本解析走"仓库当前版本";若仍失败,清掉 %TEMP%\opencode-cli-* 与 resources/opencode-cli* 再试。
3.10 构建产物树:一次打包能得到什么
以桌面端为例,完整构建后 resources/ 与产物目录会呈现这样的结构(路径以仓库为准):
bash
resources/
├── icons/ # dev 图标(dev 渠道覆盖正式图标)
├── opencode-cli(.exe) # 内核 CLI(第 3.4 节下载)
└── ...
dist/ # electron-builder 输出
├── opencode-desktop-...exe / dmg / deb / rpm # 各平台安装包
└── latest.yml / latest.json # 自动更新元数据
值得注意的是 resources/opencode-cli 与 resources/opencode-cli.exe 的关系:桌面端会把"opencode CLI"作为子进程 运行(serve 模式),渲染层再通过本地 HTTP 连它。所以桌面端的本质是:一个负责安装/升级/拉起 CLI 的壳 + 一个纯客户端 UI------这让桌面端几乎不需要维护自己的 AI 逻辑,也解释了为什么内核的每次升级都只是"换一个二进制"。
3.11 构建失败对照表(Windows 实测全记录)
把开发期反复踩过的构建错误做成一张"症状 → 原因 → 对策"表,供后来者少走弯路:
| 症状 | 根因 | 对策 |
|---|---|---|
fatal: not a git repository(构建脚本跑 git branch) |
目录不在 git 仓库内,渠道解析失败 | 设 OPENCODE_CHANNEL=dev;或 git init |
ConnectionRefused on models.dev/api.json |
网络受限/被墙,拉不到模型目录 | 设 MODELS_DEV_API_JSON 指向本地快照,或 OPENCODE_DISABLE_MODELS_FETCH=1 |
No version matching "0.0.0-next-..." |
仓库 next 版本与 npm 发布不同步 | 设 OPENCODE_CHANNEL=dev 走仓库当前版本;或对齐 npm 上存在的版本 |
ENOENT copyfile ... bin/opencode2.exe |
下载成功但 bin 文件名与预期不符 / 临时目录残留 | 清 %TEMP%\opencode-cli-* 与 resources/opencode-cli* 后重试 |
pnpm-lock.yaml 兼容警告 |
bun 无法迁移 lockfileVersion 6 | 可忽略;如需锁版本,用 pnpm 装依赖 |
do-not-run-tests-from-root |
测试必须在包目录运行 | cd packages/opencode && bun typecheck |
这张表也是第 13.6 章 FAQ 的素材来源------这些坑不是一次性的,换个环境就会再遇到。
3.12 小结
顺着 dev:desktop 的调用链,我们看到了 opencode 构建体系的三个关键设计:
- 渠道即身份 :
dev/beta/prod决定图标、版本、更新源,git 分支名参与渠道解析; - 启动期强依赖:models.dev 模型目录是构建链的启动依赖,支持离线开关但需显式配置;
- 桌面与 CLI 松耦合:桌面壳通过"下载独立 CLI 二进制 + serve 进程"获得能力,而不是共享进程内代码------这为安全隔离与按平台交付提供了便利,也带来了版本错配的风险。
理解了构建与发布,下一章进入架构核心:会话内核。我们会看到 opencode 为什么要维护 V1 和 V2 两套会话系统,以及它们各自的取舍。
4. 双会话内核:V1 与 V2 的设计之争
"会话"是 Agent 的核心抽象:它要保存历史、管理上下文、协调工具执行、与模型交互。opencode 仓库里存在两套并行的会话实现,这既是历史包袱,也是刻意为之的迁移策略。理解它们,是理解整个仓库的一把钥匙。
4.1 为什么要两套
- V1 (
packages/opencode/src/session/):传统的会话实现,围绕Session、SessionPrompt、SessionTools、SessionSummary等一组服务构建。它的特点是"功能完整、演进迅速",工具、MCP、权限、快照、汇总都长在这棵树上。 - V2 (
packages/core/src/session/+packages/core/src/session.ts):全新的会话内核,围绕SessionV2、SessionExecution、SessionRunner、EventV2、System Context构建。它的目标是"可持久化、可重放、可分布式",把会话历史的存储从"状态"变成"事件流"。
从代码组织看,V2 放在 @opencode-ai/core(核心层),V1 放在 packages/opencode(应用层)------位置本身暗示了方向:V2 才是未来要沉淀为核心能力的实现 。根目录 AGENTS.md 里那整整一节 "V2 Session Core",就是这份设计意图的书面化。
4.2 V1 会话:成熟而完整
V1 的请求路径大致是:
用户输入
→ SessionPrompt.Service(prompt 受理、消息组装)
→ LLM 调用(llm.stream)
→ 工具执行(SessionTools.resolve 把工具集物化,含 MCP 工具合并)
→ 结果回写(消息、快照、汇总、状态)
用一次真实请求把时序铺开(这是我在 Windows 上抓到的实际行为):
ini
POST /session/{id}/prompt_async {parts:[...]} → 204(受理,立即返回)
GET /session/{id}/message → 轮询结果
第 1 条:user 消息(parts = 用户文本)
第 2 条:assistant 消息(parts 依次为
step-start → reasoning(模型推理)→ tool(调用,state.status=completed,
state.output="状态: 运行中")→ step-finish reason=tool-calls)
第 3 条:assistant 消息(step-start → text(最终回答)→ step-finish reason=stop)
V1 的几个关键特征:
- 工具解析在会话层 :
packages/opencode/src/session/tools.ts里的SessionTools.resolve会把内置工具(bash、edit、read、write......)、注册工具与 MCP 工具合并成一份工具清单交给模型。这个函数是"V1 能看到 MCP 工具"的根本原因。 - MCP 资源工具 :V1 还自动注入一组 MCP 资源工具(
list_mcp_resources、list_mcp_resource_templates、read_mcp_resource),把 MCP server 暴露的 resources 变成模型可调用的函数,上限 10MB(MAX_MCP_RESOURCE_BLOB_BYTES)。 - 消息模型是 parts :V1 的消息用
parts数组表达(TextPart、ToolPart、ReasoningPart、StepStartPart、StepFinishPart......),我们上一轮实战里在GET /session/{id}/message看到的正是这种结构。
V1 的配套服务也很完整:SessionSummary(摘要)、SessionCompaction(压缩)、SessionRevert(回滚)、SessionStatus(状态)、Todo(任务清单)、SessionShare(分享)。可以说 V1 是一棵"长满功能的树",而 V2 是一棵"刚移栽的树"。
V1 的问题也很明显:会话历史就是"追加的数组" ,缺少可重放的事件流抽象;SessionPrompt.loop 这类"内存里转圈"的编排方式,在崩溃恢复、多端同步、分布式执行面前会力不从心。
4.3 V2 会话:事件溯源的内核
V2 的设计在 packages/core/src/session.ts 的 Interface 上可以看得很清楚。核心方法是:
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 switchAgent / switchModel
readonly shell / skill / compact / wait
readonly active / resume / interrupt
readonly revert: { stage; clear; commit }
}
几个值得注意的设计决策:
(1)prompt 是"受理"而非"发送"。 SessionV2.prompt(...) 返回 SessionInput.Admitted------它先把输入持久化受理 进会话的 inbox(session_input 表),然后以 resume 参数决定是否立即唤醒执行:
resume: true(或省略):受理后调度一次"建议性唤醒"(advisory wake);resume: false:只受理、不执行,等待后续显式恢复。
这个"受理与执行分离"的模型,让会话的每个输入都有持久身份,崩溃后可以从 inbox 重建,而不是靠内存里的 promise 链。
(2)执行有明确的运行协调器。 SessionExecution 是进程全局的、以 Session ID 为单位的调度器;SessionRunner 负责把受理的输入变成真实的模型调用;SessionRunCoordinator 合并同一会话的并发唤醒、为不同会话提供并行。AGENTS.md 里的定义是:
Keep
SessionExecutionprocess-global and Session-ID based... A drain has no durable identity or transcript boundary.
Session Drain(会话排干)是一次"从受理输入到无续期可跑"的进程内执行片段,它没有持久身份------这避免了把"执行实例"误当成"会话实体"。
(3)消息是类型化的 tagged union。 V2 的消息模型(schema/src/session-message.ts)定义了 8 种消息类型与 4 种工具状态(详见第 5 章),全部由 Effect Schema 驱动,运行时校验、跨端传输、持久化共用同一份定义。
(4)事件是事件溯源。 会话的每一次变更(输入受理、消息产生、工具结算、模型切换)都会以持久化事件写入 EventTable,带 aggregateID 与单调 seq(详见第 5 章)。events() 与 history() 从事件流重放,而不是直接读"最新状态"。
(5)Location 感知。 V2 的 CreateInput 显式携带 location(目录 + 可选 workspace ID)。会话的运行环境、工具、文件系统、系统上下文全部 Location-scoped------换一个目录,就是换一套环境。这让"同一套内核服务多个项目"变得自然。
(6)受理的存储层。 SessionInput.Admitted(packages/schema/src/session-input.ts)是 prompt 受理的持久化结果,字段设计值得读一下:
ts
const Admitted = Schema.Struct({
admittedSeq: NonNegativeInt, // 受理序号(inbox 单调递增)
id: SessionMessage.ID, // 将来成为用户消息的 ID
sessionID: SessionID,
prompt: Prompt, // { text, files, agents }
delivery: Delivery, // 投递模式(如 poll/steer/queue)
timeCreated: DateTimeUtcFromMillis,
promotedSeq: NonNegativeInt.pipe(optional), // 提升序号(进入 Session History 后回填)
})
promotedSeq 一旦被回填,就代表这条输入已经从"受理但未可见"的 pending 状态,转成了"模型可见"的历史消息------这就是 CONTEXT.md 里"Admitted Prompt → Prompt Promotion"的持久化形态。有了它,精确重试(reconcile exact retry)才能做到:只有当 Session、prompt、delivery 全部匹配时才复用同一 message ID。
(7)中断与活跃。 V2 的 interrupt(sessionID) 是幂等的:会话不存在报 SessionNotFoundError;已知会话若空闲/已结算/本地无执行权,则是 no-op。active 返回当前进程的"前台会话排干注册表"------后台子代理不会让父会话变成 active,进程重启清空注册表。这套语义把"中断"与"活跃状态"严格绑定到进程局部,避免跨进程误杀。
4.4 双 API:/session 与 /api/session
与双内核对应,HTTP 面上也有两条会话 API:
| 维度 | V1 /session |
V2 /api/session |
|---|---|---|
| 路由归属 | InstanceHttpApi(packages/opencode/src/server/routes/instance/httpapi/) |
Api(packages/protocol/src/api.ts 的 makeDefaultApi,HttpApi.make("server")) |
| 处理器 | handlers/session.ts → Session.Service(传统内核) |
packages/server/src/handlers/session.ts → SessionV2.Service(新内核) |
| 消息结构 | parts 数组 |
content/type 结构化消息 |
| MCP 工具 | ✅ 可见(SessionTools.resolve 合并) |
⚠️ 尚未实现(SessionRunner 的 MCP 桥接仍是 TODO) |
这一点我在实测中印象极深:同一个配置好的 MCP server,走 V1 会话发起消息,工具被真实调用;走 V2 会话发起消息,模型根本看不到任何 MCP 工具 。源码里 SessionRunner 的待实现清单明明白白写着 [ ] MCP/插件/结构化输出工具定义。这不是 bug,而是迁移尚未完成 。对集成者而言,结论很务实:当前要使用 MCP 工具,必须走 V1 会话 API。
4.5 迁移策略的启示
opencode 的 V1/V2 并存不是混乱,而是一种工程策略:
- 新内核带着完整的设计文档(CONTEXT.md/AGENTS.md)先落地,旧内核持续提供能力;
- 公共协议面(protocol 包)先行定义,SDK 从协议生成,与内核实现解耦;
- 能力按优先级迁移,MCP 这类重功能排在后面,避免"一步到位"的爆炸式重构。
这对任何正在重构核心系统的团队都有参考价值:先定义协议与数据模型,再迁移执行引擎,最后补齐能力。
4.6 V1 的配套服务群
V1 会话不是孤家寡人,它被一圈服务包围,每个服务解决一个问题:
| 服务 | 文件 | 职责 |
|---|---|---|
SessionPrompt |
session/prompt.ts |
prompt 受理、消息组装、执行编排(含 loop) |
SessionTools |
session/tools.ts |
工具物化(内置 + 注册 + MCP + MCP resources) |
SessionSummary |
session/summary.ts |
会话摘要(diff 等) |
SessionCompaction |
session/compaction.ts |
上下文压缩 |
SessionRevert |
session/revert.ts |
回滚(staged/clear/commit) |
SessionStatus |
session/status.ts |
状态机(running/idle/error...) |
SessionRunState |
session/run-state.ts |
运行状态注册 |
SessionShare |
share/session.ts |
分享会话 |
Session |
session/session.ts |
会话本体(CRUD、历史) |
MessageV2 |
session/message-v2.ts |
消息分页(before cursor + Link header) |
Todo |
session/todo.ts |
任务清单工具 |
这份清单的阅读价值在于:V1 把"Agent 运维"需要的所有能力都做全了。当你给 V2 排优先级时,这 11 项就是"待迁移功能清单"的骨架------第 13 章会回到这个视角。
4.7 V2 的 runner 内部:一次排干怎么做
V2 的执行编排在 packages/core/src/session/runner/,其中 index.ts 是主循环。一次 Session Drain 的大致流程(依据源码结构还原):
arduino
SessionRunCoordinator 合并唤醒
→ SessionRunner 开始排干
→ 检查 inbox(已受理未提升的输入)
→ 到安全边界:提升输入 → 结算工具 → 合并系统消息
→ 解析模型/agent(provider turn 开始时采样)
→ 加载/重建 LLM 消息(Context Epoch 截断 + to-llm-message 投影)
→ 一次 llm.stream(request)
→ 流式投影:文本/推理/工具调用 → 持久化事件 → 更新 Session History
→ 若模型请求工具 → 权限判定 → 工具执行(ToolRegistry.settle)
→ 输出边界化(ToolOutputStore)→ 结果回写 → 继续下一轮
→ 直到:无续期 / 达到 max-steps / 被 interrupt / 出错
配套模块分工:llm.ts(LLM 调用封装)、max-steps.ts(步数上限)、model.ts(模型解析)、publish-llm-event.ts(LLM 事件发布为持久事件)、to-llm-message.ts(历史投影)。这套拆分的核心意图是把"模型对话"变成"可观测的状态机",而不是一段顺序执行的脚本。
4.8 Agent 系统:build、plan 与自定义 agent
V1 与 V2 都围绕"Agent"组织行为。内置两个 agent:
- build:默认 agent,权限宽松,负责"动手干活"(编辑、运行、验证);
- plan:只读探索,默认拒绝文件编辑,bash 需询问------适合"先出方案"。
每个 agent 是一个可配置对象:{ description, mode, model, permission, tools, prompt }。自定义 agent 只需在 agent 配置里新增键(如 mteai),指定自己的 prompt 与权限覆盖。子代理(subagent)则复用同一套 agent 定义,但受 subagent-permissions.ts 独立约束。
一个关键语义(贯穿 V1/V2):模型提示词(prompt 字段)是 System Context 的静态来源之一 ,与 AGENTS.md 指令、日期、cwd 等动态来源组合渲染。这也解释了为什么"换 agent 换人设"能即时生效------人设只是上下文组合的一个成分。
4.10 V1 消息循环的"400 之谜"(实测踩坑)
集成 V1 时最恼人的错误是 400 Bad Request------多半与 schema 形状有关。我在 Windows 实测中对比了几种调用方式:
| 调用方式 | 结果 |
|---|---|
curl POST /session/{id}/prompt_async -d '{"parts":[{"type":"text","text":"hi"}]}' |
偶发 400(头部/转义问题在 Windows 下易踩) |
PowerShell Invoke-RestMethod 同构请求 |
偶发 400 |
Python urllib 干净 JSON |
稳定 204(成功) |
结论不是"curl 不行",而是:请求体必须严格匹配 Input schema(TextPartInput 形态),且 Content-Type 与编码正确 。Windows 下 curl.exe 的单引号、UTF-8 BOM、CRLF 都会悄悄改变载荷。给集成者的建议:
- 用真实 HTTP 库(Python requests/httpx、Node fetch、Qt QNetworkAccessManager),不要手拼 curl;
- 发消息用 Input 形态(
{type:"text", text}),读消息用 Output 形态(带 id 等); - 遇到 400 先打开
/doc对照该端点的 requestBody schema,再逐字段核对自己发的 JSON。
这个坑之所以值得写一整节,是因为它代表了"Schema 驱动开发"的典型摩擦点:类型在 schema 里是双份(Input/Output),调用方必须知道自己在用哪一份。
4.12 V2 调用的实际报文(curl 视角)
V2 与 V1 的报文差异,用两个 curl 对照最直观(/doc 里以 OpenAPI 形式给出同样信息):
bash
# V1:创建会话(directory 是 Query 参数)
curl -u opencode:pass \
-X POST "http://127.0.0.1:10010/session?directory=E%3A%2FMyApp"
# V2:创建会话(directory 走 header,model 在 body)
curl -u opencode:pass -X POST "http://127.0.0.1:10010/api/session" \
-H "x-opencode-directory: E:\\MyApp" \
-H "Content-Type: application/json" \
-d '{"model": "openai/gpt-4o"}'
# V1:发消息(parts 数组)
curl -u opencode:pass -X POST "http://127.0.0.1:10010/session/ses_xxx/prompt_async" \
-H "Content-Type: application/json" \
-d '{"parts":[{"type":"text","text":"你好"}]}'
# V2:发消息(prompt 对象 + delivery)
curl -u opencode:pass -X POST "http://127.0.0.1:10010/api/session/ses_yyy/prompt" \
-H "Content-Type: application/json" \
-d '{"prompt":{"text":"你好"},"delivery":{"mode":"poll"}}'
三个立即能感知的差异:
- directory 的传递位置 :V1 在 URL 上,V2 在请求头(
x-opencode-directory)------V2 把"运行环境"当成请求上下文而非资源参数; - model 的归属 :V1 的 model 来自实例配置,V2 的 model 可以随请求体指定(
CreateInput显式携带)------V2 的会话与模型解耦; - 投递模式显式化 :V2 用
delivery.mode(poll/steer/queue)表达"这条消息怎么被消费",V1 则是隐式的 prompt_async 语义。
4.13 OperationUnavailableError:V2 尚未支持的操作
V2 对"还没实现的功能"不是静默忽略,而是显式报错:OperationUnavailableError。它出现在 SessionV2 的多个操作上------move、shell、skill、switchAgent、compact、wait------每一个都是"V1 有、V2 待迁移"的典型。看到这类错误时,正确的响应是:切回 V1 端点,或在官方仓库跟进迁移进度,而不是怀疑配置。
这个错误类型是"双轨迁移"策略的诚实体现:协议层把"未实现"建模成语言的一等概念,客户端/集成者因此能程序化地感知能力边界(比如 UI 在 V2 会话里隐藏"移动会话"按钮)。
4.15 会话生命周期状态机
把 V1 的会话状态(SessionStatus)与 V2 的"活跃/空闲"拼在一起,得到一张完整的生命周期图:
lua
创建(POST /session) → idle(等待输入)
prompt_async 受理 → running(正在排干)
├─ 完成 → idle
├─ 出错 → error(可恢复:再次 prompt 即重试)
└─ 中断 → cancelled(interrupt 触发)
消息查询/订阅 → 任意状态可用(历史与实时分离)
删除 → terminated(不可再访问)
两个语义要点:
idle≠finished:V1 会话是"可复活的",idle 时再发 prompt 直接续跑------集成者可以利用这一点做"会话池"(常驻会话等待任务);- 状态与事件的分离:状态是"投影",事件是"事实"。UI 显示 idle 背后可能有一串事件还在结算------以事件为准,状态只作展示。
4.16 小结
V1 与 V2 的并存给出了一个重要示范:当你要重写核心系统时,"双轨并行"比"推倒重来"更稳。协议先冻结、数据模型先定义、执行引擎后迁移、重功能最后搬------每一步都可回退、可验证。这也是为什么第 9 章你会看到两条 API 同时在 /doc 里出现。
opencode 的 V1/V2 并存不是混乱,而是一种工程策略:
- 新内核带着完整的设计文档(CONTEXT.md/AGENTS.md)先落地,旧内核持续提供能力;
- 公共协议面(protocol 包)先行定义,SDK 从协议生成,与内核实现解耦;
- 能力按优先级迁移,MCP 这类重功能排在后面,避免"一步到位"的爆炸式重构。
这对任何正在重构核心系统的团队都有参考价值:先定义协议与数据模型,再迁移执行引擎,最后补齐能力。
下一章,我们深入消息与事件层,看看 V2 的数据模型如何在 SQLite 上支撑"可重放"的会话历史。
5. 消息与事件:从 parts 到 EventV2 事件溯源
如果说会话内核是 Agent 的"心脏",那么消息与事件就是它的"血液"------所有状态变化都以消息的形式被记录、被传输、被重放。这一章我们对比 V1 的 parts 模型与 V2 的类型化消息模型,然后深挖 EventV2 的事件溯源存储。
5.1 V1 的 parts:一种"扁平部件"模型
V1 消息把内容拆成扁平部件数组(parts),每个 part 用 type 标记身份。来自 GET /session/{id}/message 的实测响应里,我们能看到这些类型:
TextPart:纯文本(含synthetic、ignored等标志位);ToolPart:工具调用与结果(state.status生命周期 +state.output);ReasoningPart:模型推理文本;StepStartPart/StepFinishPart:执行步骤的开始与结束(reason字段如tool-calls、stop);AgentPart、SubtaskPart、RetryPart、CompactionPart、SnapshotPart、PatchPart等。
parts 模型的好处是自由 :任意类型的部件都能塞进一条消息,前端渲染也灵活。代价是语义弱:一条 assistant 消息到底是什么(一个回答?一次工具调用序列?一个子任务?)需要靠部件序列去推断,跨端复现时容易失真。
补充一份 V1 parts 的完整清单(来自 packages/opencode/src/server/routes/instance/httpapi/ 的 OpenAPI schema,components.schemas 里以 Part 结尾的类型):
| Part 类型 | 说明 |
|---|---|
TextPart |
文本(synthetic/ignored 标志) |
ToolPart |
工具调用与结果(state.status + state.output) |
ReasoningPart |
模型推理文本 |
StepStartPart / StepFinishPart |
步骤开始/结束(reason:tool-calls / stop 等) |
AgentPart |
子代理消息 |
SubtaskPart |
子任务 |
SnapshotPart |
快照 |
PatchPart |
补丁 |
RetryPart |
重试标记 |
CompactionPart |
压缩标记 |
传输格式上还有一个值得注意的细节:输入(Input)与输出(Output)用不同的 schema ------比如 TextPartInput(发送时只需 type + text)与 TextPart(读取时带 id/sessionID/messageID/time 等完整字段)。我最初手动调 API 时就用错了:发消息必须用 Input 形态,用 Output 形态会被校验拒绝。/doc 里这两套 schema 是分开列的,这是集成时最容易踩的坑之一。
5.2 V2 的 SessionMessage:类型化的 tagged union
V2 在 packages/schema/src/session-message.ts 里重新设计了消息模型。核心是 Message,一个用 Schema.toTaggedUnion("type") 构造的联合类型:
| 消息类型 | 用途 | 关键字段 |
|---|---|---|
user |
用户输入 | text、files(附件)、agents(@提及) |
synthetic |
系统合成消息 | text、sessionID |
system |
系统通知 | text |
shell |
shell 会话记录 | callID、command、output、completed 时间 |
assistant |
模型回复 | agent、model、content[]、snapshot、finish、cost、tokens、error |
compaction |
上下文压缩 | reason(auto/manual)、summary、recent |
agent-switched |
切换 agent | agent |
model-switched |
切换模型 | model |
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, structured, result }
ToolStateError, // { status: "error", input, content, structured, error, result }
])
一次工具调用从 pending 进入 running,最终落到 completed 或 error,每个状态都携带结构化的输入/输出。这个模型的意义在于:模型可见的"工具结果"与系统真实的"工具结果"被分开表达 ------content(给模型回放的投影)与 structured(给系统消费的结构化值)并存,为第 7 章要讲的"输出边界(bounding)"提供了数据基础。
5.3 底层存储:Drizzle + SQLite
V2 的持久化由 packages/core/src/session/sql.ts 和 packages/core/src/event/sql.ts 定义,使用 Drizzle ORM 映射 SQLite 表。按项目规范,表字段用 snake_case:
session表:会话主记录(id、project_id、worktree、vcs、sandboxes 等,见core/src/session.ts的ProjectTable插入逻辑);session_message表:消息行,id+type+data(JSON),data存整个消息体;event表:事件行,aggregate_id、seq、type、version、data;event_sequence表:每个聚合的最新序号,用于快速定位latestSequence。
消息 ID 采用品牌类型(branded type):msg_ 前缀 + 单调递增(ascending()),会话 ID 是 ses_ 前缀。这类"带语义前缀的 ID"在跨端传递、日志排查时非常有用。
5.4 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(...)
只有清单里登记过的事件才能被重放------这保证了"旧版本读不懂的新事件不会悄悄坏掉"。
读取聚合(readAggregate) 按 after 序号增量读取:
ts
const after = input.after ?? -1
// SELECT ... FROM event WHERE aggregate_id = ? AND seq > ? ORDER BY seq LIMIT ?
最新序号 由 EventSequenceTable 单独维护(latestSequence),避免每次 max() 查询。
这套设计的直接收益:
- 进程重启可重建:会话的历史就是事件流,重放即可;
- 任意点续传 :客户端记住
after序号,断线后从该序号继续,天然支持 SSE 续传; - 审计与迁移:事件是唯一的"事实源",投影(projector)可以随意重建而不污染事实;
- 为分布式铺路 :
aggregateID就是未来的分片键。
对应的代价也很明显:读路径变重 (要重放或维护投影),事件 Schema 的演进需要版本管理。opencode 用 SessionProjector(core/src/session/projector.ts)把事件流投影成消息视图,正是为了缓解读路径的成本。
5.5 客户端如何消费:从事件流到 SSE
事件溯源的价值最终要体现在"客户端能拿到什么"上。V2 为消费者提供了两条通道,语义被严格区分(CONTEXT.md 用大段文字强调这一点):
(1)sessions.events({ sessionID, after })------持久化会话事件流(可重放、可续传)。
- 先验证 Session 存在,再按
after(聚合序号)重放已提交的持久事件,之后持续推送新提交事件; - 它是"会话这个聚合"的完整事实源,SSE 传输;
- 断线后,客户端记住最后一条的序号,用
after重新订阅即可续传------不需要重放整个会话; - 注意:不含"仅实时存在"的片段(如心跳之外的一些生命周期碎片)。
(2)events.subscribe()------实例级实时流(不可重放)。
- 面向"整个实例的实时活动"(会话与非会话事件都有);
- 无重放保证,含连接、心跳、实例销毁等生命周期事件;
- 断线后不能补课,消费者必须"刷新权威状态"再重新订阅。
两条通道的 schema、重放保证、游标、失败行为都不同。对集成者的启示很直接:
- 要"可靠的会话历史同步" → 用
sessions.events+after续传; - 要"实时 UI 驱动" → 用
events.subscribe,并把断线重连设计成"先拉状态、再开流"。
(3)投影(Projector)。 事件流是"事实",但消费者要的是"视图"。SessionProjector(packages/core/src/session/projector.ts)把持久事件投影成消息视图(SessionMessage),这也是为什么"读历史"可以走投影、而"审计/重建"可以重放事件------两层各司其职。
5.6 事件类型面:从 TUI 到 LSP
schema 包里事件类型极多,覆盖了各个子系统:session-event、server-event、tui-event、ide-event、lsp-event、mcp-event、installation-event、workspace-event、vcs-event、worktree-event、session-status-event、session-compaction-event......这提示我们:在 opencode 里,几乎所有子系统都在向"事件"收敛。UI 监听事件、服务端发事件、外部系统订阅事件,事件总线(EventV2Bridge、event-manifest)是各层之间的胶水。
5.7 工具调用状态机:ToolState 详解
SessionMessage.ToolState 是"工具调用生命周期"的模型(packages/schema/src/session-message.ts),它把一次工具调用描述为:
ts
type ToolState =
| { status: "pending" } // 已声明,尚未执行
| { status: "running" } // 执行中
| { status: "completed", output: ToolOutput } // 完成,带输出
| { status: "error", error: string } // 失败
| { status: "cancelled", reason: string } // 被取消
为什么状态机要显式建模?因为它让任何消费者都能无歧义地判断"工具调用现在处于哪个阶段" :TUI 据此渲染"运行中"的旋转动画,集成者据此决定"等还是继续",审计据此追责。ToolOutput 进一步区分 text(文本)与 managed(Managed Tool Output File------完整输出落盘,正文只留引用),这对应第 7 章"输出边界化"的设计。
5.9 事件持久化与序号:怎么保证不丢不重
EventV2 的持久化模型(packages/core/src/event/sql.ts):
event表 :主存持久事件,行 = 一个 SerializedEvent(id、type、seq、aggregateID、data);event_sequence表 :{ aggregateID, sequence },记每个聚合的最新序号------latestSequence查询走这张表,O(1);- 序号分配 :写入时取当前
seq + 1,事务内完成(SQLite 单写者保证不并发冲突)。
续传协议因此非常简洁:客户端记住 latestSequence,断线后 after=latestSequence 重订,服务端只重放序号更大的事件。这与 Kafka 的 offset 思想同构,但落在 SQLite 上------对小而可靠的系统,事件溯源不需要一个独立的消息中间件。
另有一个容易误读的细节:events.subscribe()(实例级实时流)并不重放,它只推"此刻起"的事件;而 sessions.events({ after }) 重放+实时。两者的 schema 也刻意分开(SessionEvent vs InstanceEvent),避免消费者把两种语义混为一谈。
5.11 SessionMessage 的八种 type
packages/schema/src/session-message.ts 定义了消息判别联合(tagged union),type 字段共八种,每种都有专属内容结构:
| type | 语义 | 关键字段 |
|---|---|---|
user |
用户消息 | content: UserContent(text / image / file) |
assistant |
模型回复 | content: AssistantContent(text / reasoning / tool 三块) |
system |
系统消息 | content: SystemContent(被拒绝的工具调用等) |
meta |
元消息 | time、tokens、cost、model 等统计 |
step-start |
步骤开始 | seq、agent、model、cwd |
step-finish |
步骤结束 | seq、reason(tool-calls / stop / max-steps / error) |
snapshot |
快照 | 会话状态点 |
pending |
待提升 | parentID、content(未提升的受理输入) |
这条消息模型回答了一个高频问题:"前端怎么知道模型在思考 vs 在调工具?"------看 assistant.content 的三块(text/reasoning/tool)即可:有 tool 块 → 在调用工具;只有 reasoning → 在思考;step-finish 的 reason 告诉你在哪结束的。消息类型即 UI 状态,这正是 V2 相比 V1 parts 的进步。
5.13 AssistantContent 的三块结构
模型消息的内容(AssistantContent)是理解"模型一次回复里发生了什么"的关键。它由三块组成,互相独立、可同时存在:
| 块 | 内容 | 前端表现 |
|---|---|---|
text |
最终可见的回答文本 | 渲染为回复正文 |
reasoning |
模型思考过程(思维链) | 折叠/灰色展示,可选展开 |
tool |
工具调用声明与结果 | 渲染为"调用了什么工具、结果如何"卡片 |
一次典型回复的演化:先出现 reasoning(思考),再出现 tool(调用),最后 text(总结)------配合 step-start/step-finish,前端可以按"步骤"组织展示。对集成者更重要的是:三块彼此独立 意味着你可以只消费 text(做摘要)、只消费 tool(做工具审计)、或全量消费(做完整复刻)。这是 V1 的 parts 序列很难给到的确定性。
5.14 小结
消息与事件层回答了 Agent 的"记忆"问题:V1 用自由的 parts,V2 用类型化的消息联合 + 事件溯源。对读者而言,最有迁移价值的是"持久化清单 + 单调序号 + 聚合 ID"这个事件溯源三元组------它不复杂,却能让"会话可重放"成为可能。
下一章,我们离开存储,进入 Agent 的"大脑皮层":System Context------opencode 对 System Prompt 的代数化重构。
6. System Context:取代 System Prompt 的代数系统
几乎所有 Agent 系统都会遇到同一个难题:如何把"系统提示词"组织得既不臃肿、又可更新、还能被审计 ?传统做法是在每次请求前拼接一大堆系统提示文本,然后祈祷模型记得住。opencode 在 V2 里给出了一个相当激进的答案:把 System Prompt 拆成一套可组合、可版本化、可重放的代数系统 ------System Context。这一章我们基于 CONTEXT.md 的术语体系和 packages/core/src/system-context/ 的实现,完整拆解它。
6.1 术语体系:先统一语言
CONTEXT.md 是这份设计的"词汇表",其中几个核心概念值得先建立:
| 术语 | 含义 | 避免混淆的说法 |
|---|---|---|
| System Context | 呈现给模型的"结构化上下文事实集合"(初始指令 + 按时间追加的更新) | System Prompt |
| Session History | 经过投影的"对话历史"(含压缩与 Context Epoch 截断) | Session Context |
| Context Source | 一个独立观测的、带类型的上下文值:稳定 key + JSON codec + 无失败加载器 + 纯渲染器 | Prompt fragment |
| System Context Registry | Location 作用域内、有序注册的 Context Source 生产者 | --- |
| Mid-Conversation System Message | 一条持久的、按时间排序的指令消息,告诉模型某个 Context Source 的最新状态 | system update / raw text diff |
| Context Epoch | 一个"不可变基线"的存续期:从首次渲染 Baseline System Context 开始,到压缩/迁移/不兼容变更结束 | --- |
| Baseline System Context | 一个 Context Epoch 开始时渲染的完整系统上下文 | live system prompt |
| Context Snapshot | 模型不可见的 JSON 状态,用于比较每个 Source 与上次提交给模型的值 | --- |
| Safe Provider-Turn Boundary | 每次模型调用前、输入受理与工具结算之后的那个安全边界,上下文变更在此被按序接纳 | --- |
这套词汇表的潜台词是:"系统提示词"不是一个字符串,而是一个随时间演化的、可观测的值域。
6.2 代数化的三个操作
packages/core/src/system-context/index.ts 把 System Context 抽象为三个核心操作(源码注释里写得很直白):
ts
SystemContext.initialize(...) // 观测一次组合后的 System Context,产出新的 Baseline + Snapshot
SystemContext.reconcile(...) // 观测一次组合后的 System Context,返回唯一动作:不变 / 已更新 / 可替换 / 替换被阻塞
SystemContext.replace(...) // 在压缩完成等"基线替换"场景渲染新的一代
SystemContext.make(...) 则把不同类型的 Context Source 统一成一个不透明值(隐藏具体值类型,让不同来源可以混合组合);SystemContext.combine(...) 按调用者顺序组合。
关键设计约束(来自 CONTEXT.md Relationships):
- 懒采样,绝不推 :Context 变更只在安全边界被采样和接纳,Source 变化时不会异步推送。空闲会话永远不会被上下文变更唤醒。
- 原子推进 :
Context Snapshot与对应的持久化Mid-Conversation System Message原子地一起前进;多个 Source 的变更在同一个安全边界合并成一条系统消息。 - 顺序固定:新受理的用户输入、结算完的工具结果,先于合并后的系统消息进入模型请求。
- 确定性:Registry 并发评估生产者,但按稳定的贡献 key 顺序组合,保证渲染结果确定。
这些约束合在一起,避免了经典的"系统提示飘移"问题:你永远知道模型在某一轮看到的系统上下文是哪个版本,因为它在进入请求前被快照冻结了。
6.3 一个安全边界的完整时序
把 6.2 的约束拼成一条时间线,一个 provider turn 的"上下文接纳"是这样的:
markdown
[上一轮结束]
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.4 内置 Context Source
packages/core/src/system-context/builtins.ts 注册了一批内置 Source,registry.ts 实现按稳定 key 管理的注册表。已确认的内置来源包括:
- 日期(date):默认保持宿主本地日历日期行为,未来可被用户配置的时区替换;
- 指令(instructions) :把全局与项目
AGENTS.md文件当作一个有序聚合的 Context Source,在每个安全边界观察一次;变更时整条系统消息包含完整的有序指令集,若指令清空则明确告知模型"先前指令不再适用"; - Skill 引导(available-skill guidance):只列出当前 agent 被允许的技能名称与描述(权限过滤),技能正文与位置只通过"权限检查过的 skill 工具"暴露------这是"引导与执行分离"的安全设计。
内置 Source 通过 Registry 与插件/自定义 Source 共存,插件注册与热重载则被明确列为待办("Plugin-defined context registration and hot-reload lifecycle remain a follow-up")。
6.5 与执行的交互:不干扰当前轮
System Context 与执行引擎的交互规则非常精细:
- 选中的 agent 与模型在provider turn 开始时 采样;此边界之后发生的变更作用于下一轮,不会重启当前轮;
- agent 切换若导致 skill 引导变化,会产生一条
Mid-Conversation System Message,但保留当前基线; - 工具授权与待决的权限请求保留"发起那轮的 agent 策略",切换 agent 不能改变已发出调用所属的权限语义;
- 压缩(compaction)开启新的 Context Epoch:新基线 + 新快照,旧的系统消息离开模型历史但保留在持久审计历史中;
- 模型/provider 切换保留当前 Epoch 与对话历史,新选择作用于下一轮。
一句话总结:System Context 与"正在执行的模型轮次"之间有一条严格的时钟边界------上下文不会像幽灵一样在请求中间漂移。
6.6 崩溃与不可用:stale-while-revalidate
上下文观测是"无失败加载器"(infallible loader)------读取失败不等于"没有值"。系统区分两种情况:
- Unavailable Context(临时不可观测):运行时保留上一个生效状态,不发更新,直到首次成功加载;
- 成功的"空":这可能触发"移除渲染"(removal renderer),明确告诉模型该来源已消失。
首次 provider turn 渲染基线时,若某来源不可用,则阻塞本轮 而不是持久化一个残缺的基线------这保证了基线永远是完整的。SystemContext.reconcile 返回"替换被阻塞"正是用于这种场景。
6.7 Registry 与组合:确定性从何而来
packages/core/src/system-context/registry.ts 实现了 System Context Registry。两个关键设计:
(1)稳定 key + 命名空间。 每个贡献者(producer)注册时带一个稳定的贡献 key(如 date、instructions、skill-guidance)。key 全局唯一------重名会组合失败(duplicate keys fail composition)。命名空间让内置源与插件源互不干扰,也让"移除某个来源"成为可能(按 key 摘除,其源在下个安全边界自动消失)。
(2)并发评估 + 稳定排序。 Registry 并行评估所有生产者(快),但组合时按稳定的贡献 key 顺序排列(SystemContext.combine(...) 保持调用者顺序,Registry 场景则按 key 排序)------所以渲染结果确定:同样状态下,两次渲染逐字一致。这个"确定性"是模型缓存命中(provider cache)的前提:基线文本不变,缓存前缀就有效。
实现上,SystemContext.make(source) 把每个来源的值类型藏进不透明类型,让"类型不同"的来源可以统一组合;SystemContext.initialize/reconcile/replace 三个动作分别对应"首次渲染基线"、"常规增量比较"、"压缩后重建"。
6.8 指令源:AGENTS.md 如何进入上下文
opencode 的"项目指令"来源是 AGENTS.md 系列文件。指令服务(packages/opencode/src/session/instruction.ts)在安全边界做一次聚合观测:
- 观测范围:全局 AGENTS.md(
~/.config/opencode/AGENTS.md)+ 项目目录向上逐级查找的 AGENTS.md; - 聚合为一个有序集合,作为一个 Context Source(stable key:
instructions); - 指令集变化时,产生的 Mid-Conversation System Message 包含完整的新指令集,并显式"取代"先前值;指令清空时,消息明确说明"先前指令不再适用";
- 通过
OPENCODE_DISABLE_PROJECT_CONFIG可以禁用项目级发现,但全局指令仍生效; - 嵌套项目指令发现(子目录 AGENTS.md)被列为 follow-up------目前只做"向上"聚合。
结合第 11 章你会发现一个有趣的闭环:opencode 自己就在用 AGENTS.md 管理自己的开发(根目录那两份文档),这个机制对仓库内的一切 AI 协作者(包括它自己的 Agent)都生效。
6.9 与压缩(Compaction)的交互
上下文压缩(第 4 章 V1 的 SessionCompaction、V2 的 compact 待办)与 System Context 的关系容易被忽略。两个原则:
- 压缩发生在安全边界 :压缩会截断 Session History("hard cut"),此时基线系统上下文需要重新渲染------
SystemContext.initialize重新建基线,replace交换新基线,投影到压缩后的消息序列; - Epoch 重启:压缩创建一个新 Context Epoch,旧 Epoch 的快照作废;模型看到的系统提示词文本不变(基线不变),但"历史"变短------这保证压缩不改变模型的行为语义,只改变记忆长度。
换句话说:压缩只影响"记忆",不影响"人格"(系统上下文是人格,历史是记忆)。这个区分在调试长会话问题时非常有用:回答风格漂移 → 看 System Context;忘记早期事实 → 看 History 截断。
6.11 内置 Context Source 详解
packages/core/src/system-context/builtins.ts 里内置了一批来源,逐个说清它的稳定 key 与内容(这也是"模型到底看到哪些系统上下文"的答案):
| stable key | 内容 | 何时变化 |
|---|---|---|
date |
当前日期/时间("现在几点") | 每轮安全边界刷新 |
cwd |
工作目录(提醒模型"你在哪个项目") | 会话初始化/切换目录 |
instructions |
AGENTS.md 指令聚合 | 文件变化 |
agent |
当前 agent 的角色提示词(build/plan 人设) | 切换 agent |
skill-guidance |
技能目录引导("有哪些技能可用") | 技能加载 |
draft |
会话草稿上下文 | 草稿变化 |
todo |
任务清单状态 | 清单变化 |
project / project-info |
项目元信息(包名、脚本等) | 项目变化 |
这 8 个来源就是"模型知道自己是谁、在哪、几点了、有什么工具"的全部来源。它们被组合成一条稳定的基线文本------这就是为什么换 agent、改 AGENTS.md 不需要重启会话:来源是动态观测的,只在安全边界做增量替换。
6.13 System Context 与模型缓存
"确定性基线"有一个被低估的收益:provider-side prompt caching 命中率。Anthropic/OpenAI 等提供按前缀计费的缓存,前缀越长、越稳定,越省钱越快。System Context 的稳定渲染(同样状态 → 同样文本)保证:
- 基线的开头几万 token 长期不变 → 缓存几乎 100% 命中;
- 每次安全边界只替换"delta"(如日期),主体不动 → 前缀缓存不被击穿;
- 压缩/换 agent 才产生一次大更新,属于可接受的缓存失效点。
这不是玄学,而是可量化成本:长会话里,缓存命中与否可能差 3-10 倍 token 账单。opencode 把"上下文渲染确定性"作为一等约束(deterministic 语义写进 CONTEXT.md),与其说是洁癖,不如说是对模型 API 经济学的尊重。
6.15 快照与恢复:上下文的重建能力
Context Snapshot 不只是"为了缓存"。它是崩溃恢复的关键:事件溯源保证"发生过什么"不丢,快照保证"模型当时看到了什么"可重建。两者结合:
- 正常流程:安全边界推进 → 新快照冻结 → 模型以新基线继续;
- 崩溃恢复:从持久事件重放到最后安全边界 → 恢复当时快照 → 继续执行,不重跑已完成的 turn;
- 审计场景:任何人可以在任意时间点取出"那一刻模型的完整视野"(基线 + 历史),复现模型决策的输入。
这比"记录日志"强在哪?日志记录的是输出,快照记录的是输入。对需要"解释模型为什么这么干"的产品(合规、评测、调试),输入级重建是唯一可靠的答案来源。
6.17 小结
System Context 不是"拼提示词",而是一套有代数、有快照、有恢复、有确定性的领域模型。它把"模型看到什么"从偶然变成可推理、可审计、可复现的事实。这一章与第 5 章(事件溯源)一起,构成了 opencode 最值得偷师的两块设计------无论你用什么语言实现自己的 Agent 底座。
System Context 把"系统提示词"从一个自由文本升级为受控领域模型。即使你不打算照搬 Effect 实现,下面几条理念也值得带走:
- 上下文要可观测、可版本化:每个来源有稳定 key 与快照,模型看到的版本是可查询的;
- 变更要按序合并、原子推进:避免"半套新上下文 + 半套旧上下文"的中间态;
- 读取要容错但不可静默:临时不可用与真正缺失要分开处理;
- 引导与执行分离:让模型"知道有什么"与"能用什么"走不同的通道,权限检查放在执行侧。
下一章,我们看 Agent 的另一半:工具系统与 MCP------能力边界如何被定义、授权与结算。
下篇预告
上篇完成了对 opencode"骨架"的解剖:工程全景、构建链、V1/V2 双内核、消息与事件溯源、System Context。下篇将进入"能力与服务"层面------工具系统与 MCP、Provider 与模型目录、HTTP 服务面、TUI 与桌面壳、配置/Skill/权限,以及把 opencode 嵌入自有业务系统的完整实战路径(Python 客户端骨架 + Qt/C++ 集成要点 + 安全加固),并附术语表、FAQ、API 速查等附录。