§ 7.2 · Section

QKV · Query / Key / Value

The Three Vectors Behind Attention

上一篇我们说清了 Attention 的直觉——"跳回去看一眼相关的词"。这一篇要把这个直觉翻译成一个公式Attention(Q, K, V) = softmax(QKᵀ / √d) · V。看起来吓人,但只要你记住"图书馆查资料"这个比喻,它就变得再自然不过。

生活场景
📚 你去图书馆查一本书

周末,你走进图书馆,想找一本关于"深度学习"的书。你会怎么做?
第一步:脑子里先形成"我要查什么"——"深度学习相关的书",这是你的查询意图
第二步:走到目录检索机前,输入关键词。系统会拿你的关键词和每本书的索引标签做比对——"计算机 · 人工智能 · 深度学习"匹配度高、"烹饪 · 川菜"匹配度低。
第三步:系统告诉你:"这三本最相关,那两本次相关,其他不推荐"。你按推荐去书架取书,把书里的内容综合起来读。
这一整套流程,就是 QKV 的完整生活版本——查询、匹配、取值。

开场先扫清三个拦路的词 · 向量、矩阵、点积

这一篇会反复出现三个数学名词。它们看着像高数课的噩梦,其实都是初中水平的东西。花一分钟把它们翻译成大白话,后面就再也不卡了。

把这三个翻译刻在脑子里:向量 = 一串数字;矩阵 = 一张换算表;点积 = 算小票总价。接下来所有让人头晕的公式,都只是这三个动作的排列组合,不会再冒出第四样新东西。所以如果你后面看到某一行公式卡住了,只要问自己一句:"这一步是在查表,还是在算小票?"——答案总是其中一个。

三个字母各是什么

对于句子里的每一个词,Transformer 会把它同时"变身"成三个不同的向量。不是三份复制,而是三种不同的"用途视角"——就像同一个人在图书馆里既是提问者、也是被别人翻阅的书目、还带着自己的一本内容。

字母全称角色直觉理解
QQuery(查询)"我想找什么"当前词发出的询问:"哪些词跟我有关?"
KKey(键)"我是什么"每个词自我描述的"标签":让别人用来匹配我。
VValue(值)"我带来什么"每个词真正携带的信息内容,被别人加权取用。

数学上:一个词的原始向量 x,会被三个不同的矩阵 WQ, WK, WV 分别乘一下,就得到了这个词的 q, k, v 三个向量。这三个矩阵是模型训练时学出来的——它们决定了"这个词该以什么姿态提问、以什么姿态展示自己、以什么姿态贡献内容"。

Analogy · 相亲会上的三种自我

把一句话想成一场相亲会:每个词是一位嘉宾。
Q(查询):你走过去时,脑子里"我在找什么样的对象"的清单——身高、爱好、性格。
K(键):每位嘉宾佩戴的"胸牌"——身高、爱好、性格的关键词摘要。
V(值):嘉宾本人——真正的内容、故事、经历。
你的清单(Q)去逐个匹配大家的胸牌(K),得出"跟谁最合"的分数,然后按分数决定坐下来听谁多聊、听谁少聊——听到的就是 V。

三个矩阵是从哪儿来的 · 同一个人的三张名片

上面那句"乘三个矩阵"是全篇最容易糊过去的一步,我们把它掰开揉碎讲一遍。输入只有一个东西:这个词的原始向量 x——比如"猫"这个字在模型内部对应的那 512 个数字。输出却有三个东西:q、k、v。一个变三个,多出来的信息从哪来?答案是:信息没有多出来,是同一份信息被三张不同的"换算表"改写成了三种说法

打个比方。你去参加一场行业大会,主办方要求你交三样材料:一张需求卡(写清你此行想找什么样的合作方)、一张胸牌(写你的公司、职位、领域关键词)、一份详细资料包(你的完整业务介绍、案例、报价)。这三样东西的原始素材是同一个"你",但呈现出来的样子完全不同:需求卡是往外问的,胸牌是给人扫一眼的,资料包是真正被拿走细读的。WQWKWV 这三个矩阵,就是主办方给你的三张"填表模板"——同一个你,套三个模板,填出三份不同用途的材料。

关键在于:这三个模板不是人设计的,是训练时自动长出来的。刚初始化时它们是随机数,填出来的三份材料全是乱码;随着几万亿个词的训练,梯度下降会一点点修改这三张表格里的数字,最后修出来的效果是——套 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 θ,其中 θ 是两个箭头之间的夹角。这个公式听着玄,其实就是说:两个箭头指的方向越接近,点积越大;垂直时正好是零;反着指时是负的。所以点积天然就是一个"方向相似度尺"。

Analogy · 两个人推同一辆购物车

你和朋友一起推一辆卡在坡上的购物车。
两人朝同一个方向使劲——车走得飞快,合力最大。这就是点积很大。
一个往前推、一个往侧面推——侧面那位完全没帮上前进的忙,白费劲。这就是点积等于 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 的核心

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。

这会带来两个坏消息:

把点积除以 √d,正好把方差拉回到 1 附近——softmax 就能得到一个"温和分布",既有主次,也不会一边倒。这个看似不起眼的除法,是让 Transformer 能训练得动的核心技巧之一。没有这一除,你的 Transformer 十有八九训不起来。

Analogy · 调音量

把声音调太大,喇叭就炸了、只听得见一个声源;调太小,什么都听不见。
√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 维)。

QKV
[1, 0][1, 0][1, 2]
[0, 1][0, 1][3, 0]
[1, 1][1, 1][0, 1]

现在算"追"这个词的 Attention 输出:

看出来了吗?"追"的新表示,融合了"猫"、"追"、"球"三个词的信息,各按不同权重。这个新向量不再是原来那个孤立的"追",而是"在这个句子里、由这些邻居定义的追"。

把三个词全部算完 · 一张完整的注意力矩阵

上面只算了"追"一个词。真实模型是三个词同时算的,算出来的东西叫注意力矩阵(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 设计里最优雅的地方。

五个常见误解 · 一次性纠正

教学中最常碰到的五个卡点,集中说清楚,省得你带着错误印象往后读。

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-AttentionDecoder 当前状态Encoder 全部输出翻译时"看一眼源语言"

三处使用的 QKV 是不同的三套矩阵——Encoder 有自己的一套 WQ/WK/WV,Decoder 的 Self-Attention 有另一套,Cross-Attention 又有一套。它们独立学习、各司其职。整个 Transformer 里,QKV 是最基础也是最反复出现的建筑构件

Recap · 收束

QKV 是 Attention 的心脏:Q 提问、K 展示、V 贡献。四步公式——打分 → 缩放 → 归一化 → 加权求和——就是 Transformer 每一层里最核心的运算。
但一组 QKV 只能捕捉一种相关性。人类看一句话时,会同时关注语法、语义、指代、情感——一组 QKV 显然不够用。下一篇,我们把"一组"变成"很多组"——多头注意力

☰ 主页
Xue Hai Wu Ya · § 7.2 · QKV