☰
经典论文译读:Attention Is All You Need 里的多头注意力与自注意力机制拆解
2026/10/15 17:58:27 网站建设 项目流程

1. 从论文原文出发:自注意力到底在算什么

《Attention Is All You Need》这篇 2017 年的 NeurIPS 论文,全篇没有用一句循环结构,却把 WMT 2014 英译德的 BLEU 从当时最好的 26 出头直接拉到 28.4。很多人第一次读它,卡点不在“注意力”这个概念本身,而在 3.2 节那几行公式:Q、K、V 到底从哪来,为什么要除以根号 d_k,多头又是怎么把 512 维拆成 8 份的。这篇就按原文 3.2 到 3.5 的顺序,逐段把自注意力(Self-Attention)和多头注意力(Multi-Head Attention)的计算流程拆开,配一份可以直接跑的 PyTorch 权重计算示例,再给一张逐层维度对照表,让你能对着公式一步步验证张量形状。

先说清楚它是什么、能做什么、适合谁。自注意力是一种让序列里每个位置都能直接“看到”其他所有位置、并按相关程度加权聚合信息的机制。它替代了 RNN 那种必须按时间步递推的方式,所以训练时可以整条序列并行计算。适合谁读:已经用过 Transformer 模型(比如 BERT、GPT 系列)但没细看过原始公式的开发者;想自己手写一遍注意力层、搞懂维度变换的工程同学;以及准备面试、需要把 Q/K/V 讲清楚的人。你不需要先精通线性代数,只要会看矩阵乘法、知道 softmax 是把一组数变成概率分布就够了。

原文 3.2 节开头给了一个很朴素的定义:注意力函数是把一个 query 和一组 key-value 对映射成输出的过程,输出是所有 value 的加权求和,权重由 query 和对应 key 的匹配程度决定。这句话是整篇论文的骨架。放到自注意力里,“自己注意自己”意味着 Q、K、V 三个矩阵全部来自同一个输入序列,只是各自乘了不同的可学习投影矩阵。这就是它和编码器-解码器注意力(Q 来自解码器、K/V 来自编码器)的唯一区别。

我建议你读原文时先跳过 3.1 的堆叠结构,直接进 3.2.1。因为 3.1 讲的残差、LayerNorm、N=6 这些是工程细节,而 3.2 才是机制核心。等你把单头注意力的矩阵形状推明白了,回头看 3.1 的LayerNorm(x + Sublayer(x))会顺很多。下面先讲清楚前置的计算环境,再进公式。

2. 前置准备:把注意力计算跑起来需要什么

要跟着这篇验证张量形状,你只需要一个能跑 PyTorch 的环境。原文用的是 TensorFlow(tensor2tensor 仓库),但公式是框架无关的,我用 PyTorch 写示例,因为它的张量形状打印更直观,torch.Size([...])一眼就能看出维度对不对。装好torch和numpy即可,CPU 版本足够,注意力层的矩阵乘法规模很小,不需要 GPU。

如果你还想顺手验证一下真实模型里的注意力行为,可以准备一个能调用大模型接口的 Key。我平时调试这类“公式对不对”的问题时,会用一个兼容 OpenAI 协议的中转服务来快速跑通请求,省去自己搭推理环境的时间。TaoToken 的 API 地址是https://taotoken.net/api,控制台在https://taotoken.net/console,API Key 在https://taotoken.net/api-keys生成。它的接口格式和 OpenAI 一致,所以你可以用同一套openaiPython SDK 去请求,把注意力层的输出和模型实际行为对照着看。

这里要强调一点:读论文和调接口是两件事。论文里的公式是数学定义,接口返回的是模型推理结果。你可以用接口去验证“多头注意力确实让不同头关注不同位置”这个结论,但公式推导必须自己在本地跑一遍张量。我试过直接看别人博客里的维度图,当时觉得懂了,结果自己写代码时transpose和reshape的顺序一错,形状全乱。所以下面第 3 节会给一份完整可复制的配置和代码,你照着敲一遍,比看十张图都管用。

环境准备清单:Python 3.9+、pip install torch numpy。如果你要用接口做对照实验,再pip install openai。不需要装 transformers 库,因为我们要手写注意力,用现成的反而遮住了细节。原文的d_model=512、h=8、d_k=d_v=64这几个数,下面会反复用到,先记住它们的关系:d_k = d_model / h = 512 / 8 = 64,d_v同样等于 64,而多头拼接后的总维度h * d_v = 8 * 64 = 512,正好回到d_model,这样才能接残差连接。

