ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

手撕Transformer:从零实现注意力机制与核心模块

手撕Transformer:从零实现注意力机制与核心模块

最近有朋友问我,想彻底搞懂注意力机制和Transformer架构,到底该怎么学。我给的答案一直很固定:别只盯着论文和现成框架的文档看,自己动手把Transformer从零实现一遍。这就是所谓的“手撕Transformer”,也是我见过提升对深度学习模型理解最有效的方式,没有之一。这篇博文就围绕这件事展开,记录我从拿到这个任务到真正把一个可运行的Transformer训练出来,整个过程里踩过的坑、想明白的原理,以及沉淀下来的实操方法,希望对准备“手撕”或者正在“手撕”的你有实际帮助。

手撕Transformer不是让你去复刻一个能打榜的大模型,而是用最朴素的代码,把注意力机制、多头投影、位置编码、残差归一化这些零件一个个造出来,亲眼看到每个张量在每个步骤里的形状变化、数值流动。它能解决的问题很具体:为什么维度要除以根号d_k,为什么要有因果掩码,为什么训练和推理的行为会不一致,这些如果只停留在“好像是这样”的层面,一旦自己上手调模型就会立刻露馅。适合动手做这件事的人,包括刚入门深度学习、想真正吃透Transformer的学生和工程师,也包括已经在用现成框架、但遇到调试问题总感觉隔着一层纱的从业者。

我自己的体会是,手撕一遍之后,再看各类预训练模型的源码、再排查线上模型的失败案例,视角会完全不一样。你不再是一个只会改超参数的操作员,而是一个能看懂内部机制、能定位问题层级的工程师。下面我就从为什么要手撕、核心模块怎么理解、具体怎么实现、会遇到哪些坑这几个维度,把整个过程中的积累一次性写清楚。

1. 为什么值得“手撕”一次Transformer

1.1 从调包到造轮子,认知差在哪里

现在深度学习框架生态已经非常完善,一行代码就能加载预训练模型,两行代码就能跑推理。在这种环境下,很多人对Transformer的理解停留在API层面:知道调用某个类、知道输入输出形状、知道loss大概怎么算。但一旦模型效果不好,或者新的论文里出现一个变体结构,就完全无从下手。

手撕Transformer解决的就是这个认知断层。当你亲手实现多头注意力的时候,你会被迫面对几个绕不开的问题:Q、K、V各自经过的线性层输出维度是多少,为什么最后要拼接再投影,为什么缩放因子是根号d_k而不是别的数值。这些细节在官方文档里通常只是一句话,在别人的源码里只是几行代码,但只有自己写一遍、调试一遍,才会在大脑里建立起真正的计算图。

我见过不少同事,用现成框架很熟练,但一说到“为什么这里要做层归一化,放在残差之前还是之后”就说不清楚。不是他们不聪明,而是因为这些东西从来没有成为他们必须亲自解决的问题。手撕的过程,本质上是把“别人替你封装好的决策”重新摊开,一个个审视、一个个验证。

1.2 手撕不是重复造轮子,而是为理解服务

有人会抬杠,说生产环境难道要自己写Transformer吗?当然不是。手撕的目标完全不是替代框架,而是为了获得调试和改进模型的能力。

比如你在用某个预训练模型做fine-tuning,发现训练不稳定、loss震荡得厉害。如果你清楚Pre-Norm和Post-Norm的区别,就知道问题很可能出在归一化位置和残差结构的组合方式上。如果你只是调包,你根本不知道还有这种可能。再比如你想给注意力加上某种稀疏约束,或者把相对位置编码换成旋转位置编码,这些改动在没有理解底层实现的情况下,几乎无从下手。

我个人的建议是,每个想做深度学习算法工程师方向的人,都应该至少完整手撕一次Transformer。不是为了简历上多一行字,而是为了让你在后续所有模型相关的讨论中,都有底气说自己“知道里面是怎么回事”。这个底气会在你调试模型、复现论文、设计新结构的时候,持续发挥作用。

1.3 动手前需要什么基础

