从0训练LLM · 现代大模型体系 阅读+实验 ≈ 30 分钟 页内实验 ×2
C3 注意力:一个 Token 怎么从别的 Token 那里取信息 第 8 课

生成时,每一步都要把前面所有 token 的 K、V 重算一遍吗?

KV cache
生成一次只吐一个 token。每一步都把整段重新喂进模型,K/V 的计算量随长度平方增长。因果掩码保证旧 token 的 K、V 一旦算出就不会再变,所以可以存下来反复用——这就是 KV cache。它用显存换计算:锚点 Qwen3.8 每个 token 要存 64 KiB,256K 上下文就是 16 GiB。

本课一问:生成时,每一步都要把前面所有 token 的 K、V 重算一遍吗?

这一课先不碰:怎么把缓存压小(第 9、10 课);服务端怎么给成百上千个请求分配缓存(C13 的 PagedAttention);不存 KV 的线性注意力(C11)。

从已有出发

从已有出发 · 累计数据流 点已学环节看形状与参数
解码层

前七课讲的是训练时的注意力:整句一次喂进去,因果掩码保证每个位置只看左边。生成的时候不一样:模型一次只输出一个 token——把提示喂进去,取最后一个位置的概率分布采一个 token,接到末尾,再算下一个。第 t 步新 token 的 q 要和前面全部 token 的 k 打分、按权重取回它们的 v。

先自己设计:每一步整段重来

最直接的实现:每生成一个 token,就把「提示 + 已经生成的全部 token」整段重新喂进模型,和训练时一模一样。结果当然对,问题是算了多少。

  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案每一步把整段重新喂进模型

第 s 步喂进去的是当前全部 n 个 token,每一层都要为这 n 个 token 重新算 k、v,再做 n(n+1)/2 次因果打分——可前 n−1 个 token 的 k、v 上一步刚算过,一个数都没变。

概念动画 · 两条轨道同时生成:哪些 K/V 在重算,哪些从缓存读(真实记账)
不用缓存每一步把当前全部 token 重新喂进模型
K/V 投影0
q·k 打分0
用 KV cache第 1 步 prefill,之后每步只喂上一步生成的 1 个 token
K/V 投影0
q·k 打分0

数字口径:浏览器按上面的记账规则逐步累加。提示词 P = 2 个 token,共生成 G = T − P = 3 步;第 s 步不用缓存算 ns = P + s − 1 份 K/V(第 1 步是 P 份,旧版记成 P + 1 份,起点差 1 已修正)。打分只数因果掩码下需要算的 q·k 对。5 个 token 是固定例句,组件不从输出里采样。
推到更长:生成到总长度 L 时一共算了多少
不用缓存用 KV cache比值
缓存与重算,结果一样吗?
数字口径:X、WQ、WK、WV 取自本章 teaching-world.json(手工构造的教学数据,不是模型学到的);单头注意力,d = 4,分数除以 √4,因果掩码;浏览器实时算。项目里同一件事由 tests/test_core.py 的 TestKVCache.test_incremental_matches_full_forward 检查:在多种配置(不同位置编码、GQA、MoE 等)下,「prefill + 分块续写 + 逐 token」的输出与整段前向相比,最大绝对差 < 1e-4(测试里的容差)。
锚点账本:Qwen/Qwen3.8-27B 每个 token 要缓存多少字节
🎯

试三件事:① 连点「生成下一个 token」,盯住左边轨道:每一步所有旧 token 都在重算;右边只有新喂进来的那一个在算。② 在「推到更长」里把长度拉到最大,看两种方式的 K/V 次数、打分次数各差多少倍,以及增长阶:不用缓存时 K/V ∝ L²、打分 ∝ L³,用缓存时分别 ∝ L、∝ L²;③ 看「缓存与重算,结果一样吗?」那一栏:两种算法得到的输出向量完全相同——缓存不改结果,只省计算。

改进:为什么能缓存

