1. 从论文公式到能跑的最小复现:为什么你读懂了 Attention 却写不出代码
《Attention is all you need》这篇论文我前后翻过很多遍,每次卡住的地方都不一样。第一次卡在 scaled dot-product 里那个 $\sqrt{d_k}$ 到底除在哪一维,第二次卡在多头拼接之后 $W^O$ 的形状对不上,第三次卡在 positional embedding 加进去之后维度怎么还是 512。公式都认识,但真让你用 PyTorch 写一个能前向传播、能打印出张量形状的 encoder-decoder,还是会有一堆细节对不上。
这篇笔记面向的就是这种状态:你已经知道 Transformer 由 encoder 和 decoder 堆叠而成,知道多头自注意力是把 Q、K、V 投影到多个子空间,也知道 positional embedding 用 sin/cos 生成,但你想亲手跑一遍,看到torch.Size([2, 5, 512])这样的输出,确认自己没理解错。最小复现的价值不在于训练出一个能翻译的模型,而在于把论文里的每一个矩阵运算落到代码上,让形状、mask、缩放因子这些容易含糊的地方变得可验证。
我会用一个极小的配置来搭:d_model=512、h=8、N=2层、序列长度 5、batch size 2。这个规模在 CPU 上就能跑完一次前向传播,不需要 GPU。同时我会把调用通道统一到 TaoToken 的 API 上,这样你不需要在多个平台之间切换 Key,模型对话、coding plan、API Keys 都在一个控制台里管理。对于边读论文边做实验的人来说,少折腾环境就多一分把注意力放在公式上的时间。
整篇的节奏是:先把环境和一个可复制的配置片段给出来,然后逐段拆多头自注意力和 positional embedding 的实现,接着跑一次前向传播验证输出形状和数值范围,最后把常见的报错对照着排一遍。你可以跟着敲,也可以直接复制代码块跑通再回头改参数。
2. TaoToken 前置准备:统一 Key 与 API 通道,让复现实验不中断
在开始写模型之前,先把调用通道理清楚。做论文复现实验时,经常需要一边跑本地代码,一边调模型接口做对照,比如让模型解释某段 attention 实现,或者核对 positional embedding 的数值。如果每个平台都要单独注册、单独管 Key,实验节奏会被打断。TaoToken 在这里的作用是把模型对话、coding plan、API Keys 和接入文档放在同一个控制台里,你用一个 Key 就能覆盖这些场景。
具体操作上,先打开官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 进入控制台。控制台地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,在里面可以创建和管理 API Key。创建好之后,API 的基础地址是 https://taotoken.net/api ,注意这个地址不带 UTM 参数,直接用于代码里的base_url。如果你需要看接入细节,文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有不同语言和框架的调用示例。
对于这次复现实验,你至少需要准备两样东西:一个可用的 API Key,以及确认你的 Python 环境能访问https://taotoken.net/api。Key 的创建入口在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite ,进去之后新建一个,复制出来存到环境变量里,不要硬编码在代码中。我习惯用TAOTOKEN_API_KEY这个变量名,后面配置片段里也会用到。
如果你后续要做长期的编码实验或者 Agent 类的任务,可以了解一下 coding plan,入口在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。它适合需要持续调用、反复调试的场景,比单次调用更省心。模型对话的入口在 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=chat&utm_campaign=rewrite ,当你对某段 attention 代码不确定时,可以直接在里面贴代码问。
这里要强调一点:TaoToken 是统一的 API 通道,不是让你绕过什么限制,而是把 Key 管理和调用入口收敛到一处。你仍然是在自己的机器上跑 PyTorch 代码,只是当需要模型辅助时,走同一个通道。这样复现实验的上下文不会因为切换平台而丢失。
环境方面,我建议用 Python 3.10 以上,PyTorch 2.0 以上。安装命令很简单:
pip install torch numpy如果你要用 OpenAI 兼容的客户端来调 TaoToken,再装一个:
pip install openai装完之后,把 Key 写进环境变量:
export TAOTOKEN_API_KEY="你的Key"Windows 下用set或者直接在系统环境变量里配。这一步做完,前置准备就结束了,接下来进入代码部分。
3. 可复制配置:d_model=512、h=8、N=2 的最小 Transformer 片段
这一节给出可以直接复制的配置和模型代码。我把它拆成三块:超参配置、多头自注意力实现、encoder-decoder 组装。每一块都对应论文里的一个公式,你可以对照着看。
先看超参。论文 base 模型是d_model=512、h=8、N=6、d_ff=2048。为了在 CPU 上快速跑通,我把N降到 2,其他保持和论文一致,这样形状验证的结果仍然有参考意义。
# config.py import torch import torch.nn as nn import math class Config: d_model = 512 h = 8 N = 2 d_ff = 2048 dropout = 0.1 max_len = 100 vocab_size = 1000 batch_size = 2 seq_len = 5 cfg = Config()接下来是 positional embedding。论文里的公式是:
$$PE_{(pos,2i)} = \sin(pos / 10000^{2i/d_{model}})$$ $$PE_{(pos,2i+1)} = \cos(pos / 10000^{2i/d_{model}})$$
实现的时候用一个(max_len, d_model)的矩阵,偶数维用 sin,奇数维用 cos。注意div_term的计算方式,它决定了不同维度的频率。
class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len=100, 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) # (1, max_len, d_model) self.register_buffer('pe', pe) def forward(self, x): # x: (batch, seq_len, d_model) x = x + self.pe[:, :x.size(1), :] return self.dropout(x)然后是多头自注意力。论文里每个 head 的维度是d_k = d_v = d_model / h = 64。实现时用一个线性层把d_model投影到3 * d_model,再拆成 Q、K、V。缩放因子是1 / sqrt(d_k),这里d_k=64,所以除以 8。
class MultiHeadAttention(nn.Module): def __init__(self, d_model, h, dropout=0.1): super().__init__() assert d_model % h == 0 self.d_model = d_model self.h = h self.d_k = d_model // h self.w_qkv = nn.Linear(d_model, 3 * 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 = query.size(0) # 投影并拆成 h 个头 qkv = self.w_qkv(query) # (batch, seq_len, 3*d_model) q, k, v = qkv.chunk(3, dim=-1) # (batch, h, seq_len, d_k) q = q.view(batch_size, -1, self.h, self.d_k).transpose(1, 2) k = k.view(batch_size, -1, self.h, self.d_k).transpose(1, 2) v = v.view(batch_size, -1, self.h, self.d_k).transpose(1, 2) scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.d_k) if mask is not None: scores = scores.masked_fill(mask == 0, -1e9) attn = torch.softmax(scores, dim=-1) attn = self.dropout(attn) out = torch.matmul(attn, v) # (batch, h, seq_len, d_k) out = out.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model) return self.w_o(out)这里有一个容易踩的坑:view之前必须contiguous(),否则会报形状错误。另外 mask 的形状要能广播到(batch, h, seq_len, seq_len),后面排错部分会细说。
encoder 层和 decoder 层按论文结构组装。encoder 每层两个子层,decoder 每层三个子层,都带残差和 LayerNorm。
class EncoderLayer(nn.Module): def __init__(self, d_model, h, d_ff, dropout=0.1): super().__init__() self.self_attn = MultiHeadAttention(d_model, h, dropout) self.ffn = nn.Sequential( nn.Linear(d_model, d_ff), nn.ReLU(), nn.Linear(d_ff, d_model) ) self.norm1 = nn.LayerNorm(d_model) self.norm2 = nn.LayerNorm(d_model) self.dropout = nn.Dropout(dropout) def forward(self, x, mask=None): x = self.norm1(x + self.dropout(self.self_attn(x, x, x, mask))) x = self.norm2(x + self.dropout(self.ffn(x))) return x class DecoderLayer(nn.Module): def __init__(self, d_model, h, d_ff, dropout=0.1): super().__init__() self.self_attn = MultiHeadAttention(d_model, h, dropout) self.cross_attn = MultiHeadAttention(d_model, h, dropout) self.ffn = nn.Sequential( nn.Linear(d_model, d_ff), nn.ReLU(), nn.Linear(d_ff, d_model) ) self.norm1 = nn.LayerNorm(d_model) self.norm2 = nn.LayerNorm(d_model) self.norm3 = nn.LayerNorm(d_model) self.dropout = nn.Dropout(dropout) def forward(self, x, enc_out, src_mask=None, tgt_mask=None): x = self.norm1(x + self.dropout(self.self_attn(x, x, x, tgt_mask))) x = self.norm2(x + self.dropout(self.cross_attn(x, enc_out, enc_out, src_mask))) x = self.norm3(x + self.dropout(self.ffn(x))) return x最后把 embedding、positional encoding、encoder stack、decoder stack 和输出层拼起来。注意 decoder 的输入是右移过的 target 序列,训练时用 teacher forcing。
class Transformer(nn.Module): def __init__(self, cfg): super().__init__() self.src_embed = nn.Embedding(cfg.vocab_size, cfg.d_model) self.tgt_embed = nn.Embedding(cfg.vocab_size, cfg.d_model) self.pos_enc = PositionalEncoding(cfg.d_model, cfg.max_len, cfg.dropout) self.encoder_layers = nn.ModuleList([ EncoderLayer(cfg.d_model, cfg.h, cfg.d_ff, cfg.dropout) for _ in range(cfg.N) ]) self.decoder_layers = nn.ModuleList([ DecoderLayer(cfg.d_model, cfg.h, cfg.d_ff, cfg.dropout) for _ in range(cfg.N) ]) self.fc_out = nn.Linear(cfg.d_model, cfg.vocab_size) def forward(self, src, tgt, src_mask=None, tgt_mask=None): src = self.pos_enc(self.src_embed(src)) for layer in self.encoder_layers: src = layer(src, src_mask) tgt = self.pos_enc(self.tgt_embed(tgt)) for layer in self.decoder_layers: tgt = layer(tgt, src, src_mask, tgt_mask) return self.fc_out(tgt)这份代码可以直接存成model.py,配合前面的config.py使用。形状上,输入src和tgt都是(batch, seq_len)的整数张量,输出是(batch, seq_len, vocab_size)。下一节跑一次前向传播,把每个中间张量的形状打印出来核对。
4. 验证请求与成功结果:一次前向传播的形状与数值核对
代码写完之后,最关键的一步是跑一次前向传播,确认输出形状和数值范围符合预期。我见过不少人代码能跑通但形状是错的,比如把d_model和d_k搞混,或者 mask 广播方向反了,结果训练时 loss 不下降才发现。所以这里把验证动作写细一点。
先构造输入。用随机整数模拟 token id,batch size 2,序列长度 5。
import torch from config import cfg from model import Transformer torch.manual_seed(42) model = Transformer(cfg) model.eval() src = torch.randint(0, cfg.vocab_size, (cfg.batch_size, cfg.seq_len)) tgt = torch.randint(0, cfg.vocab_size, (cfg.batch_size, cfg.seq_len)) print("src shape:", src.shape) print("tgt shape:", tgt.shape)输出应该是:
src shape: torch.Size([2, 5]) tgt shape: torch.Size([2, 5])接下来构造 mask。source mask 用于 padding,target mask 是 padding mask 和 sequence mask 的组合。sequence mask 是一个上三角矩阵,保证位置 i 只能看到小于等于 i 的位置。
def make_src_mask(src, pad_idx=0): # (batch, 1, 1, seq_len) return (src != pad_idx).unsqueeze(1).unsqueeze(2) def make_tgt_mask(tgt, pad_idx=0): batch_size, seq_len = tgt.shape # padding mask pad_mask = (tgt != pad_idx).unsqueeze(1).unsqueeze(2) # sequence mask: 下三角为 1 seq_mask = torch.tril(torch.ones(seq_len, seq_len)).bool() seq_mask = seq_mask.unsqueeze(0).unsqueeze(0) return pad_mask & seq_mask src_mask = make_src_mask(src) tgt_mask = make_tgt_mask(tgt) print("src_mask shape:", src_mask.shape) print("tgt_mask shape:", tgt_mask.shape)输出:
src_mask shape: torch.Size([2, 1, 1, 5]) tgt_mask shape: torch.Size([2, 1, 5, 5])然后跑前向传播:
with torch.no_grad(): out = model(src, tgt, src_mask, tgt_mask) print("output shape:", out.shape) print("output dtype:", out.dtype) print("output mean:", out.mean().item()) print("output std:", out.std().item())预期输出:
output shape: torch.Size([2, 5, 1000]) output dtype: torch.float32 output mean: 约 0 附近 output std: 约 0.02 到 0.05 之间output shape是(batch, seq_len, vocab_size),因为最后经过fc_out映射到词表大小。mean和std取决于初始化,但不会出现nan或inf。如果出现nan,通常是 mask 里用了-1e9之后 softmax 全被 mask 掉,或者缩放因子写错导致数值爆炸。
再单独验证一下多头自注意力的输出。取一个随机输入,看形状是否回到d_model:
from model import MultiHeadAttention mha = MultiHeadAttention(cfg.d_model, cfg.h) x = torch.randn(cfg.batch_size, cfg.seq_len, cfg.d_model) with torch.no_grad(): y = mha(x, x, x) print("mha input:", x.shape) print("mha output:", y.shape)输出:
mha input: torch.Size([2, 5, 512]) mha output: torch.Size([2, 5, 512])形状一致说明投影、拆头、拼接、输出投影这条链路是对的。你还可以把h改成 4 或 16,只要d_model % h == 0,输出形状不变,但每个 head 的d_k会变。这个实验能帮你直观理解“多头”到底在切什么。
positional embedding 也可以单独验证。打印前 5 个位置、前 8 维的值,对照 sin/cos 公式:
from model import PositionalEncoding pe = PositionalEncoding(cfg.d_model, cfg.max_len) print(pe.pe[0, :5, :8])你会看到偶数维是 sin,奇数维是 cos,且随着位置增加,数值在 [-1, 1] 之间周期变化。不同维度的频率不同,低维变化快,高维变化慢,这正是论文里“不同频率的正弦信号”的含义。
到这里,一次完整的前向传播就验证完了。如果你想把结果和模型对话做对照,可以把输出形状和 mask 代码贴到 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=chat&utm_campaign=rewrite 里,让它帮你检查有没有形状不匹配的地方。实测下来,这种“先跑通再问”的方式比直接让模型写代码更可靠,因为你能拿着实际报错去定位。
5. 本篇常见错排查:401、local proxy failed、reading choices 与 OAuth 对照
复现过程中遇到的报错大致分两类:一类是 PyTorch 形状和数值问题,一类是调用 TaoToken API 时的通道问题。这一节把常见的几个列出来,对照着排。
先说 API 侧的。如果你在代码里用 OpenAI 兼容客户端调 TaoToken,最常遇到的是 401。报错信息通常是:
openai.AuthenticationError: Error code: 401 - {'error': {'message': 'Invalid API key'}}原因一般是 Key 没读到,或者环境变量名写错。检查TAOTOKEN_API_KEY是否导出成功,代码里用os.environ.get("TAOTOKEN_API_KEY")取。另外确认base_url写的是https://taotoken.net/api,不要多加路径。如果你用的是 coding plan 或模型对话,Key 的权限范围可能不同,去 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 确认一下 Key 的状态。
第二个常见的是local proxy failed。这个报错通常出现在你的运行环境配置了本地代理,但代理不可达。处理方式是检查环境变量HTTP_PROXY、HTTPS_PROXY是否指向了一个失效的地址。如果你不需要代理,直接 unset 掉:
unset HTTP_PROXY unset HTTPS_PROXY然后重新跑请求。注意这里说的是清理本地环境变量,不是让你去配什么通道,只是把无效配置移除。
第三个是reading choices相关的报错,完整信息可能是:
KeyError: 'choices'或者
TypeError: 'NoneType' object is not subscriptable这通常是因为返回体结构和你的解析代码不匹配。比如你按 OpenAI 的response.choices[0].message.content解析,但实际返回的是流式 chunk,或者请求本身失败了返回了错误体。先打印完整response看结构,再决定怎么取字段。如果你用的是 TaoToken 的模型对话接口,参考文档里的返回示例,路径在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。
第四个是 OAuth 相关。如果你在配置 Claude Code 或类似工具时看到 OAuth 报错,通常是认证流程没走完,或者回调地址不对。这类工具接入时,Base URL、Key、Model ID 三件套要写全。Base URL 用https://taotoken.net/api,Key 用你创建的 API Key,Model ID 按文档里支持的模型名填。缺任何一个都会导致认证失败。Claude Code 的接入说明在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 里有,照着填就行。
再说 PyTorch 侧的。第一个是形状错误:
RuntimeError: shape '[2, 5, 8, 64]' is invalid for input of size 5120这通常是因为view的维度乘积和输入元素数不一致。检查batch_size * seq_len * d_model是否等于batch_size * seq_len * h * d_k。因为d_model = h * d_k,正常情况下是相等的。如果不等,说明你在某个地方改了d_model但没同步改h或d_k。
第二个是 mask 广播错误:
RuntimeError: The size of tensor a (5) must match the size of tensor b (5) at non-singleton dimension 3这种报错说明 mask 的形状和 scores 的形状对不上。scores 是(batch, h, seq_len, seq_len),mask 需要能广播到这个形状。source mask 用(batch, 1, 1, seq_len),target mask 用(batch, 1, seq_len, seq_len)。如果你把 mask 写成了(batch, seq_len),就会广播失败。
第三个是nan。前向传播输出全是nan,最常见的原因是 mask 把整行都置成了-1e9,softmax 之后全是 0,再经过后续层就变成nan。检查 sequence mask 的下三角是否包含了对角线,以及 padding mask 是否把有效位置也 mask 掉了。另一个原因是学习率太大,但最小复现里不涉及训练,所以先查 mask。
第四个是 positional embedding 维度不匹配:
RuntimeError: The size of tensor a (512) must match the size of tensor b (256) at non-singleton dimension 2这说明d_model和 positional encoding 的维度不一致。检查PositionalEncoding初始化时传的d_model是否和 embedding 的维度相同。论文里明确说 positional embedding 的维度和输入 embedding 相同,都是 512。
把这几类报错对照一遍,大部分卡点都能定位。如果遇到没列出来的,把完整报错和你的配置片段贴到模型对话里,让它帮你缩小范围。记住先确认 Base URL、Key、Model ID 三件套,再查代码形状。
6. 语义一致 CTA:把复现实验接到统一通道上继续跑
最小复现跑通之后,下一步通常是改参数做对照实验,比如把h从 8 改成 16 看形状变化,或者把N从 2 加到 6 看显存占用。这些实验本身不需要外部调用,但当你需要查文档、核对某个 API 的返回结构,或者让模型帮你解释一段 attention 代码时,统一通道会省很多事。
如果你主要在做接入和排障,建议从 API Keys 和接入文档入手。API Keys 入口在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite ,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。这两个页面覆盖了 Key 管理和调用示例,遇到 401 或返回结构问题时先查这里。
如果你需要验证模型输出,比如让模型解释 positional embedding 的数值,或者核对多头注意力的拼接逻辑,用模型对话入口 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=chat&utm_campaign=rewrite 。把代码和报错一起贴进去,比单独问“Transformer 怎么写”有效得多。
如果你打算长期做编码实验或者 Agent 类任务,反复调用、反复调试,可以看 coding plan,入口在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。它适合需要持续通道的场景,不用每次重新配 Key。
最后给一个实用技巧:把这次复现的配置和代码存成一个独立目录,每次改参数前先git commit一次。这样当你把h改成 16 发现形状报错时,可以快速回退到能跑通的版本对照。论文复现最怕的就是改着改着忘了哪一步是对的,版本控制比任何调试技巧都管用。