Transformer的核心模块里,自注意力机制(Self-Attention)经常被初学者当成最难翻越的一道坎。公式里满是Q、K、V和softmax,向量维度稍不注意就晕。实际上,如果把每一步计算都想象成动画中的信息流动,它更像一个匹配、加权、汇总的过程。你不需要先记住所有矩阵形状,而应该先理解每个位置上的词是如何观察到整个序列的。接下来我们会从一个简短句子开始,把自注意力的内部计算一步步动态拆开,并配合可运行的最小代码验证理解。

一、先把自注意力看成一种动态查找过程
自注意力并不是某种神秘的神经网络结构,它的本质仍然是一组线性变换加上注意力权重计算。理解它的关键在于:序列中的每个词都会同时扮演三种角色,分别是查询、键和值。查询代表当前词想要获取的信息,键代表当前词被匹配时展示出来的索引特征,值则是真正被提取的内容。动画演示中经常把查询比作一次搜索请求,把键比作数据库里的索引,把值比作命中后返回的文档内容。区别在于,传统检索通常是离散的命中或未命中,而自注意力里的匹配程度是一个连续的相似度分数。
注意力计算的第一步,是让每个词根据自身表示分别生成查询向量Q、键向量K和值向量V。然后用Q与所有位置的K做点积,得到两两之间的相似度。当某个词与另一个词的键相似度很高时,它就会更倾向于从那个位置提取信息。动画里可以用词与词之间的连线粗细来表示这种关系强弱:线越粗,说明注意力权重越大,信息流动越明显。
和循环神经网络逐词顺序处理不同,自注意力是一次性把序列中所有位置的关系全部计算出来。这样既解决了长距离依赖路径过长的问题,也天然适合并行计算。动画视角下,它不是串行地读完前一个词再看后一个词,而是同时在所有词之间画出权重连线。例如在句子“猫追着球跑”中,“跑”这个动作可能同时关联到“猫”和“球”,自注意力可以并行地计算三组关系,而不必先经过“追”再传到“跑”。
二、用动画拆解Q、K、V的计算与加权求和
从矩阵运算的角度看,自注意力接收的输入是一个形状为[batch, seq_len, d_model]的向量矩阵。为了得到查询、键、值,需要使用三个可训练的权重矩阵W_Q、W_K、W_V分别对输入做线性投影。投影之后,Q、K、V拥有相同的序列长度,但通道维度可能不同。接着计算注意力分数矩阵:S = Q × K^T,这个矩阵的形状是[batch, seq_len, seq_len],第i行第j列代表第i个词对第j个词的原始关注分数。
得到分数矩阵后,需要除以一个缩放因子sqrt(d_k),其中d_k是键向量的维度。这个操作在动画里看起来只是数值缩放,但它对稳定训练非常重要。如果不做缩放,当d_k较大时,点积结果的方差会变得很大,softmax输出容易接近0或1,梯度就会变得极小。缩放之后,再按行做softmax归一化,每行的权重之和为1,得到注意力权重矩阵A。最终的输出就是A × V,也就是加权求和每个位置的值向量。下面是一段简化的NumPy实现:
import numpy as np
def scaled_dot_product_attention(Q, K, V, mask=None):
d_k = Q.shape[-1]
scores = np.matmul(Q, np.swapaxes(K, -1, -2)) / np.sqrt(d_k)
if mask is not None:
scores = np.where(mask == 0, -1e9, scores)
# 数值稳定处理:减去每行最大值再计算softmax
attn = np.exp(scores - np.max(scores, axis=-1, keepdims=True))
attn = attn / np.sum(attn, axis=-1, keepdims=True)
output = np.matmul(attn, V)
return output, attn
# 简化示例:batch=1, seq_len=3, d_model=4
X = np.random.randn(1, 3, 4)
W_Q = np.random.randn(4, 4)
W_K = np.random.randn(4, 4)
W_V = np.random.randn(4, 4)
Q = np.matmul(X, W_Q)
K = np.matmul(X, W_K)
V = np.matmul(X, W_V)
output, attn = scaled_dot_product_attention(Q, K, V)
print(attn.shape) # (1, 3, 3)
print(output.shape) # (1, 3, 4)
这段代码对应动画中的完整流程:先投影得到Q、K、V,再计算缩放点积,然后利用softmax把分数转成权重,最后根据权重聚合所有位置的值向量。代码里的attn矩阵可以绘制成热力图,热力图里颜色越亮的格子,表示当前词对对应位置的关注程度越高。如果把热力图和词与词之间的连线结合起来看,就能直观理解每个词的上下文信息是如何被重新组合出来的。
还有一个常被忽略的细节:softmax会把点积得分之间的差距放大。比如两个分数本来只是5和6的差别,经过softmax后权重差异可能变得非常明显。这种特性让模型可以更聚焦于少数高相关位置,但当训练不稳定时,也可能造成注意力过度集中。因此在完整Transformer中还会有残差连接、层归一化以及dropout等机制来缓解这些问题。动画里如果只看到某些词之间的连线特别粗,其他连线几乎看不见,多半就是对这一特性的直观体现。
三、从单头到多头:动画里的并行注意力通道
单头注意力只能学习到一种词与词之间的关系模式,可能偏向局部搭配,也可能偏向某种句法结构,但很难同时覆盖多种语义。Transformer对此给出的方案是多头注意力。简单说,就是把Q、K、V沿通道维度拆成若干份,每一份在独立的子空间里做一次注意力计算,最后再把多个子空间的输出拼接起来。动画演示时可以想象成并排摆放的多张注意力热力图,每一张图都代表一个头捕捉到的不同模式。
假设模型维度是512,使用8个注意力头,那么每个头的键和值维度就是64。每个头只在自己的64维子空间里做点积和加权求和,计算量总和与单头注意力相近,但表达能力显著增强。例如其中一个头可能更关注动词与主语之间的长距离连接,另一个头则聚焦于相邻形容词和名词。下面是一段多头拆分与合并的示例代码:
def split_heads(x, num_heads):
batch, seq_len, d_model = x.shape
depth = d_model // num_heads
x = x.reshape(batch, seq_len, num_heads, depth)
return np.transpose(x, (0, 2, 1, 3)) # (batch, heads, seq_len, depth)
def concat_heads(x, num_heads):
batch, num_heads, seq_len, depth = x.shape
x = np.transpose(x, (0, 2, 1, 3))
return x.reshape(batch, seq_len, num_heads * depth)
# 使用示例:输入形状 (batch, seq_len, d_model)
# split_heads(Q, 8) 将 Q 拆成 8 个注意力头
动画视角下,多头注意力相当于同时打开多个观察窗口。每个窗口看到的不是完整的512维向量,而是一个较小的64维子空间。不同窗口的投影矩阵不同,所以它们会关注输入的不同方面。把每个头的注意力矩阵绘制出来,经常能看到有的头主要关注自身的相邻词,有的头则跨越较长距离建立连接。正因为多头之间存在差异,模型才有能力同时编码局部语法和全局语义。
另外,在解码器中使用的掩码多头注意力也值得用动画表示。为了让模型在预测当前词时不能看到未来信息,注意力分数矩阵的上三角会被设置为一个极大的负数。softmax之后,这些位置的权重几乎为零。动画里可以表现为未来位置的连线被明显截断,仅保留当前词及其左侧位置的连接。理解这一点后,再去读Transformer解码器的代码会轻松很多。
四、最小可运行的前向传播与注意力可视化
把前面提到的步骤整合起来,就可以写一个极简的多头自注意力前向函数。它不需要依赖PyTorch或TensorFlow,只用NumPy就能运行,适合放在动画旁边做对照实验。下面这段代码将输入投影、多头拆分、注意力计算和结果合并串了起来:
import numpy as np
def attention_forward(X, d_k=8, num_heads=2):
batch, seq_len, d_model = X.shape
depth = d_k
# 简化投影:使用随机固定权重
W_Q = np.random.randn(d_model, d_model)
W_K = np.random.randn(d_model, d_model)
W_V = np.random.randn(d_model, d_model)
Q = np.matmul(X, W_Q)
K = np.matmul(X, W_K)
V = np.matmul(X, W_V)
def split(x):
x = x.reshape(batch, seq_len, num_heads, depth)
return np.transpose(x, (0, 2, 1, 3))
Q_h = split(Q)
K_h = split(K)
V_h = split(V)
scores = np.matmul(Q_h, np.swapaxes(K_h, -1, -2)) / np.sqrt(depth)
attn = np.exp(scores - np.max(scores, axis=-1, keepdims=True))
attn = attn / np.sum(attn, axis=-1, keepdims=True)
out_h = np.matmul(attn, V_h)
out = np.transpose(out_h, (0, 2, 1, 3)).reshape(batch, seq_len, num_heads * depth)
return out, attn
X = np.array([[[0.5, 0.1, 0.3, 0.2],
[0.2, 0.4, 0.1, 0.8],
[0.9, 0.3, 0.2, 0.1]]])
out, attn = attention_forward(X, d_k=2, num_heads=2)
print(attn.shape) # (1, 2, 3, 3)
print(out.shape) # (1, 3, 4)
运行这段代码后,attn的形状是(batch, num_heads, seq_len, seq_len),每一行代表一个位置对所有位置的注意力分布。你可以把attn[0,0]和attn[0,1]分别打印出来,观察不同注意力头在同一批词上的关注差异。进一步地,如果把attn中的数值映射成热力图,就能得到与动画演示一致的视觉结果。这种从代码到图像的对照,是理解自注意力机制非常有效的方式。
需要特别说明的是,注意力权重并不是人为设定的语法关系,也不是预先计算好的距离函数。它完全由输入向量经过点积和softmax动态生成。因此同一个词在不同上下文中,对其他词的注意力分布会显著不同。这也是为什么动画演示中连线会随着输入句子变化而变化,而不是固定地连接某些位置。理解这一层之后,就不容易把自注意力和传统依存句法分析混淆。
另一个常见误区是认为自注意力会直接输出语义表示,从而忽略它只是Transformer编码器中的一个环节。实际上,自注意力负责的是让序列中的每个位置都能按需聚合全局信息,而后续的前馈网络、残差连接和层归一化同样在起作用。动画可以帮你抓住主要信息流,但在工程实现和实际训练中,要把这些组件合在一起观察,才能形成完整的Transformer认知。
回头看公式Attention(Q,K,V)=softmax(QK^T/sqrt(d_k))V,它对应的是动画里的三次关键操作:相似度计算、权重归一化、值向量加权求和。只要把矩阵形状和每个步骤的动画画面联系在一起,原本抽象的表达就会变得非常直观。后续再学习位置编码、残差连接和完整编码器结构时,也可以继续沿用这种把公式拆成动态过程的方法。
Transformer自注意力机制动画演示修改时间:2026-10-06 04:48:23