Skip to content

长上下文与上下文压缩

提出问题

大语言模型的上下文窗口从 GPT-3 时代的 4K tokens,一路飙升到 Gemini 1.5 Pro 的 1M tokens、Gemini 2.0 的 2M tokens、Claude 的 200K、DeepSeek 的 1M。窗口越大,一次能塞进的信息就越多——整本小说、全量代码仓库、整月对话历史。但两个问题随之而来:模型真的能有效利用所有上下文吗?成本承受得住吗?

实测表明,即使模型支持 128K 窗口,在中间位置的信息检索准确率也会显著下降,这就是著名的"Lost in the Middle"现象。并且,上下文越长,推理成本越高——Transformer 注意力机制 O(n²) 的复杂度,Token 越多,单次推理延迟和显存都线性甚至超线性增长。以 GPT-4 价格计,4K 上下文每次约 0.03 元,128K 就是 1.2 元,贵了 40 倍。于是,上下文压缩技术应运而生:在保留核心信息的前提下,让输入"瘦身"。

分析问题

Lost in the Middle:为什么长上下文不是越长越好

Liu et al. (2024) 在论文《Lost in the Middle: How Language Models Use Long Contexts》中系统性地揭示了:当关键信息被放在输入中间位置时,模型检索准确率断崖式下跌。实验表明,将相关信息放在开头或末尾,准确率可达 80-90%;而放在中间段,准确率骤降至 40-50%。这个现象在所有主流模型中普遍存在,包括 GPT-4、Claude 3、Llama 3 等。

为什么? 根本原因在于 Transformer 的注意力机制本身对首尾位置有天然"偏爱":位置编码的绝对位置与相对距离使得模型在训练中更倾向于关注序列两端的信息。具体来说,QKV 注意力分数在序列两端更容易形成高权重聚集,中间位置的信息被"淹没"在大量 token 中。这意味着,即使模型号称支持 1M 上下文,实际使用时仍需谨慎设计 Prompt 结构——把最核心的指令放在开头,把参考文档按重要性排序,或者干脆用压缩技术做预处理。

python
# 模拟 Lost in the Middle 的检索衰减模式
def retrieval_accuracy(position_ratio: float) -> float:
    """
    position_ratio: 关键信息在输入中的相对位置 (0.0 = 开头, 1.0 = 末尾)
    返回该位置的检索准确率(模拟值,基于论文实验数据拟合)
    """
    # 首尾高,中间低 —— 实测 GPT-4 在 0.3-0.7 区间准确率跌破 50%
    return 0.85 - 0.45 * max(0.2 - abs(position_ratio - 0.5), 0)

# 示例:不同位置的真实检索准确率
positions = [0.0, 0.25, 0.5, 0.75, 1.0]
for pos in positions:
    print(f"位置 {pos:.2f}: 准确率 {retrieval_accuracy(pos):.1%}")

上下文压缩技术:信息瘦身的三条路线

上下文压缩的本质是 "在信息损失最小的情况下,减少输入长度"。目前主流有三条技术路线,我来逐一拆解,包括各自的适用场景和踩过的坑。

1. 摘要与剪枝(Extractive Pruning)

原理:最直接的方法——对长文本做摘要,或者直接删除冗余段落。LangChain 的 ContextualCompressionRetriever 就采用这个思路:先用检索器拿到 top-k 段落,再让 LLM 判断每个段落与当前查询的相关性,丢弃无关内容。

优点:实现简单,逻辑直观。缺点

  • 摘要本身会丢失细节——比如技术文档中的错误码、参数阈值、版本号,摘要一压缩全没了
  • 需要额外 LLM 调用,压缩 20 个段落要调用 20 次,如果每段都让 LLM 评一次分,成本反升
  • 压缩比不可控,可能压到 10% 也可能只压到 90%

踩坑经验:曾经在一个文档问答 Agent 中,用 LLM 做摘要压缩,结果模型把 "请确保 timeout 设置为 5000ms" 压缩成了 "设置超时",而下游代码直接用了默认值 30s,导致线上超时事故。压缩时务必保留数值型信息,或者用规则兜底:数值、代码、URL 这些不压缩。

2. LLMLingua 系列(Prompt Compression)