能缓存,靠的是第 4 课的因果掩码。每一层里,位置 j 的输出只依赖位置 0…j 的 token;所以位置 j 在每一层的输入、以及由它算出的 kj、vj,在第 j 个 token 出现之后就再也不会变——后来的 token 影响不到它 。于是「存下来反复用」和「每次重算」得到的是完全相同的数。我们项目的测试就断言了这一点:用缓存逐步推理与整段前向的 logits 最大差小于 10−4,贪心生成逐 token 相同(TestKVCache)。

还有两个细节:

🧭

一个悬念。锚点 Qwen3.8 有 64 层,可上面的账只算了 16 层:另外 48 层根本不存 KV,它们用的是一个大小固定、不随上下文增长的状态。那是什么注意力、为什么这样混搭,C11 揭晓。

⚠ 常见误解

⚠

「KV cache 是一种近似,会让输出变差一点。」不会。因果掩码保证旧 token 的 K、V 不随后来的 token 变化,缓存里的就是重算会得到的同一组数,输出逐位相同(组件与项目测试都核对了)。会让输出变化的是另一些压缩手段,比如把缓存量化到更低精度——那是 C9、C13 的话题。

⚠

「3D 可视化里没看到 KV cache,说明模型不需要它。」常被推荐的 bbycroft 3D 可视化每一步都重走整个流程,本来就没有缓存这个概念;它适合看一次前向的结构,学 KV cache 要用本课的步进器。

注意力的演进,你在这里

