Skip to content

约 12 分钟阅读

11. 长上下文与高效注意力:整本手册放进上下文要付什么代价 ​

上一篇的 MoE 让模型参数变多、每个 token 的计算不变,但它没有碰注意力。资料助手有一个很自然的需求:与其先检索几段制度再回答,不如把整本员工手册连同历年通知一起放进上下文,一次读完再回答“年假怎么申请?”。这样的输入轻松到几万甚至十几万 token。Llama-3.1-8B 的 max_position_embeddings 是 131072,位置编码已经支持这么长(见 第 07 篇),可真把 12 万个 token 塞进去,速度和显存会发生什么?

这一篇先算清长上下文的成本,再看几类省钱的办法各省了哪一部分:FlashAttention 改访存不改数学,滑动窗口和局部/全局交替限制每层看多远,稀疏注意力按内容挑位置,注意力汇聚解释了为什么砍掉开头几个 token 会让模型崩掉。最后讨论一个容易被忽视的问题:模型“能读”128K 不等于“读得好”。读完你应该能判断一个模型的长上下文设计省的是计算、显存还是两者都省,以及它在什么情况下会漏掉信息。

长上下文的成本账 ​

注意力的成本有两部分随上下文长度 L 增长(推导见 第 03 篇)。一是计算:当前 token 的 Q 要和 L 个历史 K 打分,再对 L 个 V 加权求和。二是存储:L 个历史 token 的 K、V 都要留在显存里。其余部分,也就是所有线性投影和 FFN,每个 token 的计算只和参数量有关。

text
权重部分(与 L 无关):每 token ≈ 2 × 参数量 = 2 × 8,030,261,248 ≈ 16.06 GFLOPs
注意力部分:每 token 每层 ≈ 4 × L × d
  d = 32 个 Q 头 × 128 = 4096;QKᵀ 打分 2·L·d,乘 V 再 2·L·d
  L = 8192:   4 × 8192 × 4096 × 32 层 ≈ 4.29 GFLOPs
  L = 131072: 4 × 131072 × 4096 × 32 层 ≈ 68.72 GFLOPs
KV Cache:每 token 128 KiB(BF16)
  L = 8192 → 1 GiB;L = 131072 → 16 GiB

Llama-3.1-8B 生成一个 token 时,权重部分 16 GFLOPs 不变,注意力部分从 8K 的 4.3 G 涨到 128K 的 68.7 G;KV Cache 从 1 GiB 涨到 16 GiB

8K 时注意力只是权重部分的四分之一;到 128K,生成每个新 token 时注意力的计算是权重部分的 4 倍多,KV Cache 16 GiB,已经和 BF16 权重(约 15 GiB)一样大。再看把整本手册读进去的那一次 Prefill:128K 个 token 的权重部分约 2.1 PFLOPs,注意力因为每个位置都要看前面所有位置,总量约 4.5 PFLOPs(因果掩码只算下三角,所以是 L² 的一半),占了三分之二以上;而 8K 时只占 12%。长度翻倍,这一项翻 4 倍,这就是“注意力平方增长”的实际含义。

所以长上下文有三个各自独立的瓶颈:Prefill 的平方级计算、Decode 每步要读完整的 KV、KV 本身占的显存。下面各种办法动的是其中不同的环节。

FlashAttention:数学不变,只改访存 ​

最早被注意到的问题其实不是 FLOPs,而是中间矩阵。按公式直接算,每个头要先写出一个 L × L 的分数矩阵再做 softmax,128K 时一个头的分数矩阵按 BF16 就有 32 GiB,根本放不下。

FlashAttention 把 Q、K、V 切成小块搬到 GPU 片上的高速缓存里,边算边用“在线 softmax”更新每行的最大值和总和,从头到尾不写出完整的 L × L 矩阵。结果和直接计算完全相同,不是近似。它让注意力的中间显存从平方级降到线性,也大幅减少了对显存的反复读写,长上下文 Prefill 才变得可行。内核细节见推理系列的 注意力算子。

要分清它没改什么:FLOPs 仍是 4 × L × d 一项不少,KV Cache 仍是每 token 128 KiB,Decode 每步仍要读完整的 KV。FlashAttention 是任何长上下文方案的前提,在它之上,模型结构还要再想办法减少“算多少”和“存多少”。