手撕Transformer需要的基础其实不高,但有一些底线。首先是Python和PyTorch的基本用法,包括张量操作、维度变换、自动求导,这些至少要熟练。其次是深度学习的核心概念,比如损失函数、优化器、反向传播、过拟合,不需要多深,但要知道训练一个模型大概是怎么个流程。最后是基本的线性代数直觉,尤其是矩阵乘法里“行和列怎么对齐”,因为Transformer里到处是维度匹配问题。

坦白说,如果你连张量形状变化都还搞不清楚,手撕过程会非常痛苦。但反过来说,手撕Transformer也是提升张量操作能力最好的训练场之一。你不需要先精通再动手,可以边写边查,踩几次维度不匹配的报错,自然就记住了。

2. 核心模块拆解:每一层的计算到底在做什么

2.1 自注意力:Q、K、V到底在干什么

很多人第一次接触自注意力,最困惑的就是Q、K、V这三个字母到底代表什么。我当时费了很大劲才建立起直观理解。你可以把自注意力想象成一场内部检索:每个词都同时扮演三个角色,它既是一个发出查询的人,也是一个被匹配的标签,还是一个最终被提取的内容。

具体到计算上,输入序列的每个位置有一个词向量。通过三个不同的线性变换,分别得到Query、Key、Value。Query的意思是“我在找什么”,Key的意思是“我身上带什么标签”,Value则是“我实际提供什么内容”。注意力分数就是每个Query和所有Key做点积,衡量匹配程度,然后用softmax转成权重,最后对每个Value做加权求和。整个过程输出的是每个位置根据全局信息融合后的新向量。

为什么点积之后要除以根号d_k呢?这是自注意力里第一个值得深究的细节。如果Query和Key的维度d_k比较大,点积的数值会随之变大,softmax的输入分布会变得很尖锐,梯度容易消失。除以根号d_k相当于把点积的方差压回一个合理范围。我当时用维度64和128分别试过,确实能感觉到缩放因子对训练稳定性的影响。这不是什么玄学,是纯粹的数值稳定性考量。

2.2 多头机制:从“一个视角”到“多个视角”

多头注意力可能是Transformer里最精妙、也最容易被人忽视的设计。直觉上,一个注意力头只能学习一种匹配模式,比如“主语关注谓语”或者“名词关注修饰语”。但实际语言里的依赖关系多种多样,单头很难覆盖全部。多头机制的做法是,把原始的Q、K、V分别投影到多个低维子空间,在每个子空间独立计算注意力,最后拼接起来再投影回完整维度。

关键细节在于,多头的“分割”不是把输入向量按段切开,而是通过不同的线性投影来实现的。比如d_model是512,head数量是8,那么每个头的维度d_k就是64。这8组线性层分别把512维投影到64维,各自计算注意力,得到8个64维的输出,拼回去是512维,再经过一个输出投影恢复或变换维度。换一种说法,多头不是让模型看8份相同的东西,而是让模型从8个不同的表示子空间去看同一份输入。

我在写代码的时候最容易出错的地方就是维度变换的顺序。PyTorch里典型的做法是先把Q、K、V分成多头,然后调换维度顺序,变成batch size、head数量、序列长度、每个头的维度。如果不小心把head维度和sequence维度搞混,后面矩阵乘法一定报错。这种错多踩几次,你就对“张量形状即信息结构”这句话有体感了。

2.3 位置编码:让模型知道词序

Transformer没有循环结构,也没有卷积的局部感应偏置,它对序列顺序本身是无感的。如果把“我喜欢你”和“你喜欢我”的输入词向量按不同顺序放进Transformer,在不加位置信息的情况下,模型看到的东西几乎是一样的。位置编码就是为了打破这种置换对称性,把位置信息注入到输入表示里。

原版Transformer使用的是正余弦位置编码。对每个位置pos和每个维度i,用sin和cos交替生成数值。这个设计有几个好处。首先它不需要学习参数,直接根据公式生成,简单可靠。其次sin和cos函数具有相对位置线性表达的特性,模型可以通过学习一组权重,间接表达相对位置关系。最后它对序列长度没有硬性限制,理论上可以外推到训练时没见过的更长序列。

