历史压缩的艺术:在信息完整性与上下文长度之间寻找最优解

58 阅读7分钟

一、引言

在上篇文章《系统提示词的分层设计》中,我们探讨了如何通过静态与动态拼接最大化利用大模型缓存。然而,缓存机制解决的是系统提示词的复用问题,而对话历史(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数组、表格等),且工具调用流程涉及两次附加:

  1. 工具定义附加:模型需要知道有哪些工具可用(系统提示词中)
  2. 工具执行结果附加:工具执行后的返回值需要传回模型

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轮对话)使用能力最强的大模型对全部历史进行完整摘要。摘要内容包含:

  1. 摘要正文:精炼的历史脉络
  2. 摘要边界标记:明确标识摘要覆盖的对话范围(从第几轮到第几轮)
  3. 关键实体引用:摘要中涉及的关键事实、数据、用户偏好
  4. 外部大内容引用:指向存储在外部的大块数据(如工具返回的长JSON)
  5. 规则与偏好提取:从对话中提炼用户的隐性规则和显性偏好

边界分离机制:

这是摘要压缩中最重要的设计——通过明确的边界标记,避免渐进式信息衰减。

// 边界分离示例
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团队的实践给了我们重要启示:

  1. 混合策略优于单一策略:没有一把万能钥匙,工具箱思维比追求"最优解"更实用

  2. 分层处理:静态配置交给规则,简单压缩交给小模型,深层理解交给大模型

  3. 边界分离是防止信息衰减的关键:明确标记"已经压缩过"的边界,避免反复压缩同一段内容

  4. 可逆性设计:尽量保留"展开"的可能,让关键信息可追溯

  5. 因材施"模":昂贵的强模型用于低频、高价值的压缩任务;便宜的模型用于高频、低价值的处理

最终,历史压缩是一门权衡的艺术——在信息完整性、上下文长度、计算成本和用户体验之间,找到适合你场景的平衡点。没有完美的方案,只有不断优化的实践。


本文为个人学习与实践总结,受限于个人水平,若有理解不当之处,恳请各位读者批评指正。