
1. 编码器整体设计与思路拆解1.1 从RNN到Transformer编码器为什么值得单独拆开讲先聊点背景。在Transformer出现之前NLP领域做序列建模基本靠RNN、LSTM这套循环网络。RNN的问题在于它天生是串行的——你要处理第t个词必须先算完前t-1个词的隐藏状态。这导致两个痛点一是长距离依赖容易丢失二是没法并行计算训练效率被卡得死死的。2017年那一篇《Attention Is All You Need》直接把这条路给掀了。Transformer把序列建模变成了“并行输入 注意力机制”的组合整段文本一次性进去靠自注意力Self-Attention建模词与词之间的关系。这就把训练时间从“按词序排队”变成了“整句同时开工”效果还更好。后来的BERT、GPT这些大家耳熟能详的预训练模型底层架构全部是从Transformer搭建起来的。所以“编码器”到底拆开讲什么简单说Transformer编码器就是把“词序列”变成“语义特征序列”的那一整套处理流程。输入一句话编码器输出一组向量每个向量对应输入中的一个词但它已经融合了上下文信息——这是整篇博文最核心的一条主线。在《Transformer 详解(上)》里我计划只覆盖编码器部分。为啥单独拆出来因为编码器是理解整个Transformer的“地基”。多头注意力、位置编码、残差连接、层归一化这些核心机制编码器全都有先把这块啃下来解码器、自回归生成那些东西你再回头看就是水到渠成的事。1.2 编码器跑通一次全流程从词向量到上下文特征在写代码之前先把编码器的宏观数据流在脑子里画一遍。整个过程可以拆成五个大步骤每一步输出的张量形状我都建议你刻在脑子里输入文本经过分词和查表变成token id序列形状是(batch_size, seq_len)。进入Embedding层把每个token id映射为稠密的词向量得到(batch_size, seq_len, d_model)。这一步是把离散符号变成连续向量。给每个token叠加位置编码Positional Encoding同样也是(batch_size, seq_len, d_model)。因为Transformer没有循环结构位置全靠这里显式注入。经过N层编码器层Encoder Layer堆叠。每层内部包含多头自注意力、残差连接、层归一化、前馈网络四个子模块输入和输出的形状保持不变——这一点极其重要意味着你可以自由堆叠层数不用担心维度对不上。输出(batch_size, seq_len, d_model)的特征序列。这个结果既可以直接接下游分类任务取[CLS]或做pooling也可以作为解码器的输入做生成任务。这个“形状不变”的设计非常优雅。整个编码器就像一个深度特征提取器输入一组向量输出一组更“懂上下文”的向量。后面写代码的时候只要盯住每个模块输入输出维度基本不会写错。1.3 核心参数速查先约定再动手写代码前先定一组默认参数后面所有实现都统一用这一套参数名典型值作用说明d_model512词向量维度也是所有层输入输出的统一宽度n_heads8多头注意力头数d_model必须能整除n_headsd_ff2048前馈网络隐藏层维度一般是d_model的4倍n_layers6编码器层堆叠数量dropout0.1防止过拟合训练时随机丢弃一部分神经元的输出seq_len512最大序列长度位置编码的表长要覆盖这个值batch_size32训练时每批样本数实际按显存调整这里有个硬性要求d_model必须能被n_heads整除。因为多头注意力要把d_model切分成n_heads个子空间每个头负责d_k d_model / n_heads维度的信息。原论文里d_model512n_heads8d_k64。这个比例不是随便定的切分的每个头维度太小会损失表达能力太大又会稀释多头带来的多样性收益。2. 编码器核心子模块原理精讲2.1 输入嵌入与位置编码为并行输入显式注入词序先说Embedding。这层没什么玄学就是一个可学习查找表把词表里的每个token id映射成一个固定维度的向量。如果用PyTorch写直接调nn.Embedding(vocab_size, d_model)就行。可以把它理解成一个初始随机初始化、随后跟着训练不断调整的词典。关键难点在于位置编码。RNN是靠循环结构天然按时间顺序处理序列的所以位置信息是隐式编码在计算顺序里的。Transformer把整句话一次性并行输入模型本身对词序一无所知。为了让模型知道“今天”和“天气”谁在前面就必须人为地在输入里加入位置信号。原论文采用的是正弦位置编码PE(pos, 2i) sin(pos / 10000^(2i / d_model))PE(pos, 2i 1) cos(pos / 10000^(2i / d_model))其中pos是token在序列中的绝对位置索引i是维度索引。为什么用sin/cos而不是简单的整数原因有三点第一编码值被限制在[-1, 1]区间数值稳定第二三角函数的和差化积性质让模型更容易学到相对位置关系第三即使测试时遇到比训练时更长的序列公式也能继续生成新的位置编码不需要训练额外的参数。不过在工程实现中很多人干脆直接用可学习的位置嵌入Learned Positional EmbeddingBERT就是这么干的效果也差不多。我下面的代码实现会用正弦编码因为这是最经典、最贴合原论文的做法而且顺便让你掌握“无需训练参数也能建模位置”的思路。2.2 多头自注意力模型理解上下文语义的核心自注意力机制是整个编码器的灵魂它的作用说白了就是一句话让每一个token都能“看到”序列里的其他token然后根据相关性加权融合信息。具体计算分四步第一步把输入向量分别乘以三个可学习的权重矩阵得到QQuery、KKey、VValue三个向量。这里可以类比检索场景Q是你提出的问题K是候选内容的标签V是候选内容本身。第二步计算Q和K的点积再除以sqrt(d_k)做缩放。点积衡量两个向量的相似度除以sqrt(d_k)是为了防止维度变大后点积结果过大导致softmax进入饱和区梯度变得极小。第三步对缩放后的分数做softmax得到归一化的注意力权重所有位置的权重和为1。第四步把注意力权重和V做加权求和得到当前token在这个注意力头下的输出。如果用公式表达Attention(Q, K, V) softmax(QK^T / sqrt(d_k)) V多头注意力就是把这套计算做多份。把d_model512的输入分成8个头每个头在d_k64的子空间里独立计算注意力最后把8个头的输出拼起来再经过一个线性层融合。为什么要多头通俗地讲一个头只能关注一种“关系”——比如语法依赖、指代关系、共现特征。多个头就像多个不同视角的专家各自关注不同的词间关系最后汇总出一个更全面的结果。自注意力还有一个容易忽视的优点它属于全局操作任意两个token之间的距离都为1不存在RNN那种距离越远信息越难传的问题。代价是计算复杂度是O(n^2)级别序列太长时内存消耗很大这也是后续各种稀疏注意力、线性注意力优化的切入点。2.3 前馈网络与残差归一化非线性变换与训练稳定器多头注意力输出的向量已经融合了上下文信息但注意力的本质是“加权求和”本质上是一系列线性操作。如果就此打住模型整体就退化成一个线性模型表达能力严重不足。所以编码器层里还配了一个前馈网络Feed-Forward Network, FFN)FFN(x) max(0, xW_1 b_1)W_2 b_2就是一个隐藏层带ReLU激活函数的两层全连接网络。它逐token独立作用对每个位置的特征做非线性变换把维度从d_model512先升到d_ff2048再降回512。升维是为了提供更大的表示空间相当于把信息先展开再压缩增强模型的表达能力。残差连接和层归一化是保证深层网络能稳定训练的关键。残差连接就是LayerNorm(x Sublayer(x))让每一层的输出等于输入加上子层的变换结果。这样一来梯度可以绕过子层直接向后传播避免深层网络出现梯度消失或退化问题。你可以把它理解为“高速公路”——即使子层学到的变换不太理想信息也能原封不动地传到下一层。层归一化的作用和Batch Normalization不太一样它是在每个样本的特征维度上做归一化和batch里其他样本无关。这对变长序列特别友好训练和推理时行为一致。具体做法是对每个特征向量减去均值除以标准差再做一次缩放和平移乘以可学习的gamma加上可学习的beta。这里还有一个业界经过反复验证的经验原论文的Transformer用的是“Post-LN”即先残差再归一化。但实际训练中Post-LN在大模型场景下容易不稳定。后来GPT系列改成“Pre-LN”也就是先归一化再进子层训练更稳定收敛更快。我的代码实现里会用Post-LN保持和原论文一致如果你要训练更深更复杂的模型建议改Pre-LN试一下。3. PyTorch实现编码器全流程3.1 环境准备和项目结构开始写代码前确认一下环境。PyTorch我建议用1.13以上的版本2.x更佳Python 3.9到3.10都行。这个项目只依赖torch和math标准库不需要额外装乱七八糟的包。注意一点我这套代码只做CPU推理演示所以没有写CUDA设备切换的逻辑。你如果在GPU环境跑把张量.to(device)即可不影响结构。整个实现我会拆成五个文件逻辑分开更清晰model.py模型定义主文件包含全部编码器相关类train.py训练脚本用随机生成的假数据跑通流程infer.py推理脚本展示如何用训练好的模型config.py统一管理超参数data.py数据加载和预处理在本文中会全部展示model.py的编码器部分实现以及train.py的完整训练循环。3.2 位置编码类的完整实现先处理位置编码。上面讲了sin/cos公式现在直接用代码落地import math import torch import torch.nn as nn class PositionalEncoding(nn.Module): 经典正弦位置编码 PE(pos, 2i) sin(pos / 10000^(2i / d_model)) PE(pos, 2i 1) cos(pos / 10000^(2i / d_model)) def __init__(self, d_model: int, max_len: int 512, dropout: float 0.1): super().__init__() self.dropout nn.Dropout(pdropout) # 形状: (max_len, d_model) pe torch.zeros(max_len, d_model) # position 形状: (max_len, 1) position torch.arange(0, max_len, dtypetorch.float).unsqueeze(1) # 除法项的形状: (d_model // 2,)对应偶数索引位置的 2i div_term torch.exp( torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model) ) # 偶数位置用 sin奇数位置用 cos 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: torch.Tensor) - torch.Tensor: x: (batch_size, seq_len, d_model) 返回: (batch_size, seq_len, d_model)在输入上叠加了位置编码 x x self.pe[:, : x.size(1), :] return self.dropout(x)几个容易踩坑的细节第一div_term为什么用torch.exp而不是直接写10000^(2i/d_model)因为10000^(2i/d_model)在i大时数值会很大容易出现浮点溢出。用exp(2i * (-log(10000) / d_model))等价变换数值稳定得多。第二pe[:, : x.size(1), :]这个切片是为了处理实际序列长度不足max_len的情况只取前seq_len行的位置编码。训练时如果对批次内序列做了padding这里会自动只取有效长度那一截不会越界。第三register_buffer而不是self.pe pe是因为pe不需要梯度但又必须和设备GPU/CPU保持一致。用buffer注册模型调用.cuda()或.to(device)时它也会跟着走不会出现“模型在GPU、位置编码在CPU”的尴尬。3.3 多头注意力类的实现这是编码器里最复杂、也最容易写错的模块。核心难点在维度和拆分我一步一步拆开讲。class MultiHeadAttention(nn.Module): 多头缩放点积注意力 def __init__(self, d_model: int, n_heads: int, dropout: float 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 # 四个线性层Q、K、V 各一个输出融合一个 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_out nn.Linear(d_model, d_model) self.dropout nn.Dropout(pdropout) def forward( self, query: torch.Tensor, key: torch.Tensor, value: torch.Tensor, mask: torch.Tensor None, ) - torch.Tensor: 参数: query, key, value: 编码器里它们都是同一个输入 x形状 (batch, seq_len, d_model) mask: (batch, seq_len) 中的 padding 位置为 True 或 0用来屏蔽无效位置 返回: 注意力输出形状同输入 (batch, seq_len, d_model) batch_size query.size(0) # 1. 线性投影 拆分为多头 # Q 从 (batch, seq_len, d_model) - (batch, seq_len, n_heads, d_k) - (batch, n_heads, seq_len, d_k) 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) # 2. 计算缩放点积注意力 # (batch, n_heads, seq_len, d_k) x (batch, n_heads, d_k, seq_len) # 得到 (batch, n_heads, seq_len, seq_len) scores Q K.transpose(-2, -1) / math.sqrt(self.d_k) # 3. 应用 mask如果有 if mask is not None: # scores 的形状是 (batch, n_heads, seq_len, seq_len) # mask 形状是 (batch, seq_len)需要扩展成 (batch, 1, 1, seq_len) 进行广播 mask mask.unsqueeze(1).unsqueeze(2) # (batch, 1, 1, seq_len) scores scores.masked_fill(mask 0, float(-inf)) # 4. softmax dropout attn_weights torch.softmax(scores, dim-1) attn_weights self.dropout(attn_weights) # 5. 加权求和 # (batch, n_heads, seq_len, seq_len) x (batch, n_heads, seq_len, d_k) # 得到 (batch, n_heads, seq_len, d_k) context attn_weights V # 6. 把多头合并回去 # (batch, n_heads, seq_len, d_k) - (batch, seq_len, n_heads, d_k) - (batch, seq_len, d_model) context context.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model) # 7. 输出线性层 output self.w_out(context) return output这段代码里最值得反复看的是view和transpose之间的操作。我们拿一个具体例子走一遍维度假设batch_size2seq_len4d_model512n_heads8。输入x形状是(2, 4, 512)。w_q(query)输出还是(2, 4, 512)。.view(2, -1, 8, 64)变成(2, 4, 8, 64)此时最后两个维度是头数每个头维度。.transpose(1, 2)变成(2, 8, 4, 64)此时中间两个维度是头数序列长度Q在第2维。transpose之后必须注意张量在内存中可能不是连续的后面做view操作前一定要.contiguous()。我在代码里第6步写了.contiguous().view(...)这是很多新手容易漏掉的一步漏了会直接报错。mask部分我再详细展开一下因为它在编码器里是保证变长batch正常训练的关键。假设一个batch里有两条句子句子A长度4[我, 爱, 吃, 苹果]句子B长度2[他, 好]需要把句子B padding成到长度4[他, 好, pad, pad]如果不对pad位置做mask那“他”和“好”都会分出一部分注意力给那两个位置相当于把没有实际含义的填充符混进了语义表示里。mask的作用就是让softmax之前这些pad位置的分值变成-infsoftmax之后权重变为0相当于完全忽略它们。在测试或推理阶段如果只有一条样本、不需要paddingmask可以传None不影响正常运行。3.4 前馈网络和编码器层的组装前馈网络实现非常简单就是一个两层MLPclass PositionwiseFeedForward(nn.Module): 逐位置前馈网络 FFN(x) max(0, x W1 b1) W2 b2 def __init__(self, d_model: int, d_ff: int, dropout: float 0.1): super().__init__() self.linear1 nn.Linear(d_model, d_ff) self.linear2 nn.Linear(d_ff, d_model) self.dropout nn.Dropout(pdropout) def forward(self, x: torch.Tensor) - torch.Tensor: return self.linear2(self.dropout(torch.relu(self.linear1(x))))注意顺序是d_model升到d_ffReLU激活Dropout抑制过拟合再压回d_model。接下来是编码器层把前面的子模块组装到一起class EncoderLayer(nn.Module): 单层编码器层多头自注意力 残差 层归一化 前馈网络 残差 层归一化 def __init__(self, d_model: int, n_heads: int, d_ff: int, dropout: float 0.1): super().__init__() self.self_attn MultiHeadAttention(d_model, n_heads, dropout) self.feed_forward PositionwiseFeedForward(d_model, d_ff, dropout) self.norm1 nn.LayerNorm(d_model) self.norm2 nn.LayerNorm(d_model) self.dropout nn.Dropout(pdropout) def forward(self, x: torch.Tensor, mask: torch.Tensor None) - torch.Tensor: # 第一步多头自注意力 残差连接 层归一化 attn_output self.self_attn(x, x, x, mask) x self.norm1(x self.dropout(attn_output)) # 第二步前馈网络 残差连接 层归一化 ff_output self.feed_forward(x) x self.norm2(x self.dropout(ff_output)) return x这个x self.dropout(attn_output)是原论文的标准写法叫Post-LN结构。先相加再做归一化。我前面提到过Pre-LN那个结构是self.norm1(x self.dropout(attn_output))改成先norm再进子层。两者各有取舍Post-LN理论上流更顺Pre-LN训练更稳。这个在第四章会展开说。3.5 完整编码器组装从词表到上下文特征所有积木搭好了现在把它们拼成一个完整可用的编码器class Encoder(nn.Module): 完整 Transformer 编码器 def __init__( self, vocab_size: int, d_model: int 512, n_heads: int 8, d_ff: int 2048, n_layers: int 6, max_len: int 512, dropout: float 0.1, ): super().__init__() self.d_model d_model # 词嵌入 self.embedding nn.Embedding(vocab_size, d_model) # 位置编码 self.positional_encoding 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) def forward( self, x: torch.Tensor, mask: torch.Tensor None, ) - torch.Tensor: 参数: x: (batch_size, seq_len) 的 token id 序列 mask: (batch_size, seq_len) 的 padding maskpad 位置为 0 返回: (batch_size, seq_len, d_model) 的上下文特征向量 # 1. 词嵌入并乘以 sqrt(d_model) 做缩放 x self.embedding(x) * math.sqrt(self.d_model) # 2. 叠加位置编码 x self.positional_encoding(x) # 3. 逐层通过编码器层 for layer in self.layers: x layer(x, mask) # 4. 最后的归一化层 return self.norm(x)这里有个原论文的细节self.embedding(x) * math.sqrt(self.d_model)。为什么词嵌入结果要开根号缩放因为位置编码的范围是[-1, 1]而词嵌入在训练初期可能随机分布在较大范围。乘以sqrt(d_model)之后词嵌入的数值范围和位置编码基本对齐两者相加时不会出现一方完全压过另一方的情况。你可能会问不乘这个缩放因子行不行实践表明训练初期可能因为数值范围不匹配导致收敛变慢建议保留原论文做法。3.6 用随机假数据跑通前向传播模型写完了先别急着接训练用一个小的随机输入验证维度对不对import torch # 构造一个小配置方便CPU快速跑通 config { vocab_size: 10000, # 模拟一个1万词的词表 d_model: 512, n_heads: 8, d_ff: 2048, n_layers: 6, max_len: 512, dropout: 0.1, } encoder Encoder(**config) # 随机生成一个batch的token id batch_size 2 seq_len 10 sample_input torch.randint(0, config[vocab_size], (batch_size, seq_len)) # 假设前5个位置有效后面全是padding pad_mask torch.zeros(batch_size, seq_len, dtypetorch.long) for i in range(batch_size): valid_len 5 i # 第一条有效5个词第二条6个词 pad_mask[i, :valid_len] 1 output encoder(sample_input, pad_mask) print(输出形状:, output.shape) # 期望看到: torch.Size([2, 10, 512])如果能在控制台看到(2, 10, 512)恭喜你模型前向传播已经跑通了。如果报错大概率是维度对不上把错误信息和上面每一步的维度检查对照一下基本能定位问题。pad_mask的构造方式值得注意我手写了一个简单版本实际项目中通常用(token_ids ! pad_token_id)来生成即非padding位置为True。这个mask在后续翻译任务里会很常用。4. 训练一个小模型验证编码器真的能学东西4.1 用语言建模任务验证编码器单独一个编码器怎么验证它是否正常工作最直接的做法是接一个语言建模任务给定前几个词预测下一个词。虽然编码器做双向建模每个位置都能看全局不适合做自回归生成但我们可以做一个简化版的任务来验证——从句子里随机mask掉一个词让编码器预测被mask掉的那个词。这就是BERT的Masked Language Model思想。任务定义很简单输入句子随机把某个位置的token换成[MASK]编码器输出的对应位置向量经过一个线性层预测那个词。4.2 完整的训练脚本先整一个简单的线性分类头把编码器输出的d_model维向量映射到词表大小class MLMHead(nn.Module): 在编码器输出后接一个预测头用于语言建模 def __init__(self, d_model: int, vocab_size: int): super().__init__() self.linear nn.Linear(d_model, vocab_size) def forward(self, encoder_output: torch.Tensor) - torch.Tensor: # (batch, seq_len, vocab_size) return self.linear(encoder_output)然后写训练循环import torch.optim as optim from torch.utils.data import DataLoader, Dataset class RandomTextDataset(Dataset): 生成随机假数据模拟一批句子。 实际项目中用真实的文本语料替换这里即可。 def __init__(self, vocab_size, seq_len, num_samples1000): self.vocab_size vocab_size self.seq_len seq_len self.num_samples num_samples def __len__(self): return self.num_samples def __getitem__(self, idx): # 生成随机token序列0 当作 [MASK] 使用 x torch.randint(1, self.vocab_size, (self.seq_len,)) mask_pos torch.randint(0, self.seq_len, (1,)).item() y x.clone() x[mask_pos] 0 # 把mask位置设为0代表[MASK] return x, mask_pos, y[mask_pos] def make_pad_mask(token_ids: torch.Tensor, pad_idx: int 1) - torch.Tensor: 简化版这里不引入真实pad符号所有位置都有效 return torch.ones_like(token_ids) def train(): vocab_size 1000 seq_len 32 d_model 128 n_heads 8 # 确保 128 % 8 0 d_ff 512 n_layers 3 model Encoder( vocab_sizevocab_size, d_modeld_model, n_headsn_heads, d_ffd_ff, n_layersn_layers, max_lenseq_len, dropout0.1, ) head MLMHead(d_model, vocab_size) dataset RandomTextDataset(vocab_size, seq_len, num_samples1000) dataloader DataLoader(dataset, batch_size32, shuffleTrue) optimizer optim.Adam( list(model.parameters()) list(head.parameters()), lr1e-4, ) criterion nn.CrossEntropyLoss() model.train() head.train() for epoch in range(10): total_loss 0.0 for batch_x, mask_pos, batch_y in dataloader: # batch_x: (batch, seq_len) # mask_pos: (batch,)每个样本的mask位置 optimizer.zero_grad() encoder_output model(batch_x, None) # (batch, seq_len, d_model) vocab_logits head(encoder_output) # (batch, seq_len, vocab_size) # 取出mask位置的logits batch_indices torch.arange(batch_x.size(0)) mask_logits vocab_logits[batch_indices, mask_pos] # (batch, vocab_size) loss criterion(mask_logits, batch_y) loss.backward() optimizer.step() total_loss loss.item() print(fEpoch {epoch 1}, Loss: {total_loss / len(dataloader):.4f}) return model, head if __name__ __main__: train()这个脚本用随机假数据就能跑通显示loss在下降说明编码器确实具备了基础的表示学习能力。如果你有真实的语料把RandomTextDataset替换成真实文本即可。4.3 推理验证看看模型预测得怎么样训练完成后简单验证一下def predict(model, head, input_ids, mask_pos): model.eval() head.eval() with torch.no_grad(): encoder_output model(input_ids, None) logits head(encoder_output) pred_idx logits[0, mask_pos].argmax(dim-1).item() return pred_idx model, head train() # 随机测试一条 sample torch.randint(1, 1000, (1, 32)) pred_word predict(model, head, sample, 15) print(预测token id:, pred_word)因为数据是随机生成的预测结果不会有什么语义含义但loss持续下降已经能证明模型在正常学习。这一步的主要目的是验证整套代码流程——包括数据加载、训练循环、参数更新、推理路径——全部没有bug。5. 常见问题与调试实录5.1 Padding Mask维度对不上怎么办写多头注意力时最容易报这个错RuntimeError: The size of tensor a (32) must match the size of tensor b (16) at non-singleton dimension 2这个错误几乎都是mask维度问题。我强调一下scores的形状是(batch, n_heads, seq_len, seq_len)mask传给模型后要在最后面扩展两个维度变成(batch, 1, 1, seq_len)这样才会在广播时作用于最后一个维度也就是“每个query对其他所有key”的维度上。一个实用的自检方法在masked_fill之前打印一下mask.shape和scores.shape人工确认它们能否广播。如果mask是(batch, seq_len)忘了扩展就直接传给masked_fill大概率会报错或者mask效果不对。5.2 训练Loss不下降先检查这四件事如果编码器训练时loss死活不降按照下面顺序排查第一检查学习率。Transformer对学习率很敏感原论文使用了warmup策略前4000步线性升到峰值之后按步数倒数衰减。你如果用固定学习率建议先试1e-4别一上来就用默认的1e-3。第二检查数据范围。nn.Embedding输出的范围很大如果跟着位置编码相加后数值不稳定确认是否乘了sqrt(d_model)。第三检查dropout是否过高。推理阶段dropout会自动关闭但训练时0.1以上可能会让梯度信号变弱特别是小数据集上。第四检查初始化和归一化。编码器最后一层输出接了nn.LayerNorm如果loss在某个值上卡住不动建议打印一下各层输出均值和方差看有没有出现数值异常。5.3 显存不足序列长度和batch_size怎么权衡Transformer显存占用和seq_len的关系是平方级增长。因为注意力矩阵是(batch, n_heads, seq_len, seq_len)seq_len从512增加到1024这部分显存直接翻4倍。遇到OOM我的调整顺序是调小batch_size这是最直接的方法对训练效果影响最可控。调小n_heads或d_model。注意d_model减小后d_ff也要跟着等比减小不然模型参数比例失衡。使用梯度累积。小batch多步累积再更新参数和增大batch的梯度方向近似。实在不行做梯度检查点用计算换显存但训练速度会明显变慢。实践中普通个人电脑或单张消费级显卡训练Transformerseq_len控制在512以内d_model控制在512以内是比较稳妥的配置。5.4 关于Pre-LN和Post-LN的工程选择原论文是Post-LN也就是我在上面代码里给的结构。但我实际操作下来在层数较多12层以上或学习率偏高时Post-LN容易出现训练震荡。原因是残差分支里的梯度会直接向后传层数一多数值波动被放大。Pre-LN把LayerNorm挪到残差相加之前等价于限制了每层输出的尺度反向时会部分跳过归一化训练稳定性明显更好。代价是模型表达能力理论上略逊于Post-LN因为归一化会影响主分支的信息传递。我的建议如果你是在实现论文复现、跑公开基准保持Post-LN如果你是自建模型、追求快速稳定收敛直接用Pre-LN把EncoderLayer里forward的顺序调整一下即可# Pre-LN 版本的 EncoderLayer.forward def forward(self, x, maskNone): # 先归一化再进子层 attn_output self.self_attn(self.norm1(x), self.norm1(x), self.norm1(x), mask) x x self.dropout(attn_output) ff_output self.feed_forward(self.norm2(x)) x x self.dropout(ff_output) return x两种结构各有拥趸别迷信任何一种用你手上的数据集各跑一次哪个收敛快就用哪个这才是实用主义。5.5 编码器的后续扩展方向编码器本身只是Transformer的半边天搞懂它之后往下可以这样延伸接解码器搭完整Transformer做机器翻译或文本生成。解码器里要多一个“掩码多头注意力”用来让当前位置只能看到它之前的token这块留到《Transformer 详解(下)》展开。换上可学习的相对位置编码比如Transformer-XL和T5的做法。相对位置编码对长文本泛化更好很多工业界模型早就迁移过去了。做图像领域改造把图片切块后按序列输入这就是ViTVision Transformer的思路。图像patch就是token位置编码换成patch序号编码器结构基本原封不动。压缩自注意力计算量用稀疏注意力或线性注意力替代标准全局注意力序列长度从几千提升到几万甚至几十万这是当前长文本建模的主流方向。我个人在实际操作中最大的体会是编码器这套“输入变特征”的框架远不止NLP能用。我拿它处理过蛋白质序列、时序传感器数据甚至音频特征序列效果都超出了预期。核心就是把离散的、带顺序关系的符号序列映射到高维语义空间这个抽象能力放哪个领域都说得通。代码这部分建议你先跑通前向传播然后尝试改改d_model和n_heads亲眼看输出形状怎么变这比背十遍公式都管用。后面如果遇到什么问题欢迎随时交流。