滑动窗口:每个 token 只看最近 W 个 ​

最简单的办法是规定每个 token 只看最近 W 个位置,这叫滑动窗口注意力(sliding window attention,SWA)。计算从 O(L) 降到 O(W),KV 也只需保留最近 W 个 token,可以做成一个循环覆盖的固定缓冲区。

Mistral 7B v0.1(2023 年 9 月)是这个设计的代表,config.json 里 sliding_window 为 4096。它的 KV 结构和 Llama-3.1-8B 相同(32 层、8 个 KV 头、头维度 128),每 token 128 KiB,窗口固定在 4096 个 token,KV 最多 512 MiB,不管对话多长。

窗口外的信息并没有完全消失。第 1 层的位置 5000 看不到位置 100,但它看得到位置 1000,而位置 1000 在第 1 层已经看过位置 100;到第 2 层,位置 5000 就能间接拿到位置 100 的信息。层层接力,32 层 × 4096 的理论感受野约 13 万个 token。问题在“间接”二字:信息每传一层就和别的内容混在一起,越远越模糊。“手册第 3 页写着年假要提前 5 个工作日申请”这种需要原样取回的细节,跨过几个窗口之后很难保证还在。Mistral 自己从 7B v0.2 起就把 sliding_window 设成了 null,改回完整注意力,上下文 32K。

局部与全局交替:大部分层看近处,少数层看全部 ​

折中的办法是让大部分层用窗口,隔几层放一层完整的全局注意力。局部层负责附近的语法和衔接,全局层负责跨越全文的检索,KV 的大头只来自少数全局层。

Gemma 3 27B 每 6 层 1 层全局,gpt-oss-120b 奇偶交替;128K 上下文时 Gemma 3 的 KV 约 10.4 GiB,全部全局要 62 GiB

Gemma 3 27B(2025 年 3 月)有 62 层,sliding_window_pattern 为 6:第 6、12、……、60 层是全局层,共 10 层,其余 52 层窗口 1024。按 BF16、每层每 token 2 × 16 个 KV 头 × 128 × 2 字节 = 8 KiB 算,单个 128K 请求的 KV:

text
全局层:10 × 8 KiB × 131072 = 10.0 GiB
局部层:52 × 8 KiB × 1024   ≈ 0.41 GiB(与上下文长度无关)
合计 ≈ 10.41 GiB;如果 62 层都是全局:62 GiB

Gemma 3 的技术报告做过比例消融,局部与全局之比一直加到 7:1,对困惑度的影响仍然很小。它还让两类层用不同的 RoPE 基数:全局层 100 万,局部层保持 1 万,局部层只需分辨 1024 以内的距离,全局层要分辨十几万的距离。

gpt-oss(2025 年 8 月)的做法更激进:120b 的 36 层奇偶交替,18 层窗口只有 128,18 层全局。128K 时 KV 约 4.5 GiB,几乎全部来自全局层,全都全局是 9 GiB。

交替结构省下的是局部层那份 KV 和计算,全局层的平方级 Prefill 和完整 KV 原样保留。它的失效方式也来自这里:全文范围的检索全靠少数几层全局注意力完成,局部层越多、全局层越少,这几层的负担越重。比例不是越激进越好,各家是在自己的评测上找平衡点。

注意力汇聚:开头的 token 为什么不能丢 ​

滑动窗口还有一个早期踩过的坑。最省事的流式实现是“只保留最近 W 个 token 的 KV,更早的扔掉”。2023 年的 StreamingLLM 论文发现,一旦文本长度超过缓存、最开头的几个 token 被挤出去,模型的困惑度会突然飙升,输出变成乱码。

原因在 softmax:每个查询的注意力权重之和必须是 1。很多时候当前 token 并不需要从历史里取什么信息,可它总得把这份权重放在某个地方。训练中模型学会把多余的权重堆到序列开头那几个 token 上,因为它们对所有后续位置都可见,又基本不携带具体内容。这些 token 就成了注意力汇聚(attention sink)。把它们扔掉,原本堆在那里的大量权重被迫分给其他位置,每层的输出都被扭曲。StreamingLLM 的解法很简单:始终保留开头几个 token 的 KV,再加最近一段窗口,这样就能稳定处理数百万 token 的流式输入;论文还发现,预训练时专门放一个占位的汇聚 token 效果更好。