我在实现的时候踩过一个小坑,就是位置编码和词向量相加之后,向量分布的数值尺度变了,导致前几轮训练特别不稳定。后面我意识到,词向量通常会乘以一个缩放系数(比如根号d_model),让词向量和位置编码在同一个数量级上,再相加才不会出现位置信息主导一切的问题。这个细节不实操很难自己悟出来。

2.4 残差连接与层归一化:稳定训练的基石

Transformer的每个子层外面都包了一层残差连接和层归一化,这是让深层网络真正能训练起来的关键。残差连接的含义是,把子层输入直接加到子层输出上。这样做的好处是把网络变成对“增量”的学习,每一层只需要学习相对于输入的变化部分,梯度可以直接从输出流回输入,极大缓解了深层次中的梯度消失问题。

层归一化则是沿着特征维度做标准化,让每个样本的隐藏层输出保持稳定的均值和方差。这和BatchNorm不同,LayerNorm不依赖batch内其他样本,对变长序列和不同batch size都更友好,所以特别适合Transformer这种结构。

真正动手的时候,你会发现Pre-Norm和Post-Norm的差异非常微妙。Post-Norm是原始论文的写法,先经过子层计算,再做残差相加和LayerNorm,结构简单但深层容易训练不稳定。Pre-Norm则是在输入进子层之前先做归一化,残差路径不经过归一化,训练更稳定,很多现代模型都采用这种变体。我实际对比下来,在同等数据规模下,Pre-Norm确实更好训练,尤其是在模型层数比较多的时候。理解了这两种结构,再看某个模型代码里为什么那样排布,就不会一头雾水了。

3. 实操过程:从零搭建一个可运行的Transformer

3.1 工具选型与数据准备

我手撕Transformer用PyTorch,主要原因有两个。一是PyTorch的张量操作和自动求导写起来最接近数学定义,几乎可以照着伪代码直接翻译。二是它的调试体验好,维度不匹配的报错信息相对清楚,对新手更友好。TensorFlow和JAX当然也能做,但如果你刚接触,我建议优先选PyTorch。

数据方面,不需要一上来就上大规模语料。我建议先用一个小型数据集验证模型结构和训练流程是正确的,比如我自己当时用了一个截断的文本语料,大概几万句的样子,词表控制在几千。为什么这么做?因为Transformer参数量不小,在小数据上能快速跑完一个训练轮次,方便你观察模型能不能过拟合。如果模型连小数据都过拟合不了,说明代码或配置大概率有问题,这时候修bug的成本比大数据上低得多。

数据预处理的关键步骤包括:分词、构建词表、将文本转为id序列、按固定长度做padding和截断、生成批次数据。这里有一个容易忽略的细节:训练时需要对源序列和目标序列分别处理,解码器输入通常要在目标序列前面加一个起始符,并去掉最后一个词,而标签则要去掉开头的起始符。这个错位处理直接关系到teacher forcing训练的正确性,我第一次写的时候就漏了这个,导致loss虽然在降,但生成出来的东西乱七八糟。

3.2 核心代码实现:按模块逐一打通

现在进入正题,我把关键模块的实现逻辑完整写出来。这里给的是清晰版,不是精简混淆版,每个部分都能和前面的原理对应上。

首先是多头注意力模块:

import torch import torch.nn as nn import torch.nn.functional as F import math class MultiHeadAttention(nn.Module): def __init__(self, d_model, n_heads, dropout=0.1): super().__init__() assert d_model % n_heads == 0, "d_model必须能被n_heads整除" self.d_model = d_model self.n_heads = n_heads self.d_k = d_model // n_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.out_proj = nn.Linear(d_model, d_model) self.dropout = nn.Dropout(dropout) def forward(self, query, key, value, mask=None): batch_size = query.size(0) # 投影到Q/K/V,然后拆多头 Q = self.w_q(query).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2) K = self.w_k(key).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2) V = self.w_v(value).view(batch_size, -1, self.n_heads, 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_weights = F.softmax(scores, dim=-1) attn_weights = self.dropout(attn_weights) context = torch.matmul(attn_weights, V) # 多头拼接并投影 context = context.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model) output = self.out_proj(context) return output, attn_weights

