上下文压缩:让 Agent 在有限窗口中继续工作

前面几章已经把 Agent 的运行过程拆开:模型产生工具调用,运行时执行工具,再把结果写回历史。这个循环每走一轮,上下文都会变长。普通对话增加的通常只是几段文字,编码 Agent 却会写入文件内容、搜索结果、编译日志和多个工具调用,一次结果就可能占用数千 Token。

模型的上下文窗口再大,也不能改变历史持续增长这件事。当输入接近上限时,运行时必须回答三个问题:什么时候开始压缩,哪些内容可以丢,压缩后怎样组成一份仍可继续工作的历史。

Microsoft Agent Framework、OpenCode 和 Codex 都实现了上下文压缩,但它们的职责不同。MAF 提供可以组合的压缩策略,决策权交给应用开发者;OpenCode 作为完整产品,会先清理旧工具结果,再生成结构化摘要;Codex 同时支持本地压缩和服务端压缩,还要把压缩检查点写入可恢复的会话记录。

压缩的对象不是数据库中的历史

理解上下文压缩前,先要区分“保存的历史”和“本轮发送给模型的历史”。压缩通常改变后者,不应简单理解为从数据库中删除旧消息。

一份完整历史至少承担三种职责:

  1. 作为下一次模型请求的输入。
  2. 供界面展示完整对话和工具执行过程。
  3. 供会话恢复、诊断和审计使用。

如果为了节省 Token 而物理删除工具结果,界面就无法展开旧输出,恢复会话时也无法解释模型为何做出某个决定。因此,成熟实现通常保留原始记录,再投影出一份较短的模型上下文。

正在渲染 Mermaid 图表...

压缩也不应等到 provider 已经返回上下文溢出错误才开始。模型还需要为本轮输出预留空间,运行时本身也可能继续注入系统提示、工具定义和环境信息。实际预算更接近下面这个关系:

inputBudget=contextWindowmax{maxOutput  reserve}\text{inputBudget}=\text{contextWindow}-\max\lbrace\text{maxOutput}\;\text{reserve}\rbrace

其中 reserve 是运行时留下的安全余量。OpenCode 默认保留 20000 Token 缓冲,MAF 的 ContextWindowCompactionStrategy 用上下文窗口减去最大输出得到输入预算,Codex 则根据模型和 TokenBudget 配置计算自动压缩边界。它们形式不同,但目的相同:在请求真正失败前腾出空间。

压缩一般分为两层。第一层处理低密度内容,例如旧工具输出和冗长日志;第二层才让模型把较早的对话提炼成摘要。直接对全部历史做一次摘要虽然简单,却会同时损失最近对话的精确措辞、工具调用的关联关系和用户原始要求。

一次压缩怎样完成

三个项目都可以放进“触发、降载、重建”这条处理链中,只是每一步的控制权和数据结构不同。

阶段要解决的问题常见做法
触发何时已经不能继续原样发送Token 预算、消息数、轮次数、provider 溢出信号、手动命令
降载优先减少哪部分内容截断工具输出、折叠工具结果、滑动窗口、LLM 摘要、直接排除旧消息
重建下一轮模型最终看到什么摘要加最近原文、保留用户消息、重新注入初始上下文、记录压缩边界

工具结果通常最先被处理。它们体积大,但真正影响后续决策的往往只有命令是否成功、错误原因、修改了哪些文件等少量事实。完整输出仍可留在持久化层,需要时重新读取;模型上下文只保留预览、占位文本或提炼后的结果。

模型摘要则是有损操作。摘要提示必须面向“怎样继续任务”,而不是泛泛复述聊天内容。OpenCode 的模板固定保留目标、重要约束、已完成工作、当前工作、阻塞项、下一步和相关文件;Codex 把摘要称为交接信息,要求下一个模型延续已有工作。两者都在保护任务状态,而不是追求语言上的完整概括。

重建阶段还要保持消息协议合法。工具调用和工具结果通常必须成对出现,不能只删除其中一条。系统指令、环境信息和最近用户要求也不能因为摘要而消失。压缩系统真正困难的地方不在“写摘要”,而在于准确维护这些边界。

