很多人一开始接触Transformer就是调库,像我最早也是直接拿现成工具包做情感分类,跑通了就觉得已经会了。后来真正从零写了一个MiniTransformer才意识到,框架把太多细节藏在了黑盒底下——注意力分数为什么除以根号d,层归一化放哪里为什么会影响训练稳定性,位置编码选型为什么直接决定外推能力,这些问题在文档里根本找不到答案。这篇是Transformer自然语言处理系列第三篇,我索性把手写解码器模型的完整过程整理出来,从模型骨架到分词训练,再到生成和部署,尽量把这几年踩过的坑一次说清楚。适合那些已经熟悉PyTorch基础操作、但想真正把Transformer内部结构摸透的读者,哪怕你暂时不打算从零训练大模型,搞懂这些对调试别人的代码也很有帮助。
1. 为什么第三篇决定手写Transformer:框架黑盒的代价
1.1 框架用久了,反而不知道模型在干什么
我见过不少同学,用开源代码微调大模型头头是道,Loss曲线调得一帆风顺,但一旦模型崩溃,就完全无从下手。原因很简单:他们对模型内部发生的计算没有具体感知。
手写一个训练规模不大的Transformer从零开始的价值就在这里——你能亲眼看到每一步张量的形状怎么变化,注意力矩阵长什么样,梯度在反向传播时数值是否异常。这个"可视化心智模型"一旦建立起来,之后再去读那些工业级代码库,速度会快一个量级。
更实际的好处是调试。我自己写的时候,曾经因为注意力掩码矩阵的维度排列问题,让模型悄悄看到了未来时刻的信息,训练出来的loss漂亮得离谱,生成的时候却驴唇不对马嘴。这种错误在调库的场景下几乎不可能定位,但在手写代码里,我逐步检查张量shape就能一眼看出问题。
1.2 手写的目标:只做最核心的Decoder-only
我们从零搭建Transformer通常有三条路线:完整的Encoder-Decoder结构适合翻译任务,Encoder-only适合理解任务,Decoder-only适合生成任务。本文选Decoder-only,原因有两点。
第一,现在主流自然语言处理应用大都围绕生成展开,Decoder-only结构不仅能做续写,加一些改造就能做分类,实际应用价值最大。第二,它的掩码机制——因果掩码——让实现过程更能暴露Transformer的细节难点。你一旦把Decoder-only从头到尾写通了,再回头理解编码器的自注意力简直像看简化版。
定了结构之后就是范围控制。我的目标是一个参数规模在150M以内的字符级/BPE混合小模型,单卡可以训练。别一上来就想复刻几百B参数的大模型,先把推理链路做通,把注意力机制写透,比追求规模有价值得多。
2. 手写Transformer:从多头注意力到位置编码的取舍
2.1 模型维度怎么定?d_model、num_heads与head_dim
手写模型的第一个决策点就是维度设计。我先给出一套我自己用的参数配置,后面所有代码都基于这套配置展开。
| 参数 | 数值 | 说明 |
|---|---|---|
| vocab_size | 32000 | 取决于BPE词典大小,实际训练前确定 |
| d_model | 512 | 词向量及隐藏层维度 |
| num_heads | 8 | 注意力头数 |
| head_dim | 64 | 等于d_model / num_heads,这是硬性要求 |
| num_layers | 8 | 解码器层数 |
| max_seq_len | 512 | 训练序列最大长度 |
| dropout | 0.1 | 训练时默认dropout比例 |
这里关键的一条是head_dim必须等于d_model整除num_heads的结果。多头注意力的做法是把d_model维度的向量切分成num_heads份,每一份做独立的自注意力,再拼接回去。如果d_model=512,num_heads=8,head_dim就是64;但是如果d_model=512,num_heads=7,那就没法均匀切分,实现上还得特判padding,非常麻烦,所以设计模型时尽量让除法整除。
每个头的计算本身和单头注意力没本质区别。对第i个头,Q、K、V分别经过独立的线性投影,得到形状为(batch_size, seq_len, head_dim)的张量,然后计算缩放点积注意力:
import torch import torch.nn.functional as F def scaled_dot_product_attention(q, k, v, mask=None, dropout=None): d_k = q.size(-1) # q, k, v: (batch, heads, seq_len, head_dim) scores = q @ k.transpose(-2, -1) / (d_k ** 0.5) if mask is not None: scores = scores.masked_fill(mask == 0, float("-inf")) weights = torch.softmax(scores, dim=-1) if dropout is not None: weights = dropout(weights) return weights @ v, weights为什么除以根号d这个细节值得说。如果不缩放,当head_dim较大时,点积结果方差也会变大,softmax会掉进梯度极小的饱和区,反向传播时梯度接近于零,训练会非常慢甚至完全停摆。除以根号d可以保证点积结果的方差稳定在一个合适量级,这个操作是整套Transformer训练的基石之一。
2.2 因果掩码:为什么mask矩阵必须做成下三角
Decoder-only模型里的掩码稍微复杂一点。核心逻辑是自助回归生成时,第t个位置只能看到第t个位置及之前的信息,不能偷看后面的词。实现上就是把softmax前的score矩阵中右上角部分填充为负无穷:
def build_causal_mask(seq_len): return torch.tril(torch.ones(seq_len, seq_len)).bool()配合批量训练时,还需要处理padding部分。一个batch里的序列长度各不相同,短序列的尾部有padding token,这部分一定不能参与注意力计算。处理方法是在attention里同时传入两个掩码:一个是causal mask,一个是padding mask,padding mask用来把padding位置的score也置为无穷小。
我第一次写就栽在这两套mask的合并上。正确做法是causal mask和padding mask做逻辑与,只保留既属于因果可见范围、又不是padding位置的格子。代码里用bool masked_fill一次搞定:
# (batch, 1, seq_len, seq_len) combined_mask = causal_mask & padding_mask.unsqueeze(1).unsqueeze(2) scores = scores.masked_fill(~combined_mask, float("-inf"))2.3 位置编码选型:可学习位置编码与旋转位置编码
Transformer本身没有顺序概念,引入位置信息的方案有好几种。初版论文用正弦余弦编码,后来的实践分化成两个方向:可学习位置编码和旋转位置编码(RoPE)。
可学习位置编码就是把位置向量做成一个可训练的嵌入表,简单直接。它的优点是实现容易,在小模型上效果也很稳;缺点是外推能力差,训练时最大长度512,到推理时一旦输入长度超过512,模型表现会急剧下降。
旋转位置编码则更优雅,它把相对位置信息通过旋转矩阵注入到Q、K向量中,核心性质是内积只与相对位置有关。RoPE在长文本场景下明显更占优,但是实现复杂度也更高。
我做这个项目时选择的是可学习位置编码。原因太简单:训练语料长度普遍不超过512,短文本上两种方案差异几乎可以忽略,而可学习编码实现成本低、更容易把注意力训练稳定。如果后期确实要处理长文本,切换到RoPE也不难,训练代码主体不用大改。
2.4 Layer Norm放哪里:Pre-LN和Post-LN的分水岭
初版Transformer把LayerNorm放在每个子层之后,这种叫Post-LN;现代很多实现把LayerNorm放在子层输入处,这叫Pre-LN。两者看起来只是挪了个位置,实际训练稳定性差距很大。
Post-LN在训练初期特别容易炸,因为梯度要穿过残差连接和LayerNorm叠加的路径,数值稍有不慎就发散。Pre-LN把LayerNorm放在残差连接的起点,等于给每一条子层路径都做了标准化,梯度流更平稳,所以现在大部分开源模型都用Pre-LN。
我选择Pre-LN,还用了一个关键初始化技巧:对每个子层最后的线性输出乘上一个缩放系数,系数与层数相关,常见做法是乘上(2 * num_layers)^-0.5。这个操作能在网络较深时抑制梯度方差爆炸。用上之后,8层模型训练稳定度肉眼可见地提升。
2.5 前馈网络:从ReLU到GELU再到GLU
Transformer每个解码器层里除了注意力,还有一层位置前馈网络。经典版本是两层线性+ReLU激活+高维映射,d_model从512升到2048再降回512。现代实现大多用GELU激活,因为它的曲线更平滑,梯度不像ReLU那样在零点硬变向。
我自己的经验是,PyTorch里直接F.gelu就好,无缝替换。更激进的选择是GLU变体,它在某些场景下能提升表现,但参数量和计算量会增加。对这个小模型够用就好。
class FeedForward(nn.Module): def __init__(self, d_model, d_ff, dropout=0.1): super().__init__() self.w1 = nn.Linear(d_model, d_ff) self.w2 = nn.Linear(d_ff, d_model) self.dropout = nn.Dropout(dropout) def forward(self, x): return self.w2(self.dropout(F.gelu(self.w1(x))))3. 让模型真正开始"说人话":数据、分词与训练策略
3.1 训练语料的预处理思路
有了模型结构,接下来的一切都围绕数据展开。我在这个项目中使用的语料是一批公开爬取的中文与英文混合文本,数量级在几千万行。数据清洗这一步看似不起眼,实际决定训练成败。
清洗流程按优先级排:先去除HTML标签和无意义符号,再过滤掉过短和过长的行,接着做段落切分,最后按连续性拼接成固定大小的训练切片。这些操作看似和Transformer无关,但脏数据会让分词器学到一堆垃圾token,模型后续生成质量直接打折。
还要特别注意语料的平衡性。如果全是短句,模型只能学会输出短句;如果混入了大量文本稀疏但显而易见过拟合的样板文本,模型生成时会频繁复读这些套路句子。行业里管这叫"数据污染",效果非常隐蔽,初期训练loss正常,生成却崩得一塌糊涂,很多新人排查半天都找不到原因。
3.2 分词器选型:字符级还是BPE?
小规模训练时有两个选择:字符级分词和BPE分词。字符级就是把每个汉字、每个英文字母当成一个token,词表大小几百,实现省事,但序列会被拉得很长,模型学语义的表达效率低。BPE则是从数据中统计学出子词切分规则,比如"自然语言处理"可能被切成"自然"、"语言"、"处理"三段子词,词表压缩效率高。
我的建议是,如果语料总量在1亿字符以上,直接上BPE;如果只是做小玩具验证,字符级也能跑通。我在这里用的是SentencePiece的BPE模式,词表32000,加上特殊token<pad>、<bos>、<eos>、<unk>。
分词器训练也要匹配推理时场景,千万别用训练语料之外的分词器,否则token id对不上,模型输出就是乱码。这个低级错误我踩过不止一次。
3.3 数据加载:动态padding比固定长度省资源
混合语料里,文本长度天然长短不一。常见做法是统一截断成固定长度,用固定长度batch训练,简单但浪费。我的做法是动态padding:按文本长度排序分组,每次组内取最大长度为batch尺寸,padding到这个最大值,整体显存利用率和训练速度都有提升。
实际代码在torch.utils.data.DataLoader里加一个collate_fn就行。关键点是训练阶段对padding部分要加掩码,前面已经提过;预测阶段则不需要padding mask,因为是单条推理。
还有一个细节是采样顺序。如果完全随机采样,相邻样本长短差异过大,动态padding的收益就不明显。我用的是bucket sampling策略,按长度分桶,桶内随机打乱,这样每个batch内的序列长度接近,padding率能降到20%以下。
3.4 优化器与学习率调度:AdamW与Warmup
Transformer训练的优化器选择基本没什么悬念:AdamW。AdamW和传统Adam的区别在于权重衰减的实现方式——AdamW把权重衰减从梯度更新中解耦,能有效降低过拟合风险,现在已经是主流默认。
学习率调度上,我强烈建议使用warmup配合后续衰减。Warmup阶段学习率从极小值线性爬升到峰值,作用是让模型在前几百步先稳住注意力头和LayerNorm的参数,避免一开始就用大学习率把梯度更新方向带偏。峰值后可以用cosine退火慢慢降低学习率,直到训练结束。
实际参数:峰值学习率5e-4,warmup步数占训练总步数的2%左右,比如总步数20000,warmup就400步。这个比例是我从多次实验中总结出来的经验值,太大会延长收敛时间,太小则容易在初期就出现loss抖动。固定好这些参数之后,我的Loss曲线基本是一条平滑下降的弧线。
3.5 损失函数:CrossEntropy与标签平滑
生成任务的损失就是下一步token预测的交叉熵。这里有一个容易被忽略的细节:padding位置不参与loss计算。做法是把logits和label都压平后,只取label不是<pad>的部分做cross entropy。
标签平滑是另一个值得开的开关。它会把"唯一正确答案"的一-hot标签稍微抹平,让模型不至于过度自信,对缓解过拟合和提升泛化能力都有帮助。这里设置为0.1,即在损失计算时把真实token的置信度从1降低到0.9,剩下的0.1平均分配给其他词表token。
顺带提醒一点,如果发现模型训练时loss总是卡在自然语言模型的"理论下界"附近,甚至低于论文报告的数字,先别高兴,很可能是学习率设置太低或者标签平滑失效导致了某种梯度坍缩。
4. 训练MiniTransformer时最常翻车的三个点
4.1 损失原地不动或稳步走高:先查学习率,再查掩码
训练开始后如果发现Loss前一百步纹丝不动,我第一个怀疑的是学习率。太大或太小都会让模型停在原地,太大时甚至直接NaN。调试方法是用一个极小的子集跑几十步,逐渐升高学习率,观察Loss是否出现先降后升的曲线,由此找到合理区间。
如果学习率正常但Loss依然不动,就要手动验证注意力掩码。我前面提到的因果掩码错误曾让我白白烧了大半天训练时间。怎么自查?写一段很小的序列,模拟前向计算,存下attention权重,人工检查第t行是否只有前t列有非零值。只要有一步越界,立刻就能看出来。
还有一个隐蔽的坑是embedding层和最后的输出层共享权重时,词表过大可能会导致输出层梯度爆炸。解决办法是输出层不要直接复用embedding权重,或者单独加一层LayerNorm做输出标准化。这两种做法在小模型上都验证有效。
4.2 显存报错和梯度截断
训练时经常遇到的第二个大坑是显存问题。8层Decoder + batch size 32 + seq_len 512,在单张消费级显卡上很容易吃紧。我的处理顺序是:先开PyTorch的混合精度训练,也就是AMP,显存能省一半左右,速度也能提升;还不够,就开gradient checkpointing,在每一层前向时丢弃中间激活,反向传播时重新计算,用时间换空间;最后还是不够,再调小batch。
梯度截断这个操作最好养成习惯。torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)放在loss.backward()之后、optimizer.step()之前。它能在某些异常batch产生巨大梯度时避免整个模型的参数被彻底冲坏,训练稳定性的提升非常显著。
4.3 过拟合、记忆化与幻觉:问题往往不在模型结构里
损失一直降得很好,但生成出来的文本反复出现训练语料里的原文段落,这是典型的记忆化现象。说明小模型没有真正学会语言规律,只是在背诵语料片段。缓解办法有几种:提升训练数据多样性、加大dropout、使用标签平滑、降低模型层数或宽度。
更深层的原因是模型参数规模相对语料太小或太大时,都会诱导记忆化。我亲身实验过,把模型从150M缩到40M时,反而出现了更多复读原文的现象,因为规模不够大,根本没法把复杂语法规律抽象出来,只能背。这个度需要靠实验来确定,不要一味堆参数,也不要认为模型越小越安全。
5. 推理解码:生成质量与速度的平衡
5.1 采样策略对比:贪心、Beam Search与Top-p
模型训练完成之后,生成文本的方式对结果影响特别大。贪心解码每次取概率最大的token,速度快,但容易陷入循环,且缺少多样性。Beam Search在机器翻译上表现好,但在开放性生成上经常产生重复严重的句子。
Top-k和Top-p是目前的主流选择。Top-k限制候选集为概率最高的k个token,Top-p则按累计概率动态截断候选集。两者可以结合使用,我在小模型上最常用的配置是k=50同时p=0.9,能比较好地平衡多样性和流畅度。
采样还受到temperature控制。temperature大于1时分布变平,输出更加随机;小于1时分布变尖,输出更保守。实际生成时,我一般从temperature=0.8开始调,然后再微调k和p,每次只看十几条输出就能判断方向。
5.2 重复生成病灶:n-gram惩罚与重复惩罚系数
生成质量最典型的问题是复读机。单靠采样策略很难根除,通常需要配合重复惩罚。最粗暴有效的方式是no_repeat_ngram_size=3,强制模型不生成任何重复的三元组。这种硬性限制能消灭大部分循环,但有可能切断一些本来合理的表达,长文本生成到最后会变得生硬。
另一个手段是Fisher惩罚系数的变体,即在候选token概率上乘以下面这个值:出现次数越多惩罚越大。我把系数设在1.2到1.5之间,它能有效减重复,同时又不会像ngram惩罚那样完全禁止可能出现的长序列重复结构。
我建议的组合是:短文本生成时只给温度+Top-p,不加强制惩罚;长文本生成时再加no_repeat_ngram_size和重复惩罚。两个场景分开调试,比单一配置通打天下效果更好。
5.3 推理速度优化:KV Cache和批处理
生成阶段的慢,主要来自每次只解码一个token,但又要对整段历史做完整前向。最成熟的优化就是KV Cache:把之前所有位的Key和Value缓存下来,新token只需计算自身的Q,再与缓存拼接后的K、V做注意力。
实现上就是在解码循环里维护两个缓存张量,把每一步新算出的K、V沿seq_len维度concat进去。第一次解码时缓存为空,后面的步数全部复用。实测KV Cache在512长度序列上,解码速度能提升3-5倍。如果还想更快,可以把多个请求拼成一个batch一起做推理,GPU吞吐会大幅上涨,但要注意各条序列的padding处理。
6. 从玩具到实际工程:微调、量化和部署的取舍
6.1 从零训练后,要不要在开源基座上做微调?
手写训练出来的小模型,生成质量和通用能力肯定是无法和成熟的开源模型比的。如果想要得到一个可用性更强的小模型,最可行的路线是拿着别人训练好的基座模型做下游任务的微调,而不是从零预训练。
微调阶段要关注三个点:下游任务数据的质量是第一优先,数据条数从几千到几万都有作用,关键是领域覆盖要准;学习率要比预训练低一到两个数量级,比如5e-5到1e-4;冻结部分底层参数,只微调高层也可以显著降低过拟合风险。这些经验在我做具体下游任务时反复验证过。
6.2 轻量化部署的路子:量化与导出
模型训练好不等于能上线。部署时先把模型导出成推理引擎格式,比如ONNX,再开启动态量化,把权重从FP32压到INT8,模型体积和推理延迟都会明显下降。量化的代价是精度损失,尤其在计算密集的注意力部分,实测在分类任务上F1下降通常在1%以内,文本生成任务上则可能更明显。
如果显存还紧张,还可以考虑蒸馏。用一个更大的教师模型指导小模型学习,让输出概率分布尽量接近教师模型。蒸馏后的小模型在保持较小体积的同时,效果往往比直接训练同样规模的新模型要好。这条路在移动端或低配CPU场景几乎是标配。
6.3 这次手写之后,我对Transformer的重新理解
最后说点个人体会。这个项目做完之后,我最大的变化是再也敢只看图就能说懂Transformer了,而是习惯性地从"具体张量形状怎么流动"去理解每一篇新模型结构。以后遇到open-source代码里一个奇怪的reshape或者transpose,我能很快意识到它服务于哪些约束。
另一个收获是训练和调试的依赖关系被彻底打通。以前遇到Loss曲线异常只会换数据换学习率,现在会先去看模型的归一化层分布、注意力权重、梯度范数这些内部信号,定位问题的速度比之前快了许多。
这次代码整体规模不大,但足以成为理解现代语言模型的支点。如果你也想把Transformer彻底吃透,我很建议你也动手写一遍,哪怕只是实现一个能跑通的最简版本,回报率远超想象。