这段代码里有几个地方值得特别说明。view和transpose的顺序必须先分成多头再交换维度,因为原始Q的最后一维是d_model,天然就是n_heads个d_k拼接在一起,所以先view再transpose是对的。如果顺序反了,会在语义上打乱头的边界。masked_fill在mask为0的位置填充一个很小的负数,这样softmax之后这些位置权重接近0,就不会attend到它们。

然后是位置编码。这里我把实现写成一个模块,注册为buffer,不参与梯度更新:

class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len=5000): super().__init__() 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): return x + self.pe[:, :x.size(1), :]

这个实现的数学原理不复杂,但有几个细节要注意。偶数索引维度用sin,奇数索引维度用cos,交替排列。div_term的作用是让不同维度拥有不同的角频率,低维度变化慢,高维度变化快,这样位置编码就能用不同的“分辨率”去表示位置。实际用的时候,通常会给词向量乘一个缩放系数,我习惯在Embedding后面乘math.sqrt(self.d_model),这样词向量就和位置编码在同一个量级。

接下来是编码器层。它由多头注意力、前馈网络、LayerNorm和残差连接组成。我这里用Pre-Norm的实现方式:

class FeedForward(nn.Module): def __init__(self, d_model, d_ff, dropout=0.1): super().__init__() self.linear1 = nn.Linear(d_model, d_ff) self.linear2 = nn.Linear(d_ff, d_model) self.dropout = nn.Dropout(dropout) def forward(self, x): return self.linear2(self.dropout(F.relu(self.linear1(x)))) class EncoderLayer(nn.Module): def __init__(self, d_model, n_heads, d_ff, dropout=0.1): super().__init__() self.self_attn = MultiHeadAttention(d_model, n_heads, dropout) self.feed_forward = FeedForward(d_model, d_ff, dropout) self.norm1 = nn.LayerNorm(d_model) self.norm2 = nn.LayerNorm(d_model) self.dropout1 = nn.Dropout(dropout) self.dropout2 = nn.Dropout(dropout) def forward(self, x, mask=None): # Pre-Norm结构 attn_output, _ = self.self_attn(x, x, x, mask) x = x + self.dropout1(attn_output) x = self.norm1(x) ff_output = self.feed_forward(x) x = x + self.dropout2(ff_output) x = self.norm2(x) return x

第二层注意力关键之处在于实现DecoderLayer。它包含自注意力、交叉注意力和前馈网络三部分:

class DecoderLayer(nn.Module): def __init__(self, d_model, n_heads, d_ff, dropout=0.1): super().__init__() self.self_attn = MultiHeadAttention(d_model, n_heads, dropout) self.cross_attn = MultiHeadAttention(d_model, n_heads, dropout) self.feed_forward = FeedForward(d_model, d_ff, dropout) self.norm1 = nn.LayerNorm(d_model) self.norm2 = nn.LayerNorm(d_model) self.norm3 = nn.LayerNorm(d_model) self.dropout1 = nn.Dropout(dropout) self.dropout2 = nn.Dropout(dropout) self.dropout3 = nn.Dropout(dropout) def forward(self, x, encoder_output, src_mask=None, tgt_mask=None): # 解码器自注意力,只能看当前位置之前 attn_output, _ = self.self_attn(x, x, x, tgt_mask) x = x + self.dropout1(attn_output) x = self.norm1(x) # 交叉注意力,Q来自解码器,K/V来自编码器 attn_output, _ = self.cross_attn(x, encoder_output, encoder_output, src_mask) x = x + self.dropout2(attn_output) x = self.norm2(x) ff_output = self.feed_forward(x) x = x + self.dropout3(ff_output) x = self.norm3(x) return x

解码器有两个需要特别记住的地方。第一,自注意力的mask必须是因果mask,形状是目标序列长度乘以目标序列长度,上三角区域为0,这样当前位置只能attend到它自己以及之前的位置。第二,交叉注意力的Query来自解码器侧,Key和Value来自编码器侧的输出,这是信息从编码器流向解码器的核心通道。如果在实现时搞反了Q/K/V的来源,模型完全学不到东西。

