OpenCode 源码拆解(二):Token 怎么省?——上下文管理的 5 个设计模式

基于 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.tsprepareOnce 函数揭示了真实机制:

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) 或 `a map(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) }
}

翻译:

python 复制代码
def 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 命中率(省钱),对话上下文通过压缩和修剪来控制总长度(防溢出)。两类机制完全独立,各管各的。


相关推荐
晨曦中的暮雨1 小时前
Learn Claude Code:CodeAgent 与后端程序大搭配——定时任务、Git Worktree 和MCP 服务
git·ai·llm·agent
chaors3 小时前
DeepResearchSystem 0x07:搜索缓存
llm·agent·ai编程
一个处女座的程序猿3 小时前
AI之Interview:Claude Code之父Boris Cherny深度访谈—删除80%提示词、产品悬余、解缚思维与AI编程的范式转移
agent·claude·harness
星栈14 小时前
oh-my-pi工程级AI编码工使用体验
人工智能·后端·agent
周末程序猿14 小时前
LLM智能路由实践:通过 Harness 工程节约模型成本
人工智能·agent
玉鸯18 小时前
多 Agent 系统通信的实现原理与最佳实践
llm·agent·mcp
Tsonglew18 小时前
OpenWorker 代码解剖:一个 AI 同事"敢让它干活"的工程学
agent·ai编程
测试开发技术20 小时前
AI 测试提效 | 告别手工写脚本,分享我的 Playwright + Skill 批量生成 UI 自动化脚本方案
自动化测试·人工智能·ui·自动化·agent·skill·ai测试