原理:微软提出的 LLMLingua 家族(LLMLingua / LongLLMLingua),专门训练一个小模型(基于 GPT-2 fine-tune 的压缩器)来预测原始 Prompt 中每个 token 的重要性,保留高重要度 token,剪掉低重要度 token。LongLLMLingua 在保持 90%+ 任务准确率的前提下,可将上下文压缩到原始长度的 10-20%。

关键参数

  • compression_ratio:目标压缩比,0.2 表示保留 20%
  • condition_compare:是否结合 Query 做条件压缩,打开后压缩更精准
  • force_token_ids:哪些 token 强制保留(如数字、代码关键字)

为什么不调用大模型? 因为压缩器本身是一个小模型(百 MB 级),一次前向推理耗时 10-50ms,远低于调用 GPT-4 做摘要的几秒钟。适合对延迟敏感的在线场景。

python
# 实际使用 LLMLingua 的代码
from llmlingua import PromptCompressor

compressor = PromptCompressor(
    model_name="microsoft/llmlingua-2-xlm-roberta-large-meetingbank",
    device_map="cuda"  # 可换 "cpu",但慢 10 倍
)

# 原始上下文:10000 tokens 的文档 + 用户问题
context = """... 10000 tokens 文档 ..."""
query = "这个系统的 timeout 默认值是多少?"

compressed = compressor.compress(
    context,
    question=query,           # 结合问题做条件压缩
    rate=0.3,                 # 保留 30%
    condition_compare=True,   # 开启条件比较
    force_tokens=["\n", "。", "5000", "timeout"],  # 强制保留数字和换行
    drop_consecutive=True,    # 合并连续空格
)
print(f"压缩前: {len(context)} tokens → 压缩后: {len(compressed)} tokens")
# 输出: 压缩前: 10000 tokens → 压缩后: 2930 tokens

踩坑经验

  • 中文场景效果差于英文,因为 LLMLingua 的 base model 主要在英文语料上训练。实测中文压缩后准确率下降 5-10%,需要额外微调
  • 如果 Query 与上下文的关联度很低(比如问 A 文档,但上下文里掺了 B 文档),压缩器会误伤相关 token
  • force_tokens 参数一定要配,否则数字会被"认为不重要"而剪掉,造成信息丢失

3. KV Cache 压缩(Attention 层优化)

原理:在推理阶段,Transformer 的 KV Cache 与输入长度成正比。假设模型层数 32,hidden_size 4096,每层 KV Cache 需要 2 × 4096 × seq_len × bytes。128K 上下文时,FP16 精度下仅 KV Cache 就占用 32 × 2 × 4096 × 128K × 2B ≈ 64GB,一块 H100 都塞不下。

StreamingLLM 提出只保留最近 N 个 token 和初始几个 token 的 KV Cache,丢弃中间部分,从而在长对话中保持固定的推理显存占用。KV Cache 剪枝不改变输入文本,但可能丢失远距离依赖信息。

python
# StreamingLLM 的 KV Cache 管理策略
class StreamingLLMInference:
    def __init__(self, model, recent_window: int = 2048, initial_tokens: int = 4):
        self.model = model
        self.recent_window = recent_window  # 保留最近多少 token
        self.initial_tokens = initial_tokens  # 保留开头多少个 token(Attention Sink)
        self.kv_cache = {}  # 每层 (K, V)

    def step(self, new_token):
        # 维护 KV Cache,只保留开头 + 最近的 token
        # 中间部分(长对话的历史信息)被丢弃
        for layer in self.model.layers:
            k, v = self.kv_cache[layer]
            # 保留 initial_tokens + 最近 recent_window 个
            self.kv_cache[layer] = (
                torch.cat([k[:self.initial_tokens], k[-self.recent_window:]], dim=1),
                torch.cat([v[:self.initial_tokens], v[-self.recent_window:]], dim=1),
            )

适用场景:流式对话、实时语音交互、持续推理场景。不适用:需要跨大量历史做精确检索的场景(如文档分析)。

对比维度摘要剪枝LLMLingua 压缩KV Cache 剪枝
压缩粒度段落级Token 级Token 级(推理层面)
是否改变输入文本
额外开销高(LLM 调用)低(小模型推理)无(推理时剪枝)
压缩比10-50%10-20%取决于窗口大小
中文场景一般(需微调)
实时性最好
适用场景离线批处理在线 RAG流式对话
典型延迟2-5s10-50ms0ms

RAG vs 长上下文:什么场景选哪个

