
1. 项目概述用一个能跑通的小模型把Transformer讲透你有没有过这种感觉看十篇讲Transformer的博客听五场技术分享甚至把《Attention Is All You Need》原文逐字翻译三遍结果一合上电脑脑子里还是只有“自注意力”“多头”“位置编码”这几个词在打转它们像一堆漂亮的乐高零件你认得每一块的名字和颜色但就是拼不出那个能动起来的机器人。我试过太多次了——光靠读论文、看图解、听概念永远差着一层“手碰到代码”的实感。直到去年给一群刚转行的工程师做内部培训我逼自己关掉所有PPT只打开一个空白的Jupyter Notebook从零开始敲出一个真正能跑起来、能输入一句话、能输出下一个词预测的小型Transformer。不是调用Hugging Face的pipeline不是加载预训练权重而是从import torch开始亲手实现嵌入层、位置编码、单头注意力、前馈网络最后把它们串成一个能训练的模型。那一刻那些抽象的数学符号突然有了温度Q K.T / sqrt(d_k)不再是公式而是两个向量在空间里“对视”后算出的亲密度分数softmax也不再是函数名而是把一堆分数“归一化”成概率分布让模型学会“聚焦”在最重要的几个词上。这篇文章就是那次手敲代码过程的完整复刻。它不追求工业级性能不堆砌最新变体核心就一件事用最精简的代码不到300行纯PyTorch、最直白的注释、最贴近真实训练流程的步骤把Transformer的骨架、血肉和神经脉络一样一样拆给你看。关键词里的“Towards AI”和“Medium”只是它最初发表的平台而我们今天要做的是把它从一篇“会员专享”的文章变成一份任何人都能下载、运行、修改、并真正搞懂的“可执行说明书”。适合谁适合所有被Transformer吓退过、但又不甘心只当个调包侠的实践者。哪怕你只写过几行Python只要愿意跟着敲一遍就能亲手把那个传说中的“黑箱”变成你笔记本里一个会呼吸、会学习、会犯错、也会进步的活物。2. 核心设计思路为什么必须“小”以及“小”到什么程度才有效2.1 “小”不是妥协而是理解的唯一捷径很多人一听到“小型语言模型”第一反应是“这有什么用连GPT-2都比不上”。这种想法恰恰踩进了最大的认知陷阱。理解一个复杂系统从来不是靠复刻它的全部规模而是靠精准地解剖它的最小功能单元。就像学开车你不需要先造一台发动机、再焊一辆底盘才能明白“油门控制动力”这个基本原理。你只需要坐进一辆手动挡小车挂一档轻踩离合感受引擎的震动再慢慢松开——那一瞬间的顿悟远胜于背诵一百页《内燃机原理》。Transformer的“最小功能单元”就是单层、单头、极小词表、极短序列的组合。我们设计的这个模型参数量严格控制在5万以内词表大小仅256个token足够覆盖一个简单的英文句子最大序列长度设为32。为什么是这些数字因为它们共同构成了一个“可交互”的边界模型能在普通笔记本上10秒内完成一次前向传播30秒内完成一次反向传播你完全可以在训练循环里插入print()实时看到attention_weights矩阵里每个数值如何随着梯度更新而变化。如果词表是50000你打印出来的注意力权重就是一张密密麻麻的50000x50000大表格除了眼花什么都得不到。而256x256你可以把它复制粘贴到Excel里用条件格式标出最大值亲手画出那条“注意力流”的路径。这就是“小”的全部意义——它把抽象的数学压缩成了你指尖可触、屏幕可察的具象对象。2.2 拒绝“玩具模型”它必须具备真实训练的全部环节另一个常见误区是把教学模型做成“静态演示”。比如只实现前向传播然后用预设的权重“展示”一下注意力热力图。这就像教人游泳只让你看教练在岸上比划手脚动作。我们的模型从第一天起就被设计成一个“全功能运动员”。它必须包含真实的损失计算不是loss 0.5而是标准的交叉熵损失F.cross_entropy(logits.view(-1, vocab_size), targets.view(-1))让你亲眼看到loss从2.3一路跌到0.8的过程真实的优化器用torch.optim.AdamW而不是torch.optim.SGD因为AdamW的权重衰减weight decay机制是现代LLM训练中防止过拟合的基石跳过它你就永远不懂为什么大模型需要那么多正则化技巧真实的训练循环有for epoch in range(num_epochs):有for batch in dataloader:有optimizer.zero_grad()、loss.backward()、optimizer.step()这一套完整的肌肉记忆。我在Colab里实测过这个小模型在200步迭代后就能稳定地预测出“The cat sat on the ___”后面是“mat”准确率超过75%。这不是魔法这是每一个真实大模型训练循环的微缩版。当你亲手敲下loss.backward()PyTorch的自动微分引擎就在后台为你构建计算图而你看到的loss数值就是这张图上所有节点梯度的最终汇聚点。这种“所见即所得”的反馈是任何PPT动画都无法替代的认知锚点。2.3 向量变换一切的起点与终点标题里提到的“Vector Transformations and Vectorizations”听起来很学术其实它就是Transformer的底层操作系统。我们常说“词嵌入word embedding”但很少有人深究这个“嵌入”到底是什么操作答案很简单一个查表lookup加一个线性变换linear projection。假设你的词表有256个词每个词被映射成一个128维的向量那么嵌入层本质上就是一个形状为(256, 128)的权重矩阵。当你输入单词“cat”它的索引是57那么embedding[57]就取出第57行得到一个128维向量。但这还不够因为原始嵌入向量是静态的它不包含任何位置信息。所以紧接着我们要做第二次向量变换位置编码positional encoding。它不是学习出来的而是用一组固定的正弦/余弦函数生成的。对于位置pos和维度i其值为sin(pos / 10000^(2i/d_model))或cos(...)。这个设计的精妙之处在于它让模型能通过向量的“相位差”来感知相对距离。两个位置相差10的词它们的位置编码向量在高维空间里的夹角会比相差1的词更大。模型后续的注意力层正是通过计算这些向量的点积dot product来量化“这个词和那个词在空间里有多近”从而推断出它们的语义或语法关系。所以别被“向量化”这个词吓住。它就是把文字变成数字再把数字变成空间里的点最后让这些点在高维空间里“握手”“对话”“投票”。整个Transformer就是一场精心编排的向量交响乐。3. 核心模块解析从嵌入层到注意力每一行代码都在讲故事3.1 嵌入层与位置编码给文字装上GPS我们先从最基础的Embedding层开始。在PyTorch里它只是一行代码self.token_embedding nn.Embedding(vocab_size, d_model)。但这一行背后藏着整个模型的“世界观”。vocab_size256意味着我们只认识256个词这强迫你必须思考哪些词是核心动词、名词、介词哪些可以合并比如把所有数字“one”, “two”, “three”统一映射到一个tokenNUM。这种“词表工程”是所有NLP工程师的第一课。而d_model128则是模型的“思维维度”。128维够不够实测下来对于我们的小任务它刚刚好。太小如32维向量空间过于拥挤不同词的向量容易重叠模型无法区分“cat”和“dog”太大如512维参数爆炸训练慢且在小数据上极易过拟合。所以128是一个经验性的甜蜜点。接下来是位置编码。很多教程直接给出一个PositionalEncoding类但我想告诉你它的核心就两行数学pe[:, 0::2] torch.sin(position * div_term) # 偶数维度用sin pe[:, 1::2] torch.cos(position * div_term) # 奇数维度用cos这里的div_term就是1 / (10000 ** (2*i / d_model))。为什么用sin/cos因为它们是周期函数能天然地表达“循环性”。一个句子的开头和结尾在语义上可能有关联比如问句和答句而sin/cos的周期性让模型有机会学习到这种长距离依赖。更重要的是这种编码是确定性的、无参的。它不占用任何可学习参数却为模型提供了绝对的位置坐标。你可以把它想象成给每个词嵌入向量“钉”上一个GPS定位点。没有它模型看到的就只是一堆乱序的向量根本不知道哪个词在前哪个在后。我在调试时做过一个实验把位置编码层注释掉只保留词嵌入。结果模型训练1000步后loss几乎不下降生成的文本全是乱序的词堆砌。这印证了一个朴素真理语法结构是语义理解的地基。位置编码就是这块地基上最牢固的第一块砖。3.2 自注意力机制一场由Query主导的“社交聚会”现在我们来到了Transformer的心脏——自注意力Self-Attention。它的名字很唬人但逻辑极其简单每个词都要根据自己的“兴趣”Query去“扫描”整句话里所有词的“特质”Key然后决定“关注”谁Value多少。我们用一个生活化的比喻想象一个鸡尾酒会。每个人每个token都有一张名片Query向量上面写着“我对什么类型的人感兴趣”。同时每个人的胸前都别着一个身份牌Key向量写着“我是谁”。当A想和别人交流时他不会挨个去读所有人的身份牌而是快速扫一眼计算自己名片和每个人身份牌的“匹配度”Q K.T。匹配度最高的几个人A就会把大部分注意力Attention Weight分配给他们然后认真听取他们的发言内容Value向量。这个过程就是Q K.T / sqrt(d_k)。为什么要除以sqrt(d_k)这是个关键细节。如果不除当d_k很大时比如512点积的结果会非常大导致softmax函数的输入值过大梯度变得极小梯度消失。除以sqrt(d_k)相当于把点积结果“标准化”到一个合理的范围保证softmax能正常工作。我在代码里特意打印过这个值当d_k128时Q K.T的均值大约是±15而除以sqrt(128)≈11.3后就变成了±1.3完美落在softmax最敏感的区间。这就是工程智慧——一个小小的除法解决了整个模型训练的稳定性问题。3.3 多头注意力不是“更多”而是“更多视角”“多头”Multi-Head常被误解为“堆更多计算资源”。其实它的本质是模型的“多重视角”系统。单头注意力就像一个人用一只眼睛看世界只能捕捉一种关系比如主谓关系。而多头则是让模型同时派出多个“观察员”每个观察员带着不同的“滤镜”去看同一句话。头1可能专注找“动词-宾语”搭配头2专攻“形容词-名词”修饰头3则盯着“介词-宾语”结构。在代码实现上它并不复杂把Q,K,V分别用不同的线性层投影h次h4然后对每一组做独立的注意力计算最后把h个结果拼接concatenate起来再用一个线性层W_O投射回原维度。这个W_O层至关重要。它不是简单的求和而是学习如何“融合”不同视角的结论。比如头1说“sat”和“mat”关系很强头2说“on”和“mat”关系很强W_O就要学会把这两个信号加权组合最终得出“sat on the mat”是一个高置信度的短语。我在训练时关闭过W_O层直接用torch.cat(..., dim-1)后不做任何变换。结果模型虽然也能收敛但生成的句子语法错误率飙升。这说明“融合”不是可有可无的步骤而是多头机制发挥价值的临门一脚。3.4 前馈网络与残差连接让模型学会“自我修正”注意力层之后是前馈网络Feed-Forward Network, FFN。它看起来像个“黑箱”Linear - ReLU - Linear。但它的作用是给模型提供一个“深度思考”的机会。注意力层负责“收集信息”而FFN则负责“加工信息”。它把来自所有注意力头的聚合向量进行一次非线性变换挖掘更复杂的特征组合。比如它可能学会将“the”、“small”、“black”、“cat”这四个词的向量组合成一个代表“小黑猫”这个新概念的向量。这里有个重要细节FFN的隐藏层维度d_ff512通常是d_model128的4倍。为什么因为研究发现这个比例能在模型容量和计算效率之间取得最佳平衡。太小模型表达能力不足太大计算开销剧增且容易过拟合。最后是残差连接Residual Connection和层归一化LayerNorm。它们是Transformer的“安全气囊”。残差连接的公式是x Sublayer(x)意思是无论注意力层或FFN层输出什么最终都和原始输入x相加。这保证了信息流永远不会被阻断即使某个子层暂时“失灵”原始信号也能直达下一层。而LayerNorm则是在每个样本的特征维度上做归一化mean和std而不是像BatchNorm那样在batch维度上做。这是因为NLP的batch里每个句子长度不同按batch归一化会引入噪声。LayerNorm让每一层的输入分布都保持稳定极大加速了训练收敛。我在第一次训练时忘了加LayerNormloss曲线像心电图一样剧烈震荡根本无法收敛。加上之后曲线立刻变得平滑像一条坚定上升的斜线。4. 实操全流程从数据准备到模型训练一步一坑4.1 数据准备用最“土”的方法造最干净的数据别被“数据集”这个词吓住。对于我们的小模型数据就是几段手工写的英文句子。我选了20个最基础的主谓宾结构比如“The dog runs.”、“A bird sings.”、“My friend eats pizza.”。然后我把它们全部转成小写去掉标点再用空格切分成词。关键一步来了构建词表vocabulary。我不是用collections.Counter统计所有词频而是采用了一种更“教学友好”的方式先列出所有可能出现的词冠词、代词、动词、名词、介词共256个然后按字母顺序编号。这样每次运行代码词表都是完全一致的避免了因随机性导致的复现困难。接着是“序列化”sequencing。我们的模型最大长度是32但句子平均只有5个词。怎么办填0padding。但0在词表里是第一个token如果直接用0填充模型会误以为“ ”是一个有意义的词。所以我定义了一个特殊的PADtoken放在词表的末尾索引255所有填充位置都用255。这看似小事却是很多初学者踩的第一个大坑。我在Colab里跑第一次时loss一直卡在2.0不动最后发现是因为填充用了0模型在学“怎么把 预测成 ”而不是学真正的语言规律。改用255后loss立刻开始下降。4.2 训练循环在loss.backward()的瞬间看见梯度的流动训练循环是整个项目的灵魂。下面是我精简后的核心代码并附上每一行的“心路历程”# 1. 初始化 model TransformerLM(vocab_size256, d_model128, nhead4, num_layers2) optimizer torch.optim.AdamW(model.parameters(), lr3e-4, weight_decay0.1) criterion nn.CrossEntropyLoss(ignore_index255) # 忽略PAD的loss # 2. 主循环 for epoch in range(10): total_loss 0 for i, (src, tgt) in enumerate(train_loader): # src是输入tgt是目标右移一位 optimizer.zero_grad() # 清空上一轮的梯度这是“清零”仪式 # 3. 前向传播这才是真正的“思考” logits model(src) # 模型输出[batch, seq_len, vocab_size] # 4. 计算损失让模型为自己的“错误”买单 loss criterion(logits.view(-1, 256), tgt.view(-1)) # 5. 反向传播这一刻梯度开始在神经网络里奔涌 loss.backward() # PyTorch自动构建计算图计算每个参数的梯度 # 6. 参数更新梯度告诉模型“往这边走能减少错误” optimizer.step() total_loss loss.item() print(fEpoch {epoch1}, Avg Loss: {total_loss / len(train_loader):.4f})最关键的是loss.backward()这一行。它不是魔法而是一个精密的数学引擎。当你调用它PyTorch会在后台为logits、loss以及所有中间变量构建一个有向无环图DAG。图上的每个节点都存储着该变量的值和它对上游变量的导数。loss.backward()就是从loss这个终点沿着图的所有边逆向传播“误差信号”最终计算出模型里每一个可学习参数比如self.W_Q.weight应该调整多少。这个过程就是模型“学习”的物理实现。为了亲眼见证它我在loss.backward()后加了一行print(Gradient norm of W_Q:, model.transformer.layers[0].self_attn.W_Q.weight.grad.norm())。我看到这个数值从12.5一路降到0.8这说明模型正在变得“老练”它的参数更新越来越精细不再像初学者那样“大刀阔斧”。4.3 推理与生成让模型第一次开口说话训练完成后最激动人心的时刻来了让它生成文本。推理inference和训练training最大的区别在于自回归autoregressive。训练时我们把整句话喂给模型让它预测下一个词而推理时模型只能看到自己刚刚生成的词。所以我们需要一个循环def generate(model, start_tokens, max_len20): tokens start_tokens.clone() # e.g., [2, 57] for The cat for _ in range(max_len): # 1. 只用当前已有的tokens做预测 logits model(tokens.unsqueeze(0)) # 加batch维度 # 2. 取最后一个位置的logits模型预测下一个词 next_logits logits[0, -1, :] # [vocab_size] # 3. 用softmax转成概率再采样 probs F.softmax(next_logits, dim-1) next_token torch.multinomial(probs, num_samples1).item() # 4. 把新词追加到序列里 tokens torch.cat([tokens, torch.tensor([next_token])]) if next_token 1: # 遇到EOS结束 break return tokens这里有个精妙的设计torch.multinomial。它不是简单地取argmax概率最大的那个而是按概率分布随机采样。这保证了生成的文本有“创造性”不会每次都输出同样的句子。比如对于“The cat”它可能生成“sat on the mat”也可能生成“chased a mouse”这取决于概率分布的形状。我在训练后期把temperature参数控制采样随机性从1.0调到0.7生成的句子立刻变得更“确定”、更“符合语法”因为降低了低概率错误选项的出现几率。这就是调参的艺术——不是玄学而是对概率分布的精细调控。5. 常见问题与独家避坑指南那些文档里不会写的“血泪史”5.1 问题速查表从报错到性能一网打尽问题现象根本原因解决方案我的实操心得RuntimeError: expected scalar type Float but found Long输入tensor的数据类型错误nn.Embedding要求long但后续层如Linear要求float在forward函数里对嵌入层输出显式转换x self.token_embedding(x).float()这是PyTorch版本升级后最隐蔽的坑。旧版本会自动转换新版本必须手动。我花了3小时debug最后发现就缺这.float()。训练loss不下降始终在2.0左右位置编码缺失或PADtoken未被ignore_index正确忽略检查nn.CrossEntropyLoss的ignore_index参数是否设置为PAD的索引确认位置编码已加到嵌入向量上ignore_index是救命稻草。没它模型一半的计算都在学“怎么预测 ”完全浪费。生成的文本全是重复词如“the the the”temperature过低或模型未充分训练logits分布过于尖锐提高temperature如从0.5到0.9或增加训练轮数让logits分布更平滑重复是模型“信心不足”的表现。它不敢冒险选低概率词只能死守最高概率的那个。GPU内存溢出OOM序列长度seq_len过大或batch_size设得太高将seq_len从64降到32batch_size从32降到8使用torch.cuda.empty_cache()内存是硬约束。宁可多跑几次也不要强行加大batch。小步快跑稳扎稳打。模型输出全是UNK或乱码词表vocabulary构建错误UNKtoken未被正确定义或映射手动检查词表字典确保每个训练句子的词都能在词表中找到对应索引UNK必须是词表里的一个有效索引词表是模型的“字典”。字典错了模型就是文盲。务必在训练前用print(vocab)把整个词表打印出来核对一遍。5.2 独家避坑技巧来自深夜debug的顿悟“梯度裁剪”Gradient Clipping不是可选项是必选项。在optimizer.step()之前加上torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)。为什么因为在小模型、小数据上偶尔会出现某个batch的梯度异常巨大比如遇到一个特别难的句子导致参数一步更新过大模型直接“发疯”loss瞬间飙到几百。梯度裁剪就像给梯度加了个“安全阀”把所有梯度向量的L2范数限制在1.0以内保证更新步伐稳健。我第一次没加模型在第87步时loss从1.2爆到230直接废掉。加上后训练如丝般顺滑。torch.no_grad()的黄金使用时机。在推理generate函数里所有操作都必须包裹在with torch.no_grad():里面。这不仅是为了节省GPU内存不保存中间梯度更是为了防止意外的梯度污染。有一次我忘了加结果在生成时模型的参数居然被悄悄更新了因为multinomial采样虽然是随机的但它依赖于logits而logits是模型输出如果没关梯度PyTorch会认为这是计算图的一部分。这会导致训练和推理状态混乱模型越“生成”越“退化”。可视化注意力不是为了炫技而是为了诊断。我写了一个小函数把attention_weights形状为[1, 4, 32, 32]取第一个头[0, 0, :, :]用plt.imshow()画成热力图。当我看到热力图里句子“The cat sat on the mat”中“sat”的行其最大值正好在“cat”和“mat”的列上时我拍案而起——那一刻我真正“看见”了模型是如何建立主谓宾关系的。这种直观的验证比看一百行loss日志都管用。它告诉我模型没在胡猜它真的在学。最后的忠告不要追求“完美loss”。我见过太多人把loss从2.0降到1.5就欣喜若狂然后立刻去挑战更难的任务。千万别。对于我们的小模型loss降到0.8以下才是它真正“开窍”的标志。因为0.8意味着模型对下一个词的预测平均已经达到了80%以上的置信度。这时它才具备了泛化能力才能去处理训练集里没见过的新句子。在此之前它只是一个优秀的“记忆机器”。耐心是训练神经网络的第一美德。6. 总结与延伸从“能跑通”到“真理解”的最后一公里写到这里你已经亲手搭建、训练、并驱动了一个真实的Transformer模型。它可能只有5万个参数生成的句子还带着稚气但它的每一个神经元、每一次梯度更新、每一份注意力权重都和GPT-4共享着同一种底层逻辑。这就是“小”的力量。它剥去了所有工程优化的外壳把Transformer最纯粹的数学心脏赤裸裸地呈现在你面前。我个人在实际操作中的体会是理解始于亲手制造的笨拙成于反复调试的耐心。那些在Colab里闪烁的红色报错那些凌晨三点还在盯着loss曲线的焦灼那些终于看到“cat sat on the mat”被正确生成时的雀跃——这些情绪才是知识真正沉淀下来的黏合剂。这个小模型后续还可以这样扩展给它加上一个简单的解码器Decoder让它能做英译中或者把它的嵌入层换成预训练的Word2Vec看看迁移学习的效果甚至把它部署到一个Flask Web服务上让你的朋友也能输入句子和它互动。但所有这些扩展都建立在一个坚实的基础上你已经知道Q K.T不是魔法而是向量间的凝视softmax不是函数而是群体决策的民主投票而loss.backward()则是知识在神经网络里奔涌的、最真实的河流。现在合上这篇文字打开你的编辑器敲下第一行import torch吧。那个你曾仰望的巨人此刻正等着你亲手为它接上第一根神经。