掩码生成是一个小而关键的环节:

def create_mask(src_input, tgt_input, pad_idx): src_mask = (src_input != pad_idx).unsqueeze(1).unsqueeze(2) tgt_pad_mask = (tgt_input != pad_idx).unsqueeze(1).unsqueeze(2) seq_len = tgt_input.size(1) tgt_sub_mask = torch.tril(torch.ones((seq_len, seq_len), device=tgt_input.device)).view(1, 1, seq_len, seq_len) tgt_mask = tgt_pad_mask & tgt_sub_mask return src_mask, tgt_mask

这里要注意的是,padding mask和因果mask必须用逻辑与叠加在一起,而不是简单地在scores里分别应用两次掩码。如果只做了padding mask而忽略了因果mask,解码器就会提前看到未来位置的信息,训练时看似loss很低,但到了推理阶段会完全崩掉。这是新手最容易忽略但又最致命的错误之一。

最终组合成完整Transformer模型:

class Transformer(nn.Module): def __init__(self, vocab_size, d_model=512, n_heads=8, num_encoder_layers=6, num_decoder_layers=6, d_ff=2048, dropout=0.1, max_len=5000): super().__init__() self.embedding = nn.Embedding(vocab_size, d_model) self.pos_encoding = PositionalEncoding(d_model, max_len) self.encoder_layers = nn.ModuleList([ EncoderLayer(d_model, n_heads, d_ff, dropout) for _ in range(num_encoder_layers) ]) self.decoder_layers = nn.ModuleList([ DecoderLayer(d_model, n_heads, d_ff, dropout) for _ in range(num_decoder_layers) ]) self.fc_out = nn.Linear(d_model, vocab_size) self.dropout = nn.Dropout(dropout) def forward(self, src_input, tgt_input, src_mask, tgt_mask): # 编码器 src_emb = self.dropout(self.pos_encoding(self.embedding(src_input) * math.sqrt(self.d_model))) encoder_output = src_emb for layer in self.encoder_layers: encoder_output = layer(encoder_output, src_mask) # 解码器 tgt_emb = self.dropout(self.pos_encoding(self.embedding(tgt_input) * math.sqrt(self.d_model))) decoder_output = tgt_emb for layer in self.decoder_layers: decoder_output = layer(decoder_output, encoder_output, src_mask, tgt_mask) logits = self.fc_out(decoder_output) return logits

3.3 训练流程与损失计算

有了模型之后,训练流程需要按标准套路来写:前向传播得到logits,计算logits和标签之间的交叉熵损失,然后反向传播更新参数。但在实现时有一个细节需要特别注意,就是数据标签的错位对齐。

比如目标句子是“我爱自然语言处理”,我们给解码器的输入是“ 我爱自然语言处理”,而我们要模型预测的标签则是“我爱自然语言处理 ”。这样模型在每一步都学习“根据之前的词预测下一个词”,和推理阶段逐词生成的行为完全一致。实现时,我把标签序列的padding位置全部设为ignore_index,这样计算loss时那些位置不参与梯度,不会干扰真实标签的统计。

训练几个关键超参数设置如下:优化器用Adam,初始学习率按Transformer论文里的Noam调度方案动态调整,也就是先线性预热再按step数的倒数开根号衰减。这个设计很有意思,预热阶段让模型先在小学习率下稳定梯度,之后再放大学习率快速学习,能明显减少训练初期发散的概率。训练轮数根据数据集大小定,我的经验是小数据集上先让它过拟合,以此确认模型有足够的表达能力。

3.4 训练过程监控与评估指标

手撕Transformer时,最容易犯的错误就是只盯着loss曲线看。我会同时记录几样东西:训练loss、验证loss、梯度范数、注意力权重的分布情况。这些指标能帮你判断模型是在正常收敛,还是在悄悄出问题。

Loss下降是最基本的,但下降得太平滑不一定好,可能说明模型学习能力不足,或数据本身太简单。验证loss和训练loss差距越来越大,就是过拟合的重要信号。梯度范数如果出现突刺,说明模型训练可能不稳定,学习率大概率需要调整。

