QKV · Query / Key / Value
上一篇我们说清了 Attention 的直觉——"跳回去看一眼相关的词"。这一篇要把这个直觉翻译成一个公式:Attention(Q, K, V) = softmax(QKᵀ / √d) · V。看起来吓人,但只要你记住"图书馆查资料"这个比喻,它就变得再自然不过。
周末,你走进图书馆,想找一本关于"深度学习"的书。你会怎么做?
第一步:脑子里先形成"我要查什么"——"深度学习相关的书",这是你的查询意图。
第二步:走到目录检索机前,输入关键词。系统会拿你的关键词和每本书的索引标签做比对——"计算机 · 人工智能 · 深度学习"匹配度高、"烹饪 · 川菜"匹配度低。
第三步:系统告诉你:"这三本最相关,那两本次相关,其他不推荐"。你按推荐去书架取书,把书里的内容综合起来读。
这一整套流程,就是 QKV 的完整生活版本——查询、匹配、取值。
开场先扫清三个拦路的词 · 向量、矩阵、点积
这一篇会反复出现三个数学名词。它们看着像高数课的噩梦,其实都是初中水平的东西。花一分钟把它们翻译成大白话,后面就再也不卡了。
- 向量(Vector,向量)所谓向量,说白了就是一串有固定顺序的数字。你的体检报告就是一个向量:身高 175、体重 68、血压 120、心率 72……四个数字排成一行,代表"你"这个人。模型眼里的一个词也是这样一串数字,只不过长得多——GPT-3 里一个词是 12288 个数字排成一行,相当于一份有一万两千个检查项目的超级体检报告。
- 矩阵(Matrix,矩阵)所谓矩阵,说白了就是一张被数字填满的表格。银行大厅墙上那块汇率牌就是一个矩阵:横着是币种,竖着是买入价卖出价。而"向量乘矩阵"这个动作干的活儿,相当于拿着一串数字去查表,换算成另一串数字——就像你拿一个人民币金额去查汇率表,一次换算出美元、欧元、日元三个数。
- 点积(Dot Product,点积)所谓点积,说白了就是两串数字对应位置相乘,再把所有结果加起来,得到一个数。超市小票就是点积:单价那一列 [3, 5, 12] 乘上数量那一列 [2, 1, 3],得到 6+5+36=47 元。整个 Attention 里最核心的"打分"动作,用的就是这个算小票总价的小学算术。
把这三个翻译刻在脑子里:向量 = 一串数字;矩阵 = 一张换算表;点积 = 算小票总价。接下来所有让人头晕的公式,都只是这三个动作的排列组合,不会再冒出第四样新东西。所以如果你后面看到某一行公式卡住了,只要问自己一句:"这一步是在查表,还是在算小票?"——答案总是其中一个。
三个字母各是什么
对于句子里的每一个词,Transformer 会把它同时"变身"成三个不同的向量。不是三份复制,而是三种不同的"用途视角"——就像同一个人在图书馆里既是提问者、也是被别人翻阅的书目、还带着自己的一本内容。
| 字母 | 全称 | 角色 | 直觉理解 |
|---|---|---|---|
| Q | Query(查询) | "我想找什么" | 当前词发出的询问:"哪些词跟我有关?" |
| K | Key(键) | "我是什么" | 每个词自我描述的"标签":让别人用来匹配我。 |
| V | Value(值) | "我带来什么" | 每个词真正携带的信息内容,被别人加权取用。 |
数学上:一个词的原始向量 x,会被三个不同的矩阵 WQ, WK, WV 分别乘一下,就得到了这个词的 q, k, v 三个向量。这三个矩阵是模型训练时学出来的——它们决定了"这个词该以什么姿态提问、以什么姿态展示自己、以什么姿态贡献内容"。
把一句话想成一场相亲会:每个词是一位嘉宾。
Q(查询):你走过去时,脑子里"我在找什么样的对象"的清单——身高、爱好、性格。
K(键):每位嘉宾佩戴的"胸牌"——身高、爱好、性格的关键词摘要。
V(值):嘉宾本人——真正的内容、故事、经历。
你的清单(Q)去逐个匹配大家的胸牌(K),得出"跟谁最合"的分数,然后按分数决定坐下来听谁多聊、听谁少聊——听到的就是 V。
三个矩阵是从哪儿来的 · 同一个人的三张名片
上面那句"乘三个矩阵"是全篇最容易糊过去的一步,我们把它掰开揉碎讲一遍。输入只有一个东西:这个词的原始向量 x——比如"猫"这个字在模型内部对应的那 512 个数字。输出却有三个东西:q、k、v。一个变三个,多出来的信息从哪来?答案是:信息没有多出来,是同一份信息被三张不同的"换算表"改写成了三种说法。
打个比方。你去参加一场行业大会,主办方要求你交三样材料:一张需求卡(写清你此行想找什么样的合作方)、一张胸牌(写你的公司、职位、领域关键词)、一份详细资料包(你的完整业务介绍、案例、报价)。这三样东西的原始素材是同一个"你",但呈现出来的样子完全不同:需求卡是往外问的,胸牌是给人扫一眼的,资料包是真正被拿走细读的。WQ、WK、WV 这三个矩阵,就是主办方给你的三张"填表模板"——同一个你,套三个模板,填出三份不同用途的材料。
关键在于:这三个模板不是人设计的,是训练时自动长出来的。刚初始化时它们是随机数,填出来的三份材料全是乱码;随着几万亿个词的训练,梯度下降会一点点修改这三张表格里的数字,最后修出来的效果是——套 WQ 模板填出来的东西,特别擅长"问出这个词此刻缺什么信息";套 WK 模板填出来的东西,特别擅长"回答别人的问题时被认出来";套 WV 模板填出来的东西,特别擅长"把有用的内容交出去"。这三张表格本身,就是 Transformer 里最主要的一部分可学习参数。
三个矩阵的形状与"换算"过程(以 d_model = 512, d_k = 64 为例)
输入: x 形状 [1, 512] ← 一个词,512 个数字
W_Q 形状 [512, 64] ← 换算表:512 进,64 出
W_K 形状 [512, 64]
W_V 形状 [512, 64]
计算: q = x · W_Q → [1, 64] ← 我的"需求卡"
k = x · W_K → [1, 64] ← 我的"胸牌"
v = x · W_V → [1, 64] ← 我的"资料包"
一整句话(比如 10 个词)一起算:
X 形状 [10, 512] ← 10 个词摞成一张表
Q = X · W_Q → [10, 64] ← 10 张需求卡,一次算完
K = X · W_K → [10, 64]
V = X · W_V → [10, 64]
注意:整句话是「一次矩阵乘法」算完的,不是循环 10 次。
这就是上一篇说的「顺序操作数 O(1)」——
10 个词、1000 个词、10000 个词,都是同一个动作,一次搞定。
顺便解决一个常见困惑:既然每个词都要乘 WQ,那不同的词会不会得到一样的 q?——不会。矩阵是同一张,但输入的 x 不同,查表查出来的结果自然不同。就像全班同学填的是同一份体检表格,但每个人填出来的数据都不一样。矩阵是共享的"规则",向量是各自的"内容"——这个区分是理解神经网络参数的一个通用要点。
点积为什么能衡量"像不像" · 一点几何直觉
Attention 用点积来打分,这一步经常被当作"公式规定"直接接受。但它背后有一个很漂亮的几何解释,弄明白之后你会觉得这个设计理所当然。
先看两个数字的例子。两串数字 [1, 0] 和 [1, 0] 做点积,结果是 1×1 + 0×0 = 1;[1, 0] 和 [0, 1] 做点积,结果是 1×0 + 0×1 = 0;[1, 0] 和 [-1, 0] 做点积,结果是 -1。你注意到规律了吗——两串数字"步调一致"时点积大,"各干各的"时点积是 0,"完全对着干"时点积是负数。
把向量画成箭头就更直观了。数学上点积等于 |a| × |b| × cos θ,其中 θ 是两个箭头之间的夹角。这个公式听着玄,其实就是说:两个箭头指的方向越接近,点积越大;垂直时正好是零;反着指时是负的。所以点积天然就是一个"方向相似度尺"。
你和朋友一起推一辆卡在坡上的购物车。
两人朝同一个方向使劲——车走得飞快,合力最大。这就是点积很大。
一个往前推、一个往侧面推——侧面那位完全没帮上前进的忙,白费劲。这就是点积等于 0。
一个往前推、一个往后拽——车反而后退了。这就是点积是负数。
Attention 打分时问的正是这件事:"我这个词的需求方向,和你那个词的自我描述方向,是不是朝一块儿使劲的?"——朝一块儿使劲的,分就高,我就该多听你的。
再补一句为什么不用别的相似度度量。理论上你可以用欧氏距离、余弦相似度等等,但点积有一个无可替代的优势:它就是矩阵乘法。GPU 上跑得最快、优化得最狠的运算就是矩阵乘法,用点积等于免费搭上硬件的高速列车。这又是一次"算法迁就硬件"的选择——上一篇讲 Transformer 为什么快时,说的是同一个道理。
Attention 的完整四步公式
把生活场景翻译成公式,只有四步——记住它,你就懂了整个 Transformer 的核心运算。
1. 打分 · QKᵀ
当前词的 q 向量,和句子里每个词的 k 向量做点积——点积越大,说明两个向量方向越接近,代表越相关。
直觉:"我的查询"和"你的自我描述"匹配吗?
结果:得到一串"原始分数",句子多长就多少个数。
2. 缩放 · ÷ √d
把上一步的分数除以 √d(d 是向量维度,通常 64 或 128)。
直觉:把分数缩小到一个"温和"的范围,避免下一步 softmax 变得极端。
细节我们下面单独讲。
3. 归一化 · softmax
把一串分数转成一串"总和为 1"的概率权重。
直觉:"每本书我该看多重?"——不是绝对分数,而是相对比例。
结果:得到一组权重,比如 [0.7, 0.2, 0.05, 0.05],总和恰好是 1。
4. 加权求和 · · V
把每个词的 v 向量按上面得到的权重加权相加。
直觉:"把最相关的那本书的内容多抄一点,不相关的少抄"——最后得到当前词的新表示。
这一步就是 Attention 的最终输出。
把四步合成一行 · 那个著名的公式
把上面四步压成一行数学,就是这个 2017 年之后所有 AI 工程师都能背下来的公式:
Attention(Q, K, V) = softmax( QKᵀ / √d ) · V
拆开读一遍:把 Q 和 K 的转置相乘(打分)→ 除以 √d(缩放)→ 走一遍 softmax(归一化)→ 再乘 V(加权求和)。
整个 Transformer 的核心运算,就是这一行;接下来的多头、层数堆叠、Encoder/Decoder,都是围绕这一行做扩展。
Softmax 到底干了什么 · "一共 100 分,分给谁多少"
四步里的第三步叫 Softmax。这个词听着玄,其实就是一台"把任意一堆数字变成一套百分比"的机器。你喂给它一串乱七八糟的数——可以有正有负、可以很大很小——它吐出来的是一串全部大于 0、加起来正好等于 1 的数。
为什么需要这一步?因为第一步点积算出来的原始分数没法直接用。假设三个词的分数是 [8.2, 3.1, -1.5],你怎么"按这个分数混合"?总量是多少?负数怎么办?Softmax 的作用就是把"绝对分数"翻译成"相对份额"——相当于把一笔奖金按业绩分给三个人:不管每人的业绩数字是多少,最后分下去的比例必须加起来是 100%。
Softmax 的三步(以分数 [2, 1, 0] 为例)
第 1 步 · 取 exp(自然指数,把负数也变成正数,同时放大差距)
e^2 = 7.389
e^1 = 2.718
e^0 = 1.000
————————————
合计 11.107
第 2 步 · 每个数除以合计(这就是"归一化")
7.389 / 11.107 = 0.665 ← 66.5%
2.718 / 11.107 = 0.245 ← 24.5%
1.000 / 11.107 = 0.090 ← 9.0%
第 3 步 · 检查:0.665 + 0.245 + 0.090 = 1.000 ✓
关键性质:原始分数只差 1 分(2 和 1),
转成份额后差了将近 3 倍(66.5% vs 24.5%)。
exp 会「放大差距」——这是 softmax 的性格,
也是下一节要除以 √d 的根本原因。
注意最后那句话,它是理解 √d 的钥匙:exp 是一个指数放大器。原始分数差 1,份额差三倍;原始分数差 10,份额就差两万倍——那时候几乎就是"赢家通吃",第一名拿 99.99%,其他人颗粒无收。所以原始分数的大小范围直接决定了注意力是"温和地分给一片人"还是"死死盯住一个人"。这就无缝接上了下一节。
为什么要除以 √d?· 一个容易被忽略的细节
公式里那个不起眼的 √d,是很多人第一次看时都会一笔带过的。但它其实是整个 Transformer 稳定训练的关键之一。
想象两个 d 维向量做点积。如果 d 很大(比如 512),点积的结果就会非常大——从统计上讲,方差会随 d 线性增长。而 softmax 有个不太友好的性质:输入数值一旦拉得太大,输出就会变得极端——最大的那个几乎是 1,其他全接近 0。
这会带来两个坏消息:
- 梯度消失softmax 输出接近 0/1 时,反向传播时梯度几乎为零——模型学不到东西。
- 注意力过于尖锐每个词只盯着一个词看,其他全忽略——失去了"综合关注多个词"的能力。
把点积除以 √d,正好把方差拉回到 1 附近——softmax 就能得到一个"温和分布",既有主次,也不会一边倒。这个看似不起眼的除法,是让 Transformer 能训练得动的核心技巧之一。没有这一除,你的 Transformer 十有八九训不起来。
把声音调太大,喇叭就炸了、只听得见一个声源;调太小,什么都听不见。
√d 缩放就是那个"合适的音量"——让每个词都能被听见,同时又有主有次。
这就是为什么 Transformer 又叫 "Scaled Dot-Product Attention"——多的那个"Scaled",就是指这一步缩放。
光说"数值会炸"不够有说服力,我们用真实的数量级算一遍,你会被差距吓一跳。
假设 q 和 k 的每一维都是均值 0、方差 1 的随机数(这是常规初始化的情形)
点积 q·k = 求和(q_i × k_i),共 d 项
统计学结论:这个和的方差 ≈ d,所以典型大小 ≈ √d
d = 4 → 点积典型范围 ±2
d = 64 → 点积典型范围 ±8
d = 128 → 点积典型范围 ±11
d = 512 → 点积典型范围 ±23
现在看 softmax 对这两种范围的反应(假设最高分和次高分差了 1 个"典型幅度")
【不除 √d,d=128 的情形】原始分数 [11, 0, 0, 0]
e^11 = 59874, e^0 = 1
份额 = [59874/59877, 1/59877, 1/59877, 1/59877]
≈ [99.995%, 0.0017%, 0.0017%, 0.0017%]
→ 几乎是硬性选中一个,其余三个的信息「一滴都没喝到」
→ 反向传播时,那三个位置的梯度约为 1e-5 量级,等于没有梯度
【除以 √d = 11.3 之后】分数变成 [0.97, 0, 0, 0]
e^0.97 = 2.64, e^0 = 1
份额 ≈ [46.9%, 17.7%, 17.7%, 17.7%]
→ 有主有次,主要看第一个,但另外三个也各贡献了近两成
→ 每个位置都有像样的梯度,模型学得动
结论:同一组相对关系,只因为「数值尺度」不同,
一个退化成硬选择、一个是健康的软混合。
这一个除法的分量,比它的外观重得多。
还可以从另一个角度理解这件事:softmax 里其实藏着一个"温度"参数。写全了是 softmax(score / T),T 叫温度(Temperature)。所谓温度,说白了就是一个控制"你有多果断"的旋钮:T 很小(相当于把分数放大),模型极其果断,只认第一名;T 很大(相当于把分数压小),模型极其犹豫,谁都差不多重要。Attention 里的 √d,本质上就是把温度固定设成了 √d 这个值——一个随维度自动调整的、刚好合适的果断程度。你在调用大模型 API 时设置的那个 temperature 参数,用的是同一个数学机制,只不过作用在最后输出词的那一步。
一个具体例子 · 用数字走一遍
假设有一句超短的话:"猫追球"——三个词。为了简单,我们把每个词的 Q/K/V 都设成 2 维向量(真实里通常是 64 或 128 维)。
| 词 | Q | K | V |
|---|---|---|---|
| 猫 | [1, 0] | [1, 0] | [1, 2] |
| 追 | [0, 1] | [0, 1] | [3, 0] |
| 球 | [1, 1] | [1, 1] | [0, 1] |
现在算"追"这个词的 Attention 输出:
- Step 1 · 打分"追"的 q=[0,1] 分别和"猫"的 k=[1,0](点积=0)、"追"的 k=[0,1](点积=1)、"球"的 k=[1,1](点积=1)。原始分数:[0, 1, 1]。
- Step 2 · 缩放除以 √2 ≈ 1.41 → [0, 0.71, 0.71]。
- Step 3 · Softmax得到权重 ≈ [0.22, 0.39, 0.39]。三者加起来是 1。
- Step 4 · 加权求和0.22×[1,2] + 0.39×[3,0] + 0.39×[0,1] = [1.39, 0.83]。这就是"追"的新表示。
看出来了吗?"追"的新表示,融合了"猫"、"追"、"球"三个词的信息,各按不同权重。这个新向量不再是原来那个孤立的"追",而是"在这个句子里、由这些邻居定义的追"。
把三个词全部算完 · 一张完整的注意力矩阵
上面只算了"追"一个词。真实模型是三个词同时算的,算出来的东西叫注意力矩阵(Attention Matrix)——所谓注意力矩阵,说白了就是一张"谁看谁、看多重"的座位表,横行是"谁在看",纵列是"被看的人",格子里的数字是分给对方的百分比。我们把这张表整个填出来。
Step 1 · 全部原始分数(每一行是一个词的 q,去点乘所有词的 k)
k=猫[1,0] k=追[0,1] k=球[1,1]
q=猫[1,0] 1 0 1
q=追[0,1] 0 1 1
q=球[1,1] 1 1 2
验算一个格子:q=球[1,1] 点乘 k=球[1,1] = 1×1 + 1×1 = 2 ✓
Step 2 · 全部除以 √2 ≈ 1.414
猫 追 球
猫 0.707 0.000 0.707
追 0.000 0.707 0.707
球 0.707 0.707 1.414
Step 3 · 每一行单独走一次 softmax(注意:是按行归一化,不是整张表)
「猫」这一行:e^0.707=2.028, e^0=1.000, e^0.707=2.028,合计 5.056
→ [0.401, 0.198, 0.401]
「追」这一行:e^0=1.000, e^0.707=2.028, e^0.707=2.028,合计 5.056
→ [0.198, 0.401, 0.401]
「球」这一行:e^0.707=2.028, e^0.707=2.028, e^1.414=4.113,合计 8.169
→ [0.248, 0.248, 0.504]
最终注意力矩阵(每一行加起来都是 1.000):
看猫 看追 看球
猫 0.401 0.198 0.401
追 0.198 0.401 0.401
球 0.248 0.248 0.504
Step 4 · 用这些权重去加权 V(V:猫[1,2]、追[3,0]、球[0,1])
猫的输出 = 0.401×[1,2] + 0.198×[3,0] + 0.401×[0,1]
= [0.401+0.594+0, 0.802+0+0.401]
= [0.995, 1.203]
追的输出 = 0.198×[1,2] + 0.401×[3,0] + 0.401×[0,1]
= [0.198+1.203+0, 0.396+0+0.401]
= [1.401, 0.797]
球的输出 = 0.248×[1,2] + 0.248×[3,0] + 0.504×[0,1]
= [0.248+0.744+0, 0.496+0+0.504]
= [0.992, 1.000]
输入是三个 2 维向量,输出还是三个 2 维向量 —— 形状不变,内容变了。
这就是「一层 Attention」做完的全部事情。
这张表有几个值得盯一会儿的细节。第一,每个词都给自己留了不少注意力——"球"给自己 50.4%,因为 q 和 k 来自同一个 x,方向天然一致,自己跟自己的点积总是偏大。这不是 bug,模型需要保留自身信息,否则一层过后原来的词就丢了。第二,矩阵不对称——"猫看追"是 0.198,"追看猫"也是 0.198,这里因为例子太对称才碰巧相等;真实模型里 WQ 和 WK 不同,"A 关注 B"和"B 关注 A"的程度可以差很远,就像现实里你很关注老板、老板不太关注你。第三,形状进出一致——三个 2 维进、三个 2 维出,这个性质极其重要,它意味着 Attention 层可以像积木一样一层摞一层,摞 96 层都不会形状不匹配,这正是 GPT-3 能堆 96 层的结构前提。
再补一个直觉:既然形状不变、内容变了,那"变"在哪?变在每个词的向量从"孤立的词典义"变成了"这句话里的语境义"。原来的"追"只是词典里那个"追";过了一层之后,它成了"猫在追球这件事里的追"。再过一层,它成了"这一整段故事里那只橘猫追球那一刻的追"。层数越深,语境范围越大、含义越具体——这就是深度堆叠的意义。
自注意力 vs 交叉注意力 · 问的是自己还是别人
上面的例子里,Q、K、V 全都来自"猫追球"这同一句话。这种情况有个专门的名字叫 Self-Attention(自注意力)——所谓自注意力,说白了就是一句话内部自己人开会,每个词问所有同伴"你们谁跟我有关"。
但还有另一种情况:Q 来自一句话,K 和 V 来自另一句话。这叫 Cross-Attention(交叉注意力),说白了就是拿着 A 组的问题,去 B 组那边找答案。最典型的例子是翻译:你正在写中文译文的第 5 个字,此刻的"我想找什么"(Q)来自中文这边,而"答案在哪"(K/V)要去英文原文那边找。
| 类型 | Q 从哪来 | K/V 从哪来 | 生活对应 |
|---|---|---|---|
| Self-Attention | 本句 | 本句 | 班会讨论:同班同学互相提问 |
| Cross-Attention | 正在写的译文 | 要翻译的原文 | 翻译时随时抬头看一眼原文 |
| Masked Self-Attention | 本句,但只能看前面 | 本句,但只能看前面 | 接着往下写作文,看不到还没写的部分 |
要特别强调的是:这三种用的都是同一个公式,一个字都没改。区别只在于"喂进去的 Q、K、V 是从哪儿取的",以及"有没有把某些格子划掉"。这就是 Attention 设计的高明之处——一个运算,靠改变输入来源就能承担三种完全不同的职能,不需要三套不同的代码。就像同一台微波炉,热饭、化冻、加热牛奶用的是同一套加热原理,只是时间和功率不同。
掩码注意力 · 考试时把后面的题遮住
做生成的时候有一个必须解决的问题:不能让模型偷看答案。训练时我们手上有完整的句子"猫追球,它抓到了",要教模型"看到'猫追球,它'之后应该输出'抓'"。但如果用普通的自注意力,"它"这个位置能看到后面的"抓到了"——那它当然能猜对下一个字,因为答案就摊在眼前。这样训出来的模型,一到真实生成场景就废了,因为那时候后面根本没有字。
解决办法叫 Causal Mask(因果掩码,也叫上三角掩码)——所谓因果掩码,说白了就是拿一张纸把试卷后面的题盖住:算注意力分数时,把"看向未来位置"的那些格子强行设成负无穷,softmax 一过,e 的负无穷次方等于 0,那些位置的份额就精确变成 0,等于压根没看见。
掩码前的分数矩阵(4 个词:"猫 追 球 了")
猫 追 球 了
猫 1.2 0.3 0.8 0.5 ← 猫不该看到 追/球/了
追 0.4 1.5 0.9 0.2 ← 追不该看到 球/了
球 0.7 1.1 1.8 0.6 ← 球不该看到 了
了 0.3 0.5 1.2 1.6 ← 了可以看全部
加上因果掩码(把右上三角全设成 -∞)
猫 追 球 了
猫 1.2 -inf -inf -inf
追 0.4 1.5 -inf -inf
球 0.7 1.1 1.8 -inf
了 0.3 0.5 1.2 1.6
按行 softmax 之后
猫 追 球 了
猫 1.000 0.000 0.000 0.000 ← 只能看自己
追 0.250 0.750 0.000 0.000 ← 看前两个
球 0.184 0.274 0.542 0.000 ← 看前三个
了 0.126 0.154 0.310 0.410 ← 看全部四个
一句话总结:把矩阵的右上角全部作废,
剩下一个下三角,就是「只许回头看」。
这个设计带来一个很划算的副产品:一句话可以同时产生 n 个训练样本。上面这四行,第一行在教"看到'猫'该说什么",第二行在教"看到'猫追'该说什么",第三行在教"看到'猫追球'该说什么"……一次前向传播,同时训练了 4 个不同长度的预测任务。一段 4000 词的文本进去,等于同时做了 4000 道题。这就是为什么 GPT 这套"预测下一个词"的训练方式数据利用率极高——这一点在 §7.4 讲"为什么 Decoder-only 赢了"时还会再提。
KV Cache · 查过的书先摆在手边
推理(也就是模型真正给你生成回答的时候)有一个巨大的浪费点,不做优化的话慢得离谱。我们先看清这个浪费。
生成是一个字一个字来的。假设模型已经写出"今天天气真",现在要生成第 6 个字。按公式,它需要前面 5 个字的 K 和 V。它算出来了,输出"好"。接着要生成第 7 个字,需要前面 6 个字的 K 和 V——注意,前 5 个字的 K/V 和上一步完全一样,因为它们的输入 x 没变、WK/WV 没变,算出来必然一模一样。可是傻办法会把这 5 个又重算一遍。生成第 100 个字时,前 99 个字的 K/V 已经被重复计算了 99 次。
KV Cache(KV 缓存)就是把这些算过的 K、V 存起来,下一步直接取用。所谓 KV Cache,说白了就是查过的书别放回书架,摆在桌角,下次伸手就拿。
不用 KV Cache 生成 1000 个字,总共要算多少次 K/V?
第 1 步算 1 个,第 2 步算 2 个 …… 第 1000 步算 1000 个
合计 = 1+2+3+…+1000 = 500500 次
用 KV Cache 呢?
每一步只算「新来的那一个字」的 K/V,老的直接读缓存
合计 = 1000 次
省下的比例 = 1 - 1000/500500 ≈ 99.8%
代价:缓存要占显存。占多少?
单个 Token 的 KV 大小
= 2(K和V)× 层数 × 头数 × 每头维度 × 精度字节数
以 Llama-2-7B 为例:2 × 32层 × 32头 × 128维 × 2字节(FP16)
= 524288 字节 ≈ 0.5 MB / Token
上下文 4096 个 Token → 约 2 GB 显存
上下文 32768 个 Token → 约 16 GB 显存
同时服务 10 个用户,每人 32K → 160 GB,两张 80G 卡装不下
这就是 §7.3 要讲的 MQA / GQA 存在的全部理由 ——
它们的唯一使命就是把上面这个数字压下来。
顺便说清一个常被搞混的点:为什么缓存的是 K 和 V,不缓存 Q?因为每一步的 Q 只有一个——就是"当前正在生成的这个位置"发出的那一次提问,用完就作废,下一步是全新的提问,没什么可复用的。而 K、V 是"被查询的资料库",只增不改,天然适合缓存。Q 是一次性的问题,K/V 是可以反复被查的档案——这也是 K/V 和 Q 在工程上待遇完全不同的原因。
为什么复杂度是平方 · 算一遍 100 万这个数
Attention 有一个绕不过去的成本:计算量随句子长度的平方增长,写作 O(n²)。这个"平方"不是什么深奥的理论,就是一个乘法口诀的问题。
道理简单到有点可笑:每个词都要跟所有词打一次分。n 个词,每个都要打 n 次分,总共就是 n × n 次。这就是全部的解释。
句子长度 n 需要打的分数个数 n² 直观对照
────────────────────────────────────────────────────────
10 个词 100 次 一句话,随手可算
100 个词 1 万次 一段话
1000 个词 100 万次 两页 A4 纸
1 万个词 1 亿次 一篇长论文
10 万个词 100 亿次 一本小说
100 万个词 1 万亿次 《红楼梦》× 8
对比一下线性增长会是什么样:
句子长 10 倍 → O(n) 涨 10 倍,O(n²) 涨 100 倍
句子长 100 倍 → O(n) 涨 100 倍,O(n²) 涨 1 万倍
再算一个更具体的:注意力分数矩阵本身要占多少显存?
n=1000: 1000×1000 = 100 万个数 × 2 字节 = 2 MB (还好)
n=10000: 1 亿个数 × 2 字节 = 200 MB (开始疼)
n=100000:100 亿个数 × 2 字节 = 20 GB (一张卡放不下一个矩阵)
而这还只是「一个头、一层」的矩阵。乘上 32 层 × 32 头 —— 天文数字。
这就是为什么"长上下文"是个真正的技术难题,而不是把参数改大一点就行。从 4K 上下文扩到 128K,长度涨了 32 倍,注意力的计算量涨了 1024 倍。业界为此发明了一大堆办法:稀疏注意力(只让每个词看一小部分,而不是全部)、滑动窗口注意力(只看左右各 4096 个邻居)、Flash Attention(不改变数学结果,只改变数据在显存里的搬运方式)、线性注意力(用数学变形把 n² 降成 n)。这些方案我们在 §7.3 和 §7.4 会分别展开,这里你只需要记住那个 100 万这个数字的来历——它是平方带来的所有痛苦的源头。
Attention 之后的两个隐形配件 · 残差、归一化,再看一遍代码
严格说来,Transformer 的一层不只有 Attention。Attention 算完之后,还要立刻做两件事,它们在公式里不显眼,但缺了任何一个,模型都训不动。
第一件叫 残差连接(Residual Connection,残差连接)——所谓残差连接,说白了就是"把原来的东西也留一份,跟新算出来的东西加在一起"。写成公式是 输出 = x + Attention(x),注意那个 x +。生活里的对应是:改稿子时,你不会把原稿撕了重写,而是在原稿上做修改标记——原稿始终在,新意见叠加上去。这样即使某一层的 Attention 学得一塌糊涂(输出接近 0),原始信息也能原封不动地传到下一层,最坏情况就是"这一层白干了",而不是"这一层把信息毁了"。96 层的模型敢这么堆,全靠残差连接兜底。
第二件叫 LayerNorm(Layer Normalization,层归一化)——所谓层归一化,说白了就是"把一串数字统一调整到一个标准的音量水平":算出这串数字的平均值和波动幅度,然后减掉平均、除以幅度,让它变成"平均 0、幅度 1"的标准形态。为什么需要?因为一层一层往上传的过程中,数值可能越来越大或越来越小,几十层之后就完全失控了。LayerNorm 相当于在每层之间放一个自动音量调节器,保证送进下一层的信号始终在合适的响度上。这个组件的摆放位置(放在 Attention 之前还是之后)居然会显著影响训练稳定性,我们在 §7.4 会专门讲这件小事。
一层完整的 Transformer 到底做了什么(Pre-LN 写法)
x_in
│
├──────────────────────────┐ ← 残差:原件留一份
↓ │
LayerNorm(调音量) │
↓ │
Multi-Head Attention │ ← 本篇讲的 QKV 就在这里
↓ │
+ ←───────────────────────┘ ← 把原件加回来
│
├──────────────────────────┐ ← 第二次残差
↓ │
LayerNorm │
↓ │
FFN(前馈网络,各自消化) │
↓ │
+ ←───────────────────────┘
│
x_out → 送进下一层,形状和 x_in 完全一样
这个结构原封不动重复 12 / 32 / 96 层 —— 这就是整个 GPT。
看完这么多文字,用代码收一下会特别踏实。下面是最朴素的实现,没有任何优化技巧,每一行都能对应到前面讲过的某一步。
import torch
import torch.nn.functional as F
def attention(x, W_q, W_k, W_v, causal=False):
"""
x : [n, d_model] n 个词,每个词 d_model 维
W_* : [d_model, d_k] 三张换算表
"""
# 第 0 步:查表,把一个 x 变成三份材料
Q = x @ W_q # [n, d_k] 需求卡
K = x @ W_k # [n, d_k] 胸牌
V = x @ W_v # [n, d_k] 资料包
d_k = Q.shape[-1]
# 第 1 步:打分 —— 每个人的需求卡去点乘所有人的胸牌
scores = Q @ K.transpose(-2, -1) # [n, n] 那张座位表
# 第 2 步:缩放 —— 把音量调到合适的水平
scores = scores / (d_k ** 0.5)
# 掩码(只在做生成时需要):把右上三角作废
if causal:
n = scores.shape[-1]
mask = torch.triu(torch.ones(n, n), diagonal=1).bool()
scores = scores.masked_fill(mask, float('-inf'))
# 第 3 步:softmax —— 一共 100 分,按行分配
weights = F.softmax(scores, dim=-1) # [n, n] 每行和为 1
# 第 4 步:加权求和 —— 按份额把大家的资料包混起来
out = weights @ V # [n, d_k]
return out, weights
# 试一把:"猫 追 球",2 维,直接用前面手算的数
x = torch.eye(3) # 三个词的假输入
W_q = torch.tensor([[1.,0.],[0.,1.],[1.,1.]]) # 让 q 正好等于手算里的值
W_k = W_q.clone()
W_v = torch.tensor([[1.,2.],[3.,0.],[0.,1.]])
out, w = attention(x, W_q, W_k, W_v)
print(w.round(decimals=3))
# tensor([[0.401, 0.198, 0.401],
# [0.198, 0.401, 0.401],
# [0.248, 0.248, 0.504]]) ← 和前面手算的注意力矩阵完全一致
请留意一件事:这段代码里没有任何循环。没有"for 每个词",没有"for 每个位置",从头到尾就是几个矩阵乘法。这正是上一篇反复强调的那个性质——整句话一次算完,GPU 上万个核心同时开工。RNN 的实现里必然有一个 for 循环,那个循环就是它的死穴。
另外注意 causal=False 这个开关。同一个函数,参数一改就从"理解模式"变成"生成模式";如果把 K、V 换成另一句话的,它又变成了"翻译模式"。三十行代码,三种职能——这是 Attention 设计里最优雅的地方。
五个常见误解 · 一次性纠正
教学中最常碰到的五个卡点,集中说清楚,省得你带着错误印象往后读。
- 误解一:Q、K、V 是三份不同的数据不是。它们来自同一个输入,只是被三张不同的换算表改写成了三种形态。就像同一个人的简历、名片、微信名片,素材都是他本人。
- 误解二:注意力权重是模型的"参数"不是。权重是每次前向传播临时算出来的,输入换了就变。真正被训练、被保存在模型文件里的是那三张换算表 WQ/WK/WV。权重是"临时的判断",矩阵是"长期的能力"。
- 误解三:注意力矩阵是对称的不是。因为 Q 和 K 走的是两张不同的表,"A 看 B 的分数"和"B 看 A 的分数"是两个独立的数。现实里也一样——员工很关注老板的一举一动,老板未必同等地关注每个员工。
- 误解四:Attention 会"选中"最相关的那个词不会。它给出的是一整套百分比,把所有词按比例混起来。哪怕最高的那个占了 90%,剩下 10% 也实实在在地进了结果。这个"不选中"的软性质,恰恰是它能被梯度下降训练的前提。
- 误解五:一层 Attention 就能理解整句话不能。一层只完成了"每个词看一眼所有词"这一轮信息交换。要形成复杂的理解,需要几十层反复交换——就像一个议题要开好几轮会才能形成共识,一次碰头会只能交换基本信息。
QKV 让 Attention 变得可训练
回到最初那个问题的另一面:为什么要搞出 Q、K、V 三个不同的向量?直接拿词向量互相点积不行吗?——
答案是:不行,会太死板。如果直接用词向量互相点积,那"相关性"就是固定的,无法根据不同任务、不同上下文灵活调整。
引入 WQ, WK, WV 三个可学习的投影矩阵后,情况就完全不同了——同一个词,可以以不同的姿态提问、以不同的姿态展示、以不同的姿态贡献。模型通过训练,学会"处理这个词时,我应该关心什么方面的信息"——这才是 QKV 设计的精髓。
K 和 V 为什么要分开 · 一个常被忽略的问题
还有一个精妙的设计——为什么 K 和 V 是两个不同的向量而不是同一个?直觉上,"用来匹配的东西"和"被取走的内容",好像也可以合并成一个吧?
但仔细想想图书馆的比喻——目录卡片(K)上写的是"标题、作者、关键词",简短、便于快速匹配;而书的正文(V)是完整内容,是你真正要读的东西。检索时用简短的标签匹配,取用时读完整的内容——这是两件不同的事,用不同的向量表达才最自然。
在数学上,把 K 和 V 分开,让模型能学到:"我用哪些维度做匹配、用哪些维度贡献内容"——这两组维度可以完全不同。比如"猫"这个词,用"名词-动物-主语候选"这些标签做匹配(K);但真正被取走的内容(V)可能包含"猫的语义、情感联想、上下文角色"等更丰富的东西。K 和 V 分离,让 Attention 的表达能力翻了一倍。
Attention 是"软"的——不是硬选一个
要理解 Attention 的另一个精妙之处:它给出的是一组权重分布,而不是"选中一个"的硬性选择。
很多人第一次学 Attention 时会想:"模型是不是像我们查资料一样,找到最相关的那一本书就打开?"——不是。Attention 是 softmax 输出的一组概率,比如 [0.4, 0.3, 0.15, 0.1, 0.05]——它不"选定"任何一个,而是按比例混合所有的候选。
这一点非常重要——它让 Attention 变成了一个可微分的操作,梯度可以通过它反向传播回去、调整参数。如果是"硬选一个",就没法算梯度,模型就没法学习了。"软注意力"是 Transformer 能被端到端训练的数学基础。
QKV 出现在 Transformer 的三个地方
了解了 QKV 的运作,还得清楚它在整个 Transformer 里出现的位置——共有三处,作用略有不同:
| 出现位置 | Q 来源 | K/V 来源 | 用途 |
|---|---|---|---|
| Encoder Self-Attention | 输入句子 | 同一输入句子 | 让每个输入词理解全句上下文 |
| Decoder Self-Attention(带掩码) | 已生成部分 | 同一已生成部分 | 生成下一词时回顾自己写过的内容 |
| Decoder Cross-Attention | Decoder 当前状态 | Encoder 全部输出 | 翻译时"看一眼源语言" |
三处使用的 QKV 是不同的三套矩阵——Encoder 有自己的一套 WQ/WK/WV,Decoder 的 Self-Attention 有另一套,Cross-Attention 又有一套。它们独立学习、各司其职。整个 Transformer 里,QKV 是最基础也是最反复出现的建筑构件。
QKV 是 Attention 的心脏:Q 提问、K 展示、V 贡献。四步公式——打分 → 缩放 → 归一化 → 加权求和——就是 Transformer 每一层里最核心的运算。
但一组 QKV 只能捕捉一种相关性。人类看一句话时,会同时关注语法、语义、指代、情感——一组 QKV 显然不够用。下一篇,我们把"一组"变成"很多组"——多头注意力。