1. 从论文到代码:Self-Attention 到底在算什么
《Attention Is All You Need》这篇论文我翻过很多遍,每次重读都会发现之前漏掉的细节。它提出的 Transformer 架构现在几乎成了大模型的地基,但论文本身写得相当紧凑,很多工程上的坑只有真正动手复现才会暴露出来。这篇笔记不打算逐段翻译论文,而是聚焦一件事:把 Self-Attention 和 Multi-Head Attention 这两个核心模块的参数配置拆开,让你能在本地环境里跑起来、打印张量维度、验证注意力权重是否符合预期。
先说清楚这套东西是什么、能做什么、适合谁。Self-Attention 是一种让序列中每个位置都能直接"看到"其他所有位置的机制,它把输入序列映射成 Query、Key、Value 三组向量,通过点积计算位置之间的相关性权重,再对 Value 加权求和。Multi-Head Attention 则是把这件事并行做 h 次,每次用不同的线性投影,让模型同时关注不同子空间的信息。它适合正在复现 Transformer、调试注意力机制、或者想搞清楚大模型内部张量流转的开发者。如果你只是调 API 用现成模型,这篇可能偏底层;但如果你想自己搭一个小 Transformer 或者排查注意力相关的 bug,下面的配置表可以直接抄。
论文里给出的核心公式是:
Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) V这个公式看着简单,但 d_k 取多少、h 取多少、d_model 怎么分配、mask 怎么加,每一个都影响最终结果。论文的设定是 d_model=512、h=8、d_k=d_v=64,编码器和解码器各堆叠 N=6 层,前馈网络中间层 d_ff=2048。这些数字不是随便定的,d_model 必须能被 h 整除,否则每个头的维度就对不上。我见过不少人直接把 h 改成 12 却忘了改 d_model,结果 reshape 的时候维度报错,排查半天。
位置编码这块也容易被忽略。因为 Transformer 没有循环也没有卷积,序列的顺序信息完全靠位置编码注入。论文用的是不同频率的 sin 和 cos 函数,偶数维用 sin、奇数维用 cos,波长从 2π 到 10000·2π 构成等比级数。这个设计的好处是对于任意固定偏移 k,PE(pos+k) 可以表示成 PE(pos) 的线性函数,模型更容易学到相对位置关系。工程上你要验证的就是:位置编码矩阵的 shape 是不是 (max_len, d_model),第 0 行是不是全 0 附近的值,相邻行的差异是否符合预期。
下面我会先讲清楚复现这套机制需要准备什么环境,然后给出可直接复制的配置片段,接着用一段完整代码验证张量维度和注意力权重,最后把常见的报错逐个拆解。整个过程你可以在本地 Python 环境里跟着做,不需要 GPU 也能跑通小规模验证。
2. 复现前的环境与 TaoToken 接入准备
在动手写注意力代码之前,先把运行环境和模型调用通道理清楚。纯 PyTorch 复现 Self-Attention 其实不需要任何外部服务,装好 torch 就行。但如果你想把复现出来的模块和真实大模型的注意力行为做对照,或者想用 API 快速验证不同参数下的输出差异,那就需要一个稳定的模型接入通道。我平时用 TaoToken 来做这类对照实验,它的接口兼容 OpenAI 格式,切换模型只要改一个 model 字段,比较适合边写代码边验证。
TaoToken 的官网入口是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 基础地址是 https://taotoken.net/api 。注意 API 地址后面不加 UTM 参数,直接拼 /v1/chat/completions 就是标准的对话补全端点。如果你要长期做编码类实验,可以看看 Coding Plan 页面;如果只是想快速对话验证,模型对话入口更直接;需要管理密钥就去 API Keys 页面。
这里要强调一点:TaoToken 是合规的模型接入服务,不是那种来路不明的中转。你在配置的时候把 Base URL 填成 https://taotoken.net/api ,Key 填自己生成的,Model ID 按文档里支持的模型名填,三件套齐了就能跑。我试过在 Cline、Continue 这类插件里配,也是同样的三件套逻辑,Base URL、API Key、Model ID 一个都不能少。
环境准备的具体步骤:
第一步,确认 Python 版本。建议 3.9 以上,太老的版本有些类型注解会报错。用python --version看一眼。
第二步,装 PyTorch。如果你只是做 CPU 上的维度验证,装 CPU 版就够:
pip install torch --index-url https://download.pytorch.org/whl/cpu第三步,装 numpy 和 matplotlib,后面可视化注意力权重会用到:
pip install numpy matplotlib第四步,如果你要用 API 做对照,装 openai 的 SDK:
pip install openai装完之后建一个工作目录,比如transformer_notes,后面所有代码文件都放这里。我习惯把配置单独放一个config.py,把模型定义放attention.py,验证脚本放verify.py,这样排查问题的时候定位快。
关于 API Key 的获取,去 TaoToken 的 API Keys 页面生成一个,复制出来存到环境变量里,别硬编码在代码里。Linux/macOS 下:
export TAOTOKEN_API_KEY="你的key"Windows PowerShell:
$env:TAOTOKEN_API_KEY="你的key"这样后面代码里用os.environ.get("TAOTOKEN_API_KEY")读取就行。把 Key 和 Base URL 分开管理,换环境的时候只改环境变量,代码不用动。
还有一点,如果你打算在 Claude Code 或者类似的编码工具里接入,配置方式略有不同。Claude Code 走的是 Anthropic 兼容格式,Base URL 要填对应的端点,Key 和 Model ID 同样不能少。具体路径参考接入文档,别自己猜。文档里把每个端点的用途写得很清楚,照着填就行。
环境搭好之后,先跑一个最小验证:python -c "import torch; print(torch.__version__)",能打印出版本号就说明基础环境没问题。接下来就可以进入注意力模块的配置环节了。
3. 可复制的 Self-Attention 与 Multi-Head Attention 配置
这一节是重点,我把论文里的参数和工程实现对照着列出来,你可以直接复制到自己的项目里。先看核心配置表:
| 参数 | 论文取值 | 说明 | 工程注意点 |
|---|---|---|---|
| d_model | 512 | 模型隐藏维度 | 必须能被 h 整除 |
| h | 8 | 注意力头数 | 改这个要同步改 d_k |
| d_k | 64 | 每个头的 Query/Key 维度 | d_model / h |
| d_v | 64 | 每个头的 Value 维度 | 通常等于 d_k |
| d_ff | 2048 | 前馈网络中间层 | 一般是 d_model 的 4 倍 |
| N | 6 | 编码器/解码器层数 | 堆叠层数 |
| dropout | 0.1 | 丢弃率 | 训练时生效 |
| max_len | 5000 | 位置编码最大长度 | 按任务调整 |
把这些参数写成一个 JSON 配置,方便不同实验之间切换:
{ "d_model": 512, "num_heads": 8, "d_k": 64, "d_v": 64, "d_ff": 2048, "num_layers": 6, "dropout": 0.1, "max_len": 5000, "vocab_size": 32000 }如果你用 TOML 管理配置(比如配合某些训练框架),等价写法是:
[model] d_model = 512 num_heads = 8 d_k = 64 d_v = 64 d_ff = 2048 num_layers = 6 dropout = 0.1 max_len = 5000 vocab_size = 32000接下来是 Self-Attention 的实现。核心就是把输入线性投影成 Q、K、V,然后按公式算。注意缩放因子是 sqrt(d_k),不是 sqrt(d_model),这是论文里明确写的,很多人第一次实现会搞错。
import torch import torch.nn as nn import math class ScaledDotProductAttention(nn.Module): def __init__(self, d_k, dropout=0.1): super().__init__() self.d_k = d_k self.dropout = nn.Dropout(dropout) def forward(self, q, k, v, mask=None): # q: (batch, heads, seq_len, d_k) # k: (batch, heads, seq_len, d_k) # v: (batch, heads, seq_len, d_v) scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.d_k) if mask is not None: scores = scores.masked_fill(mask == 0, float('-inf')) attn = torch.softmax(scores, dim=-1) attn = self.dropout(attn) output = torch.matmul(attn, v) return output, attn这段代码里 mask 的处理很关键。解码器的自注意力需要屏蔽未来位置,做法是把 softmax 输入里对应位置设成负无穷,这样 softmax 之后权重就是 0。mask 的 shape 要能广播到 scores 的 shape,通常是 (batch, 1, seq_len, seq_len) 或者 (batch, 1, 1, seq_len)。
然后是 Multi-Head Attention。思路是先做一次大的线性投影,把 d_model 拆成 h 份,每份 d_k 维,并行算注意力,最后拼接再投影回 d_model。
class MultiHeadAttention(nn.Module): def __init__(self, d_model, num_heads, dropout=0.1): super().__init__() assert d_model % num_heads == 0, "d_model 必须能被 num_heads 整除" self.d_model = d_model self.num_heads = num_heads self.d_k = d_model // num_heads self.d_v = 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.attention = ScaledDotProductAttention(self.d_k, dropout) def forward(self, q, k, v, mask=None): batch_size = q.size(0) # 线性投影并拆头 q = self.w_q(q).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2) k = self.w_k(k).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2) v = self.w_v(v).view(batch_size, -1, self.num_heads, self.d_v).transpose(1, 2) # 并行注意力 out, attn = self.attention(q, k, v, mask) # 拼接多头 out = out.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model) out = self.w_o(out) return out, attn这里view和transpose的顺序不能乱。先 view 成 (batch, seq_len, heads, d_k),再 transpose 成 (batch, heads, seq_len, d_k),这样每个头的数据是连续的。如果顺序反了,注意力计算就会串头,结果完全错乱。contiguous()在 transpose 之后调用是为了让内存连续,否则 view 会报错。
位置编码的实现也要贴出来,因为它是验证环节的重要对照:
class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len=5000, dropout=0.1): super().__init__() self.dropout = nn.Dropout(dropout) pe = torch.zeros(max_len, d_model) position = torch.arange(0, max_len, 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): x = x + self.pe[:, :x.size(1), :] return self.dropout(x)register_buffer的作用是把 pe 注册成不参与梯度更新的缓冲区,这样保存和加载模型的时候它会跟着走,但不会被优化器更新。div_term那个指数计算对应论文里的 10000^(2i/d_model),用 exp 和 log 组合是为了数值稳定。
把这三个模块拼起来,一个最小的 Transformer 编码层就有了。你可以先不接前馈网络,单独验证注意力部分。配置片段和代码都齐了,下一节我们跑起来看结果。
4. 验证请求:打印张量维度与注意力权重
代码写完不验证等于没写。这一节我带你跑一遍完整的验证流程,确认张量维度对得上、注意力权重符合预期、位置编码的数值范围正常。
先写一个验证脚本,构造随机输入,走一遍 Multi-Head Attention:
import torch from attention import MultiHeadAttention, PositionalEncoding torch.manual_seed(42) batch_size = 2 seq_len = 10 d_model = 512 num_heads = 8 mha = MultiHeadAttention(d_model, num_heads) x = torch.randn(batch_size, seq_len, d_model) out, attn = mha(x, x, x) print("输入 shape:", x.shape) print("输出 shape:", out.shape) print("注意力权重 shape:", attn.shape) print("注意力权重每行和:", attn.sum(dim=-1)[0, 0])预期输出:
输入 shape: torch.Size([2, 10, 512]) 输出 shape: torch.Size([2, 10, 512]) 注意力权重 shape: torch.Size([2, 8, 10, 10]) 注意力权重每行和: tensor([1.0000, 1.0000, ...])注意力权重的 shape 是 (batch, heads, seq_len, seq_len),最后两维是 query 位置和 key 位置。每一行经过 softmax 之后和应该为 1,这是验证 softmax 有没有算对的最直接方法。如果和不是 1,检查 dim 参数是不是设成了 -1。
接着验证位置编码:
pe = PositionalEncoding(d_model, max_len=100) pos_enc = pe.pe[0, :5, :8] print("位置编码前5行前8维:") print(pos_enc) print("位置编码 shape:", pe.pe.shape)预期看到第 0 行接近全 0(sin(0)=0,cos(0)=1,但偶数维是 sin 所以是 0,奇数维是 cos 所以是 1)。第 1 行的值会随维度变化,波长越长的维度变化越慢。你可以画个热力图,横轴是维度、纵轴是位置,会看到明显的条纹图案,这就是不同频率的正弦波叠加的效果。
再验证一下 mask 是否生效。构造一个下三角 mask,模拟解码器的自回归屏蔽:
seq_len = 5 mask = torch.tril(torch.ones(seq_len, seq_len)).unsqueeze(0).unsqueeze(0) print("mask shape:", mask.shape) x = torch.randn(1, seq_len, d_model) out, attn = mha(x, x, x, mask=mask) print("带 mask 的注意力权重:") print(attn[0, 0])预期看到注意力矩阵是下三角的,上三角全是 0。因为未来位置被设成了负无穷,softmax 之后权重为 0。如果上三角还有值,说明 mask 的 shape 或者 masked_fill 的条件写反了。
如果你想用 API 做对照验证,可以发一个请求让模型解释注意力机制,确认接入通道正常:
import os from openai import OpenAI client = OpenAI( api_key=os.environ.get("TAOTOKEN_API_KEY"), base_url="https://taotoken.net/api" ) resp = client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": "用一句话解释 scaled dot-product attention 里为什么要除以 sqrt(d_k)"}] ) print(resp.choices[0].message.content)如果返回正常文本,说明 Base URL、Key、Model ID 三件套配置正确。如果报 401,检查 Key 有没有过期或者复制的时候带了空格。如果报 model not found,检查 Model ID 拼写。
验证环节的核心就是三看:看 shape、看权重和、看 mask 效果。这三样都对了,说明你的注意力模块实现基本正确。接下来把常见的报错整理一下,方便你遇到问题时快速定位。
5. 常见报错排查:从 401 到维度不匹配
复现过程中踩的坑我基本都踩过一遍,这里按报错类型整理,你对着查就行。
报错一:401 Unauthorized / invalid api key
这个通常出现在 API 调用环节。原因无非几个:Key 没设置到环境变量、Key 复制时带了换行或空格、Key 已过期。排查步骤:先echo $TAOTOKEN_API_KEY确认环境变量有值,再检查代码里读取的变量名和设置的是否一致。如果用的是配置文件,确认没有把 Key 写错行。TaoToken 的 Key 在 API Keys 页面可以重新生成,生成后旧的立即失效,注意更新。
报错二:local proxy failed / connection refused
这个报错说明请求根本没发出去,卡在本地网络层。检查 Base URL 是不是写成了https://taotoken.net/api,注意结尾没有多余的斜杠。如果你在代码里手动拼了/v1/chat/completions,确认拼接后是https://taotoken.net/api/v1/chat/completions。另外检查有没有设置HTTP_PROXY之类的环境变量干扰,有的话临时 unset 掉再试。
报错三:reading 'choices' of undefined
这个报错说明响应体结构和你预期的不一样,通常是请求本身失败了但代码直接去读resp.choices。加一层判断:
if resp and hasattr(resp, 'choices') and len(resp.choices) > 0: print(resp.choices[0].message.content) else: print("响应异常:", resp)这样能看到真实的错误信息,而不是被 None 掩盖。
报错四:RuntimeError: shape '[2, 10, 8, 64]' is invalid for input of size ...
这是维度不匹配的典型报错,几乎都出在 Multi-Head Attention 的 reshape 环节。检查 d_model 是否等于 num_heads * d_k。如果你改了 num_heads 但没改 d_model,或者反过来,就会对不上。另外检查 view 之前的张量元素总数是否等于 batch * seq_len * d_model。transpose 之后记得加contiguous(),否则 view 会报内存不连续的错。
报错五:softmax 输出全为 nan
这个通常是 mask 处理有问题。如果你用float('-inf')填充,而某一行全是负无穷(比如 mask 把整行都屏蔽了),softmax 就会产生 nan。检查 mask 是否至少保留了一个有效位置。解码器自注意力的下三角 mask 第一行只有第一个位置有效,这是正常的,不会全屏蔽。
报错六:OAuth / authentication failed(编码工具场景)
如果你在 Claude Code 或类似工具里接入,报 OAuth 相关错误,说明认证方式选错了。这类工具走的是 Anthropic 兼容格式,不是 OpenAI 格式。Base URL、Key、Model ID 三件套要按接入文档里的说明填,别混用。文档里对每个工具的配置路径都有截图,照着填就行。
报错七:位置编码数值异常
如果位置编码出现很大的值或者 nan,检查div_term的计算。torch.arange(0, d_model, 2)生成的是偶数索引,长度是 d_model/2。如果 d_model 是奇数,这里会少一个,导致pe[:, 0::2]和pe[:, 1::2]长度不一致。所以 d_model 建议用偶数,论文里 512 就是偶数。
排查的时候有个通用技巧:在报错行前面打印相关张量的 shape 和 dtype,大部分问题看一眼 shape 就能定位。维度问题永远是深度学习调试的第一嫌疑人。
6. 把注意力机制用起来:下一步怎么走
代码跑通、维度验证通过之后,你可以做几件事把理解再推进一步。
第一件,把单层注意力扩展成完整的编码器层。加上前馈网络和残差连接,残差连接的形式是LayerNorm(x + Sublayer(x)),注意 LayerNorm 在残差之后。前馈网络是两层线性变换加 ReLU,中间维度 d_ff=2048。堆叠 6 层就是一个完整的编码器。
第二件,可视化注意力权重。把 attn 矩阵用 matplotlib 画成热力图,你会看到不同的头关注的位置模式不一样。有的头关注相邻位置,有的头关注句法结构相关的远距离位置。论文附录里展示的就是这种可视化,自己画一遍印象会深很多。
第三件,用真实文本跑一遍。把一句话 tokenize 成 id,过 embedding 和位置编码,再进注意力层,观察不同 token 之间的注意力权重。你会发现一些有意思的现象,比如动词和它的宾语之间权重往往较高。
如果你想把复现的模块和真实大模型做对照,可以用 TaoToken 的模型对话入口发一些需要理解长距离依赖的句子,观察模型的回答,再回头看你自己的注意力权重,两边对照着理解。长期做这类实验的话,Coding Plan 的额度更划算,适合反复调试。
接入文档里有各个端点的详细说明,遇到配置问题先翻文档,大部分坑里面都写了。API Keys 页面管理你的密钥,建议定期轮换。模型对话适合快速验证,Coding Plan 适合长期编码实验,按需选择。
最后留一个实用技巧:调试注意力的时候,把 batch_size 和 seq_len 都设成很小的值,比如 2 和 5,这样打印出来的矩阵你能一眼看完。等逻辑对了再放大规模。维度验证永远从小规模开始,这是省时间的关键。