第 17 章 · 序列与 Transformer
注意力机制
上一章我们想通了“为什么要让每个词直接看全局”。这一章不玩虚的:我们把这台机器 彻底拆开,还会拿三个词、几个小数字,陪你从头到尾手算一遍。 你会发现它没那么神秘——说到底,注意力只是在做一件你早就会的事:加权平均。 前面学过的点积、softmax、加权求和,在这里全都用得上,一个新公式都不需要背。
上一站(第 16 章)你已看过整条流水线的全景;本站把其中第 ③ 步的「注意力」彻底拆开、手算一遍;下一站(第 18 章)给它配上残差 / 归一化 / 前馈,拼成 Transformer。
读完这一章,你会明白
- 一句话抓住注意力的本质:按“相关性”给每个词做一次加权平均;
- Q(查询)、K(键)、V(值)到底是什么,三个投影矩阵 WQ/WK/WV 长什么样、怎么乘出来;
- 跟着一个具体的数字例子,亲手把“投影 → 打分 → 缩放 → softmax → 加权求和”算完;
- causal mask 怎么让模型“不能偷看未来”:一张下三角表、为什么用 −∞ 而非填 0、以及它凭什么能让训练一次并行算完整句;多头注意力又为什么有用;
- 逐行读懂真实的 self-attention C++ 实现,并解开新手最常见的几个困惑;
- 那四个矩阵到底怎么“训练出来”——每层各有一套,但更新方式和 MNIST 完全一样。
1. 一句话直觉:注意力就是“按相关性加权平均”
在钻进 Q、K、V 那些字母之前,先把唯一真正重要的那句话记住。 注意力对句子里的每一个词都做同一件事:
把其他所有词的内容拿过来,按“和我有多相关”打一组权重,然后做一次加权平均, 得到这个词的“新版本”——一个已经融合了上下文的向量。
“加权平均”你其实天天都在用。比如算平时成绩:期末占 0.6、作业占 0.3、考勤占 0.1, 把三个分数按这组权重加起来,就是加权平均。这里的权重是老师定死的; 注意力的唯一新意,是让权重不再写死,而是根据“词和词的相关性”当场算出来。
注意力的输出 = 一次加权平均。 难点从来不是“怎么加权平均”(小学就会),而是“权重从哪来”。 接下来的 Q、K、V、打分、softmax,全都只是在回答一个问题: 这一组加权平均的权重,该怎么算才合理?
2. 三个角色:Query、Key、Value
要算“相关性权重”,得先让每个词能被“查找”和“比对”。注意力借用了去图书馆查资料的思路, 把每个词拆成三副面孔:
- Query 查询我“想找什么”。就像你走进图书馆时脑子里的问题:“我要找关于猫的书”。
- Key 键我对外挂出的“标签”,用来被别人匹配。就像每本书书脊上的标题/索引。
- Value 值我真正的“内容”,一旦被选中就把它取出来用。就像翻开书后里面的正文。
查资料的过程就是:拿着你的问题(Query),去比对每本书的标题(Key), 越对得上的书,你越会去读它的正文(Value)。注意力干的是一模一样的事, 只不过“比对”用的是点积,“越对得上就越多读一点”用的是加权平均。
为什么偏偏要三个?一个不行吗?
这是新手最大的一个坎,我们掰开说。同一个词,在句子里扮演的角色是分裂的:
- 它作为提问者时,关心的是“我该去找谁”——这是 Query;
- 它作为被查的对象时,要挂出一个方便别人匹配的标签——这是 Key;
- 它被选中之后,真正要交出去的信息又是另一回事——这是 Value。
“找人时说的话”“被找时挂的牌子”“真正肚子里的货”,本来就该是三样东西。 所以我们用三个不同的权重矩阵 WQ、WK、WV, 把同一份词向量投影成三副面孔。关键点:
它们不是三个不同的词,而是同一条输入向量 x(这个词当前的 embedding,已经带上位置等信息) 分别左乘三个可训练矩阵得到的:
点积、加权求和仍是老运算;新意在于用三套矩阵把“问什么 / 挂什么标签 / 交什么内容”拆开, 而且注意力权重(那排 0.93、0.01…)是后面拿 Q·K 现算的,不是存在 W 里的常数。 可它到底怎么“训练出来”的?先记着,第 10 节专门拆;每层是不是同一套矩阵,也在那里讲清。
三个矩阵长什么样?
在本仓库 SelfAttention 里,它们就是三组名叫 query_weight_、key_weight_、
value_weight_ 的二维表。若 model_dim = d,则每个矩阵形状都是 d×d——
和 MNIST 一层全连接权重同类型,只是这里要三套,再加一个多头拼回去用的 WO(第 8 节)。
| 矩阵(参数) | 形状 | 作用 | 代码里 |
|---|---|---|---|
| WQ | d×d | 把输入变成 Query“我想找什么” | query_weight_ |
| WK | d×d | 把输入变成 Key“我挂什么标签” | key_weight_ |
| WV | d×d | 把输入变成 Value“我交出什么内容” | value_weight_ |
| WO | d×d | 多头拼好后做最后一次线性融合 | output_weight_ |
四个都是要训练、要存盘的参数;Q/K/V 向量和注意力权重则是每次前向现算的中间结果。
对序列里每一个词都重复同一件事:取出它的输入向量 x,分别乘三张表,得到三个小向量。
代码里一行 ProjectSequence(input, query_weight_) 就是在做“整句每个位置各乘一次 WQ”
(第 9 节会对着源码再走一遍)。
3. 完整流程:先投影,再四步走
完整前向其实是“投影 + 四步”:先把每个词的输入向量 x 乘 WQ/WK/WV, 得到 Q、K、V(第 4 节第 0 步手算); 再锁定某一个词当 query(其余每个词也会各自轮一遍),做下面四件事:
- 打分(比相关性):拿我的 Query,和每个词的 Key 做点积。点积越大 = 越“对得上” = 越相关。
- 缩放(除以 √d):把每个分数除以 √d(d 是向量维度)。维度一大,点积容易冲得很大,缩放让它别太极端。
- softmax(变成权重):把一排分数压成一排加起来等于 1 的权重(第 4 章学过)。这就是那组“加权平均”的权重。
- 加权求和(混合内容):用这排权重,对所有词的 Value 做加权平均,得到当前词的新向量。
注意力的数据流。句子里每个词都会走一遍,各自得到一个融合了上下文的新向量。
点积是 d 个乘积加起来的和。d 越大,这个和越容易“攒”得很大—— 统计上它的典型幅度大约随 √d 增长(d 项一起相加,标准差≈√d)。 比如 d=64,点积动不动就到 ±8 上下。分数一旦太大,softmax 会变得极端尖锐(几乎把全部权重压给某一个词), 反传时梯度还会趋近 0、学不动。除以 √d 恰好把分数拉回“幅度≈1”的舒适区,softmax 不至于一家独大,训练也更稳。
4. 跟着算一遍:一个能手算的小例子
光看流程还是抽象。我们把维度砍到 2 维、句子只留 3 个词, 用真实数字把整条链路从头算到尾:先由三个矩阵投影出 Q/K/V,再做打分与加权平均。 算完这一节,注意力对你就“通”了。
第 0 步:输入向量 × 三个矩阵 → Q、K、V
设句子里有三个词:猫、睡、它。 它们进到这一层 attention 时,各自已经是一条 2 维向量 x(可以想成 embedding + 位置编码之后的结果;此处不展开来源,只关心形状):
| 词 | 输入向量 x |
|---|---|
| 猫 | [1, 0] |
| 睡 | [0, 1] |
| 它 | [⅓, ⅓] ≈ [0.33, 0.33] |
这一层 attention 自带三张可训练的 2×2 矩阵(为手算方便,下面直接写出具体数字):
| 矩阵 | 具体数值(2×2) | 直觉(不必死记) |
|---|---|---|
| WQ | [[6, 0], [0, 0]] |
主要把向量第 1 维放大成 Query |
| WK | [[3, 0], [0, 3]] |
两维各自放大成 Key,方便用点积比“方向” |
| WV | [[1.5, 0], [0, 1.5]] |
把内容向量整体缩放成 Value |
投影规则就是第 2 章的矩阵乘:行向量左乘矩阵。 以“猫”为例,x = [1, 0]:
三个词各算一遍,得到整张 Q/K/V 表——后面打分用的就是这里的结果:
| 词 | Query | Key(标签) | Value(内容) |
|---|---|---|---|
| 猫 | [6, 0] | [3, 0] | [1.5, 0] |
| 睡 | [0, 0] | [0, 3] | [0, 1.5] |
| 它 | [2, 0] | [1, 1] | [0.5, 0.5] |
“它”的 Query: [⅓,⅓]·[[6,0],[0,0]] = [2,0]。 Key: [⅓,⅓]·[[3,0],[0,3]] = [1,1]。 Value: [⅓,⅓]·[[1.5,0],[0,1.5]] = [0.5,0.5]。 没有跳步——先前版本若直接给出这张表,却不说矩阵从哪来,正是读起来最别扭的地方。
下面聚焦“它”当 query(其余词也会各当一次 query,流程相同),用 q = [2, 0] 和全表的 Key、Value 继续走第 3 节的四步。
第①②步:打分,再缩放
拿 q = [2, 0] 分别和每个词的 Key 做点积(点积 = 对应位置相乘再相加),然后除以 √d = √2 ≈ 1.41:
| 词(被看的对象) | 它的 Key | 打分 = q · K | 缩放 ÷1.41 |
|---|---|---|---|
| 猫 | [3, 0] | 2×3 + 0×0 = 6 | 4.24 |
| 睡 | [0, 3] | 2×0 + 0×3 = 0 | 0.00 |
| 它 | [1, 1] | 2×1 + 0×1 = 2 | 1.41 |
“猫”得分最高(6),因为它的 Key 方向和“它”的 Query 方向最一致。
第③步:softmax,把分数变成权重
softmax 会先给每个分数取 e 的指数(放大差距,且都变正),再除以总和,让它们加起来正好等于 1:
| 词 | 缩放后分数 | e^分数 | 权重 = e^分数 ÷ 总和 |
|---|---|---|---|
| 猫 | 4.24 | 69.6 | 0.93 |
| 睡 | 0.00 | 1.0 | 0.01 |
| 它 | 1.41 | 4.1 | 0.06 |
总和 ≈ 69.6 + 1.0 + 4.1 = 74.7。三个权重 0.93 + 0.01 + 0.06 = 1.00。
看这排权重 [0.93, 0.01, 0.06]——这就是“它”的注意力分配: 它把 93% 的注意力放在了“猫”上,几乎不理“睡”。
第④步:加权求和,得到“它”的新向量
用这排权重,对三个词的 Value 做加权平均(数值来自上表第 0 步):
“它”原本只是个空洞的代词,经过注意力后,它的新向量变成了 [1.43, 0.05], 几乎和“猫”的 Value [1.5, 0] 同方向!换句话说, 注意力让“它”这个词吸收了“猫”的内容——代词指代关系,就这样被解开了。 这正是第 16 章开头那个“它到底指谁”难题的答案,而它只用了一次加权平均。
5. 为什么“点积”能当相关性?
第①步用点积打分,凭什么点积大就代表“相关”?回到刚才的数字就懂了: Query q = [2, 0] 指向“横轴”方向,而“猫”的 Key [3, 0] 也指向横轴—— 两个向量方向一致,点积就大(6);“睡”的 Key [0, 3] 指向“纵轴”, 和 q 垂直,点积就等于 0。
所以点积其实在衡量“两个向量方向有多合拍”:方向越接近,点积越大,越相关; 方向垂直,点积为 0,毫不相关;方向相反,点积为负,是“负相关”。 模型训练的目标,就是学会那三个矩阵,让该相关的词,它们的 Q 和 K 方向刚好凑得很近。
6. 自己上手玩一玩
道理算通了,再来玩个实时的。下面这个实验台,句子里每个词都有自己的向量; 你选一个 query 词,它会实时算出打分、softmax 权重和最终加权结果——和你刚才手算的完全是同一套流程。建议试试:
- 切换不同的 query 词,看注意力分布怎么变;
- 拖动 Temperature:调高会让权重更“平均”(谁都看一点),调低会更“尖锐”(死盯少数词);
- 勾上 causal mask,看它怎么把“后面的词”屏蔽掉(下一节细讲)。
把鼠标悬停在公式里的彩色项上,会高亮它对应的 token / 分数 / 权重区域。
7. 不能偷看未来:causal mask
语言模型要做的事是“根据前文预测下一个词”。训练和生成时有一条铁律: 预测第 t 个词时,只能看第 1 到 t 个词,绝不能看到它后面的词 ——否则就是“偷看答案”,学不到真本事。这条铁律有个正式名字,叫 causal mask(因果掩码),也叫“自回归掩码”。
想象一场闭卷考试:题目是“猫累了,所以___睡了”,要你填空。 如果这时有人把标准答案连同后文一起塞到你眼前,你当然“答”得又快又准—— 但你根本没在学“怎么根据前文推断”,只是在抄。模型也一样: 注意力默认是“谁都能看谁”的,如果预测某个词时允许它看到自己后面的词, 它会轻松学到一条作弊捷径——直接把答案抄过来。 这样训练时损失掉得飞快,可一旦真正生成时后文还不存在,模型立刻露馅。 causal mask 就是那位严格的监考老师:把每个词后面的内容全部遮住,逼模型只靠前文思考。
具体怎么“遮”?办法很巧妙:不真的去删词,而是在打分之后,把所有“未来位置”的分数改成负无穷(−∞)。 回忆第③步——softmax 要先算 e分数, 而 e−∞ ≈ 0,所以这些未来位置的权重就变成了 ≈ 0,一点都参与不进加权求和,相当于被彻底屏蔽。
① 一张下三角表:到底谁能看谁
把“谁能看谁”画成一张方格表最清楚:行是“正在当 query 的词”,列是“被它看的词”, 从左到右、从上到下就是词在句子里的先后顺序。亮起来的格子表示“这一对允许打分”。 causal mask 允许的,恰好是一个下三角(含对角线)——每个词只能看到自己和它左边(更早)的词:
不加 mask(双向全看)
每个词都能看全句(像 BERT 这类理解任务)
causal mask(只看左边)
下三角:第 t 行只有前 t 格亮(像 GPT 这类生成任务)
左边不加 mask,人人互看;右边加了 causal mask,只剩下三角——这正是“双向理解模型 vs 单向生成模型”最本质的一处区别。
还是“猫 → 睡 → 它”这个顺序,但现在轮到“睡”当 query。它排在第 2 位,“它”排在它后面(第 3 位)。
于是打分时把“它”那一格设成 −∞,这一排分数只剩
[猫, 睡, −∞],softmax 后“它”的权重 ≈ 0——
“睡”就只能靠“猫”和它自己来更新,永远看不到未来的“它”。
(在上面那个实验台勾选 causal,就能看到后面的词被标成 masked。)
② 为什么是改成 −∞,而不是直接删掉或填 0
初学最容易冒出的疑问:既然不让看,把那一格分数直接设成 0 不就行了?为什么非要 −∞? 关键在于 mask 这一步发生在 softmax 之前,而 softmax 是“相对”的——它把每个分数变成 e分数 再归一化:
- 若把未来位置的分数填 0:e0 = 1,它照样贡献了一份不小的权重,等于“还是偷看了一点”,没屏蔽干净。
- 若填 −∞:e−∞ ≈ 0,这一份权重被彻底压没,softmax 归一化时其余位置自动补齐到 1,干净利落。
代码里当然写不了真正的“负无穷”,通常用一个极小的数(如浮点最小值)代替,效果一样。这就是
第 9 节代码里那句
score[col] = lowest() 的由来。
③ 训练时:一次并行,靠 mask 让每个位置“各看各的前文”
这一点最容易困惑,却也是 causal mask 真正威力所在。你可能以为:要让第 1、2、3…个位置各自“只看前文”, 得一个位置一个位置地跑很多遍?其实不用——训练时整句一次性喂进去, 一次前向就同时算出所有位置的预测,靠的正是这张下三角 mask 让它们互不串味:
同一次前向里,第 t 个位置因为被 mask 限制,天然只吸收了前 t 个词的信息,它的预测就相当于“看前 t 个词猜第 t+1 个”。一句 T 个词,一次就造出 T 条训练样本。
这就是第 19 章会讲的 teacher forcing 能高效并行的根基:把标准答案的前半段整段喂进去, mask 保证每个位置都“看不到自己的答案”,于是 T 个位置的损失可以一次算完、一起反向传播,而不必逐字循环——训练因此快了很多。
仔细看那张下三角:第 t 个词的注意力永远只依赖它和它左边,后来新增的词落在它右边, 对它毫无影响。这个性质极其重要——它意味着生成时“前面那些词算出来的东西不会再变”, 正是第 21 章 KV cache 能把历史缓存下来、只算新词的根本原因。我们到第 19 章再细说。
8. 多头注意力:请几位专家各看一遍
目前为止只有一套注意力权重,也就是一种关注视角。但一句话里, 词与词的关系是多维度的:有的是“主语—谓语”,有的是“动词—宾语”,有的只是“位置相邻”。 只用一套权重,等于逼一个人同时盯所有这些关系,顾此失彼。
多头注意力(multi-head attention)的想法很朴素: 请几位各有专长的读者,各自把同一句话读一遍—— 一位专盯“谁是主语”,一位专盯“动词配哪个宾语”,一位专盯“相邻词”。 每位读者(每个“头”)独立走一遍上面的四步流程,给出自己的一套注意力权重和结果,最后把大家的结论拼起来。
做法是把每个词的向量切成几段,每段交给一个头。
如果模型维度是 d = 64、头数是 8,那每个头分到 d / 8 = 8 维,
在自己那 8 维上独立算注意力,互不干扰。代码里就是一句
head_dim_ = model_dim_ / head_num_。
设每个词是 4 维向量 [a, b, c, d]、头数 = 2,则每个头分 4 / 2 = 2 维:
头 1 只在前两维 [a, b] 上独立走完“打分 → softmax → 加权求和”那四步,
头 2 只在后两维 [c, d] 上走一遍;两头互不干扰,可能关注完全不同的词。
各自得到一个 2 维结果后,按原样拼回 4 维 [头1两维 | 头2两维],最后乘输出矩阵 WO 把两位“读者”的意见融合。
进去 4 维、出来还是 4 维,只是中途兵分两路、各看各的。
切换不同的头,你会看到它们关注的模式截然不同——这正是多头的价值:一次看清多种关系。
9. 逐行读懂 self-attention 的前向
回到真实代码。你会发现它就是上面四步的“逐字翻译”,没有任何额外魔法。 第一步:把输入投影成 Q、K、V。
last_query_ = ProjectSequence(input, query_weight_); 1
last_key_ = ProjectSequence(input, key_weight_);
last_value_ = ProjectSequence(input, value_weight_); 2
ProjectSequence就是把序列里每个词向量乘上一个权重矩阵。这里用input算出 Query(每个词的“问题”)。- K 和 V 也用同一个
input算出来——“自己的 Q 去看自己的 K/V”,这正是 “self-attention(自注意力)”名字的由来。
第二步:打分并缩放。(下面是针对某个 query 行 row、某个头的内层循环——对应第①②步)
for (int col = 0; col < n; col++) {
if (mask != nullptr && mask[row][col] <= 0) {
score[col] = lowest(); // 屏蔽未来位置(causal mask) 1
continue;
}
double dot = 0;
for (int i = 0; i < head_dim_; i++)
dot += last_query_[row][head_start + i]
* last_key_[col][head_start + i]; 2
score[col] = dot / std::sqrt(head_dim_); 3
}
- 如果有掩码且这个位置被屏蔽,直接把分数设成最小值(相当于 −∞),softmax 后它会变成 ≈0——就是第 7 节讲的 causal mask。
- Query(第
row个词)和 Key(第col个词)在当前头那段维度(head_start起的head_dim_维)上做点积——正是我们手算的q · K。 - 除以 √
head_dim_做缩放。这就是公式里的 QKT / √d,也就是手算里的“÷1.41”。
第三步:softmax,再用权重对 Value 加权求和。(对应第③④步)
auto weight = Softmax(score); 1
for (int col = 0; col < n; col++)
for (int i = 0; i < head_dim_; i++)
merged_output[row][head_start + i]
+= weight[col] * last_value_[col][head_start + i]; 2
- 把这一排分数过 softmax,变成加起来为 1 的注意力权重——就是我们手算出的 [0.93, 0.01, 0.06]。
- 用权重对所有词的 Value 加权求和,写进这个头负责的那段输出。权重大的词贡献大——这就是那句“按相关性做加权平均”落到代码里的样子。
第四步:把各个头的结果拼起来,再过一个输出投影融合。
output = ProjectSequence(last_merged_output_, output_weight_); 1
- 所有头的输出已经按维度段拼在
merged_output里;再乘一个输出权重矩阵 WO,把多位“读者”的结论混合成最终结果。至此,每个词都得到了一个“看过全局”的新向量。
注意力听起来高大上,但拆开后:点积(第 2 章加权求和)、softmax(第 4 章)、 加权求和(还是第 2 章)。它真正的新意只有一个—— 让权重由“Query 和 Key 的相关性”当场算出来,而不是固定写死。
10. 这四个矩阵到底是怎么训练出来的?
到这儿你八成憋着一个问题:我们反复说 WQ、WK、WV、WO 是“训练出来的”,可到底怎么训?是不是又有一套没学过的新算法? 是不是每一层还各有一套、各训各的?好消息:完全没有新算法—— 更新方式和 MNIST、和 FFN 的权重,一字不差。
每一层各有一套矩阵,彼此不共享
这是第二个常见误会:不是全模型只有一套 WQ/WK/WV 给所有层共用。
每堆一层 Transformer Block,里面就嵌一个独立的 SelfAttention,
带着自己那一套四个矩阵。GPT 堆 N 层,就有 N 套 attention 参数。
| 第 1 层 Block | 第 2 层 Block | 第 N 层 Block | |
|---|---|---|---|
| WQ / WK / WV / WO | 一套 d×d | 另一套 d×d | 再一套… |
| 初始化 | 各自随机小数 | 各自随机小数 | 各自随机小数 |
| 前向 | 用本层矩阵投影 → 打分 → 加权 | 拿上一层输出再投影一遍 | 同上 |
| 更新公式 | 新值 = 旧值 − 学习率 × 梯度 —— 每一层、每一个矩阵,规则完全相同 | ||
层与层之间传的是隐藏向量序列(第 18 章第 8 节); 参数不共享,但怎么学完全同一套套路。
MNIST 隐藏层权重:θ ← θ − lr·∇θ ·
FFN 的 W₁/W₂:同一公式 ·
每一层 Attention 的 query_weight_/key_weight_/value_weight_/output_weight_:还是同一公式。
差别只在前向怎么算梯度(链式法则路径不同),不在参数怎么挪。
先把两样东西彻底分清
注意力里有两种“数”,新手最容易把它们搅成一团:
- 注意力权重(手算出的那排 [0.93, 0.01, 0.06]):是每次前向当场用 Q·K 算出来的,换个句子就变,不是参数、不用存、也不用训。
- 三个投影矩阵 WQ/WK/WV(还有输出矩阵 WO):才是真正的参数,和第 2 章那些“权重旋钮”一模一样,要靠训练慢慢调、要存进模型文件。
一句话:被训练的从来不是权重,而是“生产权重的那三个矩阵”。 训练它们,就是在教模型“该拿什么去问、该挂什么标签、该交出什么内容”。
别被“注意力”三个字唬住。这一层里可训练的参数,总共就 4 个矩阵—— WQ、WK、WV、WO, 每个都是 d×d 的一格格数字,和 MNIST 里的权重矩阵是同一种东西,没有任何神秘。
· 它们怎么更新? 和 MNIST 一字不差:反向传播算出每个格子的梯度,再 新值 = 旧值 − 学习率 × 梯度(见本节末代码)。
· 那排 [0.93, 0.01, 0.06] 注意力权重呢? 不是参数,是每次前向拿 Q·K 现算的中间结果,用完即弃——不存、也不训。
· Q、K、V 向量呢? 也是现算的(输入分别乘这几个矩阵),同样不是参数。
所以“训练注意力”= 只训这 4 个矩阵,学习机制和 MNIST 完全相同;attention 特别的地方只在前向怎么算,不在怎么学。 整个模型的完整参数清单,见第 19 章。
老三样:前向 → 算损失 → 反向传播 → 微调
这三个矩阵和网络里其它所有参数一样,走的还是第 5、6 章那条流水线:
- 前向:用当前矩阵算一遍注意力,一路算到模型的最终输出(比如“下一个字的概率”)。
- 算损失:拿输出和“正确答案”比,得到一个误差(第 4 章的损失函数)。
- 反向传播:用链式法则(第 6 章)把误差反着传回去,一直传到 WQ/WK/WV,算出“每个矩阵元素该往哪边改”(梯度)。
- 微调:沿梯度反方向,把每个矩阵元素挪一小步(梯度下降,第 5 章)。
这样“挪一小步”重复亿万次,三个矩阵就从最初的随机数,长成了“懂得该关注谁”的样子。
假设某次前向,“它”把注意力大量分给了“猫”,结果模型预测对了下一个字、损失下降—— 反向传播就会给出这样的信号:让“它”的 Query 和“猫”的 Key 方向再靠近一点。 于是 WQ、WK 各被挪一小步,下次这俩的点积更大、注意力更集中。 反过来,如果关注“猫”反而让预测更糟,信号就会把这两个方向推开。 没有任何人告诉模型“‘它’指代‘猫’”——它纯粹是因为“这么做能降低损失”,自己一步步摸索出来的。 这正是第 5 节那句“让该相关的词 Q、K 方向凑到一起”真实发生的过程。
这几步落到真实代码里,就是 Backward 结尾的四行——和第 2 章“参数 = 参数 − 学习率 × 梯度”一字不差:
for (int out = 0; out < model_dim_; out++)
for (int in = 0; in < model_dim_; in++) {
query_weight_[out][in] -= learning_rate * grad_query_weight[out][in]; 1
key_weight_[out][in] -= learning_rate * grad_key_weight[out][in];
value_weight_[out][in] -= learning_rate * grad_value_weight[out][in];
output_weight_[out][in] -= learning_rate * grad_output_weight[out][in]; 2
}
- 对三个矩阵的每个元素都做“减去 学习率 × 梯度”——这正是第 5 章的梯度下降。
grad_query_weight就是前面用链式法则一层层算回来的梯度。 - WO(多头拼接后的输出矩阵)也一并更新。四个矩阵,全靠同一个损失的“反馈”来调。
你可能追问:反向传播的起点是“损失”,可注意力自己并不产生损失啊?没错。 损失来自整个模型的最终任务——比如语言模型的“预测下一个字对不对”。 注意力只是这条长链上的一环,损失的“反馈电流”会依次流过输出层、每一块 Transformer, 最后流进这三个矩阵。整条链路怎么被同一个损失一起训练,正是第 19 章“怎么学”那一节要拆开讲的事。
· Q、K、V 是三个不同的词吗? 不是。它们是同一个词乘三个矩阵得到的三副面孔。
· 只有 query 那个词在动吗? 不是。句子里每个词都会轮流当一次 query,各走一遍四步,得到自己的新向量,上面只演示了“它”。
· 注意力会改变词的个数吗? 不会。进去 n 个词,出来还是 n 个词,只是每个词的向量被“上下文增强”了。
· 权重是训练出来的参数吗? 不是。权重是每次当场用 Q·K 算的;被训练的是 WQ/WK/WV/WO 这几个矩阵。
· 每层共用同一套 W 吗? 不共用。堆 N 层 Block 就有 N 套矩阵;但每一套的更新公式都是 θ ← θ − lr·∇θ。
· self / cross 有啥区别? Q、K、V 都来自同一句话叫 self-attention;若 Q 来自一句、K/V 来自另一句(如翻译时解码器看编码器),就叫 cross-attention。
小结
- 一句话:注意力 = 按相关性给每个词做一次加权平均;全章都在回答“权重怎么算”。
- 每个词从输入算出三副面孔:Query(我找什么)、Key(我的标签)、Value(我的内容),来自三个可训练矩阵。
- 四步:先 x 乘 WQ/WK/WV 得 Q/K/V → 点积打分 → ÷√d → softmax → 对 V 加权求和。
- 手算例子:先投影出 Q/K/V,再让“它”把 0.93 的注意力给“猫”,新向量 ≈ 猫的 Value 方向。
- WQ/WK/WV/WO:每层各一套 d×d 参数;更新公式与 MNIST/FFN 相同,注意力权重本身不训练。
- 点积衡量“方向合不合拍”;causal mask 把未来位置分数设为 −∞;多头 = 几位专家各看一遍再拼接。
动手与思考
问题 1:用一句话说,注意力的输出到底是什么?
是对所有词的 Value 做的一次加权平均,权重代表“当前词和各个词有多相关”。相关性高的词,在这次平均里占的比重就大。
问题 2:为什么同一个词要拆成 Q、K、V 三个,而不是共用一个向量?
因为一个词在句子里同时扮演三种角色:主动去找别人(Query)、被别人匹配的标签(Key)、被选中后交出的内容(Value)。这三件事本就不同,所以用三个不同的矩阵,把同一个词投影成三副面孔。
问题 3:打分用 Query 和 Key 的点积,为什么点积能衡量“相关性”?
点积衡量两个向量方向合不合拍:方向越一致,点积越大,越相关;方向垂直,点积为 0;方向相反则为负。训练就是让“该相关的词”的 Q、K 方向凑到一起。
问题 4:causal mask 是怎么实现“看不到未来”的?为什么是设成 −∞,而不是把那格填 0?
在打分阶段(softmax 之前),把所有“未来位置”的分数设成负无穷。因为 softmax 要算 e分数,而 e−∞≈0,这些位置权重≈0,完全不参与加权求和,于是模型只能依赖当前位置及之前的词。不能填 0:e0=1 仍会贡献一份不小的权重,等于“还偷看了一点”,没屏蔽干净。代码里用一个极小的数(浮点最小值)代替真正的 −∞。
问题 5:训练一句 T 个词的话,是要一个位置一个位置跑 T 遍前向吗?causal mask 在这里起了什么作用?
不用跑 T 遍。整句一次性喂进去,一次前向就同时算出所有位置的预测。causal mask(那张下三角)保证第 t 个位置只吸收了前 t 个词的信息,它的预测天然等价于“看前 t 个词猜第 t+1 个”。于是一句话一次就造出 T 条互不串味的训练样本、T 份损失一起反传——这正是 teacher forcing 能高效并行的原因。
问题 6:WQ/WK/WV 是怎么“训练出来”的?它和那排注意力权重是一回事吗?
不是一回事。那排注意力权重([0.93, 0.01, …])是每次前向当场用 Q·K 算的,不是参数。真正被训练的是 WQ/WK/WV/WO 这几个矩阵——它们和普通权重一样,靠第 5、6 章的反向传播 + 梯度下降调:前向算损失,反传求梯度,再让每个矩阵元素“减去 学习率 × 梯度”。驱动力是模型最终任务的损失,模型为了降低它,自己学会了让“该相关的词”Q、K 方向凑近。
问题 7:第 1 层和第 2 层的 WQ 是同一套矩阵吗?更新方式一样吗?
不是同一套——每多堆一层 Block,就多一套独立的 query_weight_/key_weight_/value_weight_/output_weight_,初始化各自随机,训练中各自积累梯度。但更新方式完全一样:反向传播算出本层矩阵每个元素的梯度,再 新值 = 旧值 − 学习率 × 梯度,与 MNIST、FFN 无差别。
注意力是核心,但单靠它还不够。下一章我们给它配上残差连接、LayerNorm、前馈网络和位置编码, 拼出一块真正能堆叠的完整积木——Transformer Block。