1. 从论文公式到可运行代码:Transformer 自注意力机制到底怎么落地
2017 年那篇《Attention Is All You Need》我第一次读的时候,卡在 3.2.1 节整整一个下午。公式Attention(Q,K,V) = softmax(QK^T/√d_k)V看着就一行,但真动手写的时候,Q、K、V 从哪来、维度怎么对齐、mask 加在哪一步、为什么除以 √d_k 而不是 d_k,全是坑。这篇笔记就按论文逐节走一遍,把每个公式手写推导,再配一份能直接跑的 PyTorch 代码,帮你从公式到代码完整走通。
Transformer 是什么?一句话:它是第一个完全靠注意力机制做序列转换的模型,把 RNN 的循环和 CNN 的卷积全扔了,编码器和解码器各堆 6 层,每层就是多头自注意力加逐位置前馈网络。它能做什么?机器翻译、文本摘要、语言建模,后来所有大模型的底座都是它。适合谁?刚读完论文想动手复现的、面试被问自注意力细节答不利索的、想搞懂多头注意力到底在并行什么的同学。
我试过直接抄 GitHub 上的实现,结果维度对不上、mask 广播报错、softmax 梯度消失,折腾两天才跑通。所以这篇不堆概念,每个公式后面都跟一段可复制代码,你跟着敲一遍,比看十遍论文都管用。下面从论文的问题背景讲起,再到 TaoToken 的前置准备,然后是完整配置、验证请求、报错排查,最后给一个语义一致的收尾。
2. 论文问题背景与 TaoToken 前置准备:为什么自注意力能替代循环
论文第 1、2 节讲得很清楚:RNN 类模型沿序列位置逐步计算,隐藏状态 h_t 依赖 h_{t-1},这种固有的顺序性阻断了训练样本内的并行化。序列一长,内存约束就限制 batch 大小,训练慢得离谱。ConvS2S 和 ByteNet 用卷积减少顺序计算,但关联两个任意位置信号所需的操作数随距离增长——ConvS2S 是线性增长,ByteNet 是对数增长,学远距离依赖依然困难。
Transformer 的做法是把任意两个位置之间的操作数降到常数级。代价是平均注意力加权位置降低了有效分辨率,论文用多头注意力来抵消这个影响。这就是 3.2.2 节多头注意力的动机:不是花哨,是刚需。
在动手写代码之前,我建议先把环境和一个稳定的模型调用入口准备好,方便你边写边验证。我平时用 TaoToken 做模型对话和 API 调试,它的接入文档写得很清楚,Base URL 和 Key 的配置方式对新手友好。你可以先到官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 看一下整体介绍,然后到 API Keys 页面 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 生成一个 Key。如果你只是想先跑通对话验证公式理解,可以直接用模型对话页面 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite 试一下。
这里要强调一点:TaoToken 是合规的模型调用入口,不是让你去搞什么灰色中转。它的 API 地址是 https://taotoken.net/api,配置的时候 Base URL 填这个就行。我实测下来,把注意力模块的维度打印出来对照论文,再用模型对话问几个关于 QK^T 维度的问题,理解会快很多。
前置准备清单:Python 3.9+、PyTorch 2.0+、numpy。装好之后先跑一个最小验证,确认 torch 能正常 import,然后我们进入 3.1 节的编码器解码器结构。
3. 可复制配置:编码器解码器栈与多头注意力模块
论文 3.1 节说编码器由 N=6 个相同层堆叠,每层两个子层:多头自注意力、逐位置全连接前馈网络。每个子层周围用残差连接再做层归一化,输出是LayerNorm(x + Sublayer(x))。为了残差能相加,所有子层和嵌入层输出维度都是 d_model=512。解码器也是 6 层,但每层多一个第三子层,对编码器栈输出做多头注意力,并且自注意力子层要加因果 mask,防止位置 i 关注到 i 之后的位置。
先写一个可复制的配置片段,把超参数固定下来,后面所有代码都引用它。我用 JSON 格式,路径和字段名保持和论文一致:
{ "d_model": 512, "num_heads": 8, "d_k": 64, "d_v": 64, "d_ff": 2048, "num_layers": 6, "dropout": 0.1, "max_seq_length": 5000, "warmup_steps": 4000, "label_smoothing": 0.1 }注意 d_k = d_v = d_model / num_heads = 512 / 8 = 64,这是论文 3.2.2 节的设置。多头注意力把 d_model 切成 8 个头,每个头独立算缩放点积注意力,最后拼接再过一个 W^O 投影。
下面是缩放点积注意力的完整实现,对应公式 (1):
import torch import torch.nn as nn import torch.nn.functional as F import math def scaled_dot_product_attention(Q, K, V, mask=None): """ 论文公式 (1): Attention(Q,K,V) = softmax(QK^T / sqrt(d_k)) V Q: (batch, seq_len, d_k) K: (batch, seq_len, d_k) V: (batch, seq_len, d_v) """ d_k = Q.size(-1) # 步骤1: QK^T,得到 (batch, seq_len, seq_len) 的相关性矩阵 scores = torch.matmul(Q, K.transpose(-2, -1)) # 步骤2: 除以 sqrt(d_k),防止 softmax 进入饱和区 scores = scores / math.sqrt(d_k) # 步骤3: 加 mask,被屏蔽位置设 -1e9 if mask is not None: scores = scores.masked_fill(mask == 0, -1e9) # 步骤4: softmax 归一化 attention_weights = F.softmax(scores, dim=-1) # 步骤5: 加权求和 output = torch.matmul(attention_weights, V) return output, attention_weights为什么除以 √d_k?论文 3.2.1 节解释:当 d_k 较大时,点积在幅度上变大,softmax 会被推入梯度极小的区域。假设 Q、K 各分量独立、均值 0 方差 1,点积 QK^T 的方差就是 d_k,除以 √d_k 把方差拉回 1。你可以自己验证:把 d_k 设成 512 不缩放,softmax 输出会接近 one-hot,梯度几乎为 0。
接下来是多头注意力,对应公式MultiHead(Q,K,V) = Concat(head_1,...,head_h)W^O,其中head_i = Attention(QW_i^Q, KW_i^K, VW_i^V):
class MultiHeadAttention(nn.Module): def __init__(self, d_model=512, num_heads=8, dropout=0.1): 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 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) self.dropout = nn.Dropout(dropout) def forward(self, query, key, value, mask=None): batch_size, seq_len = query.size(0), query.size(1) # 线性投影 Q = self.W_q(query) K = self.W_k(key) V = self.W_v(value) # 拆成多头: (batch, seq_len, d_model) -> (batch, num_heads, seq_len, d_k) Q = Q.view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2) K = K.view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2) V = V.view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2) if mask is not None: mask = mask.unsqueeze(1).repeat(1, self.num_heads, 1, 1) # 每个头并行算缩放点积注意力 out, attn = scaled_dot_product_attention(Q, K, V, mask) # 拼接多头: (batch, num_heads, seq_len, d_k) -> (batch, seq_len, d_model) out = out.transpose(1, 2).contiguous().view(batch_size, seq_len, self.d_model) # 输出投影 W^O out = self.W_o(out) return out, attn这里有个容易踩的坑:view之后必须transpose,拼接回来的时候要先transpose再contiguous().view,否则维度会乱。我第一次写的时候忘了 contiguous,报错 "view size is not compatible",查了半天。
逐位置前馈网络对应公式 (2)FFN(x) = max(0, xW_1 + b_1)W_2 + b_2,就是两个线性层中间夹 ReLU,内层维度 d_ff=2048:
class PositionwiseFeedForward(nn.Module): def __init__(self, d_model=512, d_ff=2048, dropout=0.1): super().__init__() self.linear1 = nn.Linear(d_model, d_ff) self.linear2 = nn.Linear(d_ff, d_model) self.dropout = nn.Dropout(dropout) def forward(self, x): return self.linear2(self.dropout(F.relu(self.linear1(x))))位置编码对应 3.5 节公式PE(pos,2i) = sin(pos/10000^(2i/d_model))和PE(pos,2i+1) = cos(pos/10000^(2i/d_model)):
class PositionalEncoding(nn.Module): def __init__(self, d_model=512, max_seq_length=5000, dropout=0.1): super().__init__() self.dropout = nn.Dropout(dropout) pe = torch.zeros(max_seq_length, d_model) position = torch.arange(0, max_seq_length, dtype=torch.float).unsqueeze(1) div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) pe[:, 0::2] = torch.sin(position * div_term) pe[:, 1::2] = torch.cos(position * div_term) pe = pe.unsqueeze(0) self.register_buffer('pe', pe) def forward(self, x): return self.dropout(x + self.pe[:, :x.size(1), :])把这些模块拼成编码器层和解码器层,就得到完整的 Transformer。解码器自注意力要加因果 mask,用torch.tril生成下三角矩阵:
def create_causal_mask(seq_len, device): mask = torch.tril(torch.ones(seq_len, seq_len, device=device)) return mask.unsqueeze(0).unsqueeze(0)到这里,论文 3.1 到 3.5 节的核心公式全部落地。你可以把上面的代码存成一个transformer_blocks.py,下一步我们跑验证。
4. 验证请求与成功结果:逐行打印维度确认公式正确
写完代码不验证等于没写。我习惯用固定随机种子,把每一步的维度打印出来,对照论文的 d_model=512、num_heads=8、d_k=64 逐个核对。
if __name__ == "__main__": torch.manual_seed(42) batch_size, seq_len, d_model = 2, 10, 512 num_heads = 8 # 构造输入 x = torch.randn(batch_size, seq_len, d_model) print(f"输入维度: {x.shape}") # (2, 10, 512) # 位置编码 pos_enc = PositionalEncoding(d_model) x = pos_enc(x) print(f"位置编码后: {x.shape}") # (2, 10, 512) # 多头自注意力 mha = MultiHeadAttention(d_model, num_heads) out, attn = mha(x, x, x) print(f"多头注意力输出: {out.shape}") # (2, 10, 512) print(f"注意力权重: {attn.shape}") # (2, 8, 10, 10) # 前馈网络 ffn = PositionwiseFeedForward(d_model) out = ffn(out) print(f"FFN输出: {out.shape}") # (2, 10, 512) # 因果 mask 验证 causal_mask = create_causal_mask(seq_len, x.device) out_masked, attn_masked = mha(x, x, x, mask=causal_mask) print(f"因果mask后注意力权重[0,0]:\n{attn_masked[0,0].round(decimals=3)}")跑出来应该看到:输入 (2,10,512),位置编码后不变,多头注意力输出 (2,10,512),注意力权重 (2,8,10,10)——8 个头,每个头一个 10x10 的相关性矩阵。因果 mask 后,注意力权重矩阵应该是下三角,上三角全为 0。如果你看到上三角有非零值,说明 mask 没生效,检查masked_fill的条件是不是写反了。
再验证一下缩放的作用。把scores / math.sqrt(d_k)去掉,打印 softmax 后的最大值:
Q = torch.randn(1, 10, 512) K = torch.randn(1, 10, 512) scores_no_scale = torch.matmul(Q, K.transpose(-2, -1)) scores_scaled = scores_no_scale / math.sqrt(512) print(f"不缩放 softmax 最大值: {F.softmax(scores_no_scale, dim=-1).max().item():.4f}") print(f"缩放后 softmax 最大值: {F.softmax(scores_scaled, dim=-1).max().item():.4f}")不缩放时 softmax 最大值会接近 1,分布极度尖锐;缩放后会平缓很多。这就是论文说的"推入梯度极小区域"的直观体现。
如果你想用模型对话快速验证对某个公式的理解,可以到 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite 把 QK^T 的维度问题丢进去问,比翻论文快。API 调试的话,Base URL 填 https://taotoken.net/api,Key 用你在 API Keys 页面生成的,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 有完整示例。
5. 本篇常见错排查:401、维度不匹配、mask 广播失败
复现过程中我踩过的坑基本集中在这几类,对照着排查能省不少时间。
报错一:401 Unauthorized / invalid api key。这个一般出现在你调 TaoToken API 验证的时候。检查三件套:Base URL 是不是https://taotoken.net/api,Key 是不是从 API Keys 页面复制的完整字符串,Model ID 是不是填对了。三者缺一不可。如果还报 401,去 console 页面 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite 看一下 Key 的状态是不是被禁用或过期。
报错二:RuntimeError: The size of tensor a (10) must match the size of tensor b (8)。这是多头注意力里 mask 广播失败。mask 原始形状是 (batch, seq_len, seq_len),但 QK^T 是 (batch, num_heads, seq_len, seq_len),需要在 num_heads 维度上扩展。正确写法是mask.unsqueeze(1).repeat(1, self.num_heads, 1, 1)。如果你忘了这一步,就会报维度不匹配。
报错三:view size is not compatible with input tensor's size and stride。这是拼接多头输出时忘了contiguous()。transpose之后张量在内存里不连续,直接view会报错。正确顺序是out.transpose(1, 2).contiguous().view(batch_size, seq_len, d_model)。
报错四:local proxy failed / connection refused。这个通常是网络配置问题,检查你的 API 地址有没有写错,或者本地有没有奇怪的代理设置干扰。TaoToken 的 API 地址就是https://taotoken.net/api,不要加多余路径。
报错五:reading choices 相关解析错误。如果你在解析模型返回的 JSON 时遇到KeyError: 'choices',先打印原始 response 看看结构。不同接口返回格式可能不同,别硬套一个模板。
报错六:OAuth 相关错误。如果你用的是 Claude Code 或 Codex 这类工具接入,OAuth 流程走不通时,检查回调地址和 Key 的权限范围。Claude Code 接入可以参考 https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claude_code&utm_campaign=rewrite 的配置说明,Base URL、Key、Model ID 三件套要写全。
报错七:softmax 输出全为 nan。检查 mask 里是不是用了-inf而不是-1e9。-inf在某些情况下会导致 nan,论文实现里用-1e9是更稳的选择。另外检查有没有除零,math.sqrt(d_k)里 d_k 不能为 0。
报错八:位置编码维度对不上。self.pe[:, :x.size(1), :]这里 x.size(1) 是 seq_len,如果你的输入是 (seq_len, batch, d_model) 格式,要改成 x.size(0)。建议统一用 batch_first 格式,少踩这个坑。
排查顺序建议:先看报错类型,维度问题查 shape,网络问题查 Base URL 和 Key,数值问题查 mask 和缩放。把每一步的 shape 打印出来,90% 的问题一眼就能定位。
6. 从公式到代码走通之后:长期编码与 Agent 场景的接入建议
把上面这套代码跑通,你对自注意力、多头、位置编码、mask 的理解就落地了。接下来如果要做长期编码或者 Agent 相关的项目,建议把模型调用也规范化。TaoToken 的 Coding Plan 页面 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 有适合长期编码场景的配置说明,Base URL、Key、Model ID 三件套配好之后,可以直接在编辑器或 Agent 框架里调用。
我自己的习惯是:公式推导用纸笔,代码实现用 PyTorch,模型验证用 TaoToken 的对话和 API。三者结合,理解速度比单看论文快很多。注意力权重矩阵打印出来之后,你可以直观看到每个头关注的位置,这比任何文字描述都清楚。
最后留一个实用技巧:把attn_masked[0,0]那个 10x10 矩阵存成 CSV,用 Excel 或 pandas 画个热力图,你会看到下三角的注意力分布。论文附录里展示的注意力可视化,你自己就能复现。这一步做完,Transformer 的阅读笔记才算真正闭环。