KV Cache 驱逐实战:什么时候该放弃打分器,直接随机扔
Random Attention 证明随机驱逐在四个模型六个任务上追平最强打分器,vLLM 吞吐高出 32-43%;VestigeKV 则在 NoPE MLA 上把 H2O/SnapKV 的 0.00-0.33 救回 1.00。附架构普查表、参考实现与压测脚本。
先分清你压的是哪一段缓存
长思维链把 KV cache 推成了推理的主瓶颈。“KV cache 太大”这句话至少对应三种不同的情况,处理方式完全不同。
第一种是 prefill 之后的静态缓存。输入长、输出短,比如检索问答和文档摘要。这类场景的主流做法是 prefix caching,命中就省钱,不涉及驱逐。
第二种是推理轨迹持续增长的长输出。模型一边想一边写,缓存线性膨胀,直到显存撑不住。这里才需要驱逐,也是本文讨论的对象。
第三种是混合架构里的 recurrent 部分。Qwen3.8 这类引入 Gated DeltaNet 线性注意力的模型,recurrent 半边根本不进 KV cache,状态大小与序列长度无关。对它做驱逐是无用功。
以 Qwen3.8-27B 为例,它是 48 层 GDN 加 16 层 attention 的混合结构,只有约四分之一的层持有 KV cache。架构普查表里记下这个比例,比只记”是不是混合架构”有用得多——它直接决定驱逐能腾出的显存上限。
把第三种当成第二种处理,是最近几个月最常见的误判。
打分器的收益被高估了
主流 KV cache 压缩方法共享一个范式:给每个缓存 token 打一个”以后有多重要”的分,保留分数最高的那批。H2O 用累计注意力,SnapKV 用观察窗口内的注意力,还有一批方法用位置或窗口启发式。
Salesforce AI Research 在 9 月 3 日提交的 Random Attention 直接对这个范式开火。他们的方法不计算任何分数,保留 prompt,然后在每个注意力头内部均匀随机驱逐。
结果是四个模型(Qwen3-4B、14B、32B 与 Phi-4-reasoning)、六个推理任务上,随机驱逐与最强的打分式驱逐器准确率持平;在 vLLM 部署中,比同类的 TriAttention 快 32% 到 43%。
两个限定条件必须一并引用,否则这条结论会被误用。第一,主实验网格固定在约 4 倍压缩(LiveCodeBench 约 3 倍)的预算上;论文没有主张任意压缩比下都成立。第二,“持平”是保守读法——原文的说法是在主结果表的 60 组基线对比中,随机驱逐有 31 组显著领先。
代码在 github.com/SalesforceAIResearch/Random-Attention。
论文用受控实验给了两个解释。第一,prompt 才是缓存里脆弱的部分,各打分器之间的差距,多半只是它们的信号碰巧有没有保住 prompt。第二,推理轨迹自身带两层冗余:模型在文本层会复述它还需要的结论,每个注意力头又各自存了一份轨迹副本。prompt 安全之后,随机抽样就能留下足够多的副本,不需要分数去挑。
这条结论的工程含义很直接:打分器的算力预算,先拿去保 prompt。
换架构,结论就反过来
随机驱逐不是万能解。VestigeKV(同日提交)处理的是另一类模型。
它的起点是一个失败案例:在 NoPE MLA 架构上,靠观察注意力来选 token 的方法直接坍塌,needle 检索准确率掉到 0.00 至 0.33。原因是长生命周期的缓存在”将要读它的 query 存在之前”就必须压缩,而一个 token 的重要性此时还没被观测到。
VestigeKV 的解法是用缓存自身携带的一个 query 无关信号:64 维解耦分支。这个分支本是 RoPE 的残留,被 NoPE 训练改造成了一条显著性通道。读每行 11% 的数据,就能把缓存分层,top-m 行留在 attended tier,其余行原样移入 GPU 常驻的 archive,从不删除,每步可由一个认证触发器取回。
不需要训练,不需要量化,不改动权重和 kernel。
实测从 8k 到 65k 上下文,8 倍压缩下检索保持 1.00,32 倍压缩下 0.92,与全行选择的差距为零。32 倍压缩时 attended tier 只占 Kimi Linear 每 token 8.1 KB 缓存中的 0.25 KB。标准配置的 recall tier 在 128 倍压缩下仍是 1.00。
边界要看清楚:同一个算子放到 RoPE MLA 上,坍塌到 0.08,比朴素驱逐的 0.42 还差。
论文给的解释是,query 无关的显著性只在没有旋转位置编码时存在。top-1 目标在 NoPE 下覆盖 2.3% 到 6.7% 的 token,RoPE 下是 10.2% 到 46.8%;在 RoPE 下做 query 无关的精确合并,已被证明做不到。
落地六步
架构普查。 把你要优化的每个模型的注意力类型列成表:RoPE 或 NoPE、MHA 或 GQA 或 MLA、纯 attention 或线性注意力混合。这张表决定后面所有选型,跳过它就动手,八成会挑错方法。
驱逐时机。 prefill 后一次性压缩与生成过程中持续驱逐,需要两套实现。长推理轨迹只能选后者,而持续驱逐每步都要付一次选择开销,这正是随机驱逐能省下来的部分。
锁住 prompt。 无论最后用哪种驱逐策略,把系统提示、工具定义、用户原始请求标记为不可驱逐。Random Attention 的受控实验里,这一步贡献了大部分收益。
双指标。 单独的准确率会骗人。needle 检索测的是某个事实还在不在,端到端准确率测的是任务还能不能做完。两者都要看,且要在压缩倍率这个轴上扫,而不是只测一个点。
看吞吐。 打分式驱逐的开销体现在 decode 阶段的每步计算上,单次请求延迟未必看得出差距,QPS 才看得出。Random Attention 那 32% 到 43% 就是在 vLLM 的实际部署里测出来的。
算清显存账。 每 token 缓存占用的算法是 2(K 与 V)× 层数 × 头数 × head_dim × dtype 字节数。以 Kimi Linear 的 8.1 KB / token 为参照,32k 上下文单并发就是 260 MB,并发 32 路是 8.3 GB。把这个数和你压出来的实际占用对一下,差的那一截通常是碎片和预留块,不是你算错了。
参考实现
下面是一个按 Random Attention 机制写的驱逐钩子,接口独立于推理框架,接入时替换掉你现有的 cache prune 调用点即可。生产环境直接用官方仓库。
⚠️ 先说一个最容易写错的地方。 直觉写法是”每个 head 各抽一份保留下标,然后合起来去重再统一索引”。这样写是错的,而且错得很隐蔽:各 head 抽的是不同的 token 集合,取并集之后,保留下来的 token 数约等于
1 - keep_ratio^n_heads。32 个 head、keep_ratio=0.5时这个数是 99.9999998%——函数会几乎原样返回整个 cache,变成一个无声的 no-op,显存一点没省,你还会以为驱逐已经生效。更糟的是,取并集后所有 head 共享同一份 token 集合,而 Random Attention 的整个机制恰恰建立在”每个 head 各存一份副本、各自随机丢掉不同副本”上;并集把这份冗余抹平了,机制直接失效。正确做法是每个 head 各自 gather,且每个 head 保留的数量相同(都是
n_keep)。这样形状是规整的[n_heads, prompt_len + n_keep, head_dim],可以直接接回后续算子,同时保留了 head 之间的副本差异。
import torch
def random_attention_evict(
k_cache: torch.Tensor, # [n_heads, n_tokens, head_dim]
v_cache: torch.Tensor,
prompt_len: int, # 前 prompt_len 个 token 永不清退
keep_ratio: float = 0.5,
generator: torch.Generator | None = None,
):
"""Per-head 均匀随机驱逐。返回 [n_heads, prompt_len + n_keep, head_dim]。
关键点:每个 head 独立抽样并独立 gather,保留数量相同但 token 集合不同。
不要对各 head 的下标取并集 —— 那会让保留率趋近 100%(见上文警示)。
"""
n_heads, n_tokens, head_dim = k_cache.shape
if n_tokens <= prompt_len:
return k_cache, v_cache
device = k_cache.device
cand = torch.arange(prompt_len, n_tokens, device=device)
n_keep = max(1, int(cand.numel() * keep_ratio))
# [n_heads, n_keep] —— 每个 head 抽一份,集合互不相同
per_head = torch.stack([
cand[torch.randperm(cand.numel(), generator=generator,
device=device)[:n_keep]].sort().values
for _ in range(n_heads)
])
# [n_heads, prompt_len] 拼到前面:prompt 每个 head 都要完整保留
prompt_idx = torch.arange(prompt_len, device=device).expand(n_heads, -1)
keep = torch.cat([prompt_idx, per_head], dim=1)
idx = keep.unsqueeze(-1).expand(-1, -1, head_dim)
return k_cache.gather(1, idx), v_cache.gather(1, idx)
两个使用注意。一是 generator 要复现结果时才传,逐 head 循环里共用同一个 generator,随机数流是连续的,这和一次性抽 [n_heads, n_keep] 不等价,但对随机驱逐来说没有区别。二是 sort() 不是必需的(K/V 已经带上位置编码,注意力分数与排列无关),保留它是为了让输出顺序单调,便于和 paged kernel、以及按位置做前缀缓存的下游逻辑对齐。
vLLM 侧先把前缀缓存打开,把省下来的显存换成更大的并发:
vllm serve <model> --enable-prefix-caching --kv-cache-dtype fp8_e5m2 \
--max-model-len 131072 --gpu-memory-utilization 0.92 --enable-chunked-prefill
量化缓存 dtype 和驱逐是两件事,别混着评估。先单独测 --kv-cache-dtype fp8_e5m2 带来的准确率变化,再叠加驱逐,否则出问题定位不到是哪一层。
压测脚本要测什么
def sweep(prompt, task, ratios=(1.0, 0.75, 0.5, 0.25, 0.125)):
for r in ratios:
acc = end_to_end_accuracy(prompt, task, keep_ratio=r)
needle = needle_recall(prompt, keep_ratio=r, depths=(0.1, 0.5, 0.9))
tps, qps = bench_serving(prompt, keep_ratio=r, concurrency=32)
print(f"ratio={r:<6} acc={acc:.3f} needle={needle:.2f} qps={qps:.1f}")
三个数字缺一不可。acc 掉而 needle 不掉,说明丢的不是事实而是结构;needle 掉而 acc 不掉,说明你的任务根本不依赖长程事实,这个压测场景选错了。
三个坑
NoPE 方案别往 RoPE 模型上套。 VestigeKV 在 RoPE MLA 上从 0.42 掉到 0.08,比不做任何选择还差。论文对原因给了一个可检验的说法:query 无关的显著性只在没有旋转位置编码时存在,NoPE 下 top-1 目标只覆盖 2.3%–6.7% 的 token,RoPE 下是 10.2%–46.8%,分散到这个程度就不存在一个统一的显著子集可挑。先把架构普查做完。
顺带一个容易踩的坑:VestigeKV 的适用范围有明确边界,论文只在 Kimi Linear 上实测过。Kimi K3 用的 NoPE Gated-MLA 变体只是”若缓存布局一致则可能适用”,作者明确声明不作主张。看到”NoPE 就能用”就往自己模型上套,风险自负。
混合架构要分开算。 recurrent 半边不占 KV cache,只压 attention 半边。arXiv:2609.04098 那篇 NVFP4 W4A4 的工作专门处理 Qwen3.8-27B 的 GDN 半边(48 层 GDN + 16 层 attention),量化和驱逐完全是两个问题。
别用单一压缩倍率下结论。 8 倍无损不代表 32 倍无损。VestigeKV 从 1.00 掉到 0.92 就发生在这一段。