gpt-oss 把这个想法直接做进了结构里:每个注意力头有一个可学习的 sinks 参数,相当于一个永远存在的虚拟位置。对应 Transformers 里 modeling_gpt_oss.py 的 eager_attention_forward,简化后是:

python
# scores: [头, 查询数, 键数],已加因果/窗口掩码
sink = sinks.view(-1, 1, 1).expand(-1, scores.size(1), 1)   # 每个头一个标量
logits = torch.cat([scores, sink], dim=-1)                   # 多出一列“虚拟位置”
probs = softmax(logits, dim=-1)[..., :-1]                   # softmax 后丢掉这一列
out = probs @ V                                              # 这些权重之和可以小于 1

这一列分数参与归一化,但它没有对应的 V。当前 token 不想取任何东西时,大部分权重可以落在这一列上,实际取到的内容就少。gpt-oss 的窗口只有 128,如果没有这个机制,局部层里“开头 token”早就被窗口挤掉了。

怎样判断一个模型依赖汇聚:可视化注意力矩阵,若很多头在大部分查询上都把显著权重给了第 0 个位置,就是典型的汇聚现象;这时任何淘汰 KV 的优化(窗口缓存、KV 压缩)都要保住开头几个位置。

稀疏注意力:按内容挑要看的位置 ​

滑动窗口和汇聚按位置决定看哪里,和内容无关。可资料助手真正需要的,是在 12 万个 token 里找到写着“年假申请流程”的那几段,它们可能在任何位置。稀疏注意力的思路是:先用一个便宜的办法粗看一遍全部历史,挑出最相关的少数位置,再只对它们做完整注意力。

四种掩码:完整因果注意力看全部历史,滑动窗口只看最近几个,汇聚加窗口额外保留开头,索引器挑 top-k 时每行只看固定数量、位置不连续的 token

NSA(Native Sparse Attention,DeepSeek 与北京大学等,2025 年 2 月)让每个查询同时走三条路:把历史 K、V 每 32 个压成一个粗粒度块(步长 16)做压缩注意力;根据压缩分支的分数挑出 16 个 64 token 的细粒度块(其中固定包含开头 1 块和最近 2 块)做精确注意力;再加一个 512 的滑动窗口。三路结果用 sigmoid 门控加权。它的重点是“原生”:从预训练开始就用稀疏结构训练,而且按 GPU 的访存方式设计块大小。论文在 64K 长度、A100 上报告前向最快 9.0 倍、反向 6.0 倍,解码按访存量估计最多 11.6 倍。

DSA(DeepSeek Sparse Attention)是 DeepSeek-V3.2-Exp(2025 年 9 月)和 V3.2 用的方案,挑选粒度从块细化到单个 token。它在 MLA 前面加一个闪电索引器(lightning indexer),对每对“当前 token t、历史 token s”打一个分:

text
I(t, s) = Σⱼ wⱼ(t) × ReLU( qⱼ(t) · k(s) )

j       索引器的头,V3.2 有 64 个(index_n_heads),每头 128 维(index_head_dim)
qⱼ(t)   当前 token 的索引查询,由 MLA 的 q 低秩向量投影得到
k(s)    历史 token 的索引键,所有头共用一个 128 维向量
wⱼ(t)   当前 token 决定的每头权重

每个查询取分数最高的 2048 个位置(index_topk),MLA 只在这 2048 个位置上算完整注意力。Transformers 的 DeepseekV32Indexer 里能看到全过程:wq_b 和 wk 是索引器自己的小投影,分数经过 ReLU 后按头加权求和,加上因果掩码,最后 topk 返回位置编号;索引键有自己的缓存。官方实现还用 Hadamard 变换和 FP8 计算索引分数,Transformers 版本为了可读性用 BF16 直接算:Hadamard 是正交变换,不改变点积,FP8 只是降低精度换速度,所以两者只在数值精度上有差别。

省了多少,可以按每个查询、每层估一下(估算,不是官方测量;MLA 按推理时吸收矩阵后的形状计):

text
稠密 MLA:2 × 128 头 × (576 + 512) × L
  L = 131072 → 36.5 GFLOPs
DSA:索引器 2 × 64 头 × 128 × L = 2.15 G  +  核心注意力只算 2048 个位置 = 0.57 G
  合计 ≈ 2.7 G,约为稠密的 1/13;L = 8192 时约 1/3