3. 可复制配置:手写缩放点积注意力与多头注意力

原文公式 (1) 是缩放点积注意力:

Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) V

我把它拆成四步,每步都打印形状。先定义超参数,写成一份可以直接复制的配置。原文基础模型(base)的参数是:d_model=512、h=8、d_k=d_v=64、d_ff=2048、N=6、P_drop=0.1。下面这份 JSON 就是这些参数的对照,你可以存成transformer_base.json,后面代码直接读:

{ "d_model": 512, "h": 8, "d_k": 64, "d_v": 64, "d_ff": 2048, "N_encoder_layers": 6, "N_decoder_layers": 6, "P_drop": 0.1, "warmup_steps": 4000, "beta1": 0.9, "beta2": 0.98, "epsilon": 1e-9, "label_smoothing": 0.1 }

注意d_k和d_v不是独立超参,它们由d_model / h决定。原文 3.2.2 明确写了“每个注意力头设定 d_k = d_v = d_model / h = 64”。所以如果你改h,d_k要跟着变,否则拼接后维度对不上W^O。这是新手最容易踩的坑之一。

下面是单头缩放点积注意力的完整实现,带形状注释:

import torch import torch.nn.functional as F import math def scaled_dot_product_attention(Q, K, V, mask=None): # Q: (batch, seq_len_q, d_k) # K: (batch, seq_len_k, d_k) # V: (batch, seq_len_k, d_v) d_k = Q.size(-1) # 第一步:Q 乘 K 的转置,得到匹配分数 scores = torch.matmul(Q, K.transpose(-2, -1)) # (batch, seq_len_q, seq_len_k) # 第二步:除以 sqrt(d_k),原文的缩放因子 scores = scores / math.sqrt(d_k) # 第三步:可选 mask,解码器自注意力用,把非法位置设为 -inf if mask is not None: scores = scores.masked_fill(mask == 0, float('-inf')) # 第四步:softmax 得到权重,再对 V 加权求和 attn_weights = F.softmax(scores, dim=-1) # (batch, seq_len_q, seq_len_k) output = torch.matmul(attn_weights, V) # (batch, seq_len_q, d_v) return output, attn_weights

跑一个具体例子,用batch=2、seq_len=4、d_k=64:

torch.manual_seed(0) batch, seq_len, d_k, d_v = 2, 4, 64, 64 Q = torch.randn(batch, seq_len, d_k) K = torch.randn(batch, seq_len, d_k) V = torch.randn(batch, seq_len, d_v) out, weights = scaled_dot_product_attention(Q, K, V) print("scores 形状:", weights.shape) # torch.Size([2, 4, 4]) print("输出形状:", out.shape) # torch.Size([2, 4, 64]) print("每行权重和:", weights.sum(dim=-1)) # 全为 1.0

weights的形状是(batch, seq_len_q, seq_len_k),也就是每个 query 位置对所有 key 位置的权重分布,每行加起来等于 1。这就是原文说的“对所有 value 的加权求和”。out的形状回到(batch, seq_len, d_v),和 V 的最后一维一致。

接下来是多头注意力。原文 3.2.2 的做法是:不直接用 512 维做一次注意力,而是把 Q、K、V 各自通过 8 组不同的线性投影降到 64 维,并行做 8 次注意力,再把 8 个 64 维输出拼接成 512 维,最后过一层W^O投影。实现上通常不写 8 个循环,而是把 512 维 reshape 成 8 个头:

class MultiHeadAttention(torch.nn.Module): def __init__(self, d_model=512, h=8): super().__init__() self.d_model = d_model self.h = h self.d_k = d_model // h # 64 self.d_v = d_model // h # 64 self.W_q = torch.nn.Linear(d_model, d_model) self.W_k = torch.nn.Linear(d_model, d_model) self.W_v = torch.nn.Linear(d_model, d_model) self.W_o = torch.nn.Linear(d_model, d_model) def forward(self, query, key, value, mask=None): batch = query.size(0) # 线性投影后拆头:(batch, seq, d_model) -> (batch, h, seq, d_k) Q = self.W_q(query).view(batch, -1, self.h, self.d_k).transpose(1, 2) K = self.W_k(key).view(batch, -1, self.h, self.d_k).transpose(1, 2) V = self.W_v(value).view(batch, -1, self.h, self.d_v).transpose(1, 2) # 每个头独立做缩放点积注意力 out, attn = scaled_dot_product_attention(Q, K, V, mask) # 拼头:(batch, h, seq, d_v) -> (batch, seq, d_model) out = out.transpose(1, 2).contiguous().view(batch, -1, self.d_model) return self.W_o(out), attn