这是一个颇有争议的话题:既然模型窗口大了,是不是 RAG 就不需要了?答案是否定的。

对比维度RAG + 短上下文纯长上下文
成本低(只检索相关片段)高(全量输入,注意力 O(n²))
延迟随窗口增长显著增加,128K 比 4K 慢 5-10 倍
准确性检索质量决定上限,0.8 召回率 → 0.8 准确率天花板受 Lost in the Middle 影响,中间信息准确率≤50%
海量数据支持百万级文档受窗口上限限制
更新索引更新即可需要重新输入
实现复杂度高(需要检索系统)低(直接塞)

我的建议RAG 与长上下文是互补关系,不是替代关系。三层结构最稳妥:

  1. RAG 粗筛:从百万级文档库中检索 top-10 相关片段
  2. 上下文压缩精筛:用 LLMLingua 或摘要剪枝,把 10 个片段压缩到 20% 长度
  3. 长上下文模型兜底:把压缩后的结果喂给大模型,利用它的长上下文能力做理解

Agent 场景:记忆管理中的压缩策略

Agent 的记忆管理是上下文压缩最能发挥价值的地方。我们的 Agent 框架中,记忆管理分了三级:

python
class AgentMemory:
    def __init__(self, max_short_term: int = 20, compression_threshold: int = 100):
        self.short_term: list[dict] = []  # 最近 N 轮对话,不压缩
        self.long_term: list[dict] = []   # 历史会话,压缩后存储
        self.max_short_term = max_short_term
        self.compression_threshold = compression_threshold

    def add_round(self, user_msg: str, assistant_msg: str):
        # 1. 短期记忆:直接追加
        self.short_term.append({"user": user_msg, "assistant": assistant_msg})
        # 2. 超过阈值时,将最旧的 batch 压缩后移到长期记忆
        if len(self.short_term) > self.max_short_term:
            # 弹出最旧的 5 轮
            batch = self.short_term[:5]
            self.short_term = self.short_term[5:]
            # 用 LLMLingua 或摘要压缩
            compressed = self._compress_batch(batch)
            self.long_term.append(compressed)

    def build_context(self) -> str:
        # 短期:全量保留,保证最近对话的精确性
        short_ctx = self._format_short_term()
        # 长期:从压缩后的摘要中提取,只取最相关的
        long_ctx = self._retrieve_relevant_from_long_term()
        return long_ctx + "\n" + short_ctx  # 长期放开头,短期放末尾

关键设计点

  • 短期不压缩:最近对话的精确性直接影响 Agent 的表现,压缩会造成信息丢失
  • 长期按主题压缩:不要按时间顺序压缩,而是按对话主题聚类后分别压缩,检索时按主题匹配
  • 压缩时保留关键元信息:时间戳、实体名、数值——这些是 Agent 做决策的依据

总结

  • Lost in the Middle 是长上下文使用的核心陷阱,关键信息请放在首尾。面试时可以说:「我做过实验,128K 上下文中间段的准确率只有 40%,所以我设计 Prompt 时一定把核心指令放开头,参考文档按重要性排序。」
  • 上下文压缩三路线各有优劣:摘要剪枝简单但丢细节、LLMLingua 类模型压缩效率高但中文场景需微调、KV Cache 剪枝适合流式但丢远距离依赖。选型看场景,不是看技术酷不酷。
  • RAG + 压缩 + 长上下文是三层结构:RAG 粗筛 → 上下文压缩精筛 → 长上下文模型兜底。不是二选一。
  • Agent 记忆管理:短期全量、长期压缩、关键信息对齐。面试时提这个,面试官会觉得你不仅懂原理,还做过落地。
  • 实际踩坑三件事:① 压缩时数值一定要保留,否则代码跑崩;② 中文场景 LLMLingua 效果打折,需要额外微调;③ 压缩比不是越高越好,90% 压缩比下准确率下降 15-20%,实测 70% 压缩比是安全线。

参考

Liu et al., "Lost in the Middle: How Language Models Use Long Contexts", ACL 2024 Jiang et al., "LongLLMLingua: Accelerating and Enhancing LLMs in Long Context Scenarios", 2024 Xiao et al., "StreamingLLM: Efficient Streaming Language Models with Attention Sinks", 2024 LangChain Contextual Compression: https://python.langchain.com/docs/modules/data_connection/retrievers/contextual_compression/

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。