除了这些间接指标,还有一个我觉得很有用的观察对象:注意力权重。具体做法是训练到一定阶段后,随机取一批样本,把多头注意力权重画出来看看。你会发现不同的头学到的是不同模式的信息,有些头集中关注句子开头,有些头关注当前位置附近的词。如果所有头学出来的模式都一样,那可能就是多头机制没有充分发挥作用,或者模型退化到了某种平庸状态。观察注意力权重能帮你直观感受到模型内部到底发生什么,这是手撕相比调包最大的额外收获之一。

4. 训练中的坑:常见问题与排查技巧实录

4.1 训练不收敛的五个常见原因

我身边朋友手撕Transformer,很多人卡在第一步:loss死活降不下去。根据我的排查经验,最常见的原因主要有五种。

第一种是学习率不合适。Transformer对学习率特别敏感,太大直接发散,太小学不动。我建议先按论文的Noam方案来,或者试着找到一个合适的初始学习率,不要凭感觉拍脑袋。第二种是数据预处理的问题,比如padding和mask不匹配,导致模型attend到了大量padding位置,训练信号被稀释。每次写新模型,我第一件事就是检查mask生成的形状和值是否符合预期。第三种是参数初始化和归一化位置的问题,如果层数比较深,Post-Norm很容易训不动,换成Pre-Norm往往立刻好很多。第四种是标签错位错误,也就是decoder输入和标签没有对齐,模型学到的是“猜下一个词”的规则,但数据给的是“猜当前词”的监督,这会让loss降到一个平台然后停滞。第五种是词表或嵌入维度设置不合理,尤其是忘记加缩放系数,词向量被位置编码主导,模型根本学不了稳定的语义信息。

排查顺序上,我一般会写个最小测试,用一个小到不能再小的数据跑几十步,看loss有没有下降的迹象。如果最小测试都过不了,就别急着上大数据调参,先回过来检查代码逻辑。

4.2 维度匹配问题的系统排查方法

Transformer的前向传播路径长,涉及大量维度变换,维度不匹配的报错几乎人人都会遇到。最常见的几个位置是:多头注意力的view和transpose、scores和mask的广播、多头拼接后的view、交叉注意力里Q和K的维度匹配。

我总结了一套系统排查维度问题的流程。首先,在每个输入输出之间加打印语句,或在关键模块里打印张量形状,逐层对比和理论值是否一致。这个过程很笨但效率极高。其次,用一个小batch跑一次前向,如果形状能通过,说明当前配置下维度逻辑基本正确。第三,凡是遇到维度报错,不要只盯着报错的那一行看,要往上游找,因为报错处往往只是结果,真正的原因可能在几层之前的某个变换写错了。

比如我遇到过一个问题,提示scores和mask形状不匹配。检查后发现,mask的维度是(batch, 1, 1, seq_len),而scores的维度是(batch, n_heads, seq_len, seq_len),中间多了一个head维,广播机制没有把mask自动扩展到head维度上。这种情况需要把mask在第二维加一个单维度,变成(batch, 1, 1, seq_len, seq_len),或者直接用unsqueeze。看报错时多想想广播规则,排查起来会轻松很多。

4.3 推理阶段效果远差于训练阶段

如果你发现训练时loss一直在降,但推理生成的结果乱七八糟,别名“训练和推理不一致”,十有八九是mask或teacher forcing的问题。

推理和训练最大的区别在于,训练时每一步的解码器输入都是真实的目标词,而推理时每一步的输入是上一步模型自己生成的词。如果训练时没有用正确的因果mask,模型就会偷看未来信息,学到的表示非常“虚假繁荣”,一到推理阶段就露馅。另一种情况是训练时也用了真实目标词作为输入,但没有对输入做和推理一致的“逐步拼接”,而是直接把完整目标句子喂进去,这样模型没见过自己的错误累积状态,推理时一旦第一步生成不准确,后面就会越错越远。

这个问题没有捷径,只能在训练和推理之间尽量对齐行为。比较实用的做法是在训练时引入少量“计划采样”,即以一定概率用模型自己的预测词代替真实目标词作为解码器输入,让模型学会容忍和纠错。手撕阶段可以先不做,但如果发现训练和推理差距特别大,可以考虑试试。