关键在view(batch, -1, self.h, self.d_k).transpose(1, 2)这一行。view把最后一维 512 切成(8, 64),transpose(1, 2)把头的维度提到前面,变成(batch, h, seq, d_k),这样matmul时每个头独立计算,互不干扰。算完再transpose回来、contiguous().view拼成 512 维。contiguous()不能省,否则view会报错,这是实际写代码时的高频报错点。

4. 验证请求:逐层维度对照与成功结果

光看代码不够,得把每一步的张量形状和原文公式对上。下面这张表是我实测打印出来的,你可以照着核对自己的输出。输入设定batch=2、seq_len=4、d_model=512、h=8:

步骤操作张量形状对应原文
输入词嵌入 + 位置编码(2, 4, 512)3.4 / 3.5
Q 投影query @ W_q(2, 4, 512)3.2.2
拆头view + transpose(2, 8, 4, 64)d_k = 512/8
分数Q @ K^T(2, 8, 4, 4)QK^T
缩放/ sqrt(64) = /8(2, 8, 4, 4)公式 (1)
权重softmax(dim=-1)(2, 8, 4, 4)公式 (1)
单头输出权重 @ V(2, 8, 4, 64)公式 (1)
拼头transpose + view(2, 4, 512)Concat
输出投影@ W_o(2, 4, 512)W^O

跑一遍完整的多头注意力,验证形状:

mha = MultiHeadAttention(d_model=512, h=8) x = torch.randn(2, 4, 512) out, attn = mha(x, x, x) print("多头输出:", out.shape) # torch.Size([2, 4, 512]) print("注意力权重:", attn.shape) # torch.Size([2, 8, 4, 4])

out回到(2, 4, 512),和输入同形,这样才能做残差LayerNorm(x + Sublayer(x))。attn是(2, 8, 4, 4),8 个头各有一张 4×4 的注意力图,这正是原文说的“不同头关注不同表示子空间”的可视化入口。

如果你想用接口对照验证“多头确实学到不同模式”,可以发一个请求,让模型解释一段文本里哪些词互相关联,再和你本地算出的attn权重分布对比。请求示例(用 OpenAI 兼容格式):

from openai import OpenAI client = OpenAI( api_key="你的 TaoToken Key", base_url="https://taotoken.net/api" ) resp = client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": "用一句话解释自注意力里 Q、K、V 的关系"}] ) print(resp.choices[0].message.content)

成功返回的标志是resp.choices[0].message.content里有正常文本,且resp.usage里能看到 token 计数。如果返回 401,说明 Key 没配对;如果报local proxy failed,检查你的网络环境是否把base_url拦了。注意这里只是拿接口做语义对照,公式验证还是靠本地张量。

再补一个原文 3.2.1 的缩放动机验证。原文说d_k大时点积数值会很大,把 softmax 推进梯度极小的区域。你可以实测:把d_k从 64 改成 512,不除sqrt(d_k),看 softmax 输出的分布。

for dk in [64, 512]: q = torch.randn(1, 1, dk) k = torch.randn(1, 8, dk) raw = torch.matmul(q, k.transpose(-2, -1)) scaled = raw / math.sqrt(dk) print(f"d_k={dk} 未缩放方差={raw.var().item():.2f} 缩放后方差={scaled.var().item():.2f}")

你会看到d_k=512时未缩放的方差远大于 1,softmax 后几乎变成 one-hot,梯度接近 0。除以sqrt(d_k)后方差被拉回 1 附近,分布更平滑。这就是原文那句“为了抵消这一影响,通过 1/sqrt(d_k) 对点积进行缩放”的实测依据。

5. 本篇常见错排查:形状、掩码与报错对照

读这篇论文时,报错基本集中在三类:张量形状对不上、掩码方向搞反、以及接口调用时的认证问题。下面按真实报错逐条对照。

