基于 OpenCode(sst/opencode)真实源码,拆解 Agent 系统中最核心的问题:如何高效管理 LLM 的上下文窗口。本文覆盖 5 个设计模式。
源码地址:github.com/sst/opencode · TypeScript monorepo · Effect 框架
没接触过 JavaScript/Effect? 本文会在每段 TS 代码旁边直接给出 Python 等价写法。
先搞清楚:你要说的"上下文"是哪一种?
"上下文"这个词在 Agent 开发里有两种含义,大多数人只想到第二种,但 OpenCode 两类都要管。先看每次调 LLM API 时 messages 数组里有什么:
sql
发给 LLM 的 messages 数组
│
├── [0] System Message(系统消息)
│ │
│ │ 这一层叫「系统上下文」------AI 工作的背景知识
│ │ 不随对话增长,但每轮可能变(日期变了、文件有新错误了)
│ │
│ ├── 基础提示词("你是 OpenCode...")
│ ├── 日期 / 环境变量(core/date、core/environment)
│ ├── AGENTS.md 项目规则
│ ├── LSP 诊断结果(代码哪里有错)
│ └── 配置项(模型选择、权限规则等)
│
├── [1..N] 对话消息
│ │
│ │ 这一层叫「对话上下文」------你和 AI 的一来一回
│ │ 随对话不断增长,直到超出窗口上限
│ │
│ ├── 你的提问(user)
│ ├── AI 的回答(assistant)
│ ├── 工具调用 + 返回结果(tool)
│ └── ...
│
└── [N+1..] compact 摘要(如果有)
压缩后的旧消息摘要,替代原始消息
两类上下文的特征完全不同:
| 系统上下文 | 对话上下文 | |
|---|---|---|
| 是什么 | 背景知识、环境状态 | 对话历史 |
| 谁产生的 | 系统自动注入 | 用户和 AI 一来一回 |
| 增长方式 | 固定大小,每轮可能微调 | 每轮单调增长 |
| 核心矛盾 | 怎么只发变化的部分,不全量重发 | 太长了怎么办------截断还是压缩 |
| 本文模式 | 模式 1、2、3 | 模式 4、5 |
OpenCode 把两类上下文的管理逻辑完全分开------系统上下文走 SystemContext 模块,对话上下文走 Session 模块。本文也按这个顺序讲:先系统上下文(怎么组装背景知识),再对话上下文(怎么处理消息爆炸)。
为什么上下文管理是 Agent 的命脉
LLM 的上下文窗口是稀缺资源------就像内存,但更贵。每多一个 token,你就多花钱、多等时间。
一个典型的 Agent 场景:10 轮对话,每轮 System Message 约 3000 token(项目指令 + 工具描述 + 环境信息)。
- 无优化:每轮都全量发送 → 10 轮 = 30,000 token 仅 System Message
- 有优化:只发变化部分 → 10 轮 ≈ 3,500 token
节省 88%。 这不是理论,是 OpenCode 用以下 5 个模式实现的。
| 上下文类型 | 问题 | 模式 |
|---|---|---|
| 系统 | 哪些指令文件该注入 | 模式 1:分层上下文加载 |
| 系统 | 每轮只发变化的 System Message | 模式 2:增量 Reconcile |
| 系统 | 全局/项目/环境变量不打架 | 模式 3:配置瀑布合并 |
| 对话 | 上下文溢出时压缩而非截断 | 模式 4:上下文压缩 |
| 对话 | 过时的工具输出自动遗忘 | 模式 5:工具输出修剪 |
第一部分:系统上下文------怎么组装背景知识
模式 1:分层上下文加载(AGENTS.md findUp + claims 去重)
源码位置: packages/opencode/src/session/instruction.ts
系统上下文 vs 对话上下文: 这个模式管的是 System Message 里 AGENTS.md 规则的加载------AI 的"工作手册",不是对话历史。
背景:AGENTS.md 是什么
AGENTS.md 是项目根目录(或子目录)里的一个 Markdown 文件,里面写着给 AI 的工作规则 ,比如"本项目用 React 18,不要用 class component"、"测试用 vitest,跑 npm test"、"数据库迁移文件不要改"。OpenCode 启动时会把它的内容塞进 System Message,让 AI 知道这个项目的规矩。
一个大型项目可能有多个 AGENTS.md,分布在不同层级:
perl
my-project/
├── AGENTS.md ← 全局规则:"用 vitest,不要用 jest"
├── src/
│ ├── AGENTS.md ← src 层规则:"所有导出用具名导出"
│ ├── auth/
│ │ ├── AGENTS.md ← auth 层规则:"密码处理用 bcrypt"
│ │ └── login.ts
│ └── payment/
│ ├── AGENTS.md ← payment 层规则:"金额计算用 decimal.js"
│ └── charge.ts
问题是:AI 应该加载哪些 AGENTS.md?全部叠加?还是只取一部分?
核心设计决策:first-match-wins(只取最近的一个)
你可能以为 AI 会把目录链上所有层级的 AGENTS.md 都加载叠加。但 OpenCode 只取最近的一个。
如果你在 src/auth/ 目录下启动对话,找到了 src/auth/AGENTS.md,就不会 再加载 src/AGENTS.md 或项目根的 AGENTS.md。
为什么?因为不同层级的规则可能冲突。与其在 System Message 里堆一堆互相矛盾的指令让 AI 自己判断,不如只给它最贴近当前工作目录的那一份。
两个加载时机
但"只取最近的一个"带来一个问题:AI 在对话过程中会读不同目录的文件。它一开始在 src/auth/ 下工作,后来去读 src/payment/charge.ts------这时 src/payment/AGENTS.md("金额计算用 decimal.js")对 AI 是有用的,但启动时没加载它。
OpenCode 的解法是两个加载时机:
| 时机 | 函数 | 做什么 | 例子 |
|---|---|---|---|
| 启动时(静态) | systemPaths |
加载全局 AGENTS.md + 从当前工作目录向上找到的第一个项目 AGENTS.md | 你在 src/auth/ 启动 → 加载全局 + src/auth/AGENTS.md,到此为止 |
| AI 读文件时(懒加载) | resolve |
AI 每读一个文件,检查这个文件所在目录链上是否有还没加载过的 AGENTS.md,有就注入 | AI 读 src/payment/charge.ts → 发现 src/payment/AGENTS.md 还没加载 → 注入它 |
启动时只覆盖"当前工作目录",AI 后续探索其他目录时按需补载。下面分别看代码。
时机 1:启动时静态加载(systemPaths)
csharp
// packages/opencode/src/session/instruction.ts · systemPaths
// 全局文件列表(优先级从高到低)
const globalFiles = [
path.join(global.config, "AGENTS.md"),
// 兼容 Claude Code 的 CLAUDE.md
...(!flags.disableClaudeCodePrompt ? [path.join(global.home, ".claude/CLAUDE.md")] : []),
]
// 搜索时按优先级排序
const instructionFiles = ["AGENTS.md", "CLAUDE.md", "CONTEXT.md"]
// 项目层:findUp 向上查找,第一个匹配就停(不叠加)
for (const file of instructionFiles) {
const matches = yield* fs.findUp(file, ctx.directory, ctx.worktree)
if (matches.length > 0) {
matches.forEach((item) => paths.add(path.resolve(item)))
break // <- 关键:找到第一个就停,不继续往上找
}
}
翻译:
csharp# Python 等价 import os def system_paths(ctx): paths = set() # 全局指令 paths.add(os.path.join(ctx.config_dir, "AGENTS.md")) # 项目指令:从当前目录向上找,找到第一个就停 current = ctx.directory while current.startswith(ctx.worktree): for filename in ["AGENTS.md", "CLAUDE.md", "CONTEXT.md"]: candidate = os.path.join(current, filename) if os.path.exists(candidate): paths.add(candidate) break # 找到就停,不继续往上 else: current = os.path.dirname(current) continue break # for-else:只有 break 了才到这里 return paths
TS 写法 含义 path.join(a, b)拼接路径,Python 的 os.path.join(a, b)yield* fs.findUp(file, dir, root)从 dir 开始向上查找 file,到 root 为止。Python 类似 os.walk向上遍历paths.add(...)添加到 Set(去重集合),Python 的 set.add()
用上面那个项目结构举例------你在 src/auth/ 目录启动对话:
bash
启动时 systemPaths 的执行过程:
1. 加载全局 ~/.config/opencode/AGENTS.md → "所有项目用 vitest"
2. 从 src/auth/ 向上找 AGENTS.md
src/auth/AGENTS.md 存在!→ 加入 paths
break ← 停!不继续往上找 src/AGENTS.md 和根 AGENTS.md
最终加载:全局 + src/auth/AGENTS.md
未加载:src/AGENTS.md、根 AGENTS.md、src/payment/AGENTS.md
时机 2:AI 读文件时懒加载(resolve)
启动时只加载了 src/auth/AGENTS.md。现在 AI 开始读 src/payment/charge.ts------这个文件在 src/payment/ 下,那里有 src/payment/AGENTS.md("金额计算用 decimal.js"),但启动时没加载它。
resolve 函数在 AI 每次读文件时被触发。它从被读取文件所在目录开始,向上走到项目根,沿途检查有没有还没加载过的 AGENTS.md:
csharp
// packages/opencode/src/session/instruction.ts · resolve
const resolve = Effect.fn("Instruction.resolve")(function*(
messages: SessionV1.WithParts[],
filepath: string, // AI 正在读取的文件路径,如 "src/payment/charge.ts"
messageID: MessageID, // 当前消息 ID
) {
const sys = yield* systemPaths() // 启动时已加载的集合
const already = extract(messages) // 之前对话轮次已注入的路径集合
const root = path.resolve(yield* InstanceState.directory)
const target = path.resolve(filepath)
let current = path.dirname(target) // 从 src/payment/ 开始
// 从文件所在目录开始,向上走到项目根
while (current.startsWith(root) && current !== root) {
const found = yield* find(current) // 当前目录有没有 AGENTS.md?
// 三重过滤(下面逐个解释)
if (!found || found === target || sys.has(found) || already.has(found)) {
current = path.dirname(current)
continue
}
let set = s.claims.get(messageID)
if (!set) { set = new Set(); s.claims.set(messageID, set) }
if (set.has(found)) { current = path.dirname(current); continue }
set.add(found)
const content = yield* read(found)
if (content) results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` })
current = path.dirname(current)
}
return results
})
翻译:
ini# Python 等价 def resolve(messages, filepath, message_id): sys = system_paths() # 启动时已加载的 already = extract_injected_paths(messages) # 之前轮次已注入的 results = [] current = os.path.dirname(filepath) # 从被读文件所在目录开始 while current.startswith(root) and current != root: found = find_agents_md(current) # 三重过滤 if not found or found == filepath or found in sys or found in already: current = os.path.dirname(current) continue if found in claims.get(message_id, set()): current = os.path.dirname(current) continue claims.setdefault(message_id, set()).add(found) content = read_file(found) if content: results.append({"filepath": found, "content": content}) current = os.path.dirname(current) return results
TS 写法 含义 Map<MessageID, Set<string>>key 是消息 ID,value 是字符串集合。Python 的 dict[str, set[str]]s.claims.get(messageID)从 Map 中取值,Python 的 claims.get(message_id)path.dirname(p)取父目录,Python 的 os.path.dirname(p)
继续上面的例子------AI 读 src/payment/charge.ts 时,resolve 的执行过程:
scss
resolve("src/payment/charge.ts") 执行过程:
current = "src/payment/"
→ 找到 src/payment/AGENTS.md
→ sys 里有吗?没有(启动时只加载了 src/auth/ 的)
→ already 里有吗?没有(第一次读 payment 目录的文件)
→ claims 里有吗?没有(同一条消息里第一次遇到)
→ 三重过滤都通过!注入 src/payment/AGENTS.md
current = "src/"(继续向上)
→ 找到 src/AGENTS.md
→ sys 里有吗?没有
→ already 里有吗?没有
→ claims 里有吗?没有
→ 通过!注入 src/AGENTS.md
current = 项目根
→ 找到根 AGENTS.md
→ sys 里有吗?没有
→ already 里有吗?没有
→ claims 里有吗?没有
→ 通过!注入根 AGENTS.md
最终注入了 3 个之前没加载过的 AGENTS.md
三重过滤:每重解决什么问题
resolve 在向上走的过程中,对每个找到的 AGENTS.md 做三重检查,只有全通过才注入。这三重不是冗余,而是各自防一种重复:
| 过滤条件 | 防的是什么 | 不过滤会怎样 |
|---|---|---|
sys.has(found) |
启动时 systemPaths 已经加载过的(全局 + 当前目录的 AGENTS.md) | AI 读当前目录的文件时,把启动时已经加载的 AGENTS.md 又注入一遍 |
already.has(found) |
之前对话轮次已经通过 resolve 注入过的 | AI 第 2 轮、第 3 轮读同一目录的文件,每轮都重新注入一遍同一个 AGENTS.md |
claims.has(found) |
同一条消息内,AI 连续读多个文件时已经注入过的 | AI 一轮对话里读了 3 个 src/payment/ 下的文件,src/payment/AGENTS.md 被注入 3 遍 |
already 是跨轮次的(从历史消息里提取),claims 是轮次内的(只在当前消息 ID 的集合里检查)。两层一起确保:整个 Session 生命周期中,每个 AGENTS.md 最多被注入一次。
模式 2:增量 Reconcile(Source + 四态状态机)
源码位置: packages/core/src/system-context/index.ts · packages/core/src/system-context/builtins.ts · packages/core/src/session/context-epoch.ts
系统上下文 vs 对话上下文: 这个模式管的是 System Message 里日期、环境变量、LSP 状态等"动态背景信息"的增量更新。
先搞清楚:LLM 提示词缓存(Prompt Cache)是什么
要理解这个模式为什么这么设计,必须先理解 LLM 提供商的计费规则。这是整个模式的动机。
事实一:LLM API 是无状态的。 每次请求,你都要把完整的 messages 数组从头到尾发一遍。模型不会"记得"上一次你发过什么------它每次都从零开始处理全部输入。这一点改不了。
事实二:但提供商的服务器可以缓存中间计算结果。 LLM 处理输入时,会为每个 token 计算一种叫"KV Cache"的中间数据。如果两次请求的输入前缀完全相同,服务器就可以复用上次的 KV Cache,跳过重复计算------这就是"提示词缓存"(Prompt Cache)。
事实三:缓存匹配规则是严格前缀匹配,从第一个字节开始。
css
第 1 次请求的 messages:
[系统消息 A] [用户消息 1] [助手回复 1] [用户消息 2]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
服务器计算并缓存所有 token 的 KV Cache
第 2 次请求的 messages:
[系统消息 A] [用户消息 1] [助手回复 1] [用户消息 2] [助手回复 2] [用户消息 3]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^
前缀逐字节相同 → 这部分命中缓存 新增部分 → 正常计算
编译类比(给 Python/Java 开发者): 这就像增量编译。如果你改了项目里最后加的一个
.java文件,之前编译过的所有.class文件都能复用,只编译新增的那个。但如果你改了某个底层公共模块,所有依赖它的文件都要重新编译------前缀一变,后面的缓存全部失效。
事实四:缓存命中的部分,计费降到约 10%。 以 Anthropic Claude 为例(OpenAI、Google 类似):
| 状态 | 计费(相对于标准价) | 触发条件 |
|---|---|---|
| 缓存未命中(cache miss) | 100%(全价) | 首次发送,或前缀发生了任何变化 |
| 缓存命中(cache hit) | ~10% | 前缀与之前某次请求逐字节相同 |
关键规则:前缀里改一个字符,整个缓存链全部失效。
css
朴素做法------每次把日期写进 System Message:
第 1 轮:[系统消息 "...日期:01-15..."] [对话...] → 全价
第 2 轮:[系统消息 "...日期:01-15..."] [对话...] → 命中!~10%
第 3 轮:[系统消息 "...日期:01-16..."] [对话...] → 改了一个字!全价!
^^^^^^^^^^^^^^^^^^^^^^^^ 缓存从这里开始全部失效
第 4 轮:[系统消息 "...日期:01-16..."] [对话...] → 命中!~10%
第 5 轮:[系统消息 "...日期:01-17..."] [对话...] → 又改了!又是全价!
→ 只要日期变,就全价。一个 3000 token 的 System Message,每天都要全价重算。
这就引出了 OpenCode 要解决的核心问题:怎么让 System Message 在日期、环境变量、LSP 状态不断变化的情况下,依然保持逐字节不变? 答案就是下面的 Reconcile 机制。
OpenCode 的实际做法:baseline 存数据库,不可变
源码 context-epoch.ts 的 prepareOnce 函数揭示了真实机制:
arduino
// packages/core/src/session/context-epoch.ts · prepareOnce(简化)
const [value, stored, compaction] = yield* Effect.all([
context, // 当前系统上下文
find(db, sessionID), // 从 SQLite 读上次存的 baseline
SessionHistory.latestCompaction(db, sessionID),
])
if (!stored) {
// 首次:生成完整 baseline,存入数据库
const generation = yield* SystemContext.initialize(value)
yield* insert(db, sessionID, generation) // baseline 存入 SQLite
return { baseline: generation.baseline, baselineSeq }
}
// 非首次:对比当前值和上次快照
const result = yield* SystemContext.reconcile(value, snapshot)
if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") {
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
// ^^^^^^^^^^^^^^^^^^^^^^^^ 原封不动复用数据库里的旧 baseline!
}
if (result._tag === "Updated") {
// baseline 不变!只是发一条增量事件
yield* events.publish(
SessionEvent.ContextUpdated,
{ sessionID, text: result.text }, // delta 文本变成对话里的一条消息
)
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
// ^^^^^^^^^^^^^^^^^^^^^^^^ 还是旧 baseline!
}
if (result._tag === "ReplacementReady") {
// 只有这种情况才重写 baseline
yield* replace(db, sessionID, baselineSeq, result.generation)
return { baseline: result.generation.baseline, baselineSeq }
}
翻译:
python# Python 等价 def prepare_once(db, context, session_id): stored = db.find_context_epoch(session_id) # 从数据库读上次的 baseline if not stored: # 首次调用:生成完整 baseline,存入数据库 generation = system_context_initialize(context) db.insert_context_epoch(session_id, generation.baseline, generation.snapshot) return {"baseline": generation.baseline} # 后续调用:对比当前值和上次快照 result = system_context_reconcile(context, stored.snapshot) if result["tag"] == "Unchanged": return {"baseline": stored.baseline} # 直接复用旧的! if result["tag"] == "Updated": # baseline 不变,只发一条增量事件 events.publish("ContextUpdated", {"text": result["text"]}) return {"baseline": stored.baseline} # 还是旧的! if result["tag"] == "ReplacementReady": # 只有这种情况才重写 baseline db.replace_context_epoch(session_id, result["generation"].baseline) return {"baseline": result["generation"].baseline}
关键:baseline 文本一旦写入数据库,就不会被 Updated 修改------只有 ReplacementReady 才会重写它。 Updated 的 delta 文本不走 System Message,而是作为对话里的一条独立消息。
现在回头对照前面的缓存机制,这就完全说得通了:
- baseline 不变 → System Message 前缀逐字节不变 → 前缀严格匹配缓存 → 命中 ~10% 计费
- delta 追加在对话尾部 → 前缀没变,不影响缓存命中,只是多了一条 ~20 token 的小消息
如果换用朴素做法(把新日期直接写进 System Message),前缀从变化点开始全部失效,整个 3000 token 的 System Message 都要全价重算。Reconcile 的全部精巧设计,本质就是在"信息必须更新"和"缓存不能失效"之间找到一条路。
核心接口:Source
整个增量 Reconcile 系统围绕一个接口构建------Source<A> 是一个泛型接口,<A> 是类型参数,表示这个数据源存储的数据类型。类似 Java 的 Source<A> 或 Python 的 Source[A]:
typescript
// packages/core/src/system-context/index.ts
export interface Source<A> {
readonly key: Key // 唯一标识,如 "core/date"
readonly codec: Schema.Codec<A, Json> // 序列化器,用于比较值是否变化
readonly load: Effect.Effect<A | Unavailable> // 加载当前值
readonly baseline: (current: A) => string // 首次完整文本
readonly update: (previous: A, current: A) => string // 增量文本
readonly removed?: (previous: A) => string // 被删除时的通知文本
}
翻译:
python# Python 等价(用 Protocol 表示接口) from typing import Protocol, TypeVar, Callable A = TypeVar("A") class Source(Protocol[A]): key: str # 唯一标识 # codec 对应序列化器,Python 里可以用 pickle 或 json def load(self) -> A | Unavailable: ... # 加载当前值 def baseline(self, current: A) -> str: ... # 首次完整文本 def update(self, previous: A, current: A) -> str: ... # 增量文本 def removed(self, previous: A) -> str: ... # 被删除时的通知文本(可选)
Schema.Codec<A, Json>是什么: Effect 框架提供的序列化器。把一个内存对象(类型 A)转换成 JSON 字符串用于存储和比较,也能反向解析回来。后面 reconcile 对比新旧值时就用它来判断「值变了没有」。Python 里类似json.dumps(obj)+json.loads(str)的组合。
以 core/date 为例:
javascript
// packages/core/src/system-context/builtins.ts
SystemContext.make({
key: SystemContext.Key.make("core/date"),
codec: Schema.toCodecJson(Schema.String),
load: DateTime.nowAsDate.pipe(Effect.map(date => date.toDateString())),
baseline: (date) => `Today's date: ${date}`,
update: (_previous, date) => `Today's date is now: ${date}`, // <- 变化时只说"now"
})
翻译:
ruby# Python 等价 class DateSource: key = "core/date" def load(self): return datetime.now().strftime("%Y-%m-%d") def baseline(self, current): return f"Today's date: {current}" # 首次 def update(self, previous, current): return f"Today's date is now: {current}" # 变了,加"is now"
TS 写法 含义 Today's date: ${date}模板字符串,Python 的 f"Today's date: {date}".pipe(Effect.map(fn))管道操作, a.pipe(b)等价于b(a)。Python 可读为fn(a)或 `amap(fn)`
注意 baseline 说的是 "Today's date: 2024-01-15",而 update 说的是 "Today's date is now: 2024-01-16"------多了 "is now" 两个字,这告诉 LLM 这是更新而非首次声明。
四态状态机
每次新一轮对话开始时,reconcile 函数对比当前观测值和上一次的 Snapshot,产出四种可能结果:
| 状态 | 含义 | baseline 怎么办 | 额外消息 |
|---|---|---|---|
| Unchanged | 所有 source 值都没变 | 复用数据库里的旧 baseline | 无 |
| Updated | 部分 source 变了 | 不改,还是旧 baseline | 发一条 delta 消息(如"日期变成了 16") |
| ReplacementReady | 结构变了(增减 source) | 重写数据库里的 baseline | 无(新 baseline 本身就包含全部信息) |
| ReplacementBlocked | 想替换但某个 source 暂时不可用 | 复用旧 baseline | 无(等它恢复) |
为什么 Updated 不改 baseline?因为改了就会破坏 prompt cache------System Message 前缀一变,整个缓存失效,下一轮全价计费。把 delta 放在对话消息里,System Message 不变,缓存持续命中。
kotlin
function reconcileObservation(entries, previous) {
for (const entry of entries) {
if (entry._tag === "Unavailable") continue // 跳过不可用的
const stored = getSnapshot(previous, entry.key)
if (!stored) continue
const compared = entry.compare(stored.value)
if (compared._tag === "Incompatible") return { _tag: "Replace" } // 类型不兼容->全量替换
comparisons.set(entry.key, compared)
}
// 检查被删除的 source
for (const key of Object.keys(previous).sort()) {
if (keys.has(Key.make(key))) continue
if (previous[key].removed === undefined) return { _tag: "Replace" } // 没有 removed 函数->全量替换
}
}
翻译:
csharp# Python 等价 def reconcile_observation(entries, previous): comparisons = {} for entry in entries: if entry.tag == "Unavailable": continue # 跳过暂时不可用的 stored = previous.get(entry.key) if not stored: continue compared = entry.compare(stored["value"]) if compared["tag"] == "Incompatible": return {"tag": "Replace"} # 类型不兼容,全量替换 comparisons[entry.key] = compared return {"tag": "Updated", "comparisons": comparisons}
unavailable vs removed 的精妙区分
javascript
function replaceObservation(entries, previous) {
// 如果某个已存在的 source 现在 unavailable 了 -> BLOCKED
if (entries.some(e => e._tag === "Unavailable" && getSnapshot(previous, e.key) !== undefined))
return { _tag: "ReplacementBlocked" }
return { _tag: "ReplacementReady", generation: initializeObservation(entries) }
}
翻译:
pythondef replace_observation(entries, previous): for e in entries: if e.tag == "Unavailable" and e.key in previous: return {"tag": "ReplacementBlocked"} # 有 source 暂时不可用,等它 return {"tag": "ReplacementReady", "data": initialize(entries)}
如果一个 LSP server 正在重启(暂时 unavailable),它的 source 不会被删除------系统进入 ReplacementBlocked 状态,保留旧内容等它回来。只有它被永久移除了(不在 entries 里了),才触发真正的删除通知文本。
这个区分避免了「LSP 慢一点就丢上下文」的问题。
模式 3:配置瀑布合并(标量覆盖 + 数组拼接)
源码位置: packages/opencode/src/config/config.ts
系统上下文 vs 对话上下文: 这个模式管的是 System Message 里配置项的组装------模型选择、权限规则、自定义指令等,这些也是 AI 的"工作参数",属于背景知识。
配置优先级层次(从低到高)
markdown
1. 内置默认值
2. 全局配置 ~/.config/opencode/config.json
3. 全局环境变量 OPENCODE_*
4. 项目配置 opencode.json
5. 项目环境变量 .env
6. 运行时 Flag
出乎意料的事实
你可能以为项目配置会覆盖全局配置。但对于数组类型(如 instructions),OpenCode 是拼接而非覆盖。
你可以在全局配置里写「所有代码注释用中文」,然后在项目配置里加上「本项目使用 React 18」------两者会被拼接到一起。
| 类型 | 合并策略 | 原因 |
|---|---|---|
| 标量(string/number/boolean) | 高优先级覆盖低优先级 | 只有一个值生效 |
| 数组(如 instructions) | 拼接 | 全局指令 + 项目指令都生效 |
| 对象 | 深度递归合并 | 各层级的字段共存 |
好的配置系统不是「谁覆盖谁」,而是「谁能叠加什么」------标量覆盖,数组拼接,对象深合并。
第二部分:对话上下文------怎么处理消息爆炸
模式 4:上下文压缩(Compact 替代截断)
源码位置: packages/opencode/src/session/compaction.ts · packages/opencode/src/session/overflow.ts · packages/core/src/session/compaction.ts
系统上下文 vs 对话上下文: 从这里开始切换到对话上下文------你和 AI 的一来一回产生的消息历史。系统上下文是固定大小的背景知识,对话上下文随对话不断增长,迟早会超出窗口。
截断 vs 压缩
| 方式 | 做法 | 后果 |
|---|---|---|
| 截断 | 删掉最早的消息 | AI 会忘记之前讨论了什么 |
| 压缩 | 让 LLM 读一遍旧消息,写一段结构化摘要替代 | AI 还记得关键决策,只是不记得每句话的原话 |
OpenCode 选择压缩。下面逐步拆解它具体怎么做的。
第 1 步:什么时候触发
javascript
// packages/opencode/src/session/overflow.ts
const COMPACTION_BUFFER = 20_000 // 预留 buffer,硬编码常量
export function usable(input) {
const reserved =
input.cfg.compaction?.reserved ?? // 可配置覆盖
Math.min(COMPACTION_BUFFER, maxOutputTokens(...))
return input.model.limit.input - reserved // 可用上限 = 模型上限 - buffer
}
export function isOverflow(input) {
if (input.cfg.compaction?.auto === false) return false // 可以关闭自动压缩
return input.tokens.total >= usable(input) // 总 token >= 可用上限 → 触发
}
翻译:
python# Python 等价 COMPACTION_BUFFER = 20000 def usable(model, cfg): reserved = cfg.get("compaction", {}).get("reserved", min(COMPACTION_BUFFER, model.max_output_tokens)) return model.input_limit - reserved def is_overflow(tokens, model, cfg): if cfg.get("compaction", {}).get("auto", True) is False: return False return tokens["total"] >= usable(model, cfg)
每轮 assistant 回复完成后,处理器检查 isOverflow。例如模型上限 200000 token,buffer 20000,可用上限就是 180000------一轮对话结束时总 token 达到 180000 就触发压缩。也可以通过配置 compaction.auto: false 完全关闭。
第 2 步:压缩哪些,保留哪些
不是所有旧消息都被压缩。OpenCode 把消息分成两段:
bash
全部对话消息
├── head(旧消息)→ 让 LLM 压缩成摘要
└── tail(最近几轮)→ 原封不动保留
ini
// packages/opencode/src/session/compaction.ts · select 函数
const DEFAULT_TAIL_TURNS = 2 // 默认保留最后 2 轮
const limit = input.cfg.compaction?.tail_turns ?? DEFAULT_TAIL_TURNS
const budget = preserveRecentBudget(...) // 默认 min(8000, max(2000, usable * 0.25))
const recent = all.slice(-limit) // 取最后 N 轮
// 从后往前累加,直到超过 budget
for (let i = recent.length - 1; i >= 0; i--) {
if (total + size <= budget) { keep = ...; continue }
// 超预算时在单轮内截断拆分
}
翻译:
ini# Python 等价 DEFAULT_TAIL_TURNS = 2 # 默认保留最近 2 轮 def select(messages, cfg, usable_tokens): limit = cfg.get("compaction", {}).get("tail_turns", DEFAULT_TAIL_TURNS) budget = min(8000, max(2000, usable_tokens * 0.25)) recent = messages[-limit:] # 取最后 N 轮 keep_start = len(messages) total = 0 for msg in reversed(recent): # 从后往前 size = count_tokens(msg) if total + size <= budget: total += size keep_start -= 1 else: break # 超预算就停 return { "compress": messages[:keep_start], # head:要压缩的 "keep": messages[keep_start:], # tail:原样保留的 }
两个参数控制 tail 大小:
tail_turns(默认 2):保留最近几轮完整对话preserve_recent_tokens(默认 2000~8000):tail 的 token 预算上限,从后往前累加,超了就截断
head 里如果包含之前压缩产生的旧摘要,也会被隐藏掉不重复压缩(通过 hidden Set 过滤)。
第 3 步:摘要怎么生成------真实提示词
OpenCode 不会让 LLM 随意总结,而是给出一个固定的 Markdown 模板,要求 LLM 按结构填充:
shell
// packages/core/src/session/compaction.ts · SUMMARY_TEMPLATE
const SUMMARY_TEMPLATE = `
## Objective
## Important Details
## Work State (### Completed / ### Active / ### Blocked)
## Next Move
## Relevant Files
`
完整的提示词由 buildPrompt 函数拼接:
arduino
// packages/core/src/session/compaction.ts · buildPrompt
export const buildPrompt = (input) => [
input.previousSummary
? `Update the anchored summary below using the conversation history above.
Preserve still-true details, remove stale details, and merge in the new facts.
<previous-summary>${input.previousSummary}</previous-summary>`
: "Create a new anchored summary from the conversation history.",
SUMMARY_TEMPLATE,
...input.context, // 要压缩的旧消息
].join("\n\n")
System prompt(packages/opencode/src/agent/prompt/compaction.txt)的核心要求:
- 只总结给定的对话历史
- 如果有
<previous-summary>,把它当作当前锚点,更新而非重写 - 保留文件路径和标识符,用简洁的列表而非段落
- 不要回答对话本身,不要提"我在总结/压缩"
关键:摘要是"锚定摘要"(anchored summary),不是每次从零总结。 第二次压缩时,LLM 会拿到上次的摘要 + 新的旧消息,在旧摘要基础上更新------保留仍然成立的、删除过时的、合并新的事实。
第 4 步:摘要放哪,压缩后的消息结构
摘要生成后,存为一条新的 assistant 消息 (标记 summary: true),原始消息不删除------只是在下次发给 LLM 时过滤掉。
css
压缩前发给 LLM 的 messages:
[消息1] [消息2] [消息3] ... [消息97] [消息98] [消息99] [消息100]
|<------------- head(要压缩)------------->|<--- tail --->|
压缩后发给 LLM 的 messages:
[compaction-user] [summary-assistant] [消息99] [消息100] [消息101] [消息102]
|<--- 固定对 --->| |<- 旧 tail ->|<- 压缩后新消息 ->|
其中:
compaction-user 的文本是 "What did we do so far?"
summary-assistant 的文本是 LLM 按模板生成的摘要
arduino
// packages/opencode/src/session/compaction.ts · process 函数(简化)
const msg = {
role: "assistant",
mode: "compaction",
summary: true, // 标记为摘要消息
parts: [{
type: "text",
text: summaryResult, // LLM 生成的摘要全文
}],
}
原始消息完整保留在数据库里。 发给 LLM 时通过 filterCompacted 函数过滤------它找到最后一个 compaction 标记点,跳过 head 段的原始消息,只发送 [compaction-user, summary-assistant, tail, 新消息]。这意味着你可以 /undo 回到压缩前的完整状态,信息没有真正丢失。
总结:compact 的完整流程
bash
第 1 步:每轮对话结束,检查 token 总量 >= 可用上限?
├── 否 → 继续下一轮
└── 是 ↓
第 2 步:把消息分成 head(旧)和 tail(最近 2 轮)
├── tail:原封不动保留
└── head:发给 LLM 生成摘要 ↓
第 3 步:LLM 按固定模板生成锚定摘要
Objective / Important Details / Work State / Next Move / Relevant Files
(如果有上次摘要,在旧摘要基础上更新) ↓
第 4 步:摘要存为新 assistant 消息(summary: true)
原始消息不删除,但下次发给 LLM 时过滤掉 head 段 ↓
第 5 步:从 [compaction-user, summary-assistant, tail, ...] 继续对话
→ 回到第 1 步(如果又满了,再压缩一次)
这意味着一个任务可以在理论上无限跑下去------每次上下文快满了就压缩一轮,释放空间继续。代价是旧消息的细节会逐渐被摘要替代,但关键决策和文件路径通过模板化摘要保留下来。
模式 5:工具输出修剪(compacted 标记的差异化遗忘)
源码位置: packages/opencode/src/session/instruction.ts · extract 函数
系统上下文 vs 对话上下文: 这个模式是对模式 4 的补充------compact 之后,旧消息被摘要替代了,但对话历史里的工具调用结果(读文件的内容、跑命令的输出)该怎么处理?答案是:不同类型不同对待。
出乎意料的事实
你可能以为 compact 之后所有旧工具输出都被平等地压缩了。但 OpenCode 对不同类型的工具输出做了差异化处理------有的被遗忘,有的永远保留。
具体实现
dart
// extract 函数中的 compacted 检查
for (const part of msg.parts) {
if (part.type === "tool" && part.tool === "read" && part.state.status === "completed") {
if (part.state.time.compacted) continue // <- 被压缩过的跳过!
// 只收集未压缩的 read 路径
}
}
翻译:
dart# Python 等价 def extract(messages): paths = [] for msg in messages: for part in msg["parts"]: if part["type"] == "tool" and part["tool"] == "read": if part["state"]["status"] == "completed": if part["state"]["time"].get("compacted"): continue # 压缩过的,跳过 paths.append(part["state"]["time"]["path"]) return paths
差异化修剪策略
| 工具类型 | 会被 compacted? | 原因 |
|---|---|---|
| read | 是 | 文件内容可能已过时(AI 可能已修改了它) |
| write/edit | 是 | 写入操作已完成,只需保留结果摘要 |
| bash | 是 | 命令输出是瞬态的,旧输出几乎无价值 |
| skill | 否 | Skill 定义是静态知识,永远有效 |
不是所有工具输出都生而平等------瞬态数据应该被遗忘,永久知识应该被保留。
两类上下文怎么组合工作
系统上下文和对话上下文在一次对话轮次中是并行管理的,但职责分明:
lua
用户发消息
|
+-- 系统上下文线(模式 1/2/3)
| |
| +-- 1a. 分层加载 AGENTS.md(模式 1)
| | 全局 -> 项目 findUp(first-match)
| |
| +-- 1b. Reconcile 动态背景(模式 2)
| | 从数据库读上次的 baseline + snapshot
| | 对比当前值 vs snapshot
| | Unchanged -> 复用旧 baseline(缓存命中)
| | Updated -> 旧 baseline 不改!追加一条 delta 消息(缓存仍命中)
| | ReplacementReady -> 重写 baseline(缓存失效,但极少触发)
| |
| +-- 1c. 配置瀑布合并(模式 3)
| | 默认 -> 全局 -> 项目 -> Flag
| |
| +-- => 组装成 System Message [0](内容大多数轮次不变 -> prompt cache 持续命中)
|
+-- 对话上下文线(模式 4/5)
| |
| +-- 2a. 拼接历史消息 [1..N]
| | (如果上一轮有 Updated delta,它也在对话消息里)
| |
| +-- 2b. 检查 token 用量
| | 超阈值 -> compact(模式 4)
| | LLM 生成旧消息摘要
| |
| +-- 2c. 工具输出差异化遗忘(模式 5)
| | read/write/bash -> 可遗忘
| | skill -> 永久保留
| |
| +-- => 组装成 messages[1..N]
|
+-- 3. 发送 [System Message] + [对话消息] 给 LLM(全量重发,但 System Message 大多命中缓存)
|
+-- 4. LLM 返回 -> 执行工具 -> 回到步骤 1
关键洞察: 两种上下文优化的指标不同------系统上下文优化的是 prompt cache 命中率 (保持 System Message 前缀不变),对话上下文优化的是总消息体量(压缩 + 修剪)。每次请求仍然是全量重发,但 System Message 大多命中缓存,实际计费远低于标称 token 数。
实际的 Token 节省计算
回顾前面讲的缓存规则: 每次请求仍然全量重发 messages,但 System Message 前缀逐字节不变就能命中缓存(~10% 计费)。下面用具体数字算一下 Reconcile 到底省了多少钱。
以一个典型场景计算:5 个系统上下文 source(environment、date、AGENTS.md、LSP status、MCP tools),baseline 总共约 3000 token。
朴素做法(每次重写 System Message):
| 轮次 | System Message 内容 | Cache | 计费 token |
|---|---|---|---|
| 第 1 轮 | "...日期:01-15..." | miss | 3000 全价 |
| 第 2 轮 | "...日期:01-15..."(相同) | hit | 3000 × 10% = 300 |
| 第 3 轮 | "...日期:01-16..."(变了!重写) | miss | 3000 全价 |
| 第 4 轮 | "...日期:01-16..." | hit | 300 |
| ... | 每次日期变就 miss |
OpenCode 的做法(baseline 不可变 + delta 追加):
| 轮次 | System Message 内容 | Cache | 计费 token | 追加的 delta 消息 |
|---|---|---|---|---|
| 第 1 轮 | "...日期:01-15..." | miss | 3000 全价 | 无 |
| 第 2 轮 | "...日期:01-15..."(逐字节相同) | hit | 300 | 无 |
| 第 3 轮 | "...日期:01-15..."(还是不变! ) | hit | 300 | +20 token("日期现在是 01-16") |
| 第 4 轮 | "...日期:01-15..."(还是不变! ) | hit | 300 | 无 |
| ... | 除非 ReplacementReady,否则永远不变 |
| 10 轮总计 | 朴素做法 | OpenCode 做法 |
|---|---|---|
| System Message 计费 | ~21,000(每次日期变就全价) | ~5,700(只有第 1 轮全价,其余缓存价) |
| Delta 消息 | 0 | ~100 |
| 总系统上下文成本 | ~21,000 | ~5,800 |
真正的节省来自缓存命中率,不是"少发 token"。日期变了,朴素做法改 System Message → 缓存失效 → 全价;OpenCode 不改 System Message,追加一条 20 token 的消息 → 缓存持续命中 → 缓存价。
小结
| 上下文类型 | 模式 | 解决的问题 | 核心机制 | 你熟悉的概念 |
|---|---|---|---|---|
| 系统 | 分层上下文加载 | 哪些指令注入 | findUp first-match + 三重去重 | Python os.path.dirname 向上遍历 |
| 系统 | 增量 Reconcile | 保持 System Message 不变,delta 追加到对话 | Source 接口 + baseline 存 DB + 四态状态机 | Git 的不可变 commit + diff |
| 系统 | 配置瀑布合并 | 多来源配置不冲突 | 标量覆盖 + 数组拼接 | Spring 的 @PropertySource |
| 对话 | 上下文压缩 | 长对话不溢出 | compact 摘要替代截断 | LRU Cache 的 eviction |
| 对话 | 工具输出修剪 | 旧工具结果浪费 token | compacted 标记的差异化遗忘 | TTL 过期策略 |
这五个模式共同回答一个问题:怎么在有限(且昂贵)的上下文窗口里,让 AI 始终拥有最相关的信息。 系统上下文通过保持 baseline 不可变来最大化 prompt cache 命中率(省钱),对话上下文通过压缩和修剪来控制总长度(防溢出)。两类机制完全独立,各管各的。