做 AI Agent 开发的都需要考虑上下文爆炸的问题,不仅仅是成本问题,还有性能问题。
许多团队选择了压缩策略。但过度激进的压缩不可避免地导致信息丢失。
这背后的根本矛盾在于:Agent 需要基于完整的历史状态来决策下一步行动,但我们无法预知当前的某个观察细节是否会在未来的某个关键时刻变得至关重要。
前面一篇文章讲了整个上下文的管理策略,今天着重聊一下上下文管理的压缩策略和细节。
下面根据 Manus、Gemini CLI 和 Claude Code 这三个项目的源码来聊一下上下文的压缩。
三个产品,有三个不同的选择,或者说设计哲学。
Manus 的选择是:永不丢失。 他们认为,从逻辑角度看,任何不可逆的压缩都带有风险。所以他们选择了一条看似"笨拙"但实际上很聪明的路:把文件系统当作"终极上下文"。
Claude Code 的选择是:极限压榨。 92% 的压缩触发阈值,这个数字相当激进。他们想要榨干上下文窗口的每一个 token,直到最后一刻才开始压缩。
Gemini CLI 的选择是:稳健保守。 70% 就开始压缩,宁可频繁一点,也要保证系统的稳定性。
这三种选择没有对错,只是适用场景不同。接下来我们逐个分析。
1. Manus
Manus 的压缩最让我印象深刻的是其在可恢复性上的策略。
Manus 团队认为:任何不可逆的压缩都带有风险。你永远不知道现在丢掉的信息是不是未来解决问题的关键。
他们选择了不做真正的删除,而是将其外部化存储。
传统做法是把所有观察结果都塞进上下文里。比如让 Agent 读一个网页,它会把整个 HTML 内容都存下来,可能就是上万个 token。Manus 不这么干。
当 Agent 访问网页时,Manus 只在上下文中保留 URL 和简短的描述,完整的网页内容并不保存。需要重新查看网页内容时,通过保留的 URL 重新获取即可。这就像是你在笔记本上记录"参见第 23 页的图表",而不是把整个图表重新画一遍。
文档处理也是同样的逻辑。一个 100 页的 PDF 文档,Manus 不会把全部内容放进上下文,而是只记录文档路径、页数、最后访问的位置等元信息。当 Agent 需要查看具体内容时,再通过文件路径读取相应的页面。
Manus 把文件系统视为"终极上下文"------一个容量几乎无限、天然持久化、Agent 可以直接操作的外部记忆系统。
文件系统的层级结构天然适合组织信息。Agent 可以创建不同的目录来分类存储不同类型的信息:项目背景放一个文件夹,技术细节放另一个文件夹,错误日志单独存放。需要时按图索骥,而不是在一个巨大的上下文中大海捞针。
这不是简单的存储。Manus 训练模型学会主动使用文件系统来管理自己的「记忆」。当发现重要信息时,Agent 会主动将其写入特定的文件中,而不是试图把所有东西都记在上下文里。就像一个经验丰富的研究员,知道什么该记在脑子里,什么该写在笔记本上,什么该归档保存。
在 Manus 团队对外的文章开头,指出了为什么必须要有压缩策略:
第一,观察结果可能非常庞大。与网页或 PDF 等非结构化数据交互时,一次观察就可能产生数万个 token。如果不压缩,可能一两次操作就把上下文占满了。
第二,模型性能会下降。这是个很多人忽视的问题。即使模型声称支持 200k 的上下文窗口,但实际使用中,超过一定长度后,模型的注意力机制效率会显著下降,响应质量也会变差。这就像人的工作记忆一样,信息太多反而会降低处理效率。
第三,成本考虑。长输入意味着高成本,即使使用了前缀缓存等优化技术,成本依然可观。特别是在需要大量交互的场景下,成本会快速累积。
在具体实现过程中,可恢复压缩有几个关键点:
保留最小必要信息。对于每个外部资源,只保留能够重新获取它的最小信息集。网页保留 URL,文档保留路径,API 响应保留请求参数。这些信息占用的空间极小,但足以在需要时恢复完整内容。
智能的重新加载时机。不是每次提到某个资源就重新加载,而是根据上下文判断是否真的需要详细内容。如果只是确认文件存在,就不需要读取内容;如果要分析具体细节,才触发加载。
缓存机制。虽然内容不在上下文中,但 Manus 会在本地维护一个缓存。最近访问过的资源会暂时保留,避免频繁的重复加载。这个缓存是独立于上下文的,不占用宝贵的 token 额度。
2. Claude Code
Claude Code 的策略完全是另一个极端------他们要把上下文用到极致。
2.1 92% 的阈值
这个数字有一些讲究。留 8% 的缓冲区既保证了压缩过程有足够的时间完成,又避免了频繁触发压缩带来的性能开销。更重要的是,这个缓冲区给了系统一个「反悔」的机会------如果压缩质量不达标,还有空间执行降级策略。
javascript
const COMPRESSION_CONFIG = {
threshold: 0.92, // 92%阈值触发
triggerVariable: "h11", // h11 = 0.92
compressionModel: "J7()", // 专用压缩模型
preserveStructure: true // 保持8段结构
};
2.2 八段式结构化摘要
Claude Code 的压缩不是简单的截断或摘要,而是八段式结构。这个结构我们可以学习一下:
javascript
const COMPRESSION_SECTIONS = [
"1. Primary Request and Intent", // 主要请求和意图
"2. Key Technical Concepts", // 关键技术概念
"3. Files and Code Sections", // 文件和代码段
"4. Errors and fixes", // 错误和修复
"5. Problem Solving", // 问题解决
"6. All user messages", // 所有用户消息
"7. Pending Tasks", // 待处理任务
"8. Current Work" // 当前工作
];
每一段都有明确的目的和优先级。「主要请求和意图」确保 Agent 永远不会忘记用户最初想要什么;「关键技术概念」保留重要的技术决策和约束条件;「错误和修复」避免重复踩坑;「所有用户消息」则保证用户的原始表达不会丢失。
这种结构的好处是,即使经过多次压缩,Agent 仍然能保持工作的连贯性。关键信息都在,只是细节被逐步抽象化了。
2.3 专用压缩模型 J7
Claude Code 使用了一个专门的压缩模型 J7 来处理上下文压缩。这不是主模型,而是一个专门优化过的模型,它的任务就是理解长对话并生成高质量的结构化摘要。
javascript
async function contextCompression(currentContext) {
// 检查压缩条件
if (currentContext.tokenRatio < h11) {
return currentContext; // 无需压缩
}
// 调用专用压缩模型
const compressionPrompt = await AU2.generatePrompt(currentContext);
const compressedSummary = await J7(compressionPrompt);
// 构建新的上下文
const newContext = {
summary: compressedSummary,
recentMessages: currentContext.recent(5), // 保留最近5条
currentTask: currentContext.activeTask
};
return newContext;
}
AU2 负责生成压缩提示词,它会分析当前上下文,提取关键信息,然后构造一个结构化的提示词给 J7。J7 处理后返回符合八段式结构的压缩摘要。
2.4 上下文生命周期管理
Claude Code 把上下文当作有生命周期的实体来管理。这个设计理念很先进------上下文不是静态的数据,而是动态演化的有机体。
javascript
class ContextManager {
constructor() {
this.compressionThreshold = 0.92; // h11 = 0.92
this.compressionModel = "J7"; // 专用模型
}
async manageContext(currentContext, newInput) {
// 1. 上下文更新
const updatedContext = this.appendToContext(currentContext, newInput);
// 2. 令牌使用量检查
const tokenUsage = await this.calculateTokenUsage(updatedContext);
// 3. 压缩触发判断
if (tokenUsage.ratio >= this.compressionThreshold) {
// 4. 八段式压缩执行
const compressionPrompt = await AU2.generateCompressionPrompt(updatedContext);
const compressedSummary = await this.compressionModel.generate(compressionPrompt);
// 5. 新上下文构建
return this.buildCompressedContext(compressedSummary, updatedContext);
}
return updatedContext;
}
}
整个流程是自动化的。每次有新的输入,系统都会评估是否需要压缩。压缩不是一次性的动作,而是持续的过程。随着对话的进行,早期的详细内容会逐渐被抽象化,但关键信息始终保留。
2.5 优雅降级机制
当压缩失败时,系统不会死板地报错或者强行应用低质量的压缩结果,而是有一整套 Plan B、Plan C。这种"永不放弃"的设计理念,让系统在各种极端情况下都能稳定运行。
降级策略包括:
- 自适应重压缩:如果首次压缩质量不佳,会调整参数重试
- 混合模式保留:压缩旧内容,但完整保留最近的交互
- 保守截断:最坏情况下,至少保证系统能继续运行
2.6 压缩后的信息恢复
虽然 Claude Code 的压缩是有损的,但它通过巧妙的设计最小化了信息损失的影响。压缩后的八段式摘要不是简单的文本,而是结构化的信息,包含了足够的上下文让 Agent 能够理解之前发生了什么,需要做什么。
特别值得一提的是第 6 段"All user messages"。即使其他内容被压缩了,用户的所有消息都会以某种形式保留。这确保了用户的意图和需求不会在压缩过程中丢失。
2.7 实践指南
Claude Code 在实践中还有一些最佳实践:
- 定期使用
/compact
命令压缩长对话:用户可以主动触发压缩,不必等到自动触发 - 在上下文警告出现时及时处理:系统会在接近阈值时发出警告,用户应该及时响应
- 通过
Claude.md
文件保存重要信息:将关键信息外部化,减少上下文消耗
3. Gemini CLI
Gemini CLI 选择了一条中庸之道,或者说是实用之道。Gemini CLI 项目开源了,这部分的说明会多一些。
3.1 70/30?
Gemini CLI 选择了 70% 作为压缩触发点,30% 作为保留比例。这个比例我们也可以参考学习一下:
为什么是 70% 而不是 92%
- 更早介入,避免紧急压缩导致的卡顿
- 给压缩过程留出充足的缓冲空间
- 适合轻量级应用场景,不追求极限性能
30% 保留的合理性:
- 刚好覆盖最近 5-10 轮对话
- 足够维持上下文连续性
- 不会让用户感觉"突然失忆"
共背后的逻辑是:宁可频繁一点地压缩,也要保证每次压缩都是从容的、高质量的。
3.2 精选历史提取
Gemini CLI 有个独特的概念叫精选历史"。不是所有的历史都值得保留,系统会智能地筛选有效内容:
typescript
function extractCuratedHistory(comprehensiveHistory: Content[]): Content[] {
if (comprehensiveHistory === undefined || comprehensiveHistory.length === 0) {
return [];
}
const curatedHistory: Content[] = [];
const length = comprehensiveHistory.length;
let i = 0;
while (i < length) {
// 用户轮次直接保留
if (comprehensiveHistory[i].role === 'user') {
curatedHistory.push(comprehensiveHistory[i]);
i++;
} else {
// 处理模型轮次
const modelOutput: Content[] = [];
let isValid = true;
// 收集连续的模型轮次
while (i < length && comprehensiveHistory[i].role === 'model') {
modelOutput.push(comprehensiveHistory[i]);
// 检查内容有效性
if (isValid && !isValidContent(comprehensiveHistory[i])) {
isValid = false;
}
i++;
}
// 只有当所有模型轮次都有效时才保留
if (isValid) {
curatedHistory.push(...modelOutput);
}
}
}
return curatedHistory;
}
这个策略的巧妙之处在于:
- 用户输入全部保留:所有用户输入都被视为重要信息,无条件保留
- 模型轮次有条件保留:连续的模型轮次被视为一个整体进行评估
- 全有或全无的处理:要么全部保留,要么全部丢弃,避免了复杂的部分保留逻辑
3.3 内容有效性判断
什么样的内容会被认为是无效的?Gemini CLI 有明确的标准:
typescript
function isValidContent(content: Content): boolean {
// 检查 parts 数组是否存在且非空
if (content.parts === undefined || content.parts.length === 0) {
return false;
}
for (const part of content.parts) {
// 检查 part 是否为空
if (part === undefined || Object.keys(part).length === 0) {
return false;
}
// 检查非思考类型的 part 是否有空文本
if (!part.thought && part.text !== undefined && part.text === '') {
return false;
}
}
return true;
}
无效内容包括:空响应、错误输出、中断的流式响应等。这种预过滤机制确保进入压缩流程的都是高质量的内容。
3.4 五段式结构化摘要
相比 Claude Code 的八段式,Gemini CLI 的五段式更简洁,但涵盖了所有关键信息:
markdown
1. overall_goal - 用户的主要目标
2. key_knowledge - 重要技术知识和决策
3. file_system_state - 文件系统当前状态
4. recent_actions - 最近执行的重要操作
5. current_plan - 当前执行计划
压缩时,系统会生成 XML 格式的结构化摘要。这种格式的好处是结构清晰,LLM 容易理解和生成,同时也便于后续的解析和处理。
3.5 基于 Token 的智能压缩
Gemini CLI 的压缩不是简单的定时触发,而是基于精确的 token 计算:
typescript
async tryCompressChat(
prompt_id: string,
force: boolean = false,
): Promise<ChatCompressionInfo | null> {
const curatedHistory = this.getChat().getHistory(true);
// 空历史不压缩
if (curatedHistory.length === 0) {
return null;
}
const model = this.config.getModel();
// 计算当前历史的 token 数量
const { totalTokens: originalTokenCount } =
await this.getContentGenerator().countTokens({
model,
contents: curatedHistory,
});
// 获取压缩阈值配置
const contextPercentageThreshold =
this.config.getChatCompression()?.contextPercentageThreshold;
// 如果未强制压缩且 token 数量低于阈值,则不压缩
if (!force) {
const threshold =
contextPercentageThreshold ?? COMPRESSION_TOKEN_THRESHOLD; // 默认 0.7
if (originalTokenCount < threshold * tokenLimit(model)) {
return null;
}
}
// 计算压缩点,保留最后 30% 的历史
let compressBeforeIndex = findIndexAfterFraction(
curatedHistory,
1 - COMPRESSION_PRESERVE_THRESHOLD, // COMPRESSION_PRESERVE_THRESHOLD = 0.3
);
// 确保压缩点在用户轮次开始处
while (
compressBeforeIndex < curatedHistory.length &&
(curatedHistory[compressBeforeIndex]?.role === 'model' ||
isFunctionResponse(curatedHistory[compressBeforeIndex]))
) {
compressBeforeIndex++;
}
// 分割历史为需要压缩和需要保留的部分
const historyToCompress = curatedHistory.slice(0, compressBeforeIndex);
const historyToKeep = curatedHistory.slice(compressBeforeIndex);
// 使用 LLM 生成历史摘要
this.getChat().setHistory(historyToCompress);
const { text: summary } = await this.getChat().sendMessage(
{
message: {
text: 'First, reason in your scratchpad. Then, generate the <state_snapshot>.',
},
config: {
systemInstruction: { text: getCompressionPrompt() },
},
},
prompt_id,
);
// 创建新的聊天历史,包含摘要和保留的部分
this.chat = await this.startChat([
{
role: 'user',
parts: [{ text: summary }],
},
{
role: 'model',
parts: [{ text: 'Got it. Thanks for the additional context!' }],
},
...historyToKeep,
]);
}
这个实现有几个细节值得注意:
- 支持强制压缩:通过 force 参数,用户可以主动触发压缩
- 智能分割点选择:确保压缩点在用户轮次开始,避免打断对话逻辑
- 两阶段压缩:先生成摘要,再重建对话历史
3.6 多层压缩机制
Gemini CLI 的压缩是分层进行的,每一层都有特定的目标:
第一层:内容过滤:过滤掉无效内容、thought 类型的部分,确保进入下一层的都是有价值的信息。
第二层:内容整合:合并相邻的同类内容,比如连续的纯文本 Part 会被合并成一个,减少结构冗余。
第三层:智能摘要:当 token 使用量超过阈值时,触发 LLM 生成结构化摘要。
第四层:保护机制:确保关键信息不被压缩丢失,比如用户的最新指令、正在进行的任务等。
3.7 模型适配的 Token 限制
不同的模型有不同的 token 限制,Gemini CLI 对此有精细的适配:
typescript
export function tokenLimit(model: Model): TokenCount {
switch (model) {
case 'gemini-1.5-pro':
return 2_097_152;
case 'gemini-1.5-flash':
case 'gemini-2.5-pro':
case 'gemini-2.5-flash':
case 'gemini-2.0-flash':
return 1_048_576;
case 'gemini-2.0-flash-preview-image-generation':
return 32_000;
default:
return DEFAULT_TOKEN_LIMIT; // 1_048_576
}
}
系统会根据使用的模型自动调整压缩策略。对于支持超长上下文的模型(如 gemini-1.5-pro 的 200 万 token),可以更宽松;对于受限的模型,会更积极地压缩。
3.8 历史记录的精细处理
recordHistory
方法负责记录和处理历史,实施了多个优化策略:
- 避免重复:不会重复添加相同的用户输入
- 过滤思考过程:thought 类型的 Part 会被过滤掉,不进入最终历史
- 合并优化:相邻的模型轮次会被合并,相邻的纯文本也会合并
- 占位符策略:如果模型没有有效输出,会添加空的占位符保持结构完整
3.9 压缩的用户体验设计
Gemini CLI 特别注重压缩对用户体验的影响:
- 无感压缩:70% 的阈值确保压缩发生在用户察觉之前
- 连续性保持:保留 30% 的最新历史,确保当前话题的连贯性
- 透明反馈:压缩前后的 token 数量变化会被记录和报告
4. 写在最后
研究完这三个项目的源码,我最大的感受是:压缩策略的选择,本质上是对「什么是重要的」这个问题的回答。 Manus 说「所有信息都可能重要,所以我不删除,只是暂时收起来」;Claude Code 说「结构化的摘要比原始细节更重要」;Gemini CLI 说「用户体验比技术指标更重要」。三种回答,三种哲学。
这让我想起一句话:在 AI 时代,真正稀缺的不是信息,而是注意力。 上下文压缩就是在教 AI 如何分配注意力------什么该记住,什么可以忘记,什么需要随时能找回来。
这是人类智慧的核心能力之一。我们每天都在做类似的决策:重要的事情记在心里,次要的写在本子上,琐碎的存在手机里。Manus、Claude Code 和 Gemini CLI 只是用不同的方式在教 AI 做同样的事。
没有完美的压缩策略,只有最适合你场景的策略。 选择哪种策略不重要,重要的是理解它们背后的设计智慧,然后根据自己的需求做出明智的选择。
以上。