一、引言
在上篇文章《系统提示词的分层设计》中,我们探讨了如何通过静态与动态拼接最大化利用大模型缓存。然而,缓存机制解决的是系统提示词的复用问题,而对话历史(Conversation History)则是另一块更为棘手的挑战。
随着对话轮次增加,历史消息不断累积,上下文窗口很快就会被撑满。以GPT-4为例,128K的上下文窗口看似充裕,但在复杂的多轮工具调用场景下,几十轮对话就能轻松突破限制。更麻烦的是,系统提示词可以静态前置,但对话历史天然是动态增长的——每一轮都在变,缓存机制在这里几乎帮不上忙。
于是,我们面临一个核心矛盾:
保留足够的历史信息,让模型理解对话的完整脉络;同时控制上下文长度,避免超出窗口限制和过度消耗Token。
这就是历史压缩(History Compression)要解决的问题。本文将系统梳理当前主流的历史压缩策略,分析各自的优劣,并重点介绍Claude团队在实践中验证的有效方案。
二、为什么需要历史压缩?
2.1 三个不可调和的压力
| 压力来源 | 具体表现 |
|---|---|
| 窗口限制 | 大模型上下文窗口有限(即使是200K的Claude 3.5,也有物理上限) |
| 成本压力 | 每轮请求都携带完整历史,Token消耗随对话轮次线性增长 |
| 延迟敏感 | 输入越长,模型处理时间越长(自注意力机制的计算复杂度为O(n²)) |
2.2 压缩的本质:权衡
任何压缩策略本质上都是在做一个权衡:
- 保留更多信息 → 模型理解更准确,但窗口消耗更大
- 压缩更激进 → 节省空间和成本,但可能丢失关键信息
关键问题是:在什么场景下,丢失什么信息是可以接受的?
答案取决于应用场景。一个股票分析助手如果忘记用户三句话前提到的"茅台",用户可能只是重复一遍;但如果一个法律文档审查Agent遗漏了合同中的关键条款,后果则严重得多。
2.3 压缩的边界条件
在进行任何压缩操作之前,我们需要明确两个边界:
边界一:信息时效性。不同信息的"保质期"不同。用户说"我昨天买了茅台"——这是一个需要长期记住的事实。用户说"帮我看看茅台现在的价格"——这是一个瞬时指令,处理完即可丢弃。
边界二:压缩粒度。压缩发生在什么层级?是逐条消息压缩?还是按"话题"压缩?还是按"时间窗口"压缩?不同粒度直接影响压缩效率和信息保留度。
三、三种主流策略及其局限
3.1 策略一:滑动窗口(Sliding Window)
核心思想:只保留最近的N条消息,更早的全部丢弃。
// 滑动窗口示例
const MAX_HISTORY_TURNS = 20;
const getHistoryWindow = (fullHistory) => {
return fullHistory.slice(-MAX_HISTORY_TURNS);
};
优点:
- 实现极简,一行代码搞定
- 保证上下文长度恒定,成本可预测
- 优先保留最新信息(大多数场景下最新信息最重要)
致命缺陷:
- 容易丢失关键信息:用户在30轮前说过"我的风险偏好是保守型",30轮后被遗忘,模型可能给出激进的买入建议
- 信息丢失不可恢复:一旦滑出窗口,永久丢失
- 忽略信息价值差异:所有消息被同等对待,但"用户告知持仓情况"和"用户说'好的'"显然价值完全不同
适用场景:客服机器人等对长期记忆要求极低的场景。
3.2 策略二:信息压缩摘要(Summarization)
核心思想:当历史消息超过阈值时,调用LLM对历史进行摘要压缩,用摘要替代原始消息。
// 摘要压缩示例
const compressHistory = async (history) => {
if (history.length < 20) return history;
const summary = await llm.summarize(history);
return [{
role: 'system',
content: `【历史摘要】${summary}`,
isCompressed: true
}, ...history.slice(-5)]; // 保留最近5条原始消息
};
优点:
- 能保留信息的语义核心,而非简单截断
- 压缩比高,可将数十轮对话压缩为数百字
- 摘要内容具有可读性,便于模型理解
隐藏陷阱:
- 反复压缩导致信息衰减:第一次压缩丢失部分细节;第二次压缩的是"已压缩摘要+新消息",丢失更多;第三次再压缩……这就是渐进式信息衰减问题。就像反复复印的复印件,越来越模糊。
- 摘要质量不稳定:取决于LLM的能力和提示词质量,可能遗漏关键信息或产生幻觉
- 无法回溯验证:摘要中提到的"用户持有多只新能源股票",但具体是哪几只?摘要里没有,原始数据已丢弃,无法确认
适用场景:对历史信息要求"大概知道"即可,不需要精确细节的场景。
3.3 策略三:向量数据库召回(RAG)
核心思想:将每条历史消息向量化存入向量库,每次请求时根据当前问题召回最相关的历史片段,拼接到上下文中。
// RAG召回示例
const retrieveRelevantHistory = async (query, vectorStore) => {
const queryEmbedding = await embed(query);
const results = await vectorStore.similaritySearch(queryEmbedding, 5);
return results.map(r => r.metadata.originalMessage);
};
优点:
- 理论上可无限扩展:所有历史都存入向量库,永不丢失
- 精准召回:只取当前问题最相关的历史,效率高
- 灵活性强:可配合时间衰减、重要性加权等策略优化召回结果
根本性问题:
- 无序性:向量数据库召回的结果按相似度排序,而非时间顺序。用户先问了茅台、再问了宁德时代、最后问了腾讯,召回时可能先返回腾讯、再茅台、再宁德——对话的逻辑顺序被打乱了。
- 顺序即逻辑:在对话中,"先A后B"本身就包含信息。比如用户先问了"茅台PE是多少",后问了"腾讯PE是多少",模型不能把腾讯的PE安到茅台的结论里去。
- 上下文割裂:召回的片段是孤立的,缺少相邻对话的上下文,可能导致理解偏差。
- 召回质量不可控:语义相似≠对话相关。用户问"今天行情怎么样",可能召回"关于行情的所有历史",但真正相关的是"上次提到的那只股票今天怎么样了"。
适用场景:知识库问答、文档检索等对对话顺序不敏感的场景。
四、Claude团队的实践启示
Anthropic在Claude的开发过程中,系统性地研究了上述问题,并提出了一套混合压缩策略。以下策略并非互斥,而是可以组合使用的工具箱。
4.1 策略A:大型文档外置存储(Disk-based Document Storage)
核心思想:对于超长文档(如财报PDF、合同全文),不直接放入上下文窗口,而是存入磁盘,只在Prompt中保留文档摘要 + 文件路径。需要详细内容时,通过工具调用按需读取。
// 文档外置存储示例
const handleDocument = async (doc) => {
// 1. 提取摘要(可由小模型完成)
const summary = await cheapLLM.summarize(doc.content, 200);
// 2. 存入磁盘
const docId = uuid();
await fs.writeFile(`./docs/${docId}.txt`, doc.content);
// 3. 生成摘要占位符,供模型识别
return {
promptPlaceholder: `📄 文档《${doc.name}》已加载。摘要:${summary}。如需查阅原文,请调用工具 read_document("${docId}")`,
docId
};
};
特点:
- 纯静态处理,不涉及大模型参与压缩决策
- 保留完整信息,需要时精确读取
- 极大节省上下文窗口(一篇财报数万字,摘要仅数百字)
适用场景:需要处理大型附件、长文档的Agent场景。
4.2 策略B:基于时间的自动过期(Time-based Expiration)
核心思想:为每条历史消息设置存活期(TTL),超过阈值(如60分钟)的自动标记为"已过期",不再送入上下文。
// 时间过期示例
const isExpired = (message, ttlMinutes = 60) => {
const age = (Date.now() - message.timestamp) / 1000 / 60;
return age > ttlMinutes;
};
const getActiveHistory = (fullHistory) => {
return fullHistory.filter(msg => !isExpired(msg));
};
特点:
- 纯静态处理,不涉及大模型
- 符合对话时效性规律:大多数即时对话中,1小时前的信息已不相关
- 极低成本,无需额外Token
局限:无法区分信息价值。用户1小时前说的"我的账户是保证金账户"仍然重要,但被过期逻辑丢弃了。
改进方向:结合"重要性标记",对关键信息(如用户偏好、持仓)设置更长的TTL。
4.3 策略C:工具调用的双次附加与压缩
工具调用的特殊性:工具结果往往包含大量结构化数据(JSON数组、表格等),且工具调用流程涉及两次附加:
- 工具定义附加:模型需要知道有哪些工具可用(系统提示词中)
- 工具执行结果附加:工具执行后的返回值需要传回模型
Claude团队的优化方案:
// 工具结果压缩示例
const compressToolResult = async (result) => {
// 方案1:使用小模型压缩单条工具输出
if (result.length > 500) {
return await cheapLLM.compress(result, {
instruction: '提取关键数值和结论,保留结构化数据,删除冗余描述'
});
}
// 方案2:相邻多条工具结果合并压缩
if (pendingToolResults.length >= 3) {
const merged = await cheapLLM.merge(pendingToolResults, {
instruction: '将以下多个工具调用结果合并为简洁的要点列表'
});
pendingToolResults = [];
return merged;
}
return result;
};
关键洞察:
- 用小模型处理中间结果:便宜的小模型足以完成"提取关键信息"的任务,让贵的大模型只处理精炼后的内容
- 批量压缩:将相邻的多条工具输出合并为一条,减少消息条数,也减少注意力计算开销
4.4 策略D:相邻消息合并映射(Adjacent Message Merging)
核心思想:将连续的、同质化的多条消息合并为一条,建立"压缩→展开"的映射关系。传给大模型的是压缩版本,但需要具体内容时可以读取原始数据。
// 相邻消息合并示例
class MessageMerger {
constructor() {
this.compressedMap = new Map(); // 压缩ID -> 原始消息数组
}
mergeAdjacent(messages, threshold = 3) {
const result = [];
let buffer = [];
for (const msg of messages) {
// 如果消息来自同一角色且内容较短,放入缓冲区
if (msg.role === buffer[0]?.role && msg.content.length < 100) {
buffer.push(msg);
} else {
if (buffer.length >= threshold) {
// 压缩缓冲区
const compressed = this.compressBuffer(buffer);
result.push(compressed);
} else {
result.push(...buffer);
}
buffer = [msg];
}
}
return result;
}
compressBuffer(buffer) {
const id = uuid();
const mergedContent = buffer.map(m => m.content).join(';');
// 使用小模型做初步压缩
const compressed = cheapLLM.compress(mergedContent);
this.compressedMap.set(id, buffer);
return {
role: buffer[0].role,
content: `[压缩摘要 #${id}] ${compressed}`,
isCompressed: true,
compressedId: id
};
}
// 需要时展开
expand(compressedId) {
return this.compressedMap.get(compressedId) || null;
}
}
核心优势:
- 可逆性:压缩后仍可通过ID找回原始数据
- 智能合并:不是简单截断,而是语义层面的合并
- 小模型处理:压缩任务无需最强模型,降低成本
4.5 策略E:完全摘要压缩 + 边界分离(Full Summarization with Boundary Separation)
这是最彻底、也是最精密的方案。
核心思想:定期(如每10轮对话)使用能力最强的大模型对全部历史进行完整摘要。摘要内容包含:
- 摘要正文:精炼的历史脉络
- 摘要边界标记:明确标识摘要覆盖的对话范围(从第几轮到第几轮)
- 关键实体引用:摘要中涉及的关键事实、数据、用户偏好
- 外部大内容引用:指向存储在外部的大块数据(如工具返回的长JSON)
- 规则与偏好提取:从对话中提炼用户的隐性规则和显性偏好
边界分离机制:
这是摘要压缩中最重要的设计——通过明确的边界标记,避免渐进式信息衰减。
// 边界分离示例
class BoundarySummarizer {
constructor() {
this.lastSummary = null;
this.lastCompressedTurn = 0;
this.boundary = {
startTurn: 0,
endTurn: 0,
summary: null
};
}
async summarizeWithBoundary(history, currentTurn) {
// 只压缩"新"的部分:从上次压缩的边界到当前
const newMessages = history.slice(this.lastCompressedTurn);
if (newMessages.length < 10) {
return this.lastSummary; // 不足阈值,不压缩
}
// 使用最强模型进行压缩
const fullSummary = await powerfulLLM.summarize(newMessages, {
instruction: `
请压缩以下对话历史,输出格式如下:
1. 摘要正文:按时间顺序梳理对话要点
2. 关键事实:提取用户提到的具体数据、持仓、偏好
3. 规则提取:识别用户隐含的规则(如"不要推荐超过XX%仓位的股票")
4. 边界标记:明确标注本次摘要覆盖的对话范围
5. 外部引用:指向工具调用返回的长数据
`
});
// 更新边界:已压缩到最新位置
this.boundary = {
startTurn: this.lastCompressedTurn + 1,
endTurn: currentTurn,
summary: fullSummary
};
this.lastCompressedTurn = currentTurn;
this.lastSummary = fullSummary;
return {
boundary: this.boundary,
summary: fullSummary
};
}
// 构建传递给模型的上下文
buildContext(history, currentTurn) {
// 如果没有摘要,直接返回原始历史
if (!this.lastSummary) return history;
// 有摘要时:用摘要替换已压缩的部分,保留未压缩的新消息
const recent = history.slice(this.lastCompressedTurn);
return [
{
role: 'system',
content: `📋 【历史摘要】(覆盖第${this.boundary.startTurn}轮至第${this.boundary.endTurn}轮)
${this.lastSummary}`
},
...recent
];
}
}
边界分离的核心价值:
| 传统摘要 | 边界分离摘要 |
|---|---|
| 反复压缩→渐进式信息衰减 | 每次只压缩"新增部分",已有摘要不再被重新压缩 |
| 摘要边界模糊 | 明确标记覆盖范围,可精确追溯 |
| 丢失的信息不可恢复 | 通过外部引用可找回原文 |
| 无法判断信息时效性 | 边界清晰,可知信息"新鲜度" |
强模型的使用时机:完全摘要压缩使用Claude 3.5 Sonnet级别的最强模型,因为:
- 需要深度理解对话语境,提取隐性规则
- 摘要质量直接影响后续所有对话的准确性
- 压缩频率低(每10-20轮一次),成本可控
五、策略对比与选型指南
5.1 策略全景对比
| 策略 | 压缩方式 | 信息保留 | 成本 | 复杂度 | 是否可逆 | 适用场景 |
|---|---|---|---|---|---|---|
| 滑动窗口 | 直接截断 | ⭐ | ⭐ | ⭐ | ❌ | 简单客服 |
| 摘要压缩 | LLM摘要 | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ❌ | 通用场景 |
| 向量RAG | 相似召回 | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ✅ | 知识库问答 |
| 文档外置存储 | 外存+引用 | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐ | ✅ | 长文档处理 |
| 时间过期 | 静态过滤 | ⭐⭐ | ⭐ | ⭐ | ❌ | 即时对话 |
| 工具结果压缩 | 小模型精简 | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐ | 部分可逆 | 工具密集型 |
| 相邻消息合并 | 批量合并 | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ | ✅ | 高频短对话 |
| 边界摘要分离 | 分块摘要+边界 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 部分可逆 | 复杂多轮对话 |
5.2 方案组合推荐
在实际生产环境中,建议组合使用多种策略:
┌─────────────────────────────────────────────────────────┐
│ 请求处理流程 │
├─────────────────────────────────────────────────────────┤
│ ① 时间过期过滤:丢弃 >60分钟的低优先级消息 │
│ ② 文档外置存储:长文档仅保留摘要+路径 │
│ ③ 工具结果压缩:小模型压缩工具返回数据 │
│ ④ 相邻消息合并:合并连续的短消息 │
│ ⑤ 边界摘要替换:用摘要替换已压缩的历史块 │
│ ⑥ 最终组装:摘要块 + 最近N条原始消息 │
└─────────────────────────────────────────────────────────┘
5.3 选型决策树
是否需要保留对话的精确顺序?
├── 是 → 不能使用向量RAG(召回无序)
│ ├── 对话轮次 < 50
│ │ └── 直接使用完整历史
│ ├── 对话轮次 50-200
│ │ └── 滑动窗口(保留最近30轮)+ 边界摘要压缩
│ └── 对话轮次 > 200
│ └── 边界摘要压缩为主 + 保留最近20轮原始
│
└── 否 → 可使用向量RAG
├── 需要完整文档支持
│ └── RAG + 文档外置存储
└── 纯对话场景
└── 向量RAG + 时间过期
六、结论:压缩皆有损,但损可控
只要压缩,就会有损。 这是我们需要坦然接受的基本事实。
我们的工作不是在"有损"和"无损"之间做选择——因为无损压缩在LLM上下文中不存在——而是在不同类型的损失之间做选择。我们需要回答的是:
在这个具体场景中,什么样的信息损失是可以接受的?什么样的损失是不可接受的?
Claude团队的实践给了我们重要启示:
-
混合策略优于单一策略:没有一把万能钥匙,工具箱思维比追求"最优解"更实用
-
分层处理:静态配置交给规则,简单压缩交给小模型,深层理解交给大模型
-
边界分离是防止信息衰减的关键:明确标记"已经压缩过"的边界,避免反复压缩同一段内容
-
可逆性设计:尽量保留"展开"的可能,让关键信息可追溯
-
因材施"模":昂贵的强模型用于低频、高价值的压缩任务;便宜的模型用于高频、低价值的处理
最终,历史压缩是一门权衡的艺术——在信息完整性、上下文长度、计算成本和用户体验之间,找到适合你场景的平衡点。没有完美的方案,只有不断优化的实践。
本文为个人学习与实践总结,受限于个人水平,若有理解不当之处,恳请各位读者批评指正。