生成时,每一步都要把前面所有 token 的 K、V 重算一遍吗?
本课一问:生成时,每一步都要把前面所有 token 的 K、V 重算一遍吗?
这一课先不碰:怎么把缓存压小(第 9、10 课);服务端怎么给成百上千个请求分配缓存(C13 的 PagedAttention);不存 KV 的线性注意力(C11)。
从已有出发
前七课讲的是训练时的注意力:整句一次喂进去,因果掩码保证每个位置只看左边。生成的时候不一样:模型一次只输出一个 token——把提示喂进去,取最后一个位置的概率分布采一个 token,接到末尾,再算下一个。第 t 步新 token 的 q 要和前面全部 token 的 k 打分、按权重取回它们的 v。
先自己设计:每一步整段重来
最直接的实现:每生成一个 token,就把「提示 + 已经生成的全部 token」整段重新喂进模型,和训练时一模一样。结果当然对,问题是算了多少。
- ① 朴素方案
- ② 它在哪失败
- ③ 改进方案
第 s 步喂进去的是当前全部 n 个 token,每一层都要为这 n 个 token 重新算 k、v,再做 n(n+1)/2 次因果打分——可前 n−1 个 token 的 k、v 上一步刚算过,一个数都没变。
第 1 步(prefill)照常整段处理提示,顺手把每一层每个 token 的 k、v 存起来;之后每一步只喂上一步生成的那 1 个 token:算它的 q、k、v,把新的 k、v 追加进缓存,用它的 q 和缓存里全部的 k 打分、按权重取回 v。
| 不用缓存 | 用 KV cache | 比值 |
|---|
tests/test_core.py 的 TestKVCache.test_incremental_matches_full_forward 检查:在多种配置(不同位置编码、GQA、MoE 等)下,「prefill + 分块续写 + 逐 token」的输出与整段前向相比,最大绝对差 < 1e-4(测试里的容差)。试三件事:① 连点「生成下一个 token」,盯住左边轨道:每一步所有旧 token 都在重算;右边只有新喂进来的那一个在算。② 在「推到更长」里把长度拉到最大,看两种方式的 K/V 次数、打分次数各差多少倍,以及增长阶:不用缓存时 K/V ∝ L²、打分 ∝ L³,用缓存时分别 ∝ L、∝ L²;③ 看「缓存与重算,结果一样吗?」那一栏:两种算法得到的输出向量完全相同——缓存不改结果,只省计算。
改进:为什么能缓存
能缓存,靠的是第 4 课的因果掩码。每一层里,位置 j 的输出只依赖位置 0…j 的 token;所以位置 j 在每一层的输入、以及由它算出的 kj、vj,在第 j 个 token 出现之后就再也不会变——后来的 token 影响不到它 证据等级 · 数学可证明 在写明的前提下可以严格推导出来,不依赖实验。只要前提成立,换数据、换规模仍然成立。 本条因果掩码下,第 l 层位置 j 的表示只是 token 0…j 的函数;新 token 追加在后面,不改变它 出处待补 。于是「存下来反复用」和「每次重算」得到的是完全相同的数。我们项目的测试就断言了这一点:用缓存逐步推理与整段前向的 logits 最大差小于 10−4,贪心生成逐 token 相同(TestKVCache)。
还有两个细节:
- 只缓存 K 和 V,不缓存 Q。旧 token 的 q 只在它自己作为「最新 token」的那一步用过一次;之后每一步只有新 token 在提问,旧 token 只需要被查(k)和被取(v)。
- 缓存不是免费的。它躺在显存里,每一层、每个 KV 头、每个 token 一份,随上下文线性增长。锚点 Qwen3.8 每个 token 要存 16 层 × 2(K、V)× 4 个 KV 头 × 256 维 × 2 字节 = 65,536 字节 = 64 KiB;原生 262,144 个 token 的上下文就是 16 GiB——还只是一条请求 证据等级 · 数学可证明 在写明的前提下可以严格推导出来,不依赖实验。只要前提成立,换数据、换规模仍然成立。 本条字节算术,数字由 anchor-model.json 现算;BF16 每元素 2 字节 出处待补 。
一个悬念。锚点 Qwen3.8 有 64 层,可上面的账只算了 16 层:另外 48 层根本不存 KV,它们用的是一个大小固定、不随上下文增长的状态。那是什么注意力、为什么这样混搭,C11 揭晓。
⚠ 常见误解
「KV cache 是一种近似,会让输出变差一点。」不会。因果掩码保证旧 token 的 K、V 不随后来的 token 变化,缓存里的就是重算会得到的同一组数,输出逐位相同(组件与项目测试都核对了)。会让输出变化的是另一些压缩手段,比如把缓存量化到更低精度——那是 C9、C13 的话题。
「3D 可视化里没看到 KV cache,说明模型不需要它。」常被推荐的 bbycroft 3D 可视化每一步都重走整个流程,本来就没有缓存这个概念;它适合看一次前向的结构,学 KV cache 要用本课的步进器。
注意力的演进,你在这里
① 训练时:一个 token 怎么从别的 token 那里取信息
- 解决了能读任意长的序列,参数与长度无关;手里只带一个固定大小的状态 h。卡在ht 要等 ht−1 算完:T = 4,096 时每层 4,096 步不能并行,第 1 个 token 的信息要经过 4,095 次更新才到最后;翻译时整句还得挤进一个定长向量——Bahdanau 2014 说的瓶颈。展开岔路 B1
- 解决了解码器每一步回头给源句的每个位置打分、加权取回,不再只靠那一个定长向量:score = vT·tanh(W1q + W2k)。卡在每一对都要单独过 tanh,交不给矩阵乘单元;朴素实现要先落一个 T×T×da 的中间张量:T = 4,096、da = 256 时单头单层 8.0 GiB(BF16),点积只留 T×T 个分数,32 MiB。它还长在 RNN 翻译模型里,串行没解决。展开第 2 课
- 解决了直接比 embedding 时「给了」最像的是它自己(余弦 1.000),小明、苹果、小王排在最后;投影成 Q、K 再点积,它自己排到最后、前三名是苹果、小王、小明,全部配对一次矩阵乘 QKT;取回用单独的 V——两个人名的 K 余弦 0.995,取回 K 分不清是谁。卡在不缩放:锚点每头 256 维,点积标准差 √256 = 16,同一行分差十几二十是常态(e20 ≈ 4.9 亿),softmax 几乎独占,梯度传不回 WQ、WK。
- 解决了softmax 把分数变成正的、和为 1 的权重,只看分差(按占比分,对话样本的 10、3、−4 会给「汽车」−44.4%);除以 √dk = 16,点积方差回到 1。卡在训练时整句一次喂进去:不加掩码,「给了」一行有 32.5% 的注意力直接落在它要预测的答案「小王」上,loss 低得虚假。展开第 3 课
- 解决了旋转后 q·k 只依赖距离 j − i;V 不参与打分,不转(转了会把绝对位置混进取回的内容)。锚点每头 256 维里只转 64 维。卡在旋转矩阵随位置变,夹在展开矩阵和缓存之间就没法事先并进 Q:MLA 因此把 RoPE 单独拆出 64 维另存(DeepSeek-V2 config,第 10 课)。
- 解决了标准 softmax 逼每行分完 100%:「苹果」一行 3 个分数只差零点几(0.04、0.36、0.00),照样分成 30% / 41% / 29%;分母加可学习的 sink logit,或在输出上乘门,这个头就能「这次不取」——锚点开着输出门(attn_output_gate = true)。卡在出口的效果只有实验证据:Gated Attention 原文说门控「缓解」sink,不是消除;DeepSeek-V4 没给 sink logit 的单独消融。展开岔路 B4
② 生成时:K、V 怎么越存越少
- 解决了缓存直接除以头数:锚点形状下每 token 16 KiB,262,144 上下文 4 GiB。卡在质量会掉、训练可能不稳:GQA 论文表 1(T5-XXL,七个任务平均)MHA 47.2 → MQA 46.6。展开第 9 课
- 解决了24 个 Q 头分 4 组、每组 6 个共用 1 个 KV 头(组数 = KV 头数):每 token 64 KiB、262,144 上下文 16 GiB;T5-XXL 上 GQA-8 平均 47.1、推理 0.28 s,质量接近 MHA(47.2、1.51 s),速度接近 MQA(0.24 s)。卡在再减 KV 头就回到 MQA:DeepSeek LLM 67B 已是 GQA-8,每 token 仍要 389,120 B(每头一份的 MHA 是 3,112,960 B),1M token 单条请求 380 GiB。展开第 9 课
- 解决了不存 K、V,每层每 token 只存 512 维潜向量 + 64 维 RoPE = 576 个数,用时再展开:DeepSeek-V2(60 层)每 token 69,120 B、V3(61 层)70,272 B;相对 67B 按 BF16 降 82.2%,官方「−93.3%」还含约 6 bit 的 KV 量化。卡在仍随长度线性增长,打分仍是 T²:GLM-5.3 同是 576 个数、78 层,每 token 89,856 B,原生 1,048,576 上下文时单条请求只算潜向量就要 87.75 GiB。展开第 10 课
- 解决了换掉「按长度存」本身:锚点 64 层里 48 层不存 KV,只存固定大小的状态(FP32 共 144 MiB),与上下文长度无关(这些层是什么、怎么更新,C11 第 4 课揭晓);DeepSeek-V4.1 叠加压缩、稀疏与低精度,官方报告全局 KV 890 B/token。卡在固定状态装不下任意多对键值,锚点仍留 16 层全注意力;稀疏注意力每步只读挑出来的一部分,挑本身也有代价——各自省了什么、没省什么,C11 讲。
证据三问
- 它解决什么问题?生成时不再重复计算旧 token 的 K、V,把每步的计算量从「整段」降到「一个 token」。
- 数学上为什么有效?因果掩码下旧 token 的 K、V 恒定,所以缓存与重算结果相同 证据等级 · 数学可证明 在写明的前提下可以严格推导出来,不依赖实验。只要前提成立,换数据、换规模仍然成立。 本条见上;项目测试 test_incremental_matches_full_forward 出处待补 ;计算次数的平方 vs 线性是计数 证据等级 · 数学可证明 在写明的前提下可以严格推导出来,不依赖实验。只要前提成立,换数据、换规模仍然成立。 本条组件里闭式公式与逐步累加对照 出处待补 。
- 实验上呢?不需要实验来证明它对;需要实验回答的是它的代价——显存与带宽,这是第 9、10 课和 C13 的事。
一句话
KV cache 用显存换计算:旧 token 的 K、V 算一次就存下,生成时每一步只算新 token 的。
下一个缺口
缓存的大小 = 层数 × 2 × KV 头数 × 每头维数 × 字节 × 上下文长度。前几项是模型结构,最后一项是用户要的。锚点里 24 个 Q 头,KV 头却只有 4 个——如果每个 Q 头都存一份自己的 K、V,账会变成多少?能不能让几个头共用一份?→ 第 9 课:每个头都存一份 K/V 太贵,怎么办?
你可能会问
这一课还没有收到真实提问;你的提问会记到 05-问答记录/C3-L7.md。
🔍 旧版课程的 KV 计数差在哪?
旧版的计数把第一步记成「提示长度 + 1」:提示 3 个 token 时,它说第一步重算 4 份。可第一步喂进模型的只有提示本身(3 个),新 token 是这一步的输出,它的 K、V 要到下一步被喂进来时才算。正确的序列是 3 + 4 + 5 + …(旧版正文这样写了,组件却从 4 开始累加)。本课的步进器按「每一步的输入是什么」逐项记账,并在口径说明里写明:最后一个生成的 token 还没喂回模型,它的 K、V 还没算。
🔍 既然每步只算一个 token,生成为什么还是慢?
因为每一步都要把整个模型的权重和全部 KV 缓存从显存里读一遍,只为了算一个 token——算得少、读得多,速度被显存带宽卡住,而不是被计算卡住。这也是为什么压缩 KV(第 9、10 课)能直接提速:要读的少了。prefill 与 decode 的这种不对称,C13 用屋顶线模型细讲。