三套源码实现

MAF:把压缩拆成可组合策略

MAF 是通用框架,不替应用决定唯一的压缩方式。src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs 定义了策略基类,其中 Trigger 判断是否开始压缩,Target 判断何时停止。默认 Target!Trigger,也就是一直处理到触发条件不再成立。

策略不会直接在 List<ChatMessage> 上随意删消息。CompactionMessageIndex 先把历史组织成 CompactionMessageGroup,再由策略排除或替换消息组。Assistant 发出的函数调用和随后返回的工具结果会进入同一个工具调用组,因此不会产生只有调用、没有结果的孤儿消息。

MAF 当前提供的主要策略如下:

策略行为
ToolResultCompactionStrategy把较早的工具调用组折叠为简短结果,优先减少低密度输出
SummarizationCompactionStrategy调用另一个 IChatClient 总结旧消息,默认至少保留最近 8 个消息组
SlidingWindowCompactionStrategy按用户轮次保留最近窗口,整轮排除旧内容
TruncationCompactionStrategy直接排除最老的非系统消息组,适合作为最后兜底
ChatReducerCompactionStrategy把已有的 IChatReducer 接入同一套压缩管道

PipelineCompactionStrategy 可以按顺序执行多个策略。官方示例 samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs 采用“工具结果折叠、摘要、滑动窗口、截断”的顺序。这个顺序很重要:先做信息损失较小的处理,仍然超限时才进入更激进的阶段。

如果应用只知道模型窗口和最大输出,可以直接使用 ContextWindowCompactionStrategy

var strategy = new ContextWindowCompactionStrategy(
    maxContextWindowTokens: 128_000,
    maxOutputTokens: 16_000);

AIAgent agent = chatClient.AsBuilder()
    .UseAIContextProviders(new CompactionProvider(strategy))
    .BuildAIAgent();

它先计算 InputBudgetTokens = maxContextWindowTokens - maxOutputTokens,默认在输入预算达到 50% 时折叠工具结果,达到 80% 时截断旧消息。两个阶段都至少保留最近 2 个消息组。这里没有自动加入 LLM 摘要,因为框架无法替应用选择摘要模型、成本和提示词。

真正把策略接入 Agent 循环的是 CompactionProvider。它在模型调用前取得累计消息,创建或增量更新 CompactionMessageIndex,执行策略,然后通过 GetIncludedMessages() 只投影未排除的消息。消息组及排除状态保存在 AgentSession.StateBag,所以下一轮不必从头推断压缩边界。

如果 ChatClientAgentSession.ConversationId 表明历史由远端服务管理,CompactionProvider 会跳过本地压缩。由压缩策略生成的摘要消息还会被标记为 ChatHistory 来源,避免调用结束后作为新消息再次写入历史。

OpenCode:先清工具输出,再建立会话检查点

OpenCode 同时保留旧的 V1 会话链路和新的 V2 runner。两条实现使用不同的持久化表和边界字段,但处理思路一致:在真正生成摘要前,先限制工具输出;需要摘要时,保留最近内容,并基于上一份摘要增量更新。

V1 的轻量处理叫 prune,位于 packages/opencode/src/session/compaction.ts。它从后向前扫描已完成的工具结果,最近两个用户轮次不动,最近累计 40000 Token 的工具输出不动,skill 工具也受保护。只有可清理内容超过 20000 Token 才会执行。

prune 不删除工具 Part,也不清空保存的输出,而是在 part.state.time.compacted 上记录时间。下一次构造模型消息时,这段输出会变成:

[Old tool result content cleared]

因此原始数据仍然存在,但当前产品没有把“撤销 prune”作为公开操作,不能把它理解成用户可随时恢复的功能。

当历史仍然超过预算时,OpenCode 使用隐藏的 compaction agent 生成摘要。该 agent 默认合并一条 "*": "deny" 权限规则,并在请求中传入空工具集,使压缩过程只产生文本,不再通过工具制造新历史。用户配置仍可能覆盖 agent 权限,所以“源码上绝对不能调用工具”并不是准确表述。

