opencode 上下文压缩拆解:压缩与溢出是两条线,压完之后你会丢什么

2026-08-04

本文基于 opencode 仓库 commit 7fe9938(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/anomalyco/opencode 最新代码与文档为准。

在 opencode 里,“上下文快满了”和”这次请求真的被拒了”走的是两套不同的代码路径,它们最后汇到同一个压缩流程,但进入时携带的参数不一样,结果也不一样。 把这两件事混成一件,你就会解释不了一个常见现象:有时候压缩完 Agent 自己接着干活了,有时候压缩完却把你上一条指令原样重新发了一遍。这不是随机的,是 overflow 这个标志位决定的。

opencode 是一个跑在终端里的开源编码 Agent 项目,采用 MIT 许可证(LICENSE,Copyright 2025 opencode)。它会在你的机器上执行 shell 命令、直接改你的代码文件、把代码内容发给模型服务商,所以它怎么裁剪你的会话历史,不只是”省 token”的效率问题,也决定了它接下来在信息缺失的状态下会对你的仓库做什么。

站内已有几篇相邻的文章:Hermes 的上下文压缩 讲的是另一个常驻自托管 Agent 项目的做法,Agent 上下文管理上下文工程 讲的是不挑框架的通用方法论;本篇只干一件事——把 opencode 这一家的实现按文件读一遍,告诉你压完之后具体丢了什么。

一、两条线:预判溢出,和真的撞墙

判定逻辑集中在 packages/opencode/src/session/overflow.ts,整个文件只有两个导出函数,短到可以整段读完:

export function isOverflow(input: {
  cfg: ConfigV1.Info
  tokens: SessionV1.Assistant["tokens"]
  model: Provider.Model
  outputTokenMax?: number
}) {
  if (input.cfg.compaction?.auto === false) return false
  if (input.model.limit.context === 0) return false

  const count =
    input.tokens.total || input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write
  return count >= usable(input)
}

两个细节值得注意。第一,统计口径把缓存读写(cache.readcache.write)也算进了总量,也就是说命中缓存并不会让你”少占位置”。第二,模型没有声明上限(limit.context === 0)时它直接返回 false,压缩机制在这类模型上等于不存在。

usable 负责算出”可用额度”:优先扣掉配置里的 compaction.reserved,没配就取 COMPACTION_BUFFER 和该模型最大输出之间的较小值;如果模型单独声明了输入上限就从输入上限里扣,否则从总上下文里扣掉最大输出。留这一手的原因很直白——压缩本身也要发一次请求、也要产出一段摘要,如果不预留空间,压缩这一步自己就会失败。

这是第一条线:预判。它在两个地方被调用。一处是 packages/opencode/src/session/prompt.ts 的主循环,每轮开始前检查上一条已完成的助手消息,条件是它本身不是摘要消息且已经越线,越线就调 compaction.create({ ..., auto: true }) 插一条压缩任务,然后 continue 重新进循环。另一处是 packages/opencode/src/session/processor.ts 处理流式事件时,在这一轮用量结算的分支里判定,越线就把 ctx.needsCompaction 置为 true

第二条线是撞墙processor.ts 里的 halt 函数捕获异常,如果解析出来是 ContextOverflowError,行为分两种:配置里 compaction.auto === false 且当前消息不是摘要消息,就把错误落到消息上、发 Session.Event.Error、把会话状态置回 idle,到此为止;否则同样把 ctx.needsCompaction 置为 true,也发一条错误事件,但流程继续。

两条线都把 ctx.needsCompaction 拉起来,而流的消费侧写着 Stream.takeUntil(() => ctx.needsCompaction),一旦置位当前这轮就不再往下读。处理器最终返回字符串 "compact",主循环拿到它去创建压缩任务,关键就在这一行:

yield* compaction.create({
  sessionID,
  agent: lastUser.agent,
  model: lastUser.model,
  auto: true,
  overflow: !handle.message.finish,
})

overflow 取的是”这条助手消息压根没跑到 finish”。也就是说,预判路径下 overflow 是 undefined,撞墙路径下才是 true。这个标志位一路传到压缩流程里,直接改变压缩之后的续跑方式,第三节会讲到。

二、压缩那一轮是一次独立调用,提示词也是单独一份

很多人以为压缩是在主对话里追加一句”请总结前面的内容”。opencode 不是这么做的。packages/opencode/src/agent/agent.ts 里注册了一个名叫 compaction 的内置 agent:

compaction: {
  name: "compaction",
  mode: "primary",
  native: true,
  hidden: true,
  prompt: PROMPT_COMPACTION,
  permission: Permission.merge(
    defaults,
    Permission.fromConfig({
      "*": "deny",
    }),
    user,
  ),
  options: {},
},

hidden: true 意味着它不出现在给你挑的 agent 列表里,"*": "deny" 意味着这一轮什么工具都不许调。压缩流程调用处理器时传的也是 tools: {},空对象。这一点是有意义的安全边界:负责读你全部历史的那一轮调用,本身没有执行能力,不会趁机去跑 shell 或者改文件。

它的系统提示词就是 packages/opencode/src/agent/prompt/compaction.txt,整份不到十行,开头一句定了性质:

You are an anchored context summarization assistant for coding sessions.

Summarize only the conversation history you are given. The newest turns may be kept verbatim outside your summary, so focus on the older context that still matters for continuing the work.

注意 “anchored”(锚定)这个用词和第二句的前提:最新的几轮会在摘要之外被逐字保留,所以摘要的职责是覆盖更早、但仍然影响后续工作的那部分。提示词还明确要求:遇到 <previous-summary> 块就当成当前锚定摘要,保留仍然成立的细节、删掉过期的、并入新事实;不要回答对话本身,不要提及自己在做摘要或压缩;用与对话相同的语言回复。

输出结构不在这份系统提示词里,而在 packages/core/src/session/compaction.tsSUMMARY_TEMPLATE,由 buildPrompt 拼成用户消息。packages/opencode/src/session/compaction.ts 第 23 行直接 import { buildPrompt } from "@opencode-ai/core/session/compaction" 复用了它。模板要求模型原样输出这套 Markdown 骨架、顺序不许改:## Objective## Important Details## Work State(下含 ### Completed### Active### Blocked)、## Next Move## Relevant Files。规则区还写了四条硬约束:

Rules:
- Keep every section, even when empty.
- Use terse bullets, not prose paragraphs.
- Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known.
- Do not mention the summary process or that context was compacted.

“每个小节都保留,哪怕是空的”和”原样保留文件路径、符号、命令、错误串、URL 和标识符”这两条,暴露了这套设计真正在防什么:摘要最怕的不是文字变少,是把路径写错、把报错信息模糊化。空小节保留则让下一次压缩能稳定地合并——buildPrompt 在有历史摘要时给的指令是”用上面的对话历史更新下面这份锚定摘要”,结构固定,合并才不会跑偏。

摘要用哪个模型?看 agent.modelcompaction agent 配了模型就用它,没配就沿用当前这条用户消息的模型。配置入口是 packages/core/src/v1/config/config.tsagent 结构下的 compaction 字段。

三、留多少、丢多少:head 与 tail 的切分

真正决定”你会丢什么”的是 packages/opencode/src/session/compaction.ts 里的 select 函数。它把消息按用户轮次切成若干 Turn,取最近 compaction.tail_turns 个(DEFAULT_TAIL_TURNS 是 2),从最近的一轮往前累加估算大小,能装进预算的整轮保留。预算由 preserveRecentBudget 给出:配了 compaction.preserve_recent_tokens 就用配的,没配就在 MIN_PRESERVE_RECENT_TOKENSMAX_PRESERVE_RECENT_TOKENS 之间取可用额度的四分之一。

如果某一轮太大装不下,splitTurn 会在这一轮内部从第二条消息开始逐条往后试,找到第一个”从这里到轮末刚好塞得进剩余预算”的起点。都试不出来就记一条 tail fallback 日志,这一轮整个进 head。

切分结果是 head(送去做摘要的部分)和 tail_start_id(逐字保留的起点消息 ID)。tail_start_id 会写回那条压缩消息的 part 上,packages/opencode/src/session/message-v2.tsfilterCompacted 靠它重排消息,文件里的注释把重排后的顺序写得很清楚:[compaction-user, summary, ...retained tail..., continue-user]。数组位置从此不再等于时间顺序,这也是同一个文件里 latest 函数改用消息 ID 单调递增来取”最近一条”的原因。

head 在送进摘要模型之前还要过一道加工:先 structuredClone 复制一份,触发一次消息变换插件,然后调用 toModelMessagesEffect 时带上两个选项——stripMedia: truetoolOutputMaxChars: TOOL_OUTPUT_MAX_CHARSTOOL_OUTPUT_MAX_CHARS 定义为 2000 字符)。所以摘要模型看到的历史,图片等媒体已经被剥掉,每条工具输出最多两千字符。它给你写摘要时依据的历史,本身就已经是删节版。

