
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_model512、h8、N2层、序列长度 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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 进入控制台。控制台地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 在里面可以创建和管理 API Key。创建好之后API 的基础地址是 https://taotoken.net/api 注意这个地址不带 UTM 参数直接用于代码里的base_url。如果你需要看接入细节文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有不同语言和框架的调用示例。对于这次复现实验你至少需要准备两样东西一个可用的 API Key以及确认你的 Python 环境能访问https://taotoken.net/api。Key 的创建入口在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 进去之后新建一个复制出来存到环境变量里不要硬编码在代码中。我习惯用TAOTOKEN_API_KEY这个变量名后面配置片段里也会用到。如果你后续要做长期的编码实验或者 Agent 类的任务可以了解一下 coding plan入口在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。它适合需要持续调用、反复调试的场景比单次调用更省心。模型对话的入口在 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite 当你对某段 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你的KeyWindows 下用set或者直接在系统环境变量里配。这一步做完前置准备就结束了接下来进入代码部分。3. 可复制配置d_model512、h8、N2 的最小 Transformer 片段这一节给出可以直接复制的配置和模型代码。我把它拆成三块超参配置、多头自注意力实现、encoder-decoder 组装。每一块都对应论文里的一个公式你可以对照着看。先看超参。论文 base 模型是d_model512、h8、N6、d_ff2048。为了在 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,2i1)} \cos(pos / 10000^{2i/d_{model}})$$实现的时候用一个(max_len, d_model)的矩阵偶数维用 sin奇数维用 cos。注意div_term的计算方式它决定了不同维度的频率。class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len100, dropout0.1): super().__init__() self.dropout nn.Dropout(dropout) pe torch.zeros(max_len, d_model) position torch.arange(0, max_len, dtypetorch.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_k64所以除以 8。class MultiHeadAttention(nn.Module): def __init__(self, d_model, h, dropout0.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, maskNone): 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, dropout0.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, maskNone): 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, dropout0.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_maskNone, tgt_maskNone): 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_maskNone, tgt_maskNone): 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 idbatch 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 用于 paddingtarget mask 是 padding mask 和 sequence mask 的组合。sequence mask 是一个上三角矩阵保证位置 i 只能看到小于等于 i 的位置。def make_src_mask(src, pad_idx0): # (batch, 1, 1, seq_len) return (src ! pad_idx).unsqueeze(1).unsqueeze(2) def make_tgt_mask(tgt, pad_idx0): 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_modelfrom 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_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite 里让它帮你检查有没有形状不匹配的地方。实测下来这种“先跑通再问”的方式比直接让模型写代码更可靠因为你能拿着实际报错去定位。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_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 确认一下 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_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。第四个是 OAuth 相关。如果你在配置 Claude Code 或类似工具时看到 OAuth 报错通常是认证流程没走完或者回调地址不对。这类工具接入时Base URL、Key、Model ID 三件套要写全。Base URL 用https://taotoken.net/apiKey 用你创建的 API KeyModel ID 按文档里支持的模型名填。缺任何一个都会导致认证失败。Claude Code 的接入说明在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里有照着填就行。再说 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 把整行都置成了-1e9softmax 之后全是 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_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。这两个页面覆盖了 Key 管理和调用示例遇到 401 或返回结构问题时先查这里。如果你需要验证模型输出比如让模型解释 positional embedding 的数值或者核对多头注意力的拼接逻辑用模型对话入口 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite 。把代码和报错一起贴进去比单独问“Transformer 怎么写”有效得多。如果你打算长期做编码实验或者 Agent 类任务反复调用、反复调试可以看 coding plan入口在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。它适合需要持续通道的场景不用每次重新配 Key。最后给一个实用技巧把这次复现的配置和代码存成一个独立目录每次改参数前先git commit一次。这样当你把h改成 16 发现形状报错时可以快速回退到能跑通的版本对照。论文复现最怕的就是改着改着忘了哪一步是对的版本控制比任何调试技巧都管用。