☰
【Transformer开山之作】逐段精读《Attention is All You Need》:从Self-Attention到Multi-Head Attention的工程实现拆解
2026/10/8 12:26:17 网站建设 项目流程

1. 从论文公式到可运行代码:Self-Attention 到底在算什么

《Attention is All You Need》这篇论文最容易被误读的地方,是很多人把它当成一篇"数学论文"来读,公式推导看懂了,但一落到代码就卡住。我试过把论文第 3.2.1 节的 Scaled Dot-Product Attention 公式逐项翻译成 PyTorch,发现真正难的不是公式本身,而是矩阵维度在每一步怎么变、mask 加在哪、缩放因子为什么是 $\sqrt{d_k}$ 而不是别的。

先说清楚这篇论文解决的问题。2017 年之前,序列转换任务(机器翻译、语音识别、文本摘要)主流方案是 RNN/LSTM 加 encoder-decoder 结构。RNN 的致命伤是必须按时间步串行计算:算 $h_t$ 之前必须先算完 $h_{t-1}$,这导致训练时无法并行,序列一长内存就爆。论文提出的 Transformer 完全抛弃循环和卷积,只靠注意力机制捕捉依赖关系,在 WMT 2014 英德翻译任务上拿到 28.4 BLEU,比当时最好结果(含集成模型)还高 2 个点以上,训练只用了 8 块 GPU 跑 3.5 天。

这篇适合谁读?如果你已经能看懂 PyTorch 的nn.Linear和torch.matmul,但看到QK^T / sqrt(d_k)不知道维度怎么对齐,或者想搞清楚 Multi-Head 里 8 个头到底怎么切分、怎么拼回去,那这篇就是给你写的。我会把论文第 3 节的每个模块拆成可运行的代码片段,并且用 TaoToken 的统一 API 通道去实际调用模型,验证注意力权重的输出形状和数值范围,让"公式对不对"这件事有可观测的结果。

核心检索词先摆出来:Transformer 架构、Self-Attention 自注意力、Multi-Head Attention 多头注意力、Positional Encoding 位置编码。这四个概念是论文的骨架,也是后面所有大模型(GPT、BERT、T5)的公共底座。理解它们不是背公式,而是理解"为什么这样设计能并行、能捕捉长距离依赖"。

先看最核心的公式:

$$\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V$$

Q、K、V 三个矩阵的含义用一句话说清:Q 是"我想查什么",K 是"我有什么标签",V 是"我真正的内容"。计算过程分四步:Q 和 K 做点积得到相似度分数,除以 $\sqrt{d_k}$ 缩放,softmax 归一化成权重,最后用权重加权求和 V。

维度上,假设输入序列长度 $n=3$,模型维度 $d_{model}=512$,单头注意力里 $d_k=d_v=512$。那么 Q、K、V 都是 $3 \times 512$,$QK^T$ 是 $3 \times 3$,softmax 后还是 $3 \times 3$,最后乘 V 得到 $3 \times 512$。这个 $3 \times 3$ 的矩阵就是注意力权重图,第 $i$ 行第 $j$ 列表示第 $i$ 个 token 对第 $j$ 个 token 的关注程度。

为什么要缩放?论文原文说得很清楚:当 $d_k$ 很大时,点积结果量级会变得很大,把 softmax 推到梯度极小的区域。举个例子,如果 $d_k=512$,Q 和 K 每个元素方差为 1,那点积的方差就是 512,标准差约 22.6,softmax 输入动辄几十上百,输出几乎变成 one-hot,梯度接近 0,训练就废了。除以 $\sqrt{512} \approx 22.6$ 后,方差回到 1 附近,softmax 工作在正常区间。

下面这段代码把上面的推导完整实现,你可以直接复制运行:

import torch import torch.nn.functional as F def scaled_dot_product_attention(Q, K, V, mask=None): # Q, K, V: (batch, seq_len, d_k) d_k = Q.size(-1) scores = torch.matmul(Q, K.transpose(-2, -1)) / (d_k ** 0.5) if mask is not None: scores = scores.masked_fill(mask == 0, float('-inf')) attn_weights = F.softmax(scores, dim=-1) output = torch.matmul(attn_weights, V) return output, attn_weights # 模拟 "I Love AI" 三个 token,d_model=4 torch.manual_seed(42) X = torch.tensor([[[1., 0., 1., 0.], [0., 1., 0., 1.], [1., 1., 0., 0.]]]) # (1, 3, 4) W_q = torch.randn(4, 4) W_k = torch.randn(4, 4) W_v = torch.randn(4, 4) Q = X @ W_q K = X @ W_k V = X @ W_v out, weights = scaled_dot_product_attention(Q, K, V) print("注意力权重形状:", weights.shape) # (1, 3, 3) print("注意力权重:\n", weights) print("输出形状:", out.shape) # (1, 3, 4)

