RAG 对话系统的 6 种压缩方案:从滑动窗口到 AFM 自适应保真度

本文介绍一个 Rust 实现的 RAG 对话系统中 6 种对话历史压缩策略,从最简单的滑动窗口到学术界最新的 AFM 自适应保真度压缩,并附带完整实现代码。

一、6 种压缩方案详解

方案 1:滑动窗口(Sliding Window)

思路:保留最近 N 条消息,之前的全部丢弃。

fn apply_sliding_window(history, keep_count) {
    if history.len() <= keep_count { return history; }
    
    // 从尾部取最近 N 条
    history.into_iter()
        .rev().take(keep_count).rev()
        .collect()
}

优点:零延迟,不调 LLM,实现极简

缺点:暴力丢弃,关键设定(“我叫小爱同学”)全部丢失

适用:简单问答、测试环境

方案 2:Token 限制(Token Limit)

思路:保留前 N 条(通常含用户初始设定)+ 从尾部往前取,不超总 token 预算。

fn apply_token_limit(history, max_tokens) {
    // 1. 保留前 2 条(关键设定)
    let first_n = history.take(2);
    
    // 2. 从尾部往回加,直到 token 超限
    let mut recent = vec![];
    for msg in history.iter().rev() {
        if total_tokens + msg.tokens <= max_tokens {
            recent.insert(0, msg);
        } else { break; }
    }
    
    // 3. 组合
    [first_n, recent]
}

优点:精确控制 token 消耗,兼顾开头设定

缺点:中间消息全丢,token 估算是近似值

适用:API 成本敏感场景

方案 3:摘要压缩(Summary)

思路:旧消息全部调 LLM 生成一条摘要,替换原始消息。

async fn apply_summary_compression(history, threshold) {
    // 分离旧消息和最近消息
    let to_compress = history[..history.len() - keep_recent];
    let recent = history[history.len() - keep_recent..];
    
    // 调 LLM 生成摘要
    let prompt = format!("请将以下对话压缩成摘要(100字内):\n{}", to_compress);
    let summary = llm.invoke(prompt).await;
    
    // 组合
    [summary_msg, recent]
}

优点:保留语义,压缩率高(70-94%)

缺点:首次触发要等 LLM 返回(几秒)

适用:长对话(>20 条),需要保留大意的场景

方案 4:分层压缩(Layered)

思路:消息分三层,每层不同处理策略。

全部消息 (50条)
├── ⭐ 重要消息(含关键词:名字、设定、记住...)
│     → 原样保留
├── 📌 最近消息(最近 5 条)
│     → 原样保留
└── 📝 中间消息(剩下的)
      → 调 LLM 压缩成摘要

重要消息通过配置的关键词匹配:

important_keywords = ["我的名字", "我是", "记住", "设定", "角色"]

独创机制:重要消息内容会被 LLM 提炼后存入 important_context 字段,每次对话作为 system prompt 自动注入:

system: "请用中文回答... 【重要设定】角色=电子小狗,名字=小爱同学"

优点:关键信息永不丢失,持久化存储

缺点:关键词匹配不够智能

适用:角色扮演、任何需要保护关键设定的对话

方案 5:AFM 自适应保真度(Adaptive Focus Memory)

基于论文:Adaptive Focus Memory for Language Models (arXiv: 2511.12712)

思路:不是"保不保留"的二元选择,而是给每条消息分配三级保真度:

每条消息的保真度:
├── Full(完整保留):含关键设定、用户约束
├── Compressed(精简保留):LLM 提炼为一句话
└── Placeholder(占位符):"此处省略了 X 条闲聊"

分类由 LLM 逐条判断:

async fn classify_messages(messages) -> Vec<FidelityLevel> {
    let prompt = format!(
        "对每条消息分类:\n\
         F=完整保留(含关键设定、用户约束)\n\
         C=精简保留(有参考价值但非关键)\n\
         P=占位符(闲聊或无关内容)\n\
         按顺序输出(F/C/P),逗号分隔:\n\n{}",
        messages
    );
    let result = llm.invoke(prompt).await;
    // 解析 "F, C, P, F, C, ..." 格式
}

每条 Compressed 消息单独调 LLM 精简为 20 字内的一句话。

优点:比关键词匹配智能得多,保留信息最多

缺点:LLM 调用次数多,延迟高

适用:对信息保留要求极高的场景

方案 6:话题分段压缩(Topic Segmentation / Episodic Memory)

基于论文:Multi-Layered Memory Architectures for LLM Agents (arXiv: 2603.29194)

思路:用 LLM 检测对话中的话题切换点,每个话题独立生成摘要:

对话历史:
├── 话题1: "Rust是什么?如何安装?" (msg 1-8)
│   └── 摘要: [话题] "用户询问Rust语言的定义和基本用法"
├── 话题2: "AI扮演电子小狗,取名小爱同学" (msg 9-15)
│   └── 摘要: [话题] "用户请求AI扮演电子小狗角色,命名为小爱同学"
├── 话题3: "讨论苹果和西瓜的营养价值" (msg 16-22)
│   └── 摘要: [话题] "用户询问水果营养价值,AI以小狗口吻回答"
└── 最近消息 (msg 23-25) → 完整保留

话题边界由 LLM 自动检测:

async fn detect_topic_boundaries(messages) {
    let prompt = format!(
        "以下对话中,话题切换发生在哪条消息之后?\n\
         输出切换点的序号(从0开始),逗号分隔。\n\n{}",
        messages
    );
    // 解析 "3, 8, 15" 格式
    let boundaries = llm.invoke(prompt).await;
    // 返回 [3, 8, 15, total]
}

优点:按话题组织,比单一摘要更清晰,比分层压缩更智能

缺点:LLM 调用次数多(边界检测 + 每个话题的摘要)

适用:长对话(>50 条)且涉及多个不同话题

四、增量压缩机制:不删消息,只记进度

所有 6 种模式共享同一个增量压缩机制:

session 表字段: compressed_until INTEGER DEFAULT 0

原理:
├── 原始消息永远不删
├── compressed_until 记录已压缩到的位置
├── get_history 自动跳过已压缩的消息
└── 每次压缩只处理新增部分

查询历史:
SELECT * FROM message 
WHERE session_id = ? 
  AND (time_created > compressed_until OR role = 'summary')

效果:100 条消息,只发 6 条(摘要 + 最近 5 条),省 94% token。


二、对比总结

维度滑动窗口Token限摘要分层AFM话题分
调 LLM✅✅✅✅
延迟00几秒几秒
语义保留极好极好
关键信息保护前2条
实现复杂度★★★★★★★★★★★★★

选型建议

场景推荐
简单问答滑动窗口
控制 API 成本Token 限制
一般长对话摘要压缩
角色扮演/有设定分层压缩
信息保留要求极高AFM 自适应
长对话多话题话题分段

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