Skip to content
LLM 上下文窗口原理与上下文压缩策略
上下文窗口是 Transformer 语言模型在单次推理中能读入的最大 token 数。这个数字并不是一个产品层面的抽象承诺,而是由注意力机制、KV Cache、位置编码和推理引擎资源管理共同决定的一个运行边界。下面先解释这些机制,再对上下文压缩策略做系统分类,最后讨论如何用任务评测来判断信息保留程度。
上下文窗口的基本概念
上下文长度通常以 token 数计算。Token 是词表单位,可能是一个单词、一个子词或一个中文字符;同一个自然语言句子的 token 数取决于分词器的切分方式。
对自回归语言模型来说,输入和输出会共享同一段上下文空间。输入越长,留给生成输出的位置就越少。因此,上下文窗口不仅决定模型能“看到”多长的输入,也决定它能“写出”多长的回答。
还需要区分模型记忆与上下文窗口:
- 模型参数保存的是预训练阶段形成的长期记忆,例如事实知识、语法规律和通用推理能力。
- 上下文窗口保存的是当前请求带来的临时状态,例如用户文档、对话历史、检索结果和中间推理步骤。
两者共同影响输出,但不能互相替代。参数记忆知道“巴黎是法国首都”,上下文窗口才知道“当前这份合同编号是多少”。
Transformer 自注意力的复杂度来源
Transformer 的核心操作是自注意力。输入序列中的每个 token 被映射成三组向量:
- Query(Q):表示当前 token 想要查找什么。
- Key(K):表示当前 token 可以被什么匹配。
- Value(V):表示当前 token 携带的内容。
注意力分数通过 Query 和 Key 的内积得到。序列长度为 N 时,所有 token 两两之间的注意力分数构成一个 N×N 的矩阵。即使使用因果掩码,每个 token 只与过去 token 交互,分数对数量也是:
[ \frac{N(N+1)}{2} ]
仍然属于 O(N²) 增长。
每个 Transformer 层、每个注意力头都要执行这个过程。设有 L 层、H 个注意力头,则注意力分数总数近似为:
[ O(L \times H \times N^2) ]
下面的代码计算了不同序列长度下的注意力分数对数量:
js
function attentionPairCount(seqLen, causal = true) {
return causal ? (seqLen * (seqLen + 1)) / 2 : seqLen * seqLen;
}
function attentionScoreCount({ layers, heads, seqLen }) {
return layers * heads * attentionPairCount(seqLen);
}
const layers = 32;
const heads = 32;
for (const seqLen of [1024, 2048, 4096, 8192]) {
console.log(`${seqLen}: ${attentionScoreCount({ layers, heads, seqLen })}`);
}从输出可以看到,序列长度每翻一倍,注意力分数对数量大约变成原来的 4 倍。这就是上下文窗口无法任意扩大的架构原因之一:即使显存允许,注意力计算量也会随序列长度平方级上涨。
KV Cache 与显存占用模型
自回归生成是逐 token 进行的。生成第 t 个 token 时,模型需要计算第 t 个 token 的 Query,并且与前面所有 token 的 Key 和 Value 做注意力。如果没有缓存,每一步都要重新计算历史上所有 token 的 K/V,成本不可接受。
因此,推理引擎会在第一次处理前缀时,把每一层的 K 和 V 保存下来,后续生成时直接复用。这套缓存被称为 KV Cache。
KV Cache 的显存占用可以用以下公式表示:
设:
- L 为层数;
- H_kv 为 KV 头数;
- D 为每个 KV 头的维度;
- N 为序列长度;
- B 为并发序列数;
- b 为每个元素占用的字节数。
则 KV Cache 的字节数为:
[ B \times N \times 2 \times L \times H_{kv} \times D \times b ]
其中因子 2 表示 K 和 V 两组张量。标准多头注意力中,H_kv 等于 Query 头数;使用 GQA 或 MQA 时,KV 头数更少,因此 KV Cache 更小。
下面是一个计算 KV Cache 大小的函数:
js
function kvCacheBytes({ layers, kvHeads, headDim, seqLen, batch = 1, dtypeBytes = 2 }) {
return batch * seqLen * 2 * layers * kvHeads * headDim * dtypeBytes;
}
const model = { layers: 32, kvHeads: 8, headDim: 128 };
const bytes = kvCacheBytes({ ...model, seqLen: 8192 });
console.log(bytes); // 字节数,这里使用假想模型参数,不代表任何具体模型KV Cache 对序列长度是线性增长,但它的常数很大。更关键的是,部署时通常会有多个并发序列,实际占用要再乘以 batch 数。长上下文推理的显存瓶颈,很大程度来自 KV Cache。
位置编码与长度外推
自注意力本身对序列顺序不敏感。如果打乱输入顺序,在不考虑位置信息的情况下,注意力分布也会跟着打乱。位置编码是模型感知 token 先后关系的来源。
早期 Transformer 使用绝对位置编码,把位置向量加到 token embedding 上。这种方式在训练长度内有效,但对超过训练长度的位置没有定义,外推能力较弱。
相对位置编码不直接给每个绝对位置一个向量,而是在注意力分数中引入相对偏移。模型更容易理解“相邻”“隔了 5 个 token”这类关系,因此对长度变化的适应能力更强。
RoPE
RoPE 通过旋转操作把位置信息编码到 Query 和 Key 中。给定位置 m,把向量按一定角度旋转,得到的新向量与位置 n 的向量做内积后,结果只依赖于 m - n。RoPE 在相对位置建模上表现很好,被大量开源模型采用。
但 RoPE 的外推能力也不是无限的。位置索引变大后,旋转频率的组合会超出预训练时的分布,导致注意力分数异常。为此,可以将长位置索引插值到训练区间内,或通过增大 RoPE base 等方式调整频率分布。位置插值能让模型在更长上下文上保持稳定,但会降低位置分辨率。
ALiBi
ALiBi 不把位置信息加进 embedding,而是直接给注意力分数加上一个与距离成正比的线性偏置:
[ \text{score}(i, j) = q_i \cdot k_j - m \times |i - j| ]
远端 token 的注意力分数会被压低,模型天然更关注近处内容。因为位置信息只出现在分数计算中,ALiBi 对长度外推有较好的鲁棒性。
位置编码解决的是“模型是否知道 token 顺序”,它不解决注意力计算和 KV Cache 带来的资源问题。即使位置编码可以外推到很长,KV Cache 和注意力计算仍然可能成为限制。
系统级优化:在有限显存中扩展上下文
上下文窗口的限制来自计算量和显存两方面。系统层可以绕过一部分限制,让模型在同样的硬件上处理更长的输入。
FlashAttention 与稀疏注意力
FlashAttention 通过分块计算和在线 softmax,避免把完整的 N×N 注意力矩阵写回全局内存。它降低了显存占用和访存开销,但注意力计算量仍然是 O(N²)。
稀疏注意力则改变注意力拓扑,让每个 token 只与附近窗口内的 token 交互,并可保留少量全局 token 作为“锚点”。这种做法把单 token 的注意力计算从 O(N) 降到 O(W),其中 W 是窗口大小。代价是窗口之外的信息无法被模型直接访问,长距离依赖会丢失。
PagedAttention 与非连续 KV Cache
即使 KV Cache 总量有限,内存碎片也会降低可处理的序列数量。vLLM 的 PagedAttention 将 KV Cache 划分为固定大小的 KV Block,每个 Block 保存固定数量 token 的 K/V,并通过 block table 把逻辑 Block 映射到非连续的物理 Block [1]。
这种方式允许按需分配内存:序列增长时才申请新的 Block,序列结束时立即释放。它和操作系统中的分页机制类似,能有效减少内存碎片。
下面是一个简化示意:
js
const BLOCK_SIZE = 16;
function logicalBlock(tokenIndex) {
return Math.floor(tokenIndex / BLOCK_SIZE);
}
class KVBlockTable {
constructor() {
this.logicalToPhysical = new Map();
this.nextPhysicalBlock = 0;
}
lookup(tokenIndex) {
const logical = logicalBlock(tokenIndex);
if (!this.logicalToPhysical.has(logical)) {
this.logicalToPhysical.set(logical, this.nextPhysicalBlock);
this.nextPhysicalBlock += 1;
}
return this.logicalToPhysical.get(logical);
}
}实际推理引擎中的 PagedAttention 实现会更复杂,但这个简化类说明了核心思想:逻辑 Block 一旦被分配到某个物理 Block,后续 token 就可以通过 block table 快速定位。
vLLM 的自动前缀缓存进一步利用了 Block 的可复用性。每个 KV Block 用“前缀 token + 当前 Block token”的 hash 标识;如果多个请求共享相同前缀,就可以复用已经计算好的 KV Block [1]。固定 system prompt、few-shot 示例和重复对话历史都能从中受益。
Continuous Batching
传统批处理中,一个 batch 要等最慢的序列完成后才能整体结束,未完成序列的 KV Cache 会造成空闲。Continuous Batching 在每个 decoder step 动态调整 batch:完成的序列退出,新序列加入,刚释放的物理 Block 可以分配给新序列。配合 PagedAttention,推理引擎可以在有限显存中容纳更多并发长上下文请求。
KV Cache 量化与逐出
系统层也可以直接减少 KV Cache 的字节数,例如用低精度表示 K/V,或按重要度删除一部分 token 的 KV。这两类方法同时属于上下文压缩范畴,下一节展开说明。
上下文压缩策略分类
上下文压缩可以发生在三个层次:
- 输入文本层:在模型看到 prompt 之前做删减。
- 模型状态层:压缩 KV Cache 或改变注意力范围。
- 外部记忆层:把上下文保存到外部存储,按需取回。
后两者并不一定产生更短的 prompt;它们改变的是推理系统内部保存和读取信息的方式。
Prompt 压缩与 Token 剪枝
Prompt 压缩在输入阶段进行。常见做法是用一个小型语言模型估计 prompt 中每个 token 的信息量,再动态去除低信息量 token。LLMLingua 是这类方法的代表:压缩后的 prompt 更短,目标模型只需要处理保留下的 token。
这种方式能显著减少上下文占用,但风险也很明确。如果被删除的 token 是数字、变量名、合同条款或代码标识符,目标模型在后续回答中就无法恢复这段信息。指令部分通常应该保持不压缩,否则模型可能误解任务要求。
Token 剪枝比 Prompt 压缩更靠近模型内部。它可以在 Transformer 前向计算过程中,根据注意力权重对 token 打分,并逐出重要度较低的 token。vLLM 仓库的讨论中提出了类似的思路:对 token 按注意力权重打分,逐出不重要 token [2]。
另一种相关方向是上下文蒸馏:把长上下文交给模型压缩成一段更短的文本,或压缩成连续向量。文本摘要可以直接复用,连续向量则需要模型训练阶段支持,通用推理引擎不一定能直接使用。
摘要、记忆与 KV Cache 压缩
摘要压缩把长文档或多个历史消息变成一小段自然语言。它的优点是人类可读、可直接拼接到 prompt 中;缺点是生成摘要本身需要输出时间和 token,且原始文本中的精确细节会丢失。
记忆系统则把重要信息写入外部结构,例如键值存储或向量数据库,等需要时再取回。它更像检索而不像压缩,因为原始信息仍然保存在外部存储中,没有进入模型上下文。
KV Cache 压缩处理的是模型内部状态。一种手段是数值量化,即用更少的 bit 保存 Key 和 Value。vLLM 仓库的 issue 中讨论了一种基于 E8 格向量量化的 KV Cache 压缩方案,将 K/V 量化到 2–3 bits,并结合 token 逐出,整体压缩 10–33 倍 [2]。需要强调的是,该方案来自 issue 讨论,不是 vLLM 正式发布的功能,实际接入前需要单独验证 [2]。
KV Cache 量化中,非对称处理很重要。同一个 issue 的实验显示,3-bit Key 加 2-bit Value 的配置明显好于对称 2-bit 量化;原因是 Key 的量化噪声会经过 softmax 在所有位置上被放大 [2]。
KV Cache 逐出则是按注意力分数删除低重要度 token 的 K/V。它直接释放显存,但被删除的 token 无法再参与注意力。如果后续问题恰好需要这些 token 中的信息,模型就无法回答。
压缩策略的信息保留权衡
不同压缩策略损失的信息类型不同:
- Prompt 压缩和 Token 剪枝损失的是 token 本身。
- 摘要损失的是原始表述和细节。
- KV Cache 量化引入的是数值噪声,通常不改变 token 集合,但可能影响注意力权重。
- KV Cache 逐出和稀疏注意力损失的是远端 token 的可见性。
压缩率并不是衡量压缩效果的唯一指标。一个压缩后的 prompt 可能看起来通顺,但丢失了关键实体;一个量化后的 KV Cache 可能保持了语义,但在精确推理上下降。信息保留必须放到具体任务上测量。
信息保留的评估与权衡
困惑度不能替代任务准确率。压缩后的文本可能语言流畅,但模型未必能从中复述出准确的数字或实体。
常见的长文本评测方法包括:
- Needle-in-a-Haystack:把一句与主题无关的事实插入长文本的不同位置,看模型能否找到它。它能测试模型在长上下文中定位孤立事实的能力,但无法测试多跳推理和聚合能力。
- LongBench:覆盖问答、摘要、代码和 few-shot 等多种任务,是长文本场景常用的多任务基准。
- RULER:可生成不同长度和任务类型的合成基准,包含多针检索、多跳推理、聚合等任务,弥补单针测试过于简单的问题。
在工程选型中,除了通用基准,还应该准备自己的任务集。至少包含四类样例:
- 单点检索:事实是否存在于上下文中。
- 精确复述:数字、代码、专属名词是否保持一致。
- 多跳推理:需要把多个位置的证据组合起来。
- 长文档摘要:是否需要从全局信息中提取要点。
可以计算保留率:
[ \text{保留率} = \frac{\text{压缩后任务准确率}}{\text{未压缩任务准确率}} ]
保留率高说明压缩方法对该任务类型有效;保留率低则说明压缩策略不合适。
RAG 与上下文压缩的选型
RAG 解决的问题是“从大量外部文本中选择相关内容”。它不压缩上下文,而是通过检索器把与当前问题最相关的片段取回,避免把整个语料库塞进窗口。
RAG 与上下文压缩可以组合,而不是互斥。一个常见流程是:
text
外部语料 → 检索器取回 top-k → 重排序 → 压缩器去冗余/摘要 → 目标模型如果语料规模远大于上下文窗口,RAG 先做选择。如果检索结果仍然超过可用窗口,再用压缩策略缩短 selected chunks。原始文档保留在外部存储中,因此信息可以重新检索,不会被一次压缩永久丢失。
对于多跳推理,单次检索往往不够。系统可能需要把中间结果继续作为检索条件,或者把中间证据放回上下文。此时如果过早做压缩,中间证据可能被删除,后续推理就没有依据。
长文本应用的工程选择框架
长文本应用没有统一的压缩参数,但可以按照以下顺序做选型:
- 先定义任务类型。区分单点检索、精确复述、多跳推理、摘要和代码理解。不同任务对压缩的敏感度差异很大。
- 测未压缩基线。在目标长度上运行原始模型,记录准确率、KV Cache 显存占用和延迟。逐步加长输入,找到准确率开始下降或显存不足的临界点。
- 判断瓶颈来源。如果显存先不足,优先考虑 PagedAttention、Continuous Batching、KV Cache 量化或逐出;如果准确率先下降,可能是位置编码外推不足或注意力无法覆盖远端信息,应优先考虑位置插值、RAG 或摘要。
- 在相同资源预算下比较候选方案。例如把“4K token 的检索结果”“4K token 的摘要”“4K token 的原始文本”放在一起评测,而不是只看哪个方案压缩率更高。
- 为可恢复性保留外部原文。压缩是一种有损操作;在需要精确证据的任务中,应保留原始文档或检索源,以便后续重新查询。
如果系统 prompt 和 few-shot 示例很长且重复出现,自动前缀缓存可以避免为每个请求重复计算相同 prefix 的 KV,这也是长上下文工程中常被低估的优化项 [1]。
总结与展望
上下文窗口不是一个孤立的模型属性。它由自注意力的 O(N²) 计算量、KV Cache 的线性显存增长、位置编码的外推能力,以及推理引擎的内存管理方式共同决定。
上下文压缩也不会只沿着“把 prompt 变短”这一条路径演进。文本压缩、KV Cache 量化、token 逐出、稀疏注意力和外部检索,分别在不同层次上控制上下文成本,也各自承担不同类型的信息损失。
未来的长上下文系统更可能是多种机制的组合:基础模型提供更长的窗口,推理引擎管理 KV Cache,检索系统提供外部证据,压缩器处理窗口内的冗余信息。对应的评测体系也会从单点检索,逐步走向多跳、聚合和可扩展长度的任务设计。