跑出来你会看到权重矩阵每行和为 1,这就是 softmax 的作用。输出形状和输入 X 一致,说明自注意力不改变序列长度和维度,这也是它能堆叠 6 层的原因。

2. Multi-Head Attention 的工程拆解与 TaoToken 前置准备

单头注意力有个问题:它只能学到一种"关注模式"。比如 "I Love AI" 里,"I" 可能同时需要关注 "AI"(语义关联)和关注自己的位置(语法角色),单头 softmax 会把这些需求平均掉。论文的解法是 Multi-Head:把 $d_{model}=512$ 切成 $h=8$ 份,每份 $d_k=d_v=64$,8 个头各自独立做注意力,最后拼接再投影。

论文原文的关键句是:"Multi-head attention allows the model to jointly attend to information from different representation subspaces at different positions." 翻译过来就是:多头让模型能在不同的表示子空间里同时关注不同位置的信息。单头做平均会抑制这种能力。

维度变化是这里最容易搞混的地方。输入 $X$ 是 $(n, 512)$,8 个头各自的投影矩阵 $W_i^Q, W_i^K, W_i^V$ 都是 $(512, 64)$。每个头算完得到 $(n, 64)$,8 个头拼起来是 $(n, 512)$,最后再乘输出投影 $W^O$($512 \times 512$)得到最终输出。总计算量和单头全维度注意力相近,因为每个头的维度缩小了 8 倍,但头数多了 8 倍。

import torch import torch.nn as nn class MultiHeadAttention(nn.Module): def __init__(self, d_model=512, num_heads=8): super().__init__() assert d_model % num_heads == 0 self.d_model = d_model self.num_heads = num_heads self.d_k = d_model // num_heads # 64 self.W_q = nn.Linear(d_model, d_model) self.W_k = nn.Linear(d_model, d_model) self.W_v = nn.Linear(d_model, d_model) self.W_o = nn.Linear(d_model, d_model) def forward(self, x, mask=None): batch, seq_len, _ = x.shape # 投影并拆成多头: (batch, seq, d_model) -> (batch, heads, seq, d_k) Q = self.W_q(x).view(batch, seq_len, self.num_heads, self.d_k).transpose(1, 2) K = self.W_k(x).view(batch, seq_len, self.num_heads, self.d_k).transpose(1, 2) V = self.W_v(x).view(batch, seq_len, self.num_heads, self.d_k).transpose(1, 2) scores = torch.matmul(Q, K.transpose(-2, -1)) / (self.d_k ** 0.5) if mask is not None: scores = scores.masked_fill(mask == 0, float('-inf')) attn = torch.softmax(scores, dim=-1) out = torch.matmul(attn, V) # (batch, heads, seq, d_k) # 拼接多头: (batch, heads, seq, d_k) -> (batch, seq, d_model) out = out.transpose(1, 2).contiguous().view(batch, seq_len, self.d_model) return self.W_o(out), attn mha = MultiHeadAttention(d_model=512, num_heads=8) x = torch.randn(2, 10, 512) out, attn = mha(x) print("输出:", out.shape) # (2, 10, 512) print("注意力:", attn.shape) # (2, 8, 10, 10)

注意transpose(1, 2)之后必须.contiguous()才能view,这是 PyTorch 里踩过无数次的坑。attn的形状是(batch, heads, seq, seq),8 个头各自有一张 $10 \times 10$ 的注意力图,这正是多头"看不同东西"的可视化入口。

要验证这些注意力权重在真实模型里长什么样,需要一个能稳定调用模型的通道。TaoToken 在这里的作用是统一 Key 和 API 入口,不用为每个模型单独配一套环境变量。前置准备只有三步:注册账号拿到 API Key、确认 Base URL、选定要调用的模型 ID。

Base URL 固定为https://taotoken.net/api,API Key 在控制台的 API Keys 页面生成。模型 ID 按你实际要验证的模型填,比如做注意力可视化可以用支持返回 logprobs 或 attention 的模型。这三件套(Base URL + Key + Model ID)是后面所有配置的基础,缺一不可。

如果你打算长期做这类论文复现和 Agent 实验,Coding Plan 会比按次调用更划算,具体在 https://taotoken.net/api-keys 里能看到 Key 管理入口,接入文档在 https://taotoken.net/doc 有完整的参数说明。

3. 可复制配置:用统一通道验证注意力输出

