Transformer 的 Encoder 部分很多人第一次看论文的时候觉得“不就是多头注意力和前馈网络堆一堆嘛”但真到动手复现或者读源码的时候往往会在几个地方卡住位置编码到底怎么加、多头注意力的维度怎么切、残差连接和 LayerNorm 的先后顺序为什么和原始论文不一样、padding mask 和 causal mask 到底该在哪一层起作用。我自己在带团队做文本分类和语义匹配项目时前前后后把 Encoder 重写过好几版踩过的坑足够写一篇长文。这篇就把 Transformer Encoder 从结构到实现彻底拆开讲一遍重点放在那些文档里不会写、但实际写代码一定会遇到的地方。1. 为什么 Encoder 是理解整个 Transformer 的钥匙1.1 Encoder 到底在做什么事先把定位说清楚。Transformer 最初是为机器翻译设计的整体是 Encoder-Decoder 结构。Encoder 负责把输入序列比如一句中文编码成一堆向量表示Decoder 再基于这些向量逐词生成目标序列比如对应的英文。后来大家发现很多任务其实只需要 Encoder文本分类、语义匹配、序列标注、抽取式问答这些任务不需要生成只要把输入理解透就够了。BERT 就是典型的“只用 Encoder”的模型这也是为什么搞 NLP 的人几乎绕不开 Encoder。那 Encoder 具体在做什么一句话概括它把每个 token 的初始向量词嵌入 位置信息经过多层堆叠不断和其他 token 交换信息最终输出一组“融合了上下文”的向量。关键在于“融合上下文”这四个字。传统的 RNN 是一个词一个词往后传信息传递路径长长距离依赖容易丢而 Encoder 里的自注意力机制让任意两个位置之间都是一步直达路径长度是 O(1)。这就是 Transformer 最核心的卖点。我经常用一个类比跟新人解释RNN 像是传话游戏一句话从队首传到队尾传到最后往往变味了而自注意力像是把所有人拉到一个会议室里每个人都能直接听到其他所有人说的话然后各自决定该重点关注谁。Encoder 就是这个会议室而且开了好几层每层都在重新讨论一遍。1.2 一个 Encoder Block 的四大件一个标准的 Encoder Block 由四个部分组成缺一不可多头自注意力Multi-Head Self-Attention让每个位置和所有位置交互捕捉依赖关系。前馈网络Feed-Forward Network, FFN对每个位置独立做非线性变换增强表达能力。残差连接Residual Connection缓解深层网络的梯度消失让梯度能直接回传。层归一化Layer Normalization稳定训练加速收敛。原始论文《Attention Is All You Need》里每个子层自注意力、前馈网络外面都包了一层LayerNorm(x Sublayer(x))也就是“先残差再加最后归一化”这叫 Post-LN。但后来大量实践发现 Post-LN 在深层模型里训练不稳定需要 warmup 才能收敛于是 GPT-2、BERT 之后的模型普遍改成了 Pre-LN即x Sublayer(LayerNorm(x))。这个顺序差异看起来很小但对训练稳定性的影响是巨大的后面我会专门用一节来讲。整个 Encoder 就是把这个 Block 重复 N 层原始论文 N6每一层的输出维度保持不变原始论文 d_model512这样才能一层层叠上去。输入是(batch_size, seq_len, d_model)输出也是(batch_size, seq_len, d_model)形状不变变的是每个向量里蕴含的信息。1.3 为什么值得单独把 Encoder 拎出来研究有人会问直接调transformers库不就行了为什么要自己实现 Encoder我的理由有三个。第一调试和排错的需要。线上模型效果不对你得知道是哪一层出了问题。是 mask 加错了是位置编码越界了还是 LayerNorm 的 eps 设得太小导致数值不稳定不懂内部结构你只能盲猜。第二定制化的需要。实际项目里经常要改结构换位置编码方案、改注意力计算方式、加相对位置偏置、做稀疏注意力。这些改动都建立在你能手写一个干净 Encoder 的基础上。第三面试和知识体系的需要。Encoder 是 Transformer 的地基理解了它Decoder、Cross-Attention、各种变体都好说。我见过太多人背得出“多头注意力就是把 QKV 切成 h 份”但问他“为什么要切”“切完怎么拼”“d_k 和 d_model 什么关系”就答不上来。2. 自注意力机制的数学拆解与维度陷阱2.1 从 Q、K、V 的直觉说起自注意力的核心公式就一行Attention(Q, K, V) softmax(QK^T / sqrt(d_k)) V但这一行里藏着很多细节。先说 Q、K、V 是什么。输入序列每个 token 的向量 x维度 d_model分别乘以三个可学习矩阵 W_Q、W_K、W_V得到 Query、Key、Value 三个向量。用图书馆检索来类比Query 是你要查的问题Key 是每本书的标签Value 是书的内容。你先拿 Query 和每个 Key 算相似度点积相似度高的书权重就大最后按权重把各本书的 Value 加权求和得到的就是这个 Query 的检索结果。自注意力里 Q、K、V 都来自同一个输入序列所以叫“自”注意力。如果是 Decoder 里的 Cross-AttentionQ 来自 DecoderK、V 来自 Encoder那就是跨序列的注意力了。2.2 为什么要除以 sqrt(d_k)这是最常被问到的问题之一。原因很简单当 d_k 比较大时Q 和 K 的点积结果方差会随 d_k 线性增长。假设 Q、K 的每个分量都是均值 0、方差 1 的独立随机变量那么点积q·k sum(q_i * k_i)的方差就是 d_k。方差一大softmax 的输入就会落在饱和区梯度趋近于 0训练就废了。除以 sqrt(d_k) 相当于把方差拉回到 1 附近让 softmax 的输入分布保持在一个梯度健康的范围。我实测过d_k64 时不除 sqrt(d_k)训练 loss 几乎不下降除完之后正常收敛。这不是玄学是实打实的数值问题。2.3 多头到底“多”在哪里多头注意力的做法是把 d_model 维的 Q、K、V 分别切成 h 份每份维度 d_k d_model / h每一份独立做一次注意力计算最后把 h 个结果拼起来再过一个线性层。为什么要这么做单个注意力头只能学到一种“关注模式”比如“关注前一个词”或者“关注句子的主语”。多头让模型可以同时从不同子空间、不同角度去关注信息就像开会时不同的人负责记录不同的重点。原始论文 h8d_model512所以 d_kd_v64。这里有个维度陷阱切分不是简单地把向量按顺序切成 h 段而是通过线性层投影后在最后一维 reshape 成(h, d_k)。实现上通常是先算完整的 Q、K、V维度 d_model再 reshape 成(batch, seq_len, h, d_k)然后 transpose 成(batch, h, seq_len, d_k)这样每个头就能独立做批量矩阵乘法。很多人第一次写会在这里把维度搞混导致结果对不上。2.4 缩放点积注意力的完整实现下面是我常用的一个干净实现PyTorch 版本import torch import torch.nn as nn import math def scaled_dot_product_attention(q, k, v, maskNone): # q, k, v: (batch, h, seq_len, d_k) d_k q.size(-1) scores torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(d_k) # scores: (batch, h, seq_len, seq_len) if mask is not None: scores scores.masked_fill(mask 0, float(-inf)) attn torch.softmax(scores, dim-1) output torch.matmul(attn, v) return output, attn几个要点。第一masked_fill用的是-inf而不是 0因为 softmax 之后-inf会变成 0而直接填 0 会分走一部分概率质量。第二mask 的形状要能广播到(batch, h, seq_len, seq_len)通常 padding mask 是(batch, 1, 1, seq_len)表示哪些位置是 padding。第三transpose(-2, -1)只交换最后两维别写成transpose(1, 2)那样在 batch 维度上会出错。注意如果你用的是混合精度训练fp16-inf在某些实现里会变成 NaN。稳妥的做法是用一个很大的负数比如-1e9或者用 PyTorch 的torch.finfo(dtype).min。3. 位置编码Transformer 的“空间感”从哪来3.1 自注意力本身是“无序”的这是 Transformer 一个反直觉的地方自注意力对输入顺序完全不敏感。你把一句话的词打乱只要每个词的向量不变注意力算出来的结果只是跟着位置换了一下模型本身感知不到顺序。但语言是有顺序的“狗咬人”和“人咬狗”意思完全相反。所以必须显式地把位置信息注入进去。原始论文用的是正弦位置编码Sinusoidal Positional Encoding公式是PE(pos, 2i) sin(pos / 10000^(2i/d_model)) PE(pos, 2i1) cos(pos / 10000^(2i/d_model))其中 pos 是位置i 是维度索引。偶数维用 sin奇数维用 cos。3.2 为什么用这种奇怪的三角函数这个设计不是随便拍的它有两个巧妙之处。第一不同维度对应不同频率。低维i 小频率高变化快能区分相邻位置高维i 大频率低变化慢能编码长距离的位置关系。这有点像二进制编码低位变化快、高位变化慢组合起来能表示任意整数。第二相对位置可以通过线性变换得到。因为三角恒等式sin(ab) sin(a)cos(b) cos(a)sin(b)位置posk的编码可以表示成位置pos编码的线性函数。这让模型有能力学到“相对位置”的概念而不只是绝对位置。我实测下来正弦编码在短文本任务上和可学习位置编码效果差不多但在长文本外推训练时见短句、推理时见长句上正弦编码泛化更好因为它不依赖具体的最大长度参数。3.3 位置编码到底怎么加实现上位置编码是一个(max_len, d_model)的矩阵直接和词嵌入相加class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len5000, dropout0.1): super().__init__() pe torch.zeros(max_len, d_model) position torch.arange(0, max_len).unsqueeze(1).float() 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) self.dropout nn.Dropout(dropout) def forward(self, x): # x: (batch, seq_len, d_model) x x self.pe[:, :x.size(1)] return self.dropout(x)几个实操细节。第一register_buffer很关键它让 pe 成为模型状态的一部分会跟着.to(device)走但不会被当作可学习参数更新。第二div_term用exp(log(...))的写法比直接10000 ** (2i/d_model)数值更稳定。第三加法之后通常接一个 dropout原始论文里 dropout 率是 0.1。提示位置编码加在词嵌入上而不是拼接到后面。加法不增加维度拼接会增加 d_model后续所有矩阵都要跟着改没必要。3.4 可学习位置编码与正弦编码的取舍BERT 用的是可学习位置编码就是一个(max_len, d_model)的nn.Embedding跟着训练一起更新。它的好处是灵活能学到数据里特有的位置模式坏处是受限于 max_len推理时超过这个长度就没法处理除非做插值。我的经验是如果你的任务序列长度固定且不长比如分类任务max_len128可学习编码够用如果序列长度变化大、或者有外推需求正弦编码更稳。还有一种折中方案是相对位置编码如 T5 的 bucket 方案但那是另一个话题了。4. 前馈网络、残差与 LayerNorm 的工程细节4.1 前馈网络为什么是两层而不是一层前馈网络的结构是Linear(d_model, d_ff) - ReLU - Linear(d_ff, d_model)中间维度 d_ff 通常是 d_model 的 4 倍原始论文 d_model512d_ff2048。为什么是两层因为单层线性变换等价于一个线性映射无论怎么堆都还是线性的表达能力有限。加一个非线性激活ReLU 或 GELU之后FFN 就能拟合更复杂的函数。为什么中间要放大 4 倍这是经验值。放大给模型更多的“临时工作空间”让它能在高维空间里做非线性组合再压回原维度。我试过 2 倍和 8 倍2 倍效果略差8 倍参数涨太多收益不明显4 倍是个甜点。FFN 是逐位置独立作用的也就是说每个 token 的向量单独过这个网络token 之间的信息交换全靠注意力层。这个分工很清晰注意力负责“横向”交互FFN 负责“纵向”加工。4.2 残差连接解决的是什么问题残差连接就是output x Sublayer(x)。它的作用有两个层面。从梯度角度反向传播时梯度可以通过这条“捷径”直接回传到浅层不用穿过每一层的非线性变换极大缓解了梯度消失。这让训练几十层的网络成为可能。从信息角度残差让每一层只需要学习“增量”而不是从头学一个完整映射。如果某一层没学到东西输出至少等于输入不会比原来更差。这种“保底”机制让深层网络更容易训练。在 Encoder 里每个子层注意力和 FFN外面都包了残差。注意残差要求输入输出维度一致这也是为什么 d_model 在整层里保持不变。4.3 Post-LN 和 Pre-LN 的实战差异这是我最想强调的一节因为踩过坑。原始论文是 Post-LNLayerNorm(x Sublayer(x))。归一化在残差相加之后。这种结构在浅层6 层配合 warmup 能训但层数一深比如 12 层以上就非常不稳定loss 容易炸必须小心翼翼地调学习率和 warmup 步数。Pre-LN 是x Sublayer(LayerNorm(x))归一化在子层输入之前。这样残差路径上没有任何归一化操作梯度可以无阻碍地回传训练稳定性大幅提升。GPT-2 之后基本都用 Pre-LN。代价是最终输出前需要额外加一个 LayerNorm因为最后一层的输出没被归一化过。我做过对比实验同样 12 层 EncoderPost-LN 需要 warmup 4000 步才能稳住Pre-LN 几乎不需要 warmup 就能正常收敛而且对学习率没那么敏感。所以现在自己写 Encoder我默认用 Pre-LN。4.4 LayerNorm 为什么不用 BatchNormBatchNorm 是在 batch 维度上做归一化统计每个特征在 batch 内的均值和方差。它在 CV 里很成功但在 NLP 里问题很大序列长度不一padding 多batch 统计量不稳定而且推理时如果 batch 很小统计量偏差大。LayerNorm 是在特征维度上做归一化对每个样本、每个位置独立计算均值和方差。它不依赖 batch 大小天然适合变长序列。这就是为什么 Transformer 全线用 LayerNorm。实现上LayerNorm 有可学习的缩放参数 gamma 和平移参数 beta让模型在归一化之后还能恢复一定的表达能力。eps 一般设 1e-5 或 1e-6太小数值不稳太大归一化效果打折。5. 从零搭一个可用的 Encoder完整代码与逐行注释5.1 整体结构设计我习惯把 Encoder 拆成三个类MultiHeadAttention、FeedForward、EncoderLayer最后用Encoder把它们堆起来。这样职责清晰改哪块都不影响其他部分。先定义超参d_model512n_heads8d_ff2048n_layers6dropout0.1。这些是原始论文的配置可以直接用。5.2 多头注意力的完整实现class MultiHeadAttention(nn.Module): def __init__(self, d_model, n_heads, dropout0.1): super().__init__() assert d_model % n_heads 0 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.w_o nn.Linear(d_model, d_model) self.dropout nn.Dropout(dropout) def forward(self, x, maskNone): batch, seq_len, _ x.size() # 投影并切分多头 q self.w_q(x).view(batch, seq_len, self.n_heads, self.d_k).transpose(1, 2) k self.w_k(x).view(batch, seq_len, self.n_heads, self.d_k).transpose(1, 2) v self.w_v(x).view(batch, seq_len, self.n_heads, self.d_k).transpose(1, 2) # q,k,v: (batch, n_heads, seq_len, d_k) scores torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.d_k) if mask is not None: scores scores.masked_fill(mask 0, torch.finfo(scores.dtype).min) attn self.dropout(torch.softmax(scores, dim-1)) out torch.matmul(attn, v) # (batch, n_heads, seq_len, d_k) # 拼回多头 out out.transpose(1, 2).contiguous().view(batch, seq_len, self.d_model) return self.w_o(out)这里contiguous()不能省因为transpose之后内存不连续直接view会报错。这是新手最常遇到的报错之一。5.3 前馈网络与 EncoderLayerclass FeedForward(nn.Module): def __init__(self, d_model, d_ff, dropout0.1): super().__init__() self.net nn.Sequential( nn.Linear(d_model, d_ff), nn.GELU(), nn.Dropout(dropout), nn.Linear(d_ff, d_model), ) def forward(self, x): return self.net(x) class EncoderLayer(nn.Module): def __init__(self, d_model, n_heads, d_ff, dropout0.1): super().__init__() self.attn MultiHeadAttention(d_model, n_heads, dropout) self.ffn FeedForward(d_model, d_ff, dropout) self.norm1 nn.LayerNorm(d_model, eps1e-6) self.norm2 nn.LayerNorm(d_model, eps1e-6) self.dropout nn.Dropout(dropout) def forward(self, x, maskNone): # Pre-LN x x self.dropout(self.attn(self.norm1(x), mask)) x x self.dropout(self.ffn(self.norm2(x))) return x注意激活函数我用了 GELU 而不是 ReLU。原始论文用 ReLU但 BERT 之后 GELU 成了主流它在 0 附近更平滑梯度性质更好实测在多数任务上略优。5.4 堆叠成完整 Encoderclass Encoder(nn.Module): def __init__(self, vocab_size, d_model512, n_heads8, d_ff2048, n_layers6, max_len5000, dropout0.1): super().__init__() self.embedding nn.Embedding(vocab_size, d_model) self.pos_enc PositionalEncoding(d_model, max_len, dropout) self.layers nn.ModuleList([ EncoderLayer(d_model, n_heads, d_ff, dropout) for _ in range(n_layers) ]) self.norm nn.LayerNorm(d_model, eps1e-6) def forward(self, x, maskNone): x self.pos_enc(self.embedding(x)) for layer in self.layers: x layer(x, mask) return self.norm(x)最后的self.norm是 Pre-LN 的标配因为最后一层输出没被归一化。词嵌入通常还会乘一个sqrt(d_model)让嵌入和位置编码的量级匹配这个细节原始论文提到了但很多人会漏。6. Mask 机制padding mask 与 causal mask 的正确用法6.1 padding mask 解决什么问题实际训练时一个 batch 里句子长度不一短的要用 0 补齐到最长。这些 padding 位置本身没有意义但如果不处理注意力会去关注它们污染结果。padding mask 就是告诉模型这些位置别管。实现上mask 是一个(batch, 1, 1, seq_len)的 0/1 矩阵1 表示有效 token0 表示 padding。在 softmax 之前把 0 位置填成负无穷softmax 后这些位置的概率就是 0。def make_padding_mask(seq, pad_idx0): # seq: (batch, seq_len) mask (seq ! pad_idx).unsqueeze(1).unsqueeze(2) return mask # (batch, 1, 1, seq_len)6.2 Encoder 需要 causal mask 吗不需要。这是 Encoder 和 Decoder 的关键区别。Encoder 是双向的每个位置可以看到整个序列包括后面因为它是在做“理解”不是“生成”。Decoder 才需要 causal mask也叫 look-ahead mask保证生成第 t 个词时只能看到前 t-1 个词不能偷看未来。我见过有人把 causal mask 误加到 Encoder 上结果模型效果大打折扣还找不到原因。记住Encoder 只用 padding mask。6.3 mask 形状与广播的坑mask 的形状必须能广播到注意力分数(batch, n_heads, seq_len, seq_len)。padding mask 是(batch, 1, 1, seq_len)在 seq_len 那一维广播表示“所有 query 位置都不关注这些 key 位置”。如果你要做更复杂的 mask比如某些 token 之间不能互相看形状要相应调整。一个常见 bug 是 mask 的 dtype 不对或者用了masked_fill(mask, -inf)但 mask 是布尔值反了。建议统一用布尔 maskTrue表示保留False表示屏蔽然后masked_fill(~mask, -inf)。写单元测试验证全 padding 的行注意力输出应该全是 0。7. 训练稳定性与性能优化的实战经验7.1 初始化与 warmup 的配合Transformer 对初始化敏感。原始论文用 Xavier 初始化但实践中我更喜欢用较小的标准差比如 0.02初始化线性层权重LayerNorm 的 gamma 初始化为 1、beta 为 0。这样初始输出量级可控。即使上了 Pre-LN前几百步用 warmup 把学习率从 0 线性升到峰值仍然是个好习惯。warmup 步数一般是总步数的 5%~10%。我试过完全不用 warmupPre-LN 也能训但前期 loss 抖动会大一些。7.2 注意力计算的显存优化标准注意力的显存是 O(seq_len^2)序列一长就爆。几个优化方向梯度检查点gradient checkpointing用时间换显存把中间激活不保存反向时重算。适合显存紧张但能接受训练变慢的场景。FlashAttention通过分块计算避免显式存储完整的注意力矩阵既省显存又快。PyTorch 2.0 之后F.scaled_dot_product_attention已经内置支持直接调用即可。稀疏注意力只计算部分位置的注意力适合超长序列但实现复杂效果因任务而异。我的建议是序列长度 512 以内标准实现够用超过 1024优先考虑 FlashAttention再长再考虑稀疏方案。7.3 常见报错与排查清单报错/现象可能原因排查方向loss 变 NaNmask 用了 -inf 且混合精度换成 finfo.min训练不收敛Post-LN 没加 warmup改 Pre-LN 或加 warmup输出全是同一个值注意力被 mask 全屏蔽检查 mask 逻辑view 报错transpose 后没 contiguous加 .contiguous()效果远差于预期位置编码没加或加错打印 pos_enc 输出检查显存爆序列太长注意力矩阵太大用 FlashAttention 或减 batch这张表是我自己排错时总结的基本覆盖了 90% 的常见问题。8. 一些容易被忽略但很关键的细节8.1 词嵌入缩放原始论文里词嵌入会乘以sqrt(d_model)。原因是位置编码的值域大致在 [-1, 1]而随机初始化的词嵌入量级偏小两者相加时位置信息会占主导。乘上sqrt(d_model)让两者量级接近信息更均衡。这个细节很多开源实现会漏但对小模型影响明显。8.2 dropout 的位置dropout 在 Encoder 里有三处位置编码之后、每个子层输出之后残差相加前、注意力权重上。原始论文的 dropout 率统一是 0.1。注意力权重上的 dropout 容易被忽略但它对防止过拟合有帮助尤其是小数据集。8.3 权重共享Encoder 和 Decoder 的词嵌入矩阵、以及输出层的投影矩阵在很多实现里是共享的。这能减少参数量还能让词向量和输出空间对齐。不过纯 Encoder 模型如 BERT没有输出投影层只共享嵌入。8.4 层数、头数、维度的取舍给几个我实测的经验值小数据集几万条4 层、4 头、d_model256 就够再大容易过拟合。中等数据集几十万条6 层、8 头、d_model512原始配置。大数据集百万级以上12 层起步d_model768 或更大。头数不是越多越好。d_model512 时8 头d_k64是甜点16 头d_k32每个头容量太小效果反而下降。经验法则是 d_k 不低于 32。9. 从 Encoder 出发的延伸方向把 Encoder 吃透之后往下走有几个自然的方向。一是掩码语言模型MLMBERT 的核心预训练任务随机 mask 掉一些 token 让模型预测这需要理解 Encoder 的双向特性。二是对比学习像 SimCSE 那样用 Encoder 编码句子做语义匹配重点在 pooling 策略和损失函数设计。三是长文本建模把标准注意力换成稀疏或线性注意力处理文档级输入。四是多模态把文本 Encoder 和图像 Encoder 对齐做跨模态检索或生成。每个方向都值得单独展开但它们的共同地基都是这篇讲的 Encoder。我自己在从分类任务转向语义匹配时最大的体会就是Encoder 的输出怎么用取 [CLS]、平均池化还是最大池化对最终效果的影响有时候比模型结构本身还大。这个坑我踩过后面有机会再细说。最后分享一个我调试 Encoder 的小习惯写完之后先拿一个极小的数据集比如 100 条过拟合如果 loss 能降到接近 0说明结构和前向传播没问题如果降不下去那一定是实现有 bug别急着上大数据集。这个“先过拟合小数据”的检查帮我省下了无数次盲目调参的时间。