一、先把「名义长度」和「有效长度」拆成两件事
每次有人说一个模型「支持一百万 token 上下文」,严格来说只是在描述容量,不是在描述能力。撑起一百万 token 需要三件互相独立的事同时成立:第一,推理框架愿意为这条序列分配足够大的 KV Cache 空间;第二,位置编码在一百万个位置上不发生数值崩溃,注意力分数不塌成一团;第三,模型的注意力分布本身在这个长度上仍然携带有效信息。三件事都成立于百万,不代表模型真的能从第 800K 个 token 里把一个数字取出来。
所以工程上更实用的说法是把上下文拆成两个量:名义长度(nominal length)是显存和位置编码允许的上限,也就是「能塞进去」;有效长度(effective length)是模型在给定任务上的准确率还没有明显崩塌的长度,也就是「用得好」。这两个数字之间的差距,在 2024 年之后的开源模型上动辄是几倍到几十倍,而且它不是一个常数,它随任务类型、提示结构、甚至随你问问题的方式变化。
1.1 长上下文评测里最常见的三个错觉
第一个错觉是把大海捞针(needle in a haystack)当成长上下文能力的代理指标。这个测试把一句无关信息插在长文本的某个位置,考察模型能否原样复述。它几乎只测一件事:检索型注意力的定位能力是否还在。它测不出多跳推理、跨段落对齐、精确计数、以及「把十段分散的信息综合起来回答一个问题」这类真正吃长度的任务。更糟的是,它的分数高度依赖 haystack 本身的干扰强度——如果背景文本语义高度相关,难度会陡增;如果背景是互不相关的随机百科段落,难度会低得多。不同实现之间的数字根本没有可比性。
第二个错觉是把 Lost in the Middle 观察到的 U 型曲线当成 bug 去修。U 型曲线(开头和结尾的信息比中间的信息更容易被用到)是几乎所有使用相对或旋转位置编码的模型的共性,它反映的是注意力衰减随距离单调衰减这一物理事实:离得越远的 token,其注意力分数在归一化后被压得越小。这不是一个可以靠调参消掉的缺陷,而是注意力机制的固有代价。工程上的应对不是消灭它,而是改变信息到达注意力层的路径——把重要信息重新排布、复制、或者在更近的距离上建立摘要。
第三个错觉是把窗口大小当成模型能力的唯一维度来横向比较。两个都是 128K 的模型,一个在长文摘要上接近饱和,另一个在 4K 之后就明显退化但能跑满 128K——后者才是常态。当框架把窗口撑到 1M 时,最常见的表现不是崩溃,而是「安静地变笨」:它仍然能复述你塞在里面的内容,但需要三段信息才能回答的问题,它开始只用第一段。
1.2 有效长度是任务依赖的,甚至是指令依赖的
真正实用的判断方式不是问「有效长度是多少」,而是问「在我的任务分布上,曲线在哪里开始掉」。做法很土但有效:固定其他变量,只把样本长度等比拉伸画一条准确率曲线,看斜率什么时候变负;如果做不了评测,至少可以做一次手工的分层测试——把同一类问题的上下文长度分成 4K、16K、64K、256K 四档,各跑一遍,肉眼找拐点。
这里还有一个容易被忽略的维度:同一个模型的有效长度会随提示的组织方式变化。把相关信息聚在提示的开头和结尾、把检索出的片段按与问题的相关度排序、给模型一条「先定位再回答」的显式指令,这些都不改变任何架构参数,却能显著地移动那条拐点曲线。换句话说,长上下文架构里,提示工程和数据组织不是「模型之外的补救」,而是架构能力的一部分——因为它们直接改变了注意力稀疏模式应该长什么样。
二、位置编码:RoPE 的外推困境与插值家族
把上下文窗口从几万撑到几十万,碰到的第一个硬门槛是位置编码。大部分现代模型用的是旋转位置编码(RoPE),它把位置信息编码成对查询和键向量施加与位置相关的旋转,天然具备相对位置偏好的性质。问题在于,它的频率是由训练时的最大长度决定的:低维分量对应高频位置,衰减快、周期短;高维分量对应低频位置,周期长、几乎不随位置变化。当你把推理位置推到训练长度之外,高频分量会以训练时完全没见过的步长旋转,等效相位混叠,注意力分数失去意义。
2.1 为什么直接外推会崩,而不是慢慢变差
RoPE 的外推失败有一个容易被讲错的特征:它不是平滑退化,而是存在一个明显的临界点。模型的注意力熵会突然塌缩——大量注意力质量集中到极少数 token 上,通常是开头几个位置和最近的一小段窗口,中间的 token 几乎拿不到注意力。这种塌缩在困惑度上表现为长文本后半段完全失去语言模型能力,生成开始重复开头。
原因可以从两个层面理解。从频率角度看,高维旋转向量的周期是 2π,把它外推到 N 倍于训练长度的位置,相位已经绕了几圈,模型从未在那个相位区间上被训练过。从注意力尺度角度看,查询和键的内积会因为未经训练的高频振荡而产生异常大的绝对值,经过缩放因子和 softmax 之后,分布被极端化。原始 RoPE 论文里对频率维度的选择(不同维度用不同波长,从 2π 到 10000·2π)是针对训练长度设计的,外推时这个分布假设被彻底破坏。
还有一个容易被忽略的点:RoPE 的外推能力和注意力稀疏性是耦合的。很多「外推失败」的案例,实际原因是两层问题叠加——位置编码让分数错乱是一方面,同时全注意力在长序列上本来就无法把分数分配对。即使你完美解决了位置编码,如果预训练的长序列样本不够多,模型在 100K 之后仍然只能学会「局部看 + 开头重点看」这种退化模式。这就是为什么很多号称支持 1M 的模型在 256K 之后性能迅速下滑。
2.2 插值家族:PI、NTK-aware、YaRN
最直接的思路是把位置压缩。Position Interpolation 的做法是把位置下标除以一个比例因子 s,使所有位置的取值被压缩回训练时的范围。这样高频分量的相位变化速度变慢了,模型见过的分布重新出现。代价非常直接:相邻 token 的位置差从 1 变成 1/s,模型分辨相邻 token 的能力被稀释,短距离上的注意力分辨率下降。实践中往往表现为「知道大概意思,但细节记不清」。
NTK-aware 缩放给出了更聪明的折中。它不均匀地压缩所有频率,而是只对高频维度做较激进的压缩,对低频维度几乎不压缩或只轻微压缩,理由是高频维度正是外推时相位混叠最严重的部分,低频维度本来就长得像常向量、不会出问题。缩放通过改变 RoPE 的基频 θ 来实现:θ 从 10000 变成 10000·s^(d/(d−2)) 这类形式,使得高频维度的波长拉长、低频维度波长基本不变。它比朴素插值保留了更多局部分辨率,是很多后续工作的默认基线。
YaRN 进一步把这个想法做成了「分频段混合」加「注意力温度补偿」的组合。它把维度按波长分成三类:极短波长的维度直接用插值(因为这些维度负责最局部的信息,压缩它们的影响最小且和注意力温度相互抵消);中长波长的维度不插值(它们本来就稳定);最长的波长维度从 1 训练长度线性插值到目标长度。同时 YaRN 指出插值会整体放大注意力的熵,需要在 softmax 之前对查询向量乘一个温度系数 1/s 的倒数来抵消。这个组合在 128K 量级的上下文上表现出了很好的稳定性,是目前开源社区里最常用的 RoPE 外推方案之一。
YaRN 之后的一个共识是:频率缩放主要解决「不会崩」,不解决「会用好」。它让模型在超长位置上保持数值稳定和基本的语言能力,但并不给模型注入任何「在远处检索信息」的归纳偏置。想让模型真的用好 500K 之外的内容,通常还需要配合后文的注意力稀疏化或长序列训练数据。
2.3 LongRoPE:渐进式扩展与非均匀频段分配
LongRoPE 走的是另一条路:先解决「能训练到哪」,再解决「能推多远」。它的核心观察是,如果训练序列本身就在 2M 长度上做(哪怕其中大部分位置被 mask 掉),位置外推就退化成了一个普通的「在更多位置上训练」的问题,频率缩放不再是必需品——缩放只是用来让预训练权重在初始阶段不至于崩掉的过渡手段,等长序列微调完成后可以撤销。
LongRoPE 的第二个观察是不同维度对长度的敏感度不同,应该用不同的缩放率。它对维度做二分搜索式的分配:一部分维度在短序列上损失小、在长序列上损失大,分配最大的缩放因子;另一部分相反,分配最小的缩放因子,甚至保持不缩放。最终得到的缩放向量在整个维度空间上是分段的,而不是统一乘一个系数。配合渐进式扩展的微调流程(从短到长逐级延长训练),论文报告能在 LLaMA 系模型上把上下文推到两百万量级,同时在 passkey 检索这类需要精确远距离定位的任务上保持接近原模型的表现。
2025 年之后陆续出现的一些变体,值得关注的思路包括:位置外推与注意力稀疏化联合训练(让模型同时学会稀疏模式和外推);以及在 RoPE 之外改用别的方案——例如把旋转位置和可学习的绝对位置混合,或者把序列切成若干块、块间用一个独立的粗粒度位置通道(interleaved 类方案,Llama 4 的 iRoPE 就是把全局和局部位置编码交错排列,让同一层里同时存在两种相对尺度的距离信号)。这些方案的共同点是承认单一频率分布无法同时服务「相邻 token 的精细区分」和「百万级位置的唯一标识」这两个需求。
2.4 RoPE 之外:其他位置建模路线值不值得看
RoPE 不是唯一选择,但从工程角度看它目前仍然是最优解,值得简单交代一下其他路线的问题在哪。线性注意力偏置(ALiBi 类)直接把距离作为线性偏置加在注意力分数上,完全不引入位置向量,因此天然没有外推问题。但它的代价在超长序列上暴露:线性偏置对远距离的区分度很低,超过某个距离之后不同位置之间的分数差可以忽略;同时它和 KV Cache 的各种压缩、分页技术配合起来需要额外改造,因为在分块注意力里要正确处理跨块的偏置项,比 RoPE 麻烦得多。
可学习的相对位置偏置把距离离散化成若干桶、每个桶一个可学习标量。它的表达力强于 ALiBi,但桶数量有限,外推时未见过的桶需要外推或截断,问题只是把 RoPE 的困境换了个形式出现。完全无位置编码 + 缓存机制的路线(Transformer-XL 那一脉)把「位置」交给缓存的结构而非 token 本身,解码时可以任意延长缓存,但它在训练时的分辨率受限于训练片段长度,而且在现代的批量推理框架里缓存管理比位置编码本身复杂得多。
真正值得关注的是第四类:把位置建模的责任交给数据结构和提示组织。给长文档加目录和章节编号、给长代码加文件路径树和符号表、给长对话加滚动摘要锚点——这类做法在近两年的实践里被证明极其有效,而且完全不碰模型权重。它和位置编码是互补的:编码解决的是「token 在序列里的第几个位置」,结构解决的是「这个 token 在整份材料的语义层级上处于什么位置」。对于文档理解这类任务,后者的信息量往往比前者更大。
综合来看,RoPE 之所以能成为事实标准,是因为它是极少数同时满足三个条件的方案:既能表达绝对位置的唯一性,又能自然表达相对距离,还和主流的融合注意力内核、KV 量化、分页管理生态完全兼容。其他方案在某个维度上更优雅,但工程代价太高。
2.4 代码:一个可直接用的 RoPE 频率缩放实现
下面是 NTK-aware 缩放的核心实现,去掉了模型相关的分支,可以直接嵌入现有的 RoPE 计算路径。注意一个容易踩的坑:缩放后的频率必须与实际使用的 position_ids 保持一致,如果一部分层用缩放、一部分层不用,必须在配置里显式声明,不能靠默认值。
import torch
import torch.nn as nn
def compute_rope_freqs(dim: int, base: float, scale: float) -> torch.Tensor:
'''NTK-aware 频率缩放:只拉长高频维度的波长,低频维度基本不变。
dim 必须为偶数,且与 head_dim 一致。
'''
assert dim % 2 == 0, 'head_dim 必须是偶数'
# 原始逆频率:exp(-log(base) * 2i / dim)
inv_freq = 1.0 / (base ** (torch.arange(0, dim, 2).float() / dim))
# 关键:把 base 抬高,高频分量(inv_freq 大)的波长被拉长更多
new_base = base * (scale ** (dim / (dim - 2)))
inv_freq_scaled = 1.0 / (new_base ** (torch.arange(0, dim, 2).float() / dim))
return inv_freq_scaled
class ScaledRotaryEmbedding(nn.Module):
def __init__(self, dim: int, base: float = 10000.0, scale: float = 32.0):
super().__init__()
self.dim = dim
self.scale = scale
inv_freq = compute_rope_freqs(dim, base, scale)
self.register_buffer('inv_freq', inv_freq, persistent=False)
self._cached_len = 0
def forward(self, x: torch.Tensor, offset: int = 0) -> torch.Tensor:
'''x: (batch, seq, dim)。返回同形状的 cos / sin,供 rotate_half 使用。'''
seq = x.shape[1]
if seq + offset > self._cached_len:
t = torch.arange(offset, offset + seq, device=x.device, dtype=torch.float32)
freqs = torch.outer(t, self.inv_freq.to(x.device))
emb = torch.cat((freqs, freqs), dim=-1)
self._cache_cos = emb.cos()[None, None, :, :]
self._cache_sin = emb.sin()[None, None, :, :]
self._cached_len = offset + seq
sl = slice(offset, offset + seq)
return self._cache_cos[:, :, sl, :], self._cache_sin[:, :, sl, :]
# 在注意力里使用:
# cos, sin = rope(q)
# q_embed = q * cos + rotate_half(q) * sin
# k_embed = k * cos + rotate_half(k) * sin
# 注意 attention score 的缩放 1/sqrt(d) 不变,YaRN 那类方案还要额外
# 对 q 做 1/(t * s) 的温度补偿(t 是缩放后的注意力温度)。
三、注意力计算本身:平方复杂度与两道显存墙
解决了位置编码,接下来是更硬的问题:算不动,也放不下。这里面有两道独立的墙,混为一谈会导致方案选错。
3.1 第一道墙:计算量的平方项
注意力打分和加权的复杂度是 O(N²·d)。N 从 128K 涨到 1M,平方项就涨了约 58 倍。这个倍数看起来温和,但它叠加在「单 token 的线性项」上就变得很难看:模型参数量决定了 FLOPs 的下限(大致 2·参数量·token 数),这部分是线性的;而注意力项在大 N 时会反过来成为主导。对一个 7B 模型,1M token 的 prefill 计算量下限在 1e16 FLOPs 量级,即使在一张高算力卡上打满几十 TFLOPS 的有效吞吐,也需要几十秒量级的时间——注意这只是理论下限,实际还要考虑访存和调度。对更大规模的模型,1M token 的 prefill 从「可接受」变成「分钟级」是常态。
这解释了为什么近两年所有做长上下文的推理框架,注意力稀疏化都从「可选优化」变成了「默认路径」。
3.2 第二道墙:N×N 注意力矩阵的显存峰值
第二道墙是显存,而且它的形状比计算量更反直觉。朴素实现要在显存里物化一个 N×N 的注意力矩阵(外加 softmax 的中间结果),显存占用是 O(N²)。N = 1M 时这个矩阵的元素个数是 1e12,即使每个元素只占 1 字节也已经到 TB 级,根本不可能存在。FlashAttention 这类融合内核的价值就在这里:它通过分块计算和在线 softmax,把 N×N 矩阵从不完整的物化状态里消掉,显存降到 O(N),复杂度的渐进上界不变。它解决的是显存墙,不是算力墙——1M token 的注意力 FLOPs 一个也没少。
所以对长上下文来说,这两件事必须分开算账:FlashAttention 及后续的变体(对 KV Cache 做分页管理的注意力变体、支持稀疏块的注意力内核、支持低比特 KV 的反量化融合内核)让「显存墙」从 TB 级降到 GB 级;而注意力稀疏化才真正降低「算力墙」。很多团队在长上下文项目上只做前者,结果发现 prefill 时间随长度平方增长,用户体验依然不可接受。
3.3 MLA:换一个方向降低 KV 维度
除了事后压缩,DeepSeek 系列采用的 Multi-head Latent Attention 走的是「一开始就不要那么多 KV 头」的路子。它的思路是:把每个 token 的 K 和 V 先用一个共享的低秩投影压成一个小潜向量存进 Cache,在计算注意力时再用该 token 携带的少量分量把潜向量上投影回各个头的 K/V 空间。因为所有头共享同一个潜向量,KV Cache 的维度从「头数 × 头维 × 2」降到「潜维 + 上投影所需的小分量」。潜维在实践中取在几百的量级,相对于传统 GQA 配置动辄上万的维度,这是几十倍的下降。
MLA 的代价有两处,都值得说清楚。第一,上投影需要该 token 自己的 K 分量,这意味着即使注意力只访问其中少数位置,也必须把被访问 token 的潜向量反投影回来,计算上有额外开销。第二,MLA 和 PagedAttention 这类「按块管理 KV」的方案在工程上是互相拉扯的:PagedAttention 要求 KV 布局规整、可按页拷贝,MLA 的潜向量布局更适合整体读写。好在后续的吸收(absorb)类变体解决了一部分这个问题——把上投影矩阵提前吸收进查询和输出投影,使得在推理时可以完全不解码潜向量,直接在潜空间里做注意力,这是长上下文部署里很有价值的一个工程细节。
把这两类方向放在一起比较会得到一个更完整的判断:GQA/MQA 降的是 KV 头的数量,MLA 降的是 KV 的维度,量化降的是 KV 的位宽,分层存储降的是 KV 所在的位置层级,稀疏化降的是 KV 的数量,淘汰策略降的是常驻 KV 的数量。六种手段正交,长上下文系统通常需要同时用上三四种。
3.4 把整条链路的显存预算列出来
长上下文项目的显存预算不能只看 KV Cache 一项。一张 80GB 的卡上,真正要装的东西包括:模型权重(不量化的话是参数个数乘二字节)、KV Cache(随并发数和上下文长度线性膨胀)、激活值(prefill 阶段的一个瞬时峰值,与序列长度成正比但常数不大)、以及框架本身的碎片和预留。如果不做这张账,很容易出现「KV 算下来刚好装得下,一跑就显存溢出」的情况。
预算表的正确列法是四列:场景(典型并发数和上下文长度)、权重占用、KV 占用、激活峰值、以及碎片预留,然后看哪一列是瓶颈。我的经验判断是,在 GQA 配置下、上下文超过 32K 之后,KV Cache 会迅速成为最大的一列;而在没有 GQA 的老架构上,它在 8K 就已经是瓶颈了。这也解释了为什么架构层面的降维(GQA、MQA、MLA)比推理层的任何压缩手段都更值得优先做——它是从源头把那一列变小,而压缩是在结果上打补丁。
另一个容易被忽略的列是激活峰值。prefill 阶段的瞬时激活值虽然不大,但它和 batch 内的序列条数是正相关的:在 chunked prefill 下,峰值只和 chunk 大小有关;如果你没有做分块,而是试图一次性处理整条长 prompt,激活峰值会直接和序列长度挂钩,某些实现里它甚至会超过 KV Cache 本身。这就是 chunked prefill 除了调度之外的第二个理由——它同时是一次显存保护措施。
四、注意力稀疏化:把平方项从根上砍掉
注意力稀疏化的完整谱系,从结构假设的强弱来看可以分成四层:固定稀疏(滑动窗口)、结构化稀疏(人为规定哪些位置重要,比如垂直斜线)、检索式稀疏(用一个便宜的近似算子动态选出重要位置)、学习型稀疏(用一个小模型预测哪些块重要)。越往后效果越好,但工程复杂度和失效风险也越高。
4.1 滑动窗口与 StreamingLLM 的注意力汇
最简单的稀疏化是滑动窗口:每个 token 只看最近 W 个 token。这把注意力从 O(N²) 降到 O(N·W),W 是常数,复杂度就变成了线性。但朴素滑动窗口有一个致命问题:模型会退化。原因是训练时用的是因果全注意力,模型的注意力分布里除了局部模式,还有一个很重要的全局模式——注意力汇(attention sink):大量注意力质量会无条件地倒向序列开头的几个特殊 token,充当一个「无信息量的垃圾桶」,让 softmax 的分子分母有个稳定出口。
StreamingLLM 的关键贡献是把这个观察变成了设计原则:只保留开头的若干个 sink token 加最近的一段窗口,就能在极小的 KV Cache 预算下维持稳定的困惑度,论文报告在只保留几个 sink 加最近两三千 token 的预算下,把稳定性维持到了百万级 token。换句话说,「不重要的位置」并不是均匀分布的,它们大量聚集在开头那几个位置上——把开头当成垃圾桶既符合模型已经学到的行为,又省掉了整个中间段。
这个技巧的价值在于极其便宜:它不需要任何训练或索引器,纯粹的注意力掩码改动就能生效,而且几乎无损。它是所有长上下文方案的地基。但它的上限也很明确:滑窗看不到远处的显式内容,sink 存不下任何语义。也就是说 StreamingLLM 解决的是「不崩」,解决不了「记得住 300K 之前那段」。
4.2 结构化稀疏:MInference 与垂直斜线假设
如果模型在训练时就接触了「某些特定位置模式更重要」的归纳偏置,就可以人为规定一个稀疏拓扑。MInference 的做法是先用少量长上下文样本采样注意力图,观察到几个非常稳定的模式:垂直列模式(当前 query 的注意力集中在某些特定的 key 位置,这些 key 位置贯穿整个序列,相当于历史的 sink)、斜线模式(沿着局部对角线,典型的滑动窗口)、竖直斜线模式(分段斜线,覆盖更远的范围)、以及块内模式。每个 head 几乎固定属于其中一类。
于是稀疏化就变成了「给每个 head 分配一种模式 + 一个跨度参数」,推理时按模式算注意力。这套方案在百万级 token 上的表现被官方和社区反复验证过,是目前「结构化稀疏 + 无学习开销」路线的代表做法之一:它不需要训练,部署时只是掩码改造,精度损失在长文本理解任务上通常很小。它的局限也清楚——模式是预设的,如果实际输入的重要性分布和预设模式严重不匹配(比如一段结构极其规整的代码,和一段角色对话交替的剧本),预设的拓扑就会浪费预算或者漏掉关键位置。
4.3 检索式与学习型稀疏:让模型自己决定看哪里
检索式稀疏的思路是:先用每个 query 的前几维(有时只用一维)算一个和所有 key 的粗粒度内积,选出 top-k 个候选,再只在候选上做完整的注意力。这样粗筛的计算量是 O(N·d_kv 的一个小投影),完整注意力只在 k 个位置上做,整体复杂度近似 O(N·k + N)。这里的本质是「用低维近似的高召回索引替代昂贵的全维度精确匹配」,和向量检索里的 IVF/HNSW 是同一个思想——用一点召回损失换十倍以上的速度。
学习型稀疏则把「选哪些」这件事交给一个小模型。SpargeAttn 用一个轻量的 token 选择器预测每个 query 对各个 KV 块的重要性打分,只对高分块做稀疏注意力,并把未选中块的注意力用估计值补偿,避免 softmax 归一化出错。SeerAttention 用一个很小的门控网络(每层大约百万级参数)逐层地决定注意力预算并预测块级重要性,逐层收敛——前面的层只看很少的块,后面的层逐步放开,这样能显著降低总开销。DeepSeek 在 V3.2 引入的 DSA 也是这个思路:用一个轻量索引器为每个 query 计算 top-k 的候选位置,配合一个滑动窗口保证局部信息不丢,官方给出的口径是在长上下文上把注意力相关的成本压到了大约三分之一量级。
检索式和学习式稀疏的最大优势是「输入自适应」:同样的预算,代码任务和对话任务的注意力分布完全不同,动态选择能自动适配。但它们有两个必须正视的风险。第一是召回失败:粗筛漏掉了真正的关键位置,那一次注意力的结果就是错的,而且你无法从输出里察觉。所以这类方案通常要保留一个滑窗或 sink 作为安全网,保证局部和开头永远在预算内。第二是粗筛的稳定性:低维内积的 top-k 在数值上对量化误差和 fp8/fp16 精度比较敏感,不同 batch 的选择结果可能抖动,这在对话场景里会表现为输出不稳定。
4.4 按头分配预算:不同 head 的需求天差地别
DuoAttention 提供了一个很有启发性的视角:不是所有注意力头都需要同样多的 KV Cache。实验显示大部分头只在很短的窗口内使用(局部头),而少数头需要访问整个上下文(检索头)。如果给所有头平均分配预算,就等于为少数检索头超付、为多数局部头浪费。更合理的做法是先离线分析每个头的 KV 使用分布(用长上下文样本跑一遍,统计每个头在多大的窗口内就覆盖了 95% 的注意力质量),给局部头分配小预算、给检索头分配大预算,再训练一个轻量分类器在推理时决定每个新头属于哪一类。论文给出的口径是,KV Cache 大小降到大约四成而精度基本不变。
这个思路的价值不止于省显存,它实际上是一个「按注意力行为给头分层」的架构化视角,和 MLA 的「按维度降维」、以及稀疏化的「按层分配预算」是同一套方法论的不同切面:不同层、不同头、不同维度对长上下文的敏感度完全不同,均匀分配资源是最大的浪费源。2026 年新出的一些模型(Kimi Linear、MiniMax 系列、Gemma 3n 等)在架构说明里都出现了类似的分层混合注意力设计:部分层走线性注意力或压缩状态空间,部分层走压缩过的滑动窗口注意力,用固定的压缩比把 KV 需求降下来,官方口径都在 5:1 到 7:1 这个量级。它们的共同逻辑就是:承认长上下文里绝大多数层不需要全量注意力。
4.5 分层稀疏率:预算到底该怎么切
稀疏化落到工程上,最终要回答的是一个预算分配问题:总预算一定,怎么切给不同层。有三条经验上比较稳的原则。第一,浅层给窄、深层给宽。浅层注意力头主要做词法和局部句法匹配,需要的上下文窗口很小;深层做抽象和检索,需要跨段的信息聚合。一个反直觉但重要的现象是,深层往往同时需要更宽的窗口和更强的跨段检索能力,而浅层几乎只做局部——这正是分层注意力架构在 2025 年之后集中出现的原因。第二,不要让 sink 和滑窗吃掉全部预算。sink 保留 4 到 16 个 token 就够,滑窗在很多任务上 512 到 2048 已经覆盖了绝大部分质量提升;两者加起来应该控制在总预算的三分之一以内,剩下的才留给动态检索。第三,按输入类型切换稀疏配置。代码、对话、长文摘要需要的稀疏拓扑差别很大,如果你的服务能识别输入类型,用两套预设比一套折中配置更划算。
预算的确定方法是实测驱动的:先用一个宽松配置(比如只有滑窗加 sink)作为基线,逐步增加动态检索的预算,画出「预算 vs 下游任务分数」的曲线,找到斜率明显变平的位置。经验上这个拐点通常比大家预期的低——总预算达到训练长度的 5% 到 10% 时,收益往往已经吃掉八成以上,再往上边际收益递减很快,但显存和延迟成本却是线性增长的。
4.6 代码:一个检索式稀疏注意力的最小实现
下面这个实现演示了检索式稀疏的核心逻辑:只用一个投影维度做粗筛,选 top-k 个 KV 位置,再在选出的位置上做完整注意力。它没有做任何 flash/分页优化,但结构是完整的,可以直接跑在小模型上验证稀疏度对精度的影响。
import torch
import torch.nn.functional as F
def sparse_topk_attention(q, k, v, sink=4, window=256, k_ratio=0.02):
'''q: (B, H, N, D) k/v: (B, H_kv, N, D),GQA 下用 repeat_kv 展开
返回与全注意力同形状的输出。稀疏预算 = sink + window + topk。
'''
B, H, N, D = q.shape
k = k.repeat_interleave(H // k.shape[1], dim=1)
v = v.repeat_interleave(H // v.shape[1], dim=1)
# 预算分配:固定部分(sink + 局部窗口)永远保留
fixed = sink + window
# 动态部分:用 q 的第一维做粗筛,取 topk 个最相关的 key 位置
coarse = torch.einsum('bhid,bhjd->bhij', q[..., :1], k[..., :1]) # (B,H,N,N)
topk = max(1, int(N * k_ratio))
topk = min(topk, N - fixed)
_, idx = torch.topk(coarse, topk, dim=-1) # 每个 query 各选自己的位置
# 组装稀疏索引集合:[0:sink] ∪ [N-window:N] ∪ topk 命中
dev = q.device
s = torch.arange(sink, device=dev).expand(B, H, N, sink)
w = (torch.arange(N - window, N, device=dev)
.view(1, 1, 1, window).expand(B, H, N, window))
pos = torch.cat([s, w, idx], dim=-1) # (B,H,N,K)
K = pos.shape[-1]
# gather 出被选中的 k/v,并重算精确注意力分数
k_sel = torch.gather(
k.unsqueeze(2).expand(B, H, N, H, D), 3,
pos.unsqueeze(-1).expand(B, H, N, K, D)) # (B,H,N,K,D)
v_sel = v.unsqueeze(2).expand(B, H, N, H, D).gather(
3, pos.unsqueeze(-1).expand(B, H, N, K, D))
scores = torch.einsum('bhnid,bhnkd->bhnik', q, k_sel) / (D ** 0.5)
out = torch.einsum('bhnik,bhnkd->bhnid', scores.softmax(-1), v_sel)
return out
# 要点:
# 1) coarse 矩阵虽然是 (B,H,N,N),但它的乘加深度只有 1 维,
# 在真实实现里应该直接对 K 做投影降维后再算,永远不要物化 N×N 的 fp32。
# 2) 真实实现要用 gather_kernel / paged KV 来做,避免上面的 expand 显存爆炸。
# 3) 生产上通常和滑窗、sink 融合成一个固定模式 + 动态模式的混合预算表。
五、KV Cache 全生命周期:先算账,再谈压缩
KV Cache 的优化是长上下文工程里最琐碎但也最有确定收益的部分。它的生命周期大致是:写入(prefill 产生)→ 常驻(decode 期间被反复读取)→ 压缩/迁移(量化、分层、换出)→ 淘汰(上下文超出预算时丢弃一部分)。四个环节对应四类完全不同的技术。
5.1 显存估算:先把账算清楚再选方案
KV Cache 的字节数有一个很朴素的公式:字节数 = 2 × 层数 × KV头数 × 头维 × dtype字节数 × 序列长度(前面的 2 是 K 和 V 各一份)。用这个公式算几个真实配置,结论往往令人不适。
- 一个 32 层、32 个 KV 头、头维 128、fp16 的 7B 模型:每 token 约 512 KiB。128K 上下文单条序列就是 64 GiB,已经超过大部分单卡容量。
- 一个 80 层、GQA 到 8 个 KV 头、头维 128、fp16 的 70B 模型:每 token 约 320 KiB。128K 上下文单条序列恰好 40 GiB。这还是完全没有压缩的裸配置。
- 把上面第二行推到 1M 上下文:单条序列 320 GiB。就算一张 80GB 的卡全部用来放 KV,也只够放下四分之一条序列。
从这个账本可以直接推出三个工程结论,也是长上下文系统的三条基本路线:第一条是降维度(GQA/MQA/MLA,从源头减少 KV 头数或维度);第二条是降位宽(量化到 8bit/4bit/2bit,位宽减半是线性收益);第三条是降数量(压缩/淘汰/稀疏,只保留一部分 token 的 KV)。这三条路线互相独立也互相竞争,优先级取决于你的瓶颈是显存容量、显存带宽还是算力。
5.2 量化:位宽减半是最便宜的收益
KV 量化的思路和权重量化类似,但有一个关键区别:KV 的量化误差会在长序列上被注意力 softmax 放大。权重误差是固定的,KV 误差则取决于有多少个 token 参与了同一个 softmax 的归一化——序列越长,参与竞争的位置越多,某个被量化误差放大的 key 值就越容易在归一化后抢到不成比例的注意力质量。这就是为什么早期的 KV 量化方案在长文本上会出现「短任务无损、长任务明显退化」的现象。
KIVI 提出的关键观察是:Key 和 Value 应该用不同的量化策略。Key 向量的异常值集中在少数特定的通道上(因为它承载了位置和方向的尖锐信息),所以 Key 适合按通道量化;Value 向量的异常值集中在少数特定的 token 上(少数 token 承载了大部分信息量),所以 Value 适合按 token 量化。这个「一横一纵」的分配方式在同等位数下明显优于统一策略。后续的版本把位宽进一步压到 6bit、并引入了按注意力输出重要性加权的分组量化,官方报告的口径是在基本无损的前提下把 KV Cache 压到约四成。
工程上有两个必须注意的点。第一,量化必须按头分组而不是全模型统一:不同头、不同层的 KV 分布差异很大,统一 scale 会让方差大的头精度崩塌。第二,反量化要融合进注意力内核:如果每步 decode 都把整个 KV Cache 反量化回 fp16 再算注意力,你就把省下来的显存又还给了带宽,收益归零。真实的内核必须在读取 KV 的同时完成反量化和点积,最好用 int8/fp16 混合精度算力单元直接做。
5.3 分层存储与换出:把 KV 当成数据而不是显存
KV Cache 本质上是「每个 token 的一段向量」,它完全可以放在 CPU 内存、SSD 甚至对象存储里,按需换入。这条路线的历史可以追溯到很早的 CPU 卸载推理方案,那时候的做法是把全部层都卸载,逐 token 换入换出,带宽完全被 PCIe 占满,吞吐低到不可用。
后来的分层方案在思路上做了两个关键改进。第一是只把「不常用」的部分换出去:prefill 产生的 KV 里,最近生成的那些 token 在接下来每一步都会被访问,必须留在显存;而很久以前的历史 token 在生成阶段只需要被访问一次(生成下一个 token 时),换出它们对吞吐的影响相对小。KTransformers 这类方案正是基于这个观察——它把 KV Cache 放在 CPU 内存里,靠优化过的按 token 稀疏访问路径来减少 PCIe 往返,关键不在于「全部卸载」,而在于「只卸载深层和只访问稀疏层」。
第二是分层注意力:ShadowKV 这类方案在低层保留一个完整但很小的滑窗 KV(最近的几百个 token),高层只保留极少的「shadow token」(通过重要性打分挑出来的一小撮历史 token)。这样在深层做注意力时只需要读很少的 KV,把 PCIe 带宽需求降到了 GPU 直接显存方案的水平附近。LayerCache 是另一个方向的思路:层间复用同一份 KV,把相邻层共享的部分只存一份,对 KV 高度冗余的模型能省不少。
换出路线最大的问题是延迟抖动。SSD 的随机读延迟在几十到几百微秒量级,一旦某个 token 需要多次换入,尾延迟就会变得非常难看。所以这条路更适合离线批处理或者对延迟不敏感的场景;面向在线服务的系统,主流选择还是「量化 + 稀疏 + 分页」的组合。
5.4 淘汰与压缩:到底谁该被扔掉
当上下文超出预算时,必须丢掉一部分历史 token 的 KV。淘汰策略的核心问题是:丢谁。这里的谱系比注意力稀疏化更「有损」,因为一旦丢掉就真的看不到了。
- Sliding window / StreamingLLM:丢掉最旧的,保留开头 sink。实现最简单,但丢掉的可能正是任务需要的证据。
- 注意力分数启发式(H2O 类):统计每个 token 被注意到的累积分数,保留高分的一批。问题在于它需要维护每个 token 的分数计数器,而且累积分数有很强的历史偏好,早期被看到的 token 会一直高分,这本身是一种偏差。
- 观察窗 + 池化(SnapKV 类):在 prompt 末尾放一段观察窗(observation window),用这段窗口的 query 去对 prompt 的所有 key 打分,取 top-k 并对保留位置做一个聚类/一维池化补偿。这是目前工程上最常用的方案,因为它的打分是「针对当前这次请求的相关性」,而不是全局历史统计,而且不需要额外的训练。论文里典型的配置是把每层压缩到 1K~4K 个 token。
- 任务感知 / 分层预算(PyramidKV 类):不同层分配不同预算——浅层更多做局部模式匹配,需要的窗口小;深层做抽象推理,需要看得更远,所以给深层更大的预算。同时按剩余可用上下文动态调整预算比例,剩余上下文少时把预算收缩。
- 注意力汇 + 重要 token 常驻:保留开头 sink、保留 prompt 末尾的最近窗口、保留一段从全文中挑出的高分 token,三段拼起来。这基本是当前生产系统的默认配方。
淘汰策略的共同陷阱是:它们都在用「生成时的访问模式」去预测「整条请求的正确性需求」。这两者高度相关但不等价。一个反例是多跳检索:真正需要的那条证据在生成到第 3 步之前从未被访问过,因此任何基于访问统计的淘汰策略都可能提前把它丢掉。对这类任务,缓解办法通常是在 prompt 层预先做检索(用外部检索把相关段落塞进 prompt 的开头或结尾),而不是指望模型内部的淘汰策略救场。这是长上下文系统里「外部检索」和「内部稀疏」长期共存的原因:前者解决召回,后者解决成本。
5.5 KV Cache 的可观测性:必须盯的四个指标
长上下文的调优如果没有度量,就全靠猜。至少应该持续采集四个指标。第一个是KV Cache 占用率与碎片率:显存里实际被 KV 占据的比例和空闲块的比例,直接告诉你分页配置是否合理。第二个是每 token 的 KV 字节数:这个值随序列增长而变化,因为淘汰策略在不同长度下的收缩比例不同,它比总量更能说明压缩策略的健康度。第三个是前缀缓存命中率:在多轮对话或共享系统提示的场景里,这个命中率直接决定吞吐;命中率低于预期,通常意味着前缀被淘汰策略误伤,或者分页的块对齐方式让逻辑前缀无法被复用。第四个是各层注意力质量的分布:定期抽样统计每层实际访问到的 KV 比例、每层的有效窗口大小,这是调整分层预算最直接的依据。
这四项里最被低估的是最后一项。分层预算听上去是个架构问题,但真正决定怎么分的,是每层注意力分布的实测数据——不同模型、甚至同一模型的不同训练阶段,这个分布差别都很大。把「每层每步实际访问的 KV 比例」做成一个随时间变化的看板,比任何经验参数都可靠。
六、分页管理:PagedAttention 解决的是碎片
当并发请求的上下文长度各不相同,KV Cache 的分配就会遇到经典的外部碎片问题:一个请求要 8K、另一个要 100K,如果按最大长度预留,连续服务下显存会被大量空洞吃掉。PagedAttention 的做法是借鉴操作系统的虚拟内存分页:把 KV Cache 切成固定大小的块,每块作为一个「页」,请求的逻辑 KV 序列映射到一组物理页上,物理页从共享池里按需分配。
vLLM 论文里给出的口径是,传统连续分配的碎片浪费在 60%~80% 量级(取决于长度分布),分页之后降到 4% 以内。除了省显存,分页还带来三个直接的系统收益:一是几乎零拷贝的 prefix caching——共享的系统提示词、few-shot 示例、多轮对话的历史部分都以页为单位共享,命中就是命中,不需要拷贝;二是波前式的连续批处理——一个新请求可以在任意时刻插入正在运行的 batch 尾部,不必等整个 batch 跑完;三是稀疏注意力可以按页寻址——前面讲的 top-k 检索、块级重要性打分,全都建立在「块」这个单位上,分页天然就是这个单位。
分页的代价是每页的元数据开销,以及注意力内核必须能处理「非连续物理页 + 逻辑位置」的映射。实际工程里这意味着你需要一个真正支持变长块表的注意力内核,而不是拿一个只支持连续 KV 的通用 flash 注意力库凑合。SGLang 的 RadixAttention 是同一思路的另一实现,它用基数树(前缀树)来组织共享前缀,命中判断和淘汰都变成树上的操作。
分页还有一个常被忽略的杀手级用例:共享前缀的写时复制。当一个请求需要从某个中间位置分叉出多条候选(比如束搜索、多路采样、或者树状生成),传统做法必须把整条 KV 复制一遍,在长上下文下复制成本和序列长度成正比,几万 token 的前缀复制一次就要几秒钟。分页之后只需要修改页表:候选之间共享同一个前缀的物理页,只有分叉点之后的页才各自新建。这条性质让长上下文和树状解码、多候选生成这类技术第一次变得经济可行,而它们恰恰是长上下文里「一次生成多个可能更优答案」这条产品逻辑的底层依赖。
但共享前缀也带来隔离问题:不同租户、不同用户的请求共享同一页前缀时,一旦出现越界写入就会造成跨请求的信息泄露。在线服务的推理框架必须保证物理页的写权限是独占的,读权限才可以共享。这不是性能优化能绕过的安全问题,验收时要专门测一次并发下的越界场景。
七、Prefill 与 Decode:两个完全不同的瓶颈
把前面的所有优化放回一次真实的推理里,你会发现最关键的判断失误来自「没有区分 prefill 和 decode」。这两个阶段的瓶颈在硬件上是相反的。
7.1 Roofline:算力受限 vs 带宽受限
Prefill 阶段是算力受限(compute-bound)。它一次性处理成百上千个 token,矩阵乘的规模足够大,GPU 的浮点单元能被喂饱,注意力内核的融合、稀疏、量化全都能转化为墙钟时间上的收益。此时 KV Cache 还没建立,显存压力相对小,主要瓶颈是 FLOPs。
Decode 阶段是显存带宽受限(memory-bound)。每生成一个 token,每一层都要把整个 KV Cache 从 HBM 读一遍——这是硬性的代价,和生成多少 token 无关。7B 模型单 token 的权重读取约 14 GB,在 2 TB/s 的带宽上就是 7 ms;再加上 KV Cache 的读取,128K 上下文下每 token 要多读 40~64 GB,带宽需求直接翻几倍。此时降低 KV 的字节数比降低 FLOPs 重要得多,而计算稀疏化的收益很低(因为每次只算一个 query,稀疏化省的是本来就很少的计算)。
推论很明确:prefill 侧该上注意力稀疏化和分块并行,decode 侧该上 KV 量化、KV 淘汰和分页复用。很多团队把这两件事搞反了——在 decode 阶段强调「稀疏注意力」,却忘了 decode 的瓶颈是搬运字节而不是计算。
7.2 调度:chunked prefill 与混合批处理
chunked prefill 的动机是:一次处理 100K token 的 prefill 会占满 GPU 几秒钟,这段时间里所有正在 decode 的请求全部被阻塞,首 token 延迟(TTFT)飙升而每输出 token 延迟(TPOT)正常,体验很差。chunked prefill 把长 prompt 切成固定大小的块,和其他请求的 decode 步骤交织起来调度,让两者共存于同一个 batch。代价是 prefill 的总时间略微变长(块之间有同步开销),收益是 TTFT 和 TPOT 的平衡大幅改善。
进一步就是混合批处理(continuous batching):把 decode 视作每步追加一个 token 的 prefill,两者在同一个 batch 图里执行,由一个统一的调度器按 token 预算分配算力。这套机制是现代推理引擎吞吐能力的基础,但也带来新的问题——当 KV Cache 显存不够时,调度器必须抢占(preempt)某些请求把它们换出或丢弃。抢占策略的选择直接影响长请求的公平性:一个 500K 的请求如果频繁被抢占,它的 TTFT 会非常难看。这就需要在上下文长度调度和优先级之间做显式的权衡。vLLM 后来引入的「按 token 预算分配 + 分层队列」这类机制,本质上都是在解决长请求和短请求共存时的调度公平性。
还有一个常被忽略的点:speculative decoding 在长上下文 decode 阶段的收益被放大了。因为 decode 是带宽受限的,一次前向只产出一个有效 token 而要搬运全部 KV,这个「带宽换算力」的交换非常划算。5~10 倍的无损加速在长上下文 decode 上几乎白拿——前提是草稿模型足够小、目标模型的验证阶段能复用同一份 KV Cache。
7.3 延迟预算:把长上下文的需求写进服务等级协议
长上下文项目最容易在验收阶段出问题的地方,是延迟指标没有被分解。一个 1M 上下文的服务,如果只给一条「首 token 延迟小于 5 秒」的指标,团队就会去优化 prefill,而 decode 阶段的多轮对话体验无人负责。建议把延迟拆成四个数字:首 token 时间(取决于 prefill 成本与排队)、每输出 token 时间(取决于 KV 字节数与带宽)、缓存未命中时的首 token 时间(这个往往才是高分位延迟的真正来源),以及上下文增长斜率(每输出 token 时间随输入长度增长的曲线)。最后一个尤其重要,因为它直接告诉你这个服务的最大并发规模如何随上下文长度变化。
另一个实践上的提醒:压缩方案要在高分位上验收,不能只看平均值。KV 换出、请求抢占、分页碎片这三类代价都高度集中在长尾,而长尾恰好是用户投诉的来源。一个平均 200 毫秒但高分位是 4 秒的生成延迟,在对话产品里是不可用的。
八、训练期的长序列切分与推理期的完整上下文之间的落差
这是长上下文里最容易被绕过、也最要命的一个问题:推理时你想让模型看 1M,但训练时它几乎肯定没见过连续 1M 的序列。这个落差有几个层面。
第一是序列切分带来的位置断裂。为了把有限的显存用满,训练时会把长文档切成固定长度的片段。问题在于,片段是用文档 id 而非 position id 打包的(为了跨样本 attention mask),而推理时位置 id 是连续递增的。模型在训练中学会的是「在每个片段内部做相对定位」,推理时却会遇到一个片段内位置高达 100 万的输入。RoPE 对绝对位置是有依赖的(尽管它设计成主要编码相对位置),这个断裂直接导致分布外。常见的修复是把位置 id 也按片段重新从零开始(这等价于给每个片段一个独立的「文档」),但这样模型就永远学不到跨越片段的长距离关系。
第二是注意力模式的训练覆盖不足。模型在训练中如果主要见到的是「短片段 + 文档边界」,它对超长序列的真实注意力分布是没有见过的。解决方案是渐进式扩展:先在 4K 上训,再在 32K 上继续训,再在 256K 上训,每一阶段用上一阶段的权重初始化,并且位置编码的缩放比例随阶段调整(这就是 LongRoPE 的渐进扩展思路)。这类训练的成本是真实的——在 256K 上做一次有意义的继续训练,GPU 成本通常是短上下文微调的几十上百倍。
第三是长序列样本本身稀缺。网页、代码、书籍里有大量超过 100 万 token 的自然文档,但在清洗、去重、质量过滤之后,剩下的高质量长序列样本远少于短样本。如果长序列训练数据里混入了大量低质量拼接(截断的文档、模板化内容),模型学到的不是长程依赖而是「如何忽略噪声」。这是很多「号称支持长上下文」的模型实际表现不佳的根因,而它是一个数据问题,不是架构问题。
第四是loss 加权不均。在 1M 长度的序列上,如果对所有 token 的 loss 等权平均,模型在「开头 1K」(往往是文档开头的信息密集区)的梯度贡献远大于中间部分。常见的做法是按 chunk 加权或者只对一部分 token 计算 loss,但这会引入新的偏置。这是一个没有普适解的工程细节,各家做法差别很大。
8.1 训练端的代码:块级 attention mask 与 packing
下面这段代码演示的是训练时长序列的核心 mask 构造:把多条短样本打包成一个长序列,用块级 mask 阻断跨样本注意力。这是绝大多数团队的实际做法,也是推理端最长上下文断层的来源。
import torch
def build_block_causal_mask(seqlen, block_size, device):
'''因果 mask + 块内双向:因果注意力的标准变体。
block_size > 1 时,同一块内允许互相看见,块间仍然严格因果。
推理时如果用滑窗 + 全局 sink,这个结构要保持一致,否则训练/推理不匹配。
'''
idx = torch.arange(seqlen, device=device)
causal = idx.view(-1, 1) <= idx.view(1, -1)
blk = (idx.view(-1, 1) // block_size) >= (idx.view(1, -1) // block_size)
# 混合注意力(如 Gemma 3n / 交替滑窗层)就靠这里给不同层返回不同的 mask
return causal & blk
def packed_document_mask(seq_lens, total_len, device):
'''多文档 packing 的 mask:文档之间完全隔离。
注意 position_ids 要在这里同步重置为 0,
否则模型会把不同文档的位置接成一条连续的长序列——
这正是训练/推理落差的直接来源。
'''
doc_id = torch.zeros(total_len, dtype=torch.long, device=device)
position_ids = torch.zeros(total_len, dtype=torch.long, device=device)
cur, pid = 0, 0
for n in seq_lens:
doc_id[cur:cur + n] = pid
position_ids[cur:cur + n] = torch.arange(n, device=device)
cur += n
pid += 1
mask = doc_id.view(-1, 1) == doc_id.view(1, -1)
# 再和因果 mask 求交,并保留前若干个全局 sink 列
return mask, position_ids
# 训练循环里的常见组合:
# mask = packed_document_mask(lens, N)[0] & causal_mask
# out = model(input_ids, position_ids=position_ids, attention_mask=mask)
# # 关键:position_ids 用上面重置后的版本,不要用默认的 arange(N)
# 对照检查:如果你的推理引擎在 decode 阶段用单调递增的绝对 position,
# 而训练时每篇文档都从 0 开始,那么长到 10 万之后就会有明显的分布漂移。
8.2 长上下文训练数据怎么造
如果你手头没有天然的长文档,只能自己造,有几个原则。第一,拼接必须自然。直接用分隔符把无关文档连起来,会让模型学到一个捷径:文档之间是无关的,于是长序列里的大量注意力被浪费在识别边界和无效位置上。更好的做法是用同一主题的相关文档、同一本书的连续章节、或者同一个代码仓库中互相引用的文件。第二,刻意构造需要远距离依赖的样本。在前部埋入关键实体、在后部提问;或者在前部给出定义、在后部使用它。公开的长上下文评测集本质上就是这么构造的,训练集里也该有对应的比例,否则模型学到的只是局部共现而不是长程依赖。第三,控制超长样本的比例。如果超长样本占比超过某个阈值,短文本能力会明显退化(灾难性遗忘),因为优化器的更新会被少数超长梯度主导。常见做法是超长样本占百分之一到百分之五,其余维持常规长度,靠它们守住短上下文性能。
还有一个容易被忽略的维度:在混合长度训练里,超长样本的损失应该按 token 而不是按样本加权,否则一条百万长度的样本会主导整个批次的梯度。这和前面讲的打包是配套的——打包让多条短样本共享一次计算,超长样本通常单独成批,两者需要不同的梯度累积策略才能稳定训练。实践中很多团队的超长分支训不稳,根因就在这里。
九、失效边界:这些方案在哪里会翻车
长上下文是一个「在很多任务上够用、在少数任务上彻底失效」的领域。把失效模式列清楚,比记住方法名有用得多。
9.1 位置编码方案的失效边界
纯频率缩放在需要精确远距离定位的任务上退化最明显:在一个 200K 的上下文里,「第 173,842 个 token 是什么」,插值方案几乎必然失败,因为它把所有位置压进了训练时见过的范围,相邻位置的分辨率被稀释了。这类任务的正解是外部检索或者显式的位置标注,而不是指望位置编码。
另一个失效点是推理时外推幅度远超训练时。一个在 32K 上做过 YaRN 缩放、宣称支持 128K 的模型,如果你把它直接推到 1M,缩放比例已经不匹配了(YaRN 的 128K 配置在 1M 上需要 8 倍的缩放,而 8 倍插值的局部分辨率损失是致命的)。渐进式扩展的价值就在于保证任何时候模型都是「在当前长度的分布内」被训练过。
9.2 注意力稀疏化的失效边界
结构化稀疏(垂直斜线类)的失效场景是输入结构与预设拓扑不匹配。它在自然语言和一般代码上表现好,但如果你处理的输入是高度规整的表格、时序数据、或者结构重复度极高的日志,垂直列模式可能把预算浪费在大量无意义的同位置 token 上。
检索式稀疏的失效场景是粗筛和精排的相关性排序不一致。低维投影下最相似的向量,不一定在全维下是最相关的;而真正决定答案的那条证据,可能和 query 只有语义关联而没有字面或方向上的关联。特别是在「需要跨段综合、而不是找到唯一相关段落」的任务上,top-k 检索式稀疏的假设(存在少数几个强相关位置)本身就不成立。代码任务里也有类似问题:一个 bug 的根因在文件开头,症状在文件结尾,两者在低维空间里可能都离 query 很远。
学习型稀疏的失效场景是选择器和主干模型的分布漂移。选择器是在训练分布上学的,如果线上遇到分布外的输入(超长代码、多语言混合、结构异常的文档),它给出的重要性预测不可靠,而这种不可靠是静默的。还有一个实践上的坑:选择器本身增加了 prefill 的开销,如果它不够轻,稀疏化省下来的注意力 FLOPs 可能不够覆盖选择器的成本。
9.3 KV 压缩与淘汰的失效边界
KV 压缩(尤其是 4bit 及以下)在数值敏感型任务上会出问题:数学推理、代码精确匹配、需要从长表中查具体数值的任务。这些任务对每一个 token 的表示精度都有要求,量化的噪声会被 softmax 归一化和多层累积放大。观察到的现象通常不是明显的错误,而是「答案格式对但数值差一点点」或者「多步推理中间某一步的判断偏了」。
淘汰策略的失效场景前面已经说过:多跳检索。特别值得强调的是,SnapKV 这类「用 prompt 末尾 query 打分」的策略在多跳任务上有一个结构性缺陷:末尾的 query 只关心最后一跳的问题,它给第一跳需要的证据打分会很低。缓解办法是把文档开头和结尾都作为观察窗,或者干脆不淘汰(直接上更大的显存)。
9.4 训练与推理落差的失效边界
最隐蔽的一类失效:框架宣称支持的长度,和模型实际被训练过的长度,差着一个数量级。判断方法很直接——查模型卡上明确写的训练/微调最大长度,如果只写「通过 YaRN 扩展到 128K」,那它的强项大概率在 32K 以内。如果写「在 256K 上继续训练」,8K 以内的能力通常保留得很好,128K 以后则要实测。这不是架构缺陷,是资源约束的必然结果,但几乎所有宣传材料都不说。
9.5 落地前必做的一组对照实验
在决定上哪套方案之前,我建议至少跑完这四组对照,因为它们的结论在实践中反复推翻了直觉。第一组是裸长与压缩后等效:在同一个模型上,分别用完整 KV 和压缩到四分之一的 KV 跑同一个任务集,看量化精度损失具体落在哪些任务上,这能直接回答业务能不能承受量化。第二组是稀疏配置扫描:固定总预算,只改变动态检索与滑窗的分配比例,找到自己任务上的最优点。第三组是有效长度曲线:把输入长度等比拉伸画准确率曲线,这条曲线既是容量规划的依据,也是唯一能防止模型悄悄变笨的护栏。第四组是前缀命中率对吞吐的敏感性:在真实流量回放下测不同淘汰策略下的命中率与端到端延迟,这一项经常是投入产出比最高的优化,因为它不需要改模型。
这四组实验的共同点是:不依赖任何「模型更强所以能更长」的假设,全部建立在自己的任务分布上。这也是我对整个长上下文领域的基本判断——架构上的位置外推、注意力稀疏化、KV 压缩解决的是成本和上限,而你的系统到底能走多远,取决于你在自己的数据上把有效长度测出来,并围绕它做的那套工程取舍。
十、六个反复出现的常见误区
- 把窗口数字当成能力承诺。模型卡上的「支持一百万」是容量描述,不是质量描述。判断标准只有一个:在你自己的任务上,答案质量在多长的输入上开始下降。这个数字通常远小于标称值,而且不写在任何宣传材料里。
- 以为上了 FlashAttention 就解决了长上下文。融合注意力内核解决的是显存峰值,不是计算量。序列长度翻倍,注意力部分的墙钟时间大致翻四倍,而模型主体部分只翻一倍。平方项在小长度下被线性项掩盖,恰恰在长长度下才暴露。
- 在解码阶段强调注意力稀疏化。解码每步只生成一个 token,瓶颈是把 KV 从显存搬进计算单元的带宽,不是算力。压缩 KV 的字节数(量化、淘汰、分层)才有收益,减少注意力计算几乎没有收益。这是长上下文优化里最普遍的一次方向性错误。
- 用全局访问统计做淘汰。基于累积注意力分数保留高分 token 的方案,在单跳任务上很好用,但它的分数带有强烈的历史偏好:早期被看到的 token 会一直保持高分,而多跳任务里真正需要的那条证据往往要到第二跳才会被用到,那时它可能已经被淘汰了。
- 只在自然语言上做长上下文测试。代码、表格、时序数据的注意力分布和自然语言完全不同,而且它们对精确性的要求高得多。量化误差在代码任务上更容易暴露为语法错误,在表格任务上更容易暴露为数值偏差。只测自然语言,会让你在上线后才发现问题。
- 升级模型时不回归有效长度。模型权重、位置编码配置、推理引擎的调度策略、提示的组织方式,这四者任意一个变化都可能让有效长度回退。版本升级时如果只跑常规短任务,长上下文能力可能在无人察觉的情况下退化几个数量级。
十一、2026 年的工程共识与选型清单
把上面所有内容压缩成一张可以拿去开会的表:
| 环节 | 主流做法 | 典型收益 | 主要代价 |
|---|---|---|---|
| 位置编码外推 | YaRN 类分频混合 + 注意力温度补偿;长上下文则用渐进式扩展 + 非均匀缩放 | 128K 量级基本无损 | 不解决「远处检索」,远超训练长度仍退化 |
| 预训练阶段 | 混合长度课程,短序列为主 + 少量超长序列保底 | 为后续扩展留出可行性 | GPU 成本高,长序列数据稀缺 |
| 架构降维 | GQA/MQA;需要更强压缩时用 MLA(潜空间 + 吸收技巧) | KV 维度降 10~100 倍 | 精度略降,MLA 与分页有工程张力 |
| 注意力稀疏 | 滑窗 + sink 打底,叠加按层/按头的模式化稀疏或 top-k 检索 | prefill FLOPs 降数倍 | 召回失败是静默的,需要外部检索兜底 |
| KV 量化 | Key 按通道、Value 按 token;分组量化 4~8bit | KV 显存降 2~3 倍 | 长序列 + 数值敏感任务上退化 |
| KV 淘汰 | sink + 最近窗口 + 观察窗高分池;按层分配预算 | 长请求显存可控 | 多跳检索会被误删 |
| 显存管理 | PagedAttention / 前缀树,共享前缀零拷贝 | 碎片从 60~80% 降到 4% 以内 | 需要自研/专用的分页注意力内核 |
| 调度 | chunked prefill + 连续批处理 + 投机解码 | TTFT/TPOT 平衡,decode 加速数倍 | 长请求在混合批处理里容易被抢占 |
如果要提炼成几条判断原则,我的看法是这样的。
落到具体执行上,下面这份上线前的检查清单是我认为值得逐条过一遍的。它不针对任何特定框架,只针对长上下文系统本身容易出问题的那些位置。
- 有效长度已经测出来,并且写进了监控。不是估算,不是模型卡的数字,是你自己任务上的准确率曲线,并且这条曲线要有随版本变化的趋势图。
- 模型卡的标称长度和微调长度都已确认。如果标称长度远大于微调长度,那么超出微调长度的部分必须由外部检索兜底,不能指望模型本身。
- 预填充与解码两个阶段的瓶颈指标分开采集。首 token 时间、每输出 token 时间、缓存未命中时的首 token 时间,以及每输出 token 时间随输入长度的增长斜率,四个数字都要有。
- 高分位延迟单独验收。压缩率、换出策略、抢占策略的代价都集中在长尾,平均值会掩盖问题。
- 分页配置与前缀命中率有看板。碎片率、每 token 的 KV 字节数、前缀命中率、以及各层实际访问的 KV 比例,四个指标持续可见。
- 压缩方案的精度损失已经定位到具体任务。不是「总体无损」,而是明确知道哪一类任务在什么压缩率下开始退化,并据此设定业务侧的阈值。
- 版本升级后长上下文回归测试已重跑。模型权重、位置编码配置、推理引擎版本、提示模板,任意一项变更都要触发这条回归。
- 多跳任务有独立的召回兜底方案。明确哪些场景必须走外部检索,而不是寄希望于模型内部的稀疏化和淘汰策略。
把这八条做扎实,长上下文项目从「能演示」到「能上线」的跨越基本就完成了。
第一,先确定有效长度,再定名义长度。绝大多数团队的错误顺序是先冲窗口数字,再发现业务根本用不到那么长,最后发现自己在为用不到的长度付显存和延迟代价。先测你的任务在多长的地方开始掉,窗口只买到你需要的位置加一点余量。
第二,把 prefill 和 decode 当成两个独立的项目来优化。它们的瓶颈、手段、验收指标全都不一样。prefill 看 TTFT 和稀疏化收益,decode 看 TPOT 和 KV 字节数。用一套指标同时考核两者,是长上下文项目里最常见的资源错配。
第三,内部稀疏和外部检索不是二选一。稀疏化解决成本,检索解决召回。多跳任务上两者必须共存:一个 1M 的纯注意力方案和一个 32K + 检索的方案,在很多生产场景里的效果差距远小于它们的成本差距。真正的工作量在「怎么把检索结果组织进上下文」——放在开头、结尾、还是按相关度排序,直接决定了最终准确率。
第四,把长上下文能力当成一个需要持续维护的系统属性。它依赖于模型权重、位置编码配置、推理引擎的调度策略、提示的组织方式这四者的联合配置。升级任何一个而不验证另一个,是长上下文系统最常见的退化来源。至少要有一组回归测试固定住「有效长度」这个指标。
如果还要看未来一两年,这个方向上有几件事值得持续关注。一是稀疏化与量化的一体化:目前稀疏化决定看哪些位置、量化决定每个位置存多少字节,两者是分开设计的,下一波演进大概率是把重要性预测和位宽分配联合起来,让模型自己决定哪些 token 值得存八位、哪些只需要两位。二是注意力汇的重新解释:业界现在倾向于把它当作一个工程技巧,但从机制上看,它可能是解码器在大规模上下文下的一个固有的信息路由机制,值得从预训练数据构造的角度重新设计,而不是只靠推理时的掩码去凑。三是上下文与外部记忆的边界重划:当内部 KV 可以被压缩、分层、按需换出,传统的向量数据库就不再是唯一的记忆层,两者会逐步融合成一套统一的分层记忆系统。
最后一点留给想自己造轮子的团队:上面提到的绝大多数技术,其核心洞察(相对位置编码的频率分布、注意力汇的存在性、不同头的重要性差异、token 重要性与位置的解耦)都是公开且已经被反复验证的。真正难的不是想出这些点子,而是在生产环境里把它们组合起来、把每一步的精度损失控制住、并且在算子层面把理论收益真正兑现。这部分工作的量,通常比想法本身大一个数量级。