两点要看清。第一,索引器仍要和全部 L 个历史 token 打分,复杂度还是 O(L²),只是头少、维度小、能用 FP8,常数小得多;V3.2 的技术报告写得很明确:核心注意力从 O(L²) 降到 O(L·k),索引器本身仍是 O(L²)。第二,DSA 省的是计算和读取,不省存储:哪个历史 token 会被挑中事先不知道,所有 token 的 MLA 潜向量都得留着,还要多存一份每 token 每层 128 维的索引键。

DSA 不是从头训出来的。V3.2 的报告描述了两个阶段:先冻结主模型、保持稠密注意力,只训练索引器 1000 步(约 21 亿 token),用 KL 散度让索引器的打分分布贴近主注意力的分布;再打开稀疏选择,主模型和索引器一起训练 15000 步(约 9437 亿 token)。V3.2-Exp 与训练配置对齐的 V3.1-Terminus 相比,公开基准大体持平。这说明稀疏化可以作为后续训练加到已有模型上,但需要专门的训练,不能直接给一个稠密模型打开。之后的模型也在沿用这条路:智谱 GLM-5 的 config.json(Transformers 里的 glm_moe_dsa)同样有 index_topk 2048 和一组索引器参数,DeepSeek-V4 则把滑动窗口、压缩稀疏和重度压缩三种注意力层组合在一起,细节分别在 第 15 篇 和 第 16 篇。

稀疏注意力的失效方式是“挑错”:索引器没把关键位置排进前 k 个,主注意力就根本看不到它。需要同时综合大量分散信息的任务(比如统计手册里所有提到“调休”的条款)比“找一处”更容易受影响,判断时要专门测这类任务,而不只看单点检索。

能读和读得好是两回事 ​

所有这些设计让模型“能”接收 128K 甚至更长的输入,但能不能真正用好这些输入,要另外验证。

最常见的长上下文测试是大海捞针(needle in a haystack,NIAH):在一大段无关文本的某个位置插入一句“通行码是 7392”,最后问通行码是多少。很多模型在这个测试上全长度都接近满分。RULER 基准(2024)指出,原版 NIAH 只测到很表层的检索能力;它增加了多根针、多跳追踪(A 指向 B、B 指向 C)和聚合(统计出现最多的词)等 13 个任务,评测的 17 个声称支持 32K 以上的模型里,只有约一半在 32K 时还能保持令人满意的表现。另一项研究“Lost in the Middle”(2023)发现,在多文档问答里,相关信息放在输入开头或结尾时效果最好,放在中间时明显变差,曲线呈 U 形。

针在文本里通常和周围内容格格不入,模型靠“这句话不一样”就能找到;真实的员工手册里,“年假”出现在十几个章节,有的是旧规定,有的是例外条款,有的在附件里修订过。要回答“入职不满一年的员工年假怎么申请”,模型得找到多处、判断哪条有效、再组合起来,这比捞针难得多。

资料助手要判断某个模型的长上下文是否可用,最好用自己的文档做测试:把关键条款放在不同位置(开头、中间、结尾)分别提问;设计需要综合两三处信息的问题;加入新旧规定冲突的情况,看模型是否能选对版本。如果中间位置或多跳问题的准确率明显下降,就说明“能读”不等于“读得好”,这时先检索出相关段落再交给模型,往往比整本塞进去更可靠也更便宜。

长上下文的成本有三块:Prefill 的平方级计算、Decode 每步读完整 KV、KV 本身的显存。FlashAttention 只解决中间矩阵和访存,不减少计算和 KV;滑动窗口和局部/全局交替限制大部分层的视野,省下这些层的 KV 和计算,全文检索留给少数全局层;注意力汇聚说明开头 token 在 softmax 里承担着“放多余权重”的作用,不能随意丢掉;DSA 这类稀疏注意力按内容挑位置,大幅减少计算,但存储不变,而且可能挑错。这些方法都还保留着至少一部分随长度增长的 KV。下一篇看更彻底的做法:线性注意力、状态空间模型与混合架构,用一个固定大小的状态代替整个 KV Cache,以及为什么走这条路的模型几乎都选择了“混合”。

别急,先让缓存热一下。