压完之后怎么续?分岔就在第一节说的 overflow 标志位:

  • 没带 overflow(预判路径):走 experimental.compaction.autocontinue 插件钩子,默认 enabled: true,插入一条 synthetic: true 的用户消息,文本是 Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed.,并打上 metadata: { compaction_continue: true }(代码注释明说这个标记不是稳定的插件契约,可能变或消失)。Agent 就在摘要+尾部的基础上自己接着干。
  • 带 overflow(撞墙路径):往前找到上一条不含压缩 part 的真实用户消息,把它作为 replay 重新插入一条新用户消息,并把参与摘要的消息截到它之前。重放时,属于媒体类型的文件 part 会被替换成一段文本占位,形如 [Attached <mime>: <filename>]。这就是开头说的”把你上一条指令原样重发一遍”——因为撞墙时那一轮压根没产出结果,重放才能接上。而如果往前找不到可用内容(hasContent 为假),replay 会被撤销、消息集合还原,退回普通压缩。

摘要这一步自己也可能再次失败。处理器返回 "compact" 时,压缩流程会把消息标成 ContextOverflowError 并结束,两种文案区分得很细:走了重放路径是 Conversation history too large to compact - exceeds model context limit,没走重放是 Session too large to compact - context exceeds model limit even after stripping media。你在终端里看到后一句,说明连剥掉媒体都不够,这个会话基本只能重开。

