第 05 篇 · 第二部分 · 注意力和 Transformer
自注意力拆开看:带着问题去找答案
上一篇讲清了注意力机制为何出现。这一篇换个方向,把自注意力按零件拆开:Q、K、V 分别代表什么,那个 softmax 在计算什么,为什么公式里要乘一个看似突兀的 1/√d。拆开之后会发现,这套数学味很浓的公式背后,其实只是一个很朴素的流程——拿着问题,去所有地方找答案。
一、先看一个具体的问题
句子:小明把书放在了桌子上,然后他走了。
这里的"他"是谁?人看一眼就知道是小明。机器要回答这个问题,必须同时看见"他"和"小明",还要判断这两个词之间的关系比"他"和"桌子"更近。
早年的循环神经网络(RNN)是逐个词往前读的。读到"他"的时候,关于"小明"的信息早就被压缩进了一个固定长度的隐藏状态里,其中还夹杂着"书""桌子"这些内容,层层传递下来已经模糊不清。序列越长,前面的信息越容易被冲淡。
注意力机制的做法就直接得多:读到"他"这个位置时,让模型回过头把整句话每个位置都重新看一遍,然后按相关程度分配注意力。相关性高的位置给予更多关注,不相关的给予较少甚至不给予关注。"他"自然会把大部分注意力放在"小明"上。
二、先借用图书馆检索来理解
你在图书馆找资料,流程大致是三步:
- 你手里有一个查询(query)——你想知道的事;
- 每本书有一个标签(key)——它大概讲什么;
- 每本书有具体的内容(value)——真正有价值的部分。
拿查询和每本书的标签比一比相似度。相似度最高的那几本,其内容会被更多地引入答案;相似度越高,引入的内容越多。
自注意力就是把这一整套动作搬到了同一句话内部,而且每个词都同时发起一次检索。"他"发起一次,"小明"发起一次,"桌子"也发起一次,所有词一起查询、一起作答。
这里有个容易被忽略的关键点:查询、标签、内容这三样东西,没有一样是从外部输入的,全都是从句子自身计算出来的。所以它叫"自注意力"——自己对自己做注意力。
三、同一个词,三副面孔
输入是一个矩阵 X,每一行是一个词(严格说是 token)的向量表示。接下来用三个不同的权重矩阵去乘它:
同一个 X,乘上三套不同的参数,就得到了三套向量。可以这样理解它们的角色:
- Q(Query,查询):这个词在问什么、它想找什么;
- K(Key,键):这个词能对外提供什么样的"特征标签";
- V(Value,值):这个词实际携带的内容。
三个权重矩阵 W_Q、W_K、W_V 都是可训练参数。它们一开始是随机的,训练完之后,"小明"的 K 和"他"的 Q 之间会自然地对应上——这是模型自己学出来的,没有人手工指定过指代关系。
四、打分、归一化、加权求和
有了 Q、K、V,剩下的就是三步流水线。完整公式只有一行:
Attention(Q, K, V) = softmax( Q·KT / √dk ) · V拆开来看:
第一步:打分
Q·KT 得到的是一个 n×n 的矩阵(n 是序列长度)。第 i 行第 j 列的数字,表示第 i 个词对第 j 个词的关注程度。行是"谁在看",列是"看谁"。
第二步:归一化
分数是原始实数,可能很大也可能为负。softmax 把它按行转成一排概率:每行加起来正好等于 1,越相关的列分到的比例越大。这就是"注意力权重"。
第三步:加权求和
拿这排权重去乘 V。某个位置的权重是 0.8,它对应的内容就有八成被纳入结果;权重是 0.01,基本可以忽略。所以每个词的新表示,是全句所有词内容的一次加权平均,权重由它自己发起的这次检索决定。
整件事的可贵之处在于:从打分到加权,全程都是可微运算。意思是梯度能顺畅地传回去,模型能靠训练自动学会这套注意力分配。
五、那个 1/√d 究竟在防止什么
这是最令人费解的地方:为什么要除以一个维度的平方根?
原因与 softmax 的性质有关。Q·KT 里每个数是一对向量的点积,而点积是把 dk 个乘积加在一起。如果每个维度的值彼此独立、均值为 0、方差为 1,那么点积的方差大约是 dk。维度越高,点积的数值就越往两端发散,绝对值越来越大。
而 softmax 对大数值非常敏感:当输入里某个数特别大,它输出的分布就会极度尖锐——几乎全部集中到一个位置,其余全接近 0。这种分布的梯度很小,这部分参数几乎收不到有效的训练信号,难以学习。
除以 √dk,恰好把点积的方差调回到 1 附近,让分布保持在一个"有区分度但不极端"的区间。一句话:它是为了稳定 softmax 的数值表现,不是装饰性的数学技巧。
六、多头注意力:为什么需要多个视角
单头注意力在一次计算里只能产出一套权重分布。但一个词同时需要考虑的关系往往不止一种:
- "他"需要找它的指代对象;
- "走了"需要绑定它的主语;
- 修饰语需要找它修饰的中心词。
把这些需求集中在一套权重里,只能得到一种折中的平均结果。多头注意力的思路很直接:把 dmodel 维的空间切成 h 份,每一份独立做一次完整的注意力,最后把 h 个结果拼回去,再过一次线性层 W_O。
等于给了模型 h 个并行的观察窗口。每个头各自学习自己关心的那类关系,有的头专门关注指代关系,有的头关注语法结构,有的头关注固定搭配。这种分工不是人工设计的,是在训练中自然分化出来的。
注意总计算量并没有涨——每个头的维度变成了 dmodel/h,h 个加起来正好抵消。多头换来的是"视角的多样性",并不产生额外的算力开销。
七、代价:n 的平方
要算两两之间的关系,就必须完整填满一个 n×n 的矩阵。序列长度翻一倍,计算量和这个矩阵的显存占用大约变成原来的四倍。这是注意力机制无法回避的代价。
也正是这一代价,决定了后面许多工程做法的走向:为什么长上下文那么昂贵,为什么推理时要缓存 K 和 V(KV Cache),为什么 FlashAttention 这类实现值得反复优化。这些都留到后面的文章再展开。
八、一个必须记住的性质:它自己不知道顺序
注意力还有一个很反直觉的性质:如果把输入的词打乱顺序,输出也会随之被打乱,但每个位置拿到的数值集合不变。也就是说,纯粹的注意力对词序无感。
把"我打你"和"你打我"输入进去,仅靠这一层算术,模型无法区分两者。这显然不可取。所以必须在输入上额外加入位置信息——这就是下一篇文章要专门解决的问题。
九、骨架代码
把数学换成代码,核心其实只有几行。为了看清结构,这里用 numpy 手写一遍:
import numpy as np
def softmax(x, axis=-1):
x = x - x.max(axis=axis, keepdims=True) # 先减最大值,防指数溢出
e = np.exp(x)
return e / e.sum(axis=axis, keepdims=True)
def self_attention(X, W_q, W_k, W_v):
Q = X @ W_q # n × d_k
K = X @ W_k # n × d_k
V = X @ W_v # n × d_v
d_k = Q.shape[-1]
scores = Q @ K.T / np.sqrt(d_k) # n × n,两两打分
weights = softmax(scores, axis=-1) # 每行和为 1
out = weights @ V # 加权求和
return out, weights
真实实现里还要处理 batch 维度、加因果掩码(让模型看不到未来的词)等等,但骨架就是这几行。换个模型、换个任务,这套结构反复出现,只是参数量不同。
十、小结
自注意力不神秘。它把"这句话里谁该参考谁"这么一个模糊的问题,翻译成了一次可微、可并行的加权平均。
可微意味着它能被训练;可并行意味着它能在 GPU 上把整句一次性铺开算,不像 RNN 必须一步一步串着走。这两点,才是它取代老架构、成为主流架构的真正原因——不是因为它更"像大脑"。
参考资料
- Ashish Vaswani 等, Attention Is All You Need, NeurIPS 2017. arXiv:1706.03762(自注意力与多头注意力的原始出处)
- Dzmitry Bahdanau, Kyunghyun Cho, Yoshua Bengio, Neural Machine Translation by Jointly Learning to Align and Translate, 2014. arXiv:1409.0473(注意力机制的早期形态)
- Jay Alammar, The Illustrated Transformer. jalammar.github.io/illustrated-transformer(图解入门,适合对照阅读)
- Andrej Karpathy, Let's build GPT: from scratch, in code, spelled out. YouTube(跟着从零手写一遍注意力,理解最快)
- Tri Dao 等, FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness, 2022. arXiv:2205.14135(后文会提到的工程优化)