第一类,RuntimeError: shape '[2, 4, 8, 64]' is invalid for input of size 4096。这通常是你view的顺序写错了。(2, 4, 512)的元素总数是 4096,view(2, 4, 8, 64)也是 4096,本身没问题;但如果你写成view(2, 8, 4, 64)再transpose,就会和后续matmul的维度冲突。正确顺序是view(batch, seq_len, h, d_k)再transpose(1, 2),先拆头再换位。记住:view只改变形状解读,不改变内存排列,所以拆头时h和d_k必须相邻且顺序正确。

第二类,RuntimeError: view size is not compatible with input tensor's size and stride。这是漏了contiguous()。transpose之后张量在内存里不连续,直接view会失败。在拼头那一步写成out.transpose(1, 2).contiguous().view(batch, -1, d_model)即可。这个报错在多头注意力实现里出现频率极高,几乎每个手写一遍的人都会遇到。

第三类,解码器掩码方向反了,导致模型“看到未来”。原文 3.2.3 说,为了防止信息向左流动,把 softmax 输入里非法连接的值设为负无穷。掩码应该是一个上三角为 0、其余为 1 的矩阵(对(seq_len, seq_len)而言),masked_fill(mask == 0, -inf)。如果你把方向搞反,训练 loss 会异常低但推理崩掉,因为模型在训练时偷看了答案。验证方法:打印attn_weights,看第一行是不是只有第一个位置有权重,第二行只有前两个位置有权重。

第四类,接口侧报错。401 Unauthorized说明 API Key 无效或没带上,检查api_key是否从https://taotoken.net/api-keys正确复制,注意别把前后空格带进去。local proxy failed通常是本地网络配置把请求拦了,检查base_url是否写成了https://taotoken.net/api,不要多加路径。Error reading choices一般出现在流式响应里,如果你用了stream=True但按非流式解析,就会读不到choices,改成stream=False或按 SSE 格式逐块解析即可。OAuth 相关报错多见于 Claude Code 这类工具接入时,如果你是用 Claude Code 连,需要在配置里同时写全三件套:Base URL、API Key、Model ID,缺一个都会认证失败。

第五类,d_k和h不匹配。原文规定d_k = d_model / h,如果你设d_model=512、h=8但d_k手写成 128,拼头后总维度变成 1024,接W_o(期望 512 输入)时就会报维度错误。永远让d_k由d_model // h算出来,不要硬编码。

6. 继续深入:从公式到可复用的注意力层

把 3.2 节吃透之后,你会发现原文后面几节其实都在为这个核心机制服务。3.3 的前馈网络FFN(x) = max(0, xW_1 + b_1)W_2 + b_2是逐位置独立应用的,d_ff=2048先把 512 升维再降回 512,给注意力层补充非线性变换能力。3.5 的位置编码用正弦余弦函数,是因为自注意力本身对位置无感——你把序列打乱,注意力算出来的权重分布会跟着乱,但机制本身不知道顺序,所以必须显式注入位置信息。原文选择正弦形式而非学习式嵌入,理由是“可能使模型推断到比训练期间更长的序列长度”,表 3 的 (E) 行也验证了两者结果几乎相同。

如果你想把这套注意力层用到自己的项目里,建议按这个顺序推进:先跑通单头,确认(batch, seq, d_k)进、(batch, seq, d_v)出;再加多头,确认拼头后回到d_model;最后加掩码,确认解码器不会看到未来。每一步都打印形状,别跳步。我踩过的坑就是一次性把多头、掩码、残差全写上,结果报错时根本不知道是哪一层的问题。

对于需要长期做编码或 Agent 开发的场景,反复手写注意力层不现实,可以用现成的推理服务来验证想法。TaoToken 的 Coding Plan 适合这种持续性的编码任务,模型对话入口在https://taotoken.net/models,接入文档在https://taotoken.net/doc,Claude Code 的接入说明在https://taotoken.net/claude-code。这些入口能帮你把“公式验证”和“实际推理”两条线分开:公式在本地跑,推理走接口,互不干扰。

最后留一个可操作的练习:把本文第 3 节的多头注意力代码复制下来,把h从 8 改成 1,再改成 16,观察attn权重的形状变化和输出质量差异。原文表 3 的 (A) 行说单头比最佳设置低 0.9 BLEU,头太多也会下降。你自己跑一遍,就能理解为什么“多头”不是越多越好,而是要在d_k和信息多样性之间找平衡。这个实验不需要 GPU,CPU 几秒就能出结果,是读懂这篇论文最划算的一步。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询