组成部分它负责什么对应仓库位置你什么时候会碰到它
isOverflow / usable判定是否越线、算出可用额度并预留缓冲packages/opencode/src/session/overflow.ts会话没报错却自己开始压缩时
压缩主流程 process切分 head/tail、组装摘要请求、决定续跑方式packages/opencode/src/session/compaction.ts每次压缩都会走
compaction agent 系统提示词约束摘要者的身份与合并规则packages/opencode/src/agent/prompt/compaction.txt想改摘要风格时先读它
buildPrompt / SUMMARY_TEMPLATE定死摘要的五个小节 Markdown 结构与保留规则packages/core/src/session/compaction.ts摘要缺了你要的信息时
prune清空更早的已完成工具输出packages/opencode/src/session/compaction.ts显式开了 compaction.prune 之后
filterCompacted压缩后重排消息给模型消费packages/opencode/src/session/message-v2.ts排查”历史顺序怎么变了”
主循环触发点决定何时插压缩任务、传不传 overflowpackages/opencode/src/session/prompt.ts排查压缩为什么没触发
compaction 配置块auto / prune / tail_turns / preserve_recent_tokens / reservedpackages/core/src/v1/config/config.ts调参时

四、prune:默认关着的另一把刀,而且它改的是存储

压缩之外还有一条独立机制。prune 在主循环跑完之后被 fork 出去异步执行,第一句就是判断:

const cfg = yield* config.get()
if (!cfg.compaction?.prune) return

配置文档里写明 prune 默认为 false,要显式打开。它倒着遍历消息,规则有几条硬保护:遇到用户消息就计一次轮次,轮次不到 2 的一律跳过(保护最近的对话);碰到摘要消息直接跳出整个循环;PRUNE_PROTECTED_TOOLS 里列的工具(当前只有 skill)跳过;某个 part 已经带了 compacted 时间戳,说明前面清理过,跳出。

剩下的已完成工具输出按倒序累计估算量,累计值没超过 PRUNE_PROTECT 之前全部保护,超出的部分才进待清理列表。最后还有一道门槛:清理量必须大于 PRUNE_MINIMUM 才真的动手,避免为了几百个 token 去改一堆记录。

动手做的事是给 part 写上 part.state.time.compacted = Date.now() 然后 session.updatePart。这里是本节的重点:它写的是会话存储,不是这一次请求的临时视图。 message-v2.ts 在把消息转成模型消息时判断这个时间戳,命中就把输出整个换成字符串 [Old tool result content cleared],同时把附件数组清空。这个替换是不可逆的——原始工具输出不会再回到上下文里,你之后翻这条会话也看不回来。

五、边界与代价:这套设计放弃了什么

摘要不是无损的,而且它自己看到的历史就已经删节过。 送去摘要的 head 剥了媒体、工具输出截到 2000 字符。一个长命令的完整输出、一张你贴的截图,在摘要阶段就不在场了。模板要求保留精确路径和错误串,但保不住”当时那条 4 万字符的日志里第 3 万字符处的那行”。实际后果是 Agent 压缩后可能重新去读它十分钟前刚读过的文件、重跑刚跑过的命令——这不是它笨,是那段信息确实没了。

预算是估算的,不是精算的。 估算走的是把消息序列化成 JSON 之后交给 Token.estimate,这是估计而非真实分词。所以 reserved 这道缓冲是必需的,而不是可有可无的保险。

它不管跨会话记忆。 这套机制的作用域是单条会话内部:切分、摘要、重排、清理,全部围绕当前 sessionID。跨会话的知识沉淀不在它的职责范围里,那属于另一层设计,可以参照 Agent 上下文预算 里讲的分层思路。

它不保证压缩一定成功。 上面提到的两条错误文案就是它承认失败的方式。会话太大到剥完媒体仍然装不下时,它选择明确报错而不是无限截断。