摘要不是自由格式文本。packages/core/src/session/compaction.ts 中的模板要求固定输出:

Objective
Important Details
Work State: Completed / Active / Blocked
Next Move
Relevant Files

工具结果在交给压缩模型前最多保留 2000 个字符,避免压缩请求自己先撑爆窗口。新的 V2 工具输出还会经过 ToolOutputStore.bound,默认预览上限是 2000 行或 50KB;超出的完整内容写入托管文件,上下文中只留下预览和路径。

V1 会尽量原样保留最近两个用户轮次,Token 预算默认取可用输入的 25%,并限制在 2000 到 8000 Token 之间。较早内容进入摘要,保留尾部的起点写入 CompactionPart.tail_start_id。压缩完成后,一条带 CompactionPart 的 user 消息和一条 summary: true 的 assistant 消息组成压缩边界。下一轮由 MessageV2.filterCompacted 将它们重排为“摘要在前、最近原文在后”。

V2 不再使用 tail_start_idpackages/core/src/session/compaction.ts 直接把历史分为 headrecent,生成的 compaction message 同时保存 summaryrecent。构造模型请求时,它被渲染为 conversation checkpoint。默认设置是自动压缩、20000 Token 缓冲,并保留约 8000 Token 的最近内容。

两条链路都会读取上一份摘要:

const prompt = buildPrompt({
  previousSummary,
  context,
})

提示词要求保留仍然成立的事实、删除已经过时的状态,再合并新历史。这样做不是把“摘要的摘要”不断套娃,而是维护一份持续更新的任务检查点。

自动触发也有两层保护。V1 根据上一轮实际 Token 用量判断是否超过 usable,并允许 provider 的 overflow 信号进入压缩流程;V2 在发送请求前估算 system、messages 和 tools 的总量,超过 context - max(output, buffer) 就先压缩。如果 V2 首次请求仍收到上下文溢出,它会执行一次 compactAfterOverflow 并重跑;恢复后再次溢出则停止,不会无限压缩重试。

Codex:压缩是一种可恢复的会话事件

Codex 的内存历史由 ContextManager 管理,持久化历史则写入 rollout JSONL。rollout 不只记录模型消息,还记录 TurnContextWorldStateCompactedItem 等运行时事件。因此,压缩不是覆盖一个字符串,而是产生一个可以恢复的检查点。

自动压缩的入口集中在 codex-rs/core/src/session/turn.rs。它会在采样前检查 Token 状态,也能在轮中发现窗口不足后压缩;用户还可以通过 /compact 手动执行。切换到上下文更小或兼容性不同的模型时,ModelDownshiftCompHashChanged 也可能触发压缩。

run_auto_compact 会根据配置和 provider 能力选择执行路径:

路径实际行为
LocalCodex 自己调用模型生成交接摘要,再重建历史
Remote V1调用 provider 的远程压缩能力,客户端再清洗返回项
Remote V2使用新版远程压缩协议,并限制客户端保留消息的预算
TokenBudget开启新的 context window,不生成摘要

最后一项容易被误解。codex-rs/core/src/compact_token_budget.rs 复用了压缩事件和 hook 生命周期,但它做的是 start_new_context_window,不是第四种摘要算法。

本地压缩位于 codex-rs/core/src/compact.rs。它先把 SUMMARIZATION_PROMPT 作为合成用户输入追加到历史,让模型生成一份交接信息。如果连压缩请求都超过窗口,循环会从最老的历史项开始移除并重试,尽量保留最近消息。

得到摘要后,Codex 不会只留这一段文字。collect_user_messages 会收集真实用户消息,build_compacted_history_with_limit 在 20000 Token 预算内优先保留它们,再把带 SUMMARY_PREFIX 的摘要放到历史末尾。工具调用、工具结果和 reasoning 不会原样进入这份替换历史,它们的重要结论应已经进入摘要。