4.4 那些很隐蔽的细节坑

除了上面几类大坑,还有些很隐蔽但影响巨大的细节。我挑几个印象最深的分享。

第一个是embedding和输出层权值共享。很多实现里,输入embedding矩阵和最后的输出线性层可以共享同一个权重矩阵,这样能显著减少参数量。但共享时必须注意维度对齐,如果输出层本来要输出词表大小,embedding输入也是词表大小,用同一个矩阵就完全没问题。我一开始没共享,参数量大了不少,动手优化参数效率时才发现。

第二个是dropout的作用位置。Transformer里dropout遍地都是,包括注意力权重上的dropout、每个子层输出上的dropout、embedding之后的dropout。每处dropout都有自己存在的理由。注意力权重上的dropout用来防止某些位置被过度聚焦,类似正则化;子层输出上的dropout用来提高泛化能力。推理时必须把所有dropout关掉,否则模型每次输出都不一样,这是训练和推理不一致的另一个常见来源。

第三个是padding对loss计算的影响。如果标签里padding位置没有设为ignore_index,模型就会拼命把padding位置也预测为某种词,白白消耗学习能力。这个细节非常容易被忽视,但直接影响模型收敛效果。

5. 手撕之后:几个真正改变我认知的瞬间

5.1 调参实验从“盲人摸象”变成“有章可循”

手撕Transformer之前,我做实验经常是换个学习率、换个dropout,跑了看结果,效果不好再换,基本靠运气。手撕之后,我逐渐能根据模型行为反推问题原因。比如loss震荡得厉害,我会先思考是不是学习率过高、数据batch太小导致梯度噪声大,或者归一化位置不合理导致深层梯度不稳定。这种从现象到原因的推理能力,是在亲手实现每个模块之后才真正培养起来的。

现在再看网上各种模型变体的代码,比如某公司开源的推理模型、某实验室的多模态模型,我的视角不再停留在整体架构层面,而是会关注一些细节:它们的LayerNorm放在哪里、注意力头的维度怎么设、位置编码是learned还是fixed、是否有额外的偏置项。这些细节恰恰决定了模型的训练难度和最终性能,而在没有手撕经验之前,我根本不会留意这些。

5.2 从复现走向改进的自由感

最让我兴奋的是,手撕完之后,我可以按照自己的想法去改动模型结构。想加一个相对位置偏置?直接修改attention分数计算逻辑就行。想试试不同的归一化位置组合?把EncoderLayer里的几个模块换个顺序就行。想做稀疏注意力?在scores矩阵上加上自定义mask就行。这些改动在调包模式下几乎不可能实现,因为你面对的是一个黑盒API,内部的每个计算步骤都被封装死了。

这种自由感的另一个体现是,你能独立判断一篇论文里提出的新架构到底有没有道理。比如之前看到一篇关于注意力机制改进的论文,我读完第一反应不是“好高大上”,而是去推演它在现有代码里需要改哪几行、改动后维度能不能对齐、计算复杂度会增加多少。这种评估和判断能力,对手撕Transformer来说算是意外收获,但对日常工作来说是实打实的竞争力。

5.3 给正在或准备手撕的人几句实在话

如果你决定要手撕Transformer,我的建议很简单:不要追求一次写对,要接受报错是常态,每一次报错都是加深理解的机会。写代码的时候按模块拆解,先把多头注意力跑通,再写编码器层,再写解码器层,最后才组合完整模型,分阶段验证比一次性写完再调要高效得多。

遇到问题的时候,先加打印、看形状、看loss,不要直接怀疑框架或者调随机种子。绝大多数情况下,问题都出在你自己对某个细节的理解上。也不要一上来就在大数据集上跑,那样改bug的时间成本太大。先用小数据验证一切正常,再去扩大规模,这是我在无数项目中验证过的最稳路径。最后再强调一次,开发环境准备好、数据准备好之后,再用小模型跑通一个完整的训练加推理流程,比任何论文阅读都更能让你理解Transformer。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进