这一节给出可直接复制的配置片段。核心思路是:把 TaoToken 的 Base URL 和 Key 写进环境变量或配置文件,然后用 OpenAI 兼容的 SDK 调用,这样同一套代码可以切换不同模型来对比注意力行为。

先看环境变量方式,适合本地快速验证:

export TAOTOKEN_BASE_URL="https://taotoken.net/api" export TAOTOKEN_API_KEY="sk-你的Key"

然后是 Python 调用片段,用openaiSDK 指向 TaoToken 的 Base URL:

from openai import OpenAI import os client = OpenAI( base_url=os.environ["TAOTOKEN_BASE_URL"], api_key=os.environ["TAOTOKEN_API_KEY"], ) resp = client.chat.completions.create( model="你的模型ID", messages=[ {"role": "system", "content": "你是一个只输出 JSON 的助手。"}, {"role": "user", "content": "把句子 'I Love AI' 拆成 token 并返回 JSON 数组。"} ], temperature=0, ) print(resp.choices[0].message.content)

如果你用的是 Claude Code 这类工具,配置走的是settings.json,路径通常在~/.claude/settings.json,内容如下:

{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_API_KEY": "sk-你的Key", "ANTHROPIC_MODEL": "你的模型ID" } }

注意 Claude Code 用的是ANTHROPIC_BASE_URL而不是OPENAI_BASE_URL,这是两套不同的环境变量命名,混用会直接 401。三件套在这里体现为:Base URL 填https://taotoken.net/api,Key 填控制台生成的,Model ID 填你要用的模型标识。

如果你用 Cline 或带 MCP 的编辑器,配置通常写在cline_mcp_settings.json或对应的 MCP 配置文件里,结构类似:

{ "mcpServers": { "taotoken": { "command": "npx", "args": ["-y", "your-mcp-server"], "env": { "BASE_URL": "https://taotoken.net/api", "API_KEY": "sk-你的Key", "MODEL_ID": "你的模型ID" } } } }

Codex 用户如果走auth.json,路径一般在~/.codex/auth.json,字段是OPENAI_API_KEY和OPENAI_BASE_URL,同样填 TaoToken 的地址和 Key。

配置写完先别急着跑复杂任务,用一条最小请求验证连通性:

resp = client.chat.completions.create( model="你的模型ID", messages=[{"role": "user", "content": "回复 OK 两个字母"}], max_tokens=10, ) print(resp.choices[0].message.content)

返回OK说明 Base URL、Key、Model ID 三件套都对。这一步没过,后面所有注意力验证都是空谈。

4. 验证请求与成功结果:对照论文公式比对输出

配置通了之后,真正要做的是把论文公式的每一步和实际输出对上。我用的验证方法是:构造一个固定输入,让模型返回 token 化结果,然后本地用第 1 节的scaled_dot_product_attention函数算一遍,比对形状和数值范围。

先发一个请求拿到 token 序列:

resp = client.chat.completions.create( model="你的模型ID", messages=[{"role": "user", "content": "只输出 JSON,不要解释:把 'I Love AI' 按空格拆成数组"}], temperature=0, ) print(resp.choices[0].message.content) # 期望输出类似: ["I", "Love", "AI"]

拿到 3 个 token 后,本地构造随机 embedding(真实场景用模型的 embedding 层),跑第 1 节的注意力函数,观察三件事:

第一,注意力权重矩阵形状是 $(3, 3)$,每行和为 1。这对应论文公式里 softmax 沿最后一维归一化。如果某行和不是 1,说明 softmax 维度搞错了。

第二,缩放前后的分数对比。把d_k ** 0.5去掉再跑一次,你会看到未缩放的分数绝对值明显更大,softmax 后更接近 one-hot。这就是论文说的"大 $d_k$ 导致梯度消失"的直观体现。

第三,Multi-Head 的输出。用第 2 节的MultiHeadAttention,输入 $(2, 10, 512)$,输出attn形状 $(2, 8, 10, 10)$。8 个头里,你可以挑第 0 个头和第 1 个头分别打印,会发现它们的注意力分布不同——这正是多头"关注不同子空间"的证据。

成功结果的判断标准有三条:形状对得上($n \times n$ 权重、$n \times d$ 输出)、权重每行和为 1、多头之间分布有差异。三条都满足,说明你对论文第 3.2 节的理解和实现是一致的。

Positional Encoding 的验证稍微不同。论文用的是正弦余弦函数:

$$PE_{(pos,2i)} = \sin(pos/10000^{2i/d_{model}}), \quad PE_{(pos,2i+1)} = \cos(pos/10000^{2i/d_{model}})$$

import numpy as np def positional_encoding(seq_len, d_model): pe = np.zeros((seq_len, d_model)) for pos in range(seq_len): for i in range(0, d_model, 2): pe[pos, i] = np.sin(pos / (10000 ** (i / d_model))) pe[pos, i+1] = np.cos(pos / (10000 ** (i / d_model))) return pe pe = positional_encoding(10, 512) print("PE 形状:", pe.shape) # (10, 512) print("PE 数值范围:", pe.min(), pe.max()) # 约 -1 到 1

验证点:PE 形状和 embedding 一致才能相加,数值范围在 $[-1, 1]$ 之间。论文第 3.4 节提到 embedding 要乘以 $\sqrt{d_{model}}$,就是为了让 embedding 的量级和 PE 匹配,否则语义信息会被位置信息淹没。你可以把 embedding 乘和不乘 $\sqrt{512}$ 各跑一次,观察相加后数值量级的变化。

5. 本篇常见错排查:401、local proxy failed 与 reading choices

配置和验证过程中最容易撞上的几类报错,这里逐个对照。

401 Unauthorized:最常见的原因是 Key 没生效或 Base URL 写错。检查三件套:ANTHROPIC_BASE_URL或OPENAI_BASE_URL是否填了https://taotoken.net/api(注意结尾不要多加/v1,除非文档明确要求),Key 是否从控制台复制完整(前后不要有空格),Model ID 是否是当前账号可用的。Claude Code 里如果只填了ANTHROPIC_API_KEY没填ANTHROPIC_BASE_URL,会默认打到官方地址,同样 401。

local proxy failed / connection refused:这类报错通常出现在本地起了代理但没启动,或者环境变量里残留了旧的代理配置。检查HTTP_PROXY、HTTPS_PROXY、ALL_PROXY是否指向了一个没运行的端口。清掉这些变量再试:

unset HTTP_PROXY HTTPS_PROXY ALL_PROXY

reading 'choices' of undefined:这个报错说明返回体结构和你代码里取字段的路径不匹配。常见于把 OpenAI 格式的响应当成 Anthropic 格式解析,或者反过来。OpenAI 兼容接口返回的是resp.choices[0].message.content,Anthropic 原生格式返回的是resp.content[0].text。用 TaoToken 统一通道时,确认你用的 SDK 和 Base URL 对应的协议一致。

OAuth 相关报错:Claude Code 某些版本会走 OAuth 流程,如果配置里同时存在 OAuth token 和 API Key,可能冲突。排查方法是清掉 OAuth 缓存目录(通常在~/.claude/下),只保留settings.json里的 API Key 配置。

注意力权重形状不对:如果attn形状是 $(batch, seq, seq)$ 而不是 $(batch, heads, seq, seq)$,说明view和transpose的顺序写反了。正确顺序是先view(batch, seq, heads, d_k)再transpose(1, 2),得到 $(batch, heads, seq, d_k)$。

softmax 后权重和不为 1:检查dim参数。注意力权重是对最后一维(key 维度)归一化,所以是dim=-1。如果写成dim=-2,归一化方向就错了。

mask 位置搞反:decoder 的自注意力要遮住未来位置,mask 是上三角矩阵,masked_fill(mask == 0, -inf)把不该看的位置设成负无穷。如果 mask 写反,模型会"偷看"未来 token,训练 loss 异常低但推理效果差。

6. 从论文到工程:把注意力验证接入日常开发流

把上面的验证跑通之后,下一步是让它变成可复用的工具。我的做法是写一个attention_probe.py,输入任意句子,输出三样东西:token 序列、单头注意力权重矩阵、多头各头的权重对比。这样每次改模型或换配置,跑一遍就能确认注意力行为没跑偏。

调用模型做 token 化和语义验证时,走 TaoToken 的模型对话入口最直接:https://taotoken.net/api-keys 管理 Key,https://taotoken.net/doc 查接入参数。如果你要做的是长期编码或 Agent 类任务,Coding Plan 的额度模型更适合反复调用:https://taotoken.net/coding-plan。

实际工程里,注意力验证的价值不只是"论文复现"。当你微调模型、换位置编码方案、调整头数时,注意力权重的分布变化是最早能观测到的信号。比如把正弦位置编码换成可学习位置编码,注意力图在长序列上的分布会明显不同;把头数从 8 改成 16,每个头的关注范围会变窄。这些变化在 loss 曲线上可能要很久才体现,但在注意力权重上立刻可见。

最后一个实用技巧:把第 1 节的scaled_dot_product_attention和第 2 节的MultiHeadAttention存成一个模块,配上 pytest 写几个断言——权重行和为 1、输出形状等于输入形状、多头输出拼接后维度等于d_model。这样每次重构都有回归保护,比肉眼比对公式靠谱得多。

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

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

立即咨询