轮前压缩和轮中压缩对初始上下文的处理不同。轮前或手动压缩使用 InitialContextInjection::DoNotInject,清除旧基线,下一次正常 turn 会重新注入环境和指令。轮中压缩后要立刻继续当前生成,没有下一轮可等待,因此使用 BeforeLastUserMessage,把初始上下文插到最后一条真实用户消息之前,同时让摘要保持为历史末项。

远程压缩也不是把服务端结果直接塞回历史。codex-rs/core/src/compact_remote.rsshould_keep_compacted_history_item 会保留真实用户消息、assistant 消息、Agent 消息和压缩项,过滤 developer 消息、reasoning、函数调用及结果、Shell 调用、Web 搜索等运行细节。Remote V2 还设置 64000 Token 的保留消息预算,单条 Agent 消息最多保留 10000 Token,之后仍会经过同一套最终过滤规则。

替换历史时,Session::replace_compacted_history 会创建 CompactedItem,记录摘要、替换后的历史以及 window_numberfirst_window_idprevious_window_idwindow_id,再作为 RolloutItem::Compacted 写入 JSONL。恢复会话时,rollout_reconstruction.rs 可以据此还原替换历史、窗口链、初始上下文引用和 World State 基线。

Codex 还在工具输出进入历史时就应用截断策略。codex-rs/utils/output-truncation 支持按字节或 Token 从中间截断,保留更可能重要的开头和结尾,并加入原始 Token 数和总行数提示。这里不能简单写成“全局固定 10KB”,具体策略来自当前模型配置。

压缩成功后,Codex 会明确警告:长会话和多次压缩可能降低准确性,条件允许时应开启新会话。摘要能延长任务寿命,但不能无损恢复所有细节。

设计和选择压缩策略

三套实现放在一起,可以看到一些稳定规律:

维度MAFOpenCodeCodex
定位可组合框架能力产品内置的分级压缩可恢复会话子系统
触发开发者配置 Trigger 和 Target请求前估算、轮后用量、overflow、手动轮前、轮中、手动、模型切换
首要降载对象由管道决定,通常先折叠工具结果工具预览和旧工具结果工具输出先截断,压缩后过滤调用与结果
最近内容按组数或轮次保留V1 留尾,V2 保存 recent保留用户原始消息并重注入必要上下文
压缩边界消息组的排除状态V1 消息对,V2 checkpoint messageCompactedItem 与 window 链
持久化策略StateBag 保存消息索引状态V1/V2 各自保存消息和压缩记录rollout JSONL 保存完整压缩事件

如果在 MAF 中构建业务 Agent,先使用 ToolResultCompactionStrategy 降低工具噪声,再根据业务是否允许有损摘要决定是否加入 SummarizationCompactionStrategy,最后用滑动窗口或截断兜底。不要只配置窗口大小,还要为最大输出留出预算。

如果设计的是 OpenCode 这类编码产品,应把输出限制放在工具层。一次测试产生十万行日志时,等会话接近窗口上限再摘要已经太晚。完整输出可以落盘,模型只接收预览;旧结果逐步退出上下文;摘要只负责保存已经被 Agent 消化过的事实。

如果系统要求跨重启恢复长任务,应参考 Codex,把压缩作为显式事件持久化。只保存最终摘要不足以解释历史是怎样替换的,也无法正确恢复窗口编号、环境基线和压缩后的模型输入。

无论采用哪种实现,都要避免四个常见错误:删除单条工具调用或结果而破坏配对关系;把系统指令一起压掉;反复对摘要做无约束再摘要;把 provider 报错当作唯一触发点。压缩应该在安全预算内主动发生,并留下明确边界。

上下文压缩解决的是“怎样继续”,不是“怎样永远不遗忘”。工具输出可以重新读取,事实可以写入摘要,原始历史可以保存在持久化层,但每次摘要仍会损失一部分细节。任务已经偏离原目标或经历多次压缩时,开启新会话并显式交接,通常比继续压缩更可靠。