多出来的一次外发。 压缩是一次真实的模型调用,head 那一大坨历史会被完整地再发一次给模型服务商。如果你给 compaction agent 单独配了一个跟主模型不同的服务商,那么你的代码历史就同时落到了两家。这一点在评估私有代码外泄面时必须算进去。各家服务商的数据使用规则不同且会调整,以官方最新说明为准。

自动续跑意味着信息更少时继续动手。 预判路径下默认插入的那条续跑消息会让 Agent 直接接着干活,而此刻它的上下文刚被换成一份摘要。如果这个会话对仓库有写权限,压缩点恰恰是误改风险最高的位置之一。

六、上手与避坑清单

压缩明明该触发却没动。 会踩是因为触发条件里有一道 input.model.limit.context === 0 的短路——模型元数据里没有上下文上限声明时,isOverflow 恒为 false,你会一路撞到服务商报错才走兜底路径。怎么避:先确认你用的模型在 opencode 这边有 limit.context,自定义 provider 尤其要查;同时确认环境变量 OPENCODE_DISABLE_AUTOCOMPACT 没被设上,它在 packages/core/src/flag/flag.ts 里注册,文档 cli.mdx 的环境变量表也列了。

以为关掉 compaction.auto 就只是”不自动压缩”。 会踩是因为这个开关同时改了撞墙路径的行为:processor.tshalt 里,auto === false 且当前不是摘要消息时会直接把错误抛给你、会话转 idle,不再尝试压缩自救。怎么避:把它当成”出错就停在我面前”的开关来用,适合你要保留完整现场做排查的场景,不适合长跑任务。

开了 prune 之后回头找不到工具输出。 会踩是因为它写的是持久化的 compacted 时间戳,不是临时裁剪,之后渲染一律是 [Old tool result content cleared]。怎么避:只在确实被工具输出撑爆的长会话里开;需要留证据的会话(安全审计、事故复盘)保持默认关闭。配置形如文档 config.mdx 给出的这段:

{
  "$schema": "https://opencode.ai/config.json",
  "compaction": {
    "auto": true,
    "prune": false,
    "reserved": 10000
  }
}

tail_turns 调得很大,指望多留点原文。 会踩是因为保留量真正的上限是 preserveRecentBudget 给出的 token 预算,而不是轮次数。轮次调大而预算没动,多出来的轮次照样装不下,最后还是被 splitTurn 从中间切开。怎么避:要多留原文就一起调 compaction.preserve_recent_tokens,两个参数配套改。

觉得摘要质量差就去改系统提示词。 会踩是因为决定输出形态的是 SUMMARY_TEMPLATE(在 packages/core/src/session/compaction.ts),compaction.txt 只管身份和合并规则。怎么避:先分清你不满意的是”该记的没记”还是”格式不对”,前者看模板那五个二级小节(Work State 下面还分 Completed/Active/Blocked)够不够用,后者才动提示词;另外流程里预留了 experimental.session.compacting 插件钩子,可以注入额外上下文或整体替换压缩提示词,比改源码更可维护。

排查时按数组顺序读消息。 会踩是因为 filterCompacted 重排过,压缩后数组位置不再是时间顺序。怎么避:认消息 ID 的单调性,别认下标。

压缩点之后直接放手让它继续改代码。 会踩是因为默认插入的续跑消息会让 Agent 立刻接着动手,而它此刻掌握的信息比压缩前少。怎么避:长跑任务在压缩发生后主动补一句关键约束(尤其是”不要动哪些文件”这类),或者用 experimental.compaction.autocontinue 钩子把自动续跑关掉,改由你确认后再推进。关于终端类 Agent 的权限边界,可以对照 开源终端 Agent 选型 那篇一起看。

收束:接下来该读哪几个文件

如果你要在自己的项目里复用这套思路,读的顺序建议是:先 packages/opencode/src/session/overflow.ts 弄明白判定口径(尤其是缓存也计入总量这件事),再 packages/opencode/src/session/compaction.tsselectprocessCompaction 看切分与续跑,最后 packages/core/src/session/compaction.ts 看模板长什么样。三个文件加起来不算长,一个下午能读完。

日常使用的自检清单可以简化成三问:我用的模型有没有上下文上限声明(没有的话压缩形同虚设);我开没开 prune(开了就等于放弃老工具输出的原文);压缩发生之后我有没有把关键约束再说一遍(默认它会自己往下干)。这三问答清楚了,你至少不会在压缩点上莫名其妙地丢掉工作成果。

本篇属于一个把开源AI 编程 Agent 项目 opencode逐层拆开讲的系列,整体地图见 opencode 是什么:终端里的开源 AI 编程 Agent 全景地图;沿着这条线往下,还可以看 opencode 会话全流程:消息组织、单轮步骤与中断重试的落点opencode 为什么备了 14 份系统提示词:开源终端编码 Agent 的提示词分家现实

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。