注意力的演进:每一代都被上一代的某个真数字逼出来(本课所在的一代已高亮;另一条线可展开)
① 训练时:一个 token 怎么从别的 token 那里取信息
  1. 起点 RNN:一个接一个地读 注意力之前的主流
    解决了能读任意长的序列,参数与长度无关;手里只带一个固定大小的状态 h。
    卡在ht 要等 ht−1 算完:T = 4,096 时每层 4,096 步不能并行,第 1 个 token 的信息要经过 4,095 次更新才到最后;翻译时整句还得挤进一个定长向量——Bahdanau 2014 说的瓶颈。
    展开岔路 B1
  2. 第一代 Bahdanau 加性注意力 Bahdanau 2014
    解决了解码器每一步回头给源句的每个位置打分、加权取回,不再只靠那一个定长向量:score = vT·tanh(W1q + W2k)。
    卡在每一对都要单独过 tanh,交不给矩阵乘单元;朴素实现要先落一个 T×T×da 的中间张量:T = 4,096、da = 256 时单头单层 8.0 GiB(BF16),点积只留 T×T 个分数,32 MiB。它还长在 RNN 翻译模型里,串行没解决。
    展开第 2 课
  3. 第二代 ① 自注意力:Q/K/V 三种投影 + 点积 Vaswani 2017 §3.2
    解决了直接比 embedding 时「给了」最像的是它自己(余弦 1.000),小明、苹果、小王排在最后;投影成 Q、K 再点积,它自己排到最后、前三名是苹果、小王、小明,全部配对一次矩阵乘 QKT;取回用单独的 V——两个人名的 K 余弦 0.995,取回 K 分不清是谁。
    卡在不缩放:锚点每头 256 维,点积标准差 √256 = 16,同一行分差十几二十是常态(e20 ≈ 4.9 亿),softmax 几乎独占,梯度传不回 WQ、WK。
  4. 第二代 ② softmax 与 ÷√d:缩放点积 Vaswani 2017 式 (1)
    解决了softmax 把分数变成正的、和为 1 的权重,只看分差(按占比分,对话样本的 10、3、−4 会给「汽车」−44.4%);除以 √dk = 16,点积方差回到 1。
    卡在训练时整句一次喂进去:不加掩码,「给了」一行有 32.5% 的注意力直接落在它要预测的答案「小王」上,loss 低得虚假。
    展开第 3 课
  5. 第二代 ③ 因果掩码 Vaswani 2017 §3.2.3
    解决了softmax 之前把右上三角置为 −∞:未来的权重恰为 0,一次前向做完 4 道「只看前缀」的题(逐个前缀喂要 5 次前向、15 个位置)。
    卡在一次 softmax 只有一份 100%:「给了」要同时找「谁给的」和「给了什么」,只能在苹果 56.5% 与小明 39.4% 之间劈开,取回两个 V 的混合。
    展开第 4 课
  6. 回扣 C2 RoPE 只转 Q、K Su 2021;锚点部分旋转
    解决了旋转后 q·k 只依赖距离 j − i;V 不参与打分,不转(转了会把绝对位置混进取回的内容)。锚点每头 256 维里只转 64 维。
    卡在旋转矩阵随位置变,夹在展开矩阵和缓存之间就没法事先并进 Q:MLA 因此把 RoPE 单独拆出 64 维另存(DeepSeek-V2 config,第 10 课)。
  7. 第二代 ④ 多头注意力 Vaswani 2017 §3.2.2
    解决了同一组投影按列切成几个头,各做各的 softmax,参数与乘法次数不变:头 A 把 89.5% 给「小明」、头 B 把 77.0% 给「苹果」;锚点 24 个头 × 256 维。
    卡在生成时一次只出一个 token:每一步把整段重新喂进去,提示 2 个、再生成 3 个就要算 9 份 K/V,其中 5 份是上一步刚算过的;K/V 次数随长度平方增长。
    展开第 7 课
  8. 旁支 attention sink 与它的出口 Xiao 2023 → Gated Attention 2025、DeepSeek-V4
    解决了标准 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 怎么越存越少
  1. 第三代 KV cache 自回归生成的标准做法 你在这里
    解决了因果掩码保证旧 token 的 K、V 永不再变,存下来反复用:同一个例子只算 4 份,输出逐位相同(项目测试核对)。
    卡在缓存随上下文线性增长:若 24 个 Q 头各存一份(MHA),锚点形状下每 token 16 层 × 2 × 24 × 256 × 2 B = 384 KiB,原生 262,144 上下文 96 GiB——一条请求,还没算权重。
    展开第 8 课
  2. 第四代 MQA:所有头共用一份 K、V Shazeer 2019
    解决了缓存直接除以头数:锚点形状下每 token 16 KiB,262,144 上下文 4 GiB。
    卡在质量会掉、训练可能不稳:GQA 论文表 1(T5-XXL,七个任务平均)MHA 47.2 → MQA 46.6。
    展开第 9 课
  3. 第五代 GQA:分组共用 K、V Ainslie 2023;锚点 4 组
    解决了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 课
  4. 第六代 MLA:只存一个低秩潜向量 DeepSeek-V2 2024 → V3、GLM-5.3
    解决了不存 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。
  5. 交接 C11 线性注意力 / 稀疏注意力 锚点另外 48 层;DeepSeek-V4.1
    解决了换掉「按长度存」本身:锚点 64 层里 48 层不存 KV,只存固定大小的状态(FP32 共 144 MiB),与上下文长度无关(这些层是什么、怎么更新,C11 第 4 课揭晓);DeepSeek-V4.1 叠加压缩、稀疏与低精度,官方报告全局 KV 890 B/token。
    卡在固定状态装不下任意多对键值,锚点仍留 16 层全注意力;稀疏注意力每步只读挑出来的一部分,挑本身也有代价——各自省了什么、没省什么,C11 讲。
数字口径:锚点 Qwen3.8-27B 与 DeepSeek LLM 67B / V2 / V3、GLM-5.3 的字节数由 anchor-model.json / frontier-models.json 的官方 config 在构建时现算(BF16 每元素 2 字节);「给了」一行的余弦、权重、各头注意力与 sink 那一行,由本章 teaching-world.json 现算——那是手工构造的教学向量,不是模型学到的,与各课组件同一份数据;T = 4,096 是第 2 课与岔路 B1 取的示例长度;T5-XXL 分数出自 GQA 论文表 1,「−93.3%」出自 DeepSeek-V2 报告,890 B 出自 DeepSeek-V4.1 模型卡(均为官方自报)。

证据三问

  1. 它解决什么问题?生成时不再重复计算旧 token 的 K、V,把每步的计算量从「整段」降到「一个 token」。
  2. 数学上为什么有效?因果掩码下旧 token 的 K、V 恒定,所以缓存与重算结果相同 ;计算次数的平方 vs 线性是计数 。
  3. 实验上呢?不需要实验来证明它对;需要实验回答的是它的代价——显存与带宽,这是第 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 用屋顶线模型细讲。