做NLP项目这些年我绕不开一个话题——Transformer。从文本分类到序列标注从短文本到长文档架构选择的天平几乎都指向它。这篇东西算是我从RNN时代一路用过来的记录会聊清楚为什么Transformer能成为NLP领域的事实标准也会拆解自注意力、多头机制、位置编码这些核心模块再给出一段可以照着跑的代码和实际任务中的经验教训。不管你是刚入门还是已经写过不少模型应该都能从这里找到一些有用的东西。1. 从RNN到TransformerNLP架构切换背后的真实理由1.1 序列建模的老问题长程依赖与并行计算的矛盾在Transformer成为默认选择之前NLP领域的主力是RNN、LSTM、GRU这些循环架构。它们按时间步逐个读取输入天然适合序列数据。但它们有两个难以调和的矛盾第一是长程依赖问题信息需要经过很多步才能传递中间会有梯度衰减或爆炸所以LSTM引入了门控机制GRU做了精简但这只是缓解不是根治第二是训练速度问题因为是逐步计算每个时间步都要等前一步完成GPU并行能力发挥不出来序列一长训练时间就让人崩溃。举个例子一句话有20个词LSTM要串行处理20步。每一步的隐状态依赖前一步的输出无法把句子拆成小块同时计算。这在短句上还能忍但到了段落、文档级别训练成本会指数级上涨。我当时用LSTM做一个长文本匹配项目单条样本超过500字一个epoch就要跑很久调整超参数的成本特别高。而Transformer直接摒弃了循环结构用注意力机制一次性看到整个序列所有位置的表示可以同时计算。这意味着训练时间主要花在矩阵运算上而这恰恰是GPU最擅长的。所以说Transformer的流行不只是效果更好更是一整套工程效率上的胜利。1.2 注意力机制如何绕过“逐步传递”的限制注意力机制的出现不是Transformer发明的更早的seq2seq模型里也用了attention目的是让解码器在生成每个词时动态地看编码器的哪些位置更有用。Transformer把这种思想推到极致不再是解码器只看编码器而是编码器内部每个位置都与其他所有位置做注意力计算这就是自注意力。自注意力的核心是建立一个“全连接”的依赖图。对任意两个词不管它们在句子中相隔多远都能直接建立关联。比如“A把书放在桌上然后走出了房间”这里的“A”需要作为“走出”的主语传统RNN需要隔着十几个词慢慢传递而自注意力一步到位。这在实际项目里带来的最大变化是长距离语法关系、指代消解、共指问题都能更容易学到。代价是计算量变为序列长度的平方这在后面会提。1.3 什么时候你会真正需要Transformer有人会问是不是所有NLP任务都应该用Transformer我的经验是不能一概而论。对于真正需要捕捉全局语义、长距离依赖的任务——比如文本分类、情感分析、阅读理解、关系抽取——Transformer几乎是无法绕过的选项。但对于一些极短的输入比如关键词抽取、布尔查询匹配传统方法依然有优势。速度快、可解释性强、不需要大规模GPU。另外一个容易被忽略的点Transformer需要更多数据来训练。从零训练一个Transformer比训练RNN更困难因为它非常依赖大语料和较长的训练步数。所以我更推荐大多数场景直接使用基于Transformer架构的预训练语言模型做微调而不是从零开始。这个选择会直接影响项目周期。我会在后面第4节详细展开。2. Transformer核心组件拆解自注意力、多头与位置编码2.1 自注意力计算的完整流程与维度推导先从最简单的单头自注意力说起。输入是序列X形状为(seq_len, d_model)d_model是每个词向量的维度。通过三个可学习的权重矩阵W_Q、W_K、W_V分别把X映射为Q、K、V形状都是(seq_len, d_k)这里d_k通常等于d_model/h。注意力分数矩阵等于Q乘以K的转置再除以根号d_k得到(seq_len, seq_len)的分数矩阵。最后对每一行做softmax乘以V得到新的表示。为什么要除以根号d_k因为Q和K中的元素通常是独立同分布的点积后数值的方差会随着d_k变大而增大。如果d_k是100点积结果可能很大softmax的输入一旦进入饱和区梯度会非常小模型很难训练。除以根号d_k相当于把点积的方差恢复到1附近。这一点是Transformer论文里比较容易被忽略的细节但如果你自己实现过就会明白它有多重要。维度推导也有一个小坑。假设输入batch是16条文本每条文本128个tokend_model512n_heads8。那么Q、K、V在拆头之后应该是(16, 8, 128, 64)而不是把512直接分给头维度。很多新手在这里搞混导致转置和矩阵乘法维度对不上。写代码时务必用view把最后一个维度拆开再用transpose把头和序列位置调换否则出来的形状完全不是预期。2.2 多头注意力为什么不是“多个注意力”那么简单多头注意力就是把d_model分成h个子空间每个子空间单独做自注意力然后把h个头的结果拼接再过一层输出投影。直观理解是每个头可以关注不同的依赖模式。有的头可能更关注相邻词的语法关系有的头则关注较远距离的指代关系。concat之后模型能看到多种视角的组合。这里容易踩的坑是很多人以为多头就是简单复制多个注意力层。其实关键在于把维度切分。比如d_model512h8每个头的d_k64。如果你直接把QKV复制8份每份维度也是512那么计算量会膨胀8倍模型参数量也变大但效果并不等价。正确做法是先通过一个线性层把维度压缩到64再做注意力。后面代码里我会给出具体写法。另外多头注意力的头数选择也有讲究。头数太少比如1个模型退化成单头注意力可能学不到多角度的依赖头数太多比如超过64每个头的维度太少反而容易丢失信息。常见的经验值在8到16之间。如果你用的是预训练模型头数通常已经定好不用自己折腾。2.3 位置编码的工程选型与相对位置方案的取舍Transformer没有循环结构它无法天然知道词的顺序。所以需要给每个位置注入位置信息。最基本的位置编码是使用不同频率的正弦和余弦函数每个位置得到一组固定向量加到词嵌入上。优势是能泛化到训练时没见过的长度。另一种做法是把位置编码当作可学习参数随机初始化后在训练中更新。可学习编码在固定长度任务上通常更灵活但遇到超长输入时没有见过就难处理。相对位置编码则更近一步在计算注意力分数时直接使用两个位置之间的相对偏移量偏置。比如对于序列中相距k的两个词在QK^T上加上一个可学习的偏置。这种方式在长文本任务中更稳定因为相对距离比绝对位置更有语义意义比如“第5个词”这种绝对信息远不如“当前词前面第三个词”来得自然。如果你处理的是长文档我强烈建议优先考虑相对位置编码的实现。从我自己的项目经验看如果只是做标准长度512 token以内的分类任务正弦位置编码完全够用但如果要处理长段落、多句拼接或者对长度外推有要求相对位置编码会更稳。选位置编码时不要只看短期训练集表现还要想想线上会不会出现比训练集更长的输入。3. 从零搭建一个Transformer编码器关键代码与踩坑记录3.1 网络结构定义与超参数选择这部分给出实际可跑的代码。我用PyTorch实现一个标准的Transformer编码器层然后叠加几层组成分类模型。这里的关键超参数是d_model、n_heads、d_ff、num_layers。还有一个容易被忽略的是dropout率推荐0.1左右。下面是一个简化的编码器层实现import torch import torch.nn as nn import torch.nn.functional as F class MultiHeadSelfAttention(nn.Module): def __init__(self, d_model, n_heads, dropout0.1): super().__init__() assert d_model % n_heads 0 self.d_k d_model // n_heads self.n_heads 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 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) scores Q K.transpose(-2, -1) / (self.d_k ** 0.5) if mask is not None: scores scores.masked_fill(mask 0, -1e9) attn F.softmax(scores, dim-1) attn self.dropout(attn) ctx attn V ctx ctx.transpose(1, 2).contiguous().view(batch, seq_len, -1) return self.out(ctx)然后是前馈网络和残差连接class TransformerEncoderBlock(nn.Module): def __init__(self, d_model, n_heads, d_ff, dropout0.1): super().__init__() self.attn MultiHeadSelfAttention(d_model, n_heads, dropout) self.norm1 nn.LayerNorm(d_model) self.ff nn.Sequential( nn.Linear(d_model, d_ff), nn.ReLU(), nn.Dropout(dropout), nn.Linear(d_ff, d_model), ) self.norm2 nn.LayerNorm(d_model) self.dropout nn.Dropout(dropout) def forward(self, x, maskNone): x x self.dropout(self.attn(self.norm1(x), mask)) x x self.dropout(self.ff(self.norm2(x))) return x这里把LayerNorm放在残差分支之前也就是Pre-LN结构。相比原始论文的Post-LNPre-LN在深网络中更稳定训练初期不容易出现梯度爆炸。我自己搭模型时一开始用的Post-LN结果20层就翻车了学习率怎么调都不收敛换成Pre-LN之后立刻稳定下来。所以别小看这几行代码的顺序。3.2 训练中的不稳定因素学习率、梯度累积与 Warmup从零训练Transformer最常遇到的问题就是损失不降或直接产出NaN。第一个要检查的是学习率。Transformer对学习率非常敏感原论文用的是带Warmup的自适应学习率先让学习率从很小的值线性上升到峰值再按步数的倒数衰减。这个策略我一直在用。warmup步数通常设置为总训练步数的5%到10%。如果batch太大warmup可以短一点batch小warmup要长一些。学习率峰值一般设在1e-4到5e-4之间使用Adam优化器时要配上betas(0.9, 0.98)和epsilon1e-9。这个设置来自Transformer论文实际用下来很稳。学习率调度器可以写成下面这样def get_lr(step, d_model, warmup_steps): return d_model ** -0.5 * min(step ** -0.5, step * warmup_steps ** -1.5)如果你喜欢框架自带的学习率调度器也可以实现同样的策略。关键是step从1开始算前warmup_steps步内学习率线性上升之后按step的平方根倒数衰减。另外如果训练数据长度差异很大容易造成一个batch内padding过多浪费算力还影响收敛。可以按长度相似度进行桶排序把长度接近的样本放一个batch。这能显著减少padding比例。我试过同样的数据只改batching逻辑训练速度就快了一半还多。3.3 损失下降异常的排查链路我统计了一下A同学刚开始复现Transformer时遇到最多的问题就是训练一段时间后loss变成NaN或者一直卡在很高的位置不降。其实排查链路是固定的。第一步看输入是否有NaN、分词结果是否正常、padding mask是否正确。第二步看学习率把学习率降到原来的十分之一看第一个量级的loss是否下降。第三步看梯度范数在训练循环里打印一下梯度范数如果超过一定阈值就做梯度裁剪。第四步看LayerNorm位置和残差连接顺序。第五步检查注意力mask和softmax维度是否对齐。把这条链路走一遍基本能排除90%的问题。我自己遇到过一次非常离谱的问题——损失一直在0.7左右不降像是有个小错误但又不崩溃。最后发现是位置编码我直接加在了embedding后面但embedding没有乘以系数sqrt(d_model)。这在原论文里是有的一个小细节embedding层输出要放大sqrt(d_model)再和位置编码相加。不乘这个系数相当于位置信息相对词嵌入占比被削弱了模型不容易区分不同位置的语义。改完之后loss顺利降下去了。4. 把Transformer用到实际NLP任务文本分类与序列标注的经验4.1 预训练微调的关键操作细节如果是实际项目我不会从头训练Transformer。更常见的做法是加载一个基于Transformer架构的开源预训练语言模型然后在自己的下游任务上微调。这里要处理好几个关键点。第一输入格式。预训练模型有自己固定的分词方式和特殊标记比如开头的[CLS]、句间的[SEP]这些不是随意加的。如果你的文本包含多个句子要正确拼接并生成对应的token type ids否则模型会把句子之间的边界搞混。第二学习率。微调时的学习率要比预训练低很多常见是1e-5到5e-5。很多人直接沿用从零训练时的1e-4结果模型很容易灾难性遗忘在预训练阶段学到的通用语言知识被冲掉下游任务表现反而变差。我习惯先从2e-5开始观察验证集loss如果出现过拟合就降到1e-5如果欠拟合就提到5e-5。第三max length。预训练模型对输入长度有上限常见的是512个token。如果你的任务需要处理更长文本不要简单地把超过长度的内容截断那样会丢失关键信息。可以先做切分把文本分成多个片段分别编码再用池化或注意力池化汇总特征。4.2 数据增强与样本不平衡的处理NLP任务中数据不平衡非常常见。比如垃圾评论分类负面样本可能只占5%。直接用原始比例训练模型会倾向把所有样本都预测为多数类。我常用的处理方式包括对少数类样本做同义词替换、回译增强、基于简单规则的文本扰动设置Class Weight在损失函数中给少数类更高权重或者使用Focal Loss让模型更关注难分类样本。但注意数据增强不是越多越好。在序列标注任务中如果对实体词做同义词替换很可能会破坏实体边界和标签一致性。比如“北京”改成“首都”实体类型还是地点这个可以但如果你把一个多字词随机删除或插入标签序列就错位了。所以做增强时一定要考虑任务本身的标签约束。我倾向于先做基于预训练模型的掩码替换生成语义通顺的新样本再重新跑一遍分词和标签对齐这样安全很多。对于文本分类回译增强是最稳妥的方案之一。把原始中文句子翻译成英文再翻译回中文得到的句子语义基本不变但表达方式会变。这个操作对情感分析、主题分类这类任务很有效。不过回译比较耗时通常只对少数类样本做把数量补到和多数类大致平衡。4.3 推理阶段的速度优化与内存控制训练完成后部署时的推理优化也是大头。Transformer模型参数量大推理时显存占用高、延迟也高。我常用的优化手段有几项。一是把模型转成半精度FP16在支持Tensor Core的GPU上速度提升明显。不过要注意某些算子在FP16下不稳定必要时使用混合精度。二是做模型蒸馏用一个大的教师模型指导学生模型把知识迁移到更小的模型上可以大幅降低推理成本。三是使用批处理时动态padding一个batch内按最长样本做padding但样本长度差异大会浪费算力所以最好是按长度排序再组batch。如果对延迟要求极高还可以考虑量化到INT8。量化后的模型体积缩小到原来的四分之一推理速度也提升明显但效果可能会掉几个点。我一般会先跑一遍验证集确认性能降幅可接受再上线。这里还要提一个容易被忽略的点Transformer的tokenization在推理阶段也会占时间。尤其是用正则分词器逐步切词时CPU单线程处理几百条文本可能成为瓶颈。实际部署时可以把分词器也放到GPU侧或者提前做好离线分词缓存能省很多时间。5. Transformer的边界与下一步长文本、可解释性与稀疏注意力5.1 计算复杂度与长文本处理的瓶颈前面提过自注意力的计算复杂度是O(n^2)这里n是序列长度。当文本从几百字变成几千字时显存占用和推理延迟会急剧上涨。我处理过一个长文档分类项目单条文档超过4000个token直接塞进Transformer显存直接爆掉。后来不得不做滑窗切分把长文档切成多个重叠窗口分别编码后池化。虽然效果还行但显然不是高效方案。这也是近几年出现各种稀疏注意力、滑动窗口注意力、线性注意力变体的原因。它们的核心思路都是想办法降低自注意力的计算量同时尽量保留长距离依赖的捕捉能力。对于普通项目需求我的建议是如果文本超过2000个token不要直接硬塞先做文档结构切分或者用检索的方式先定位关键段落再用Transformer编码。工程上更省心。稀疏注意力的一种常见实现是局部窗口加全局感知。每个token只和固定窗口内的邻居做全量注意力另外每隔若干个token设一个全局token负责和整个序列交互。这样计算量从n^2降成n乘以窗口大小长文档处理成本大幅降低。如果你的业务有强长文本需求可以研究一下这一类改造。不要一上来就自己写新结构优先看成熟的开源实现。5.2 注意力分布的可解释性问题很多人觉得Transformer的注意力权重可以直接用来解释模型决策比如看哪个词和哪个词关联度高。但实际用下来注意力权重并不等于重要性。我在一个文本分类项目里做过对比实验把注意力权重高的词替换成无关词预测结果有时候变化不大有时候却剧烈变化。这说明注意力权重只是模型内部计算的一个中间量它受到多种因素影响不能简单当作归因结果。如果你需要解释模型建议用更稳定的方法比如积分梯度、LIME这类归因算法而不是直接看注意力权重的热力图。这一点在落地给非技术同事汇报时特别重要否则很容易被质疑模型的可信度。另外多头注意力中不同头关注的内容差异很大没有一个统一的“正确”注意力图。有些头学到的模式可能和语法很接近但有些头完全学的是与任务相关的、人类难以理解的模式。所以别把注意力可视化当作逻辑链条它更多是辅助调试工具。5.3 对后续发展的观察与个人实践方向从近几年的工作看Transformer本身还在进化。比如把注意力计算变成分块或稀疏模式在保证全局依赖的前提下降低复杂度再比如加入检索模块让模型能在推理时临时访问外部知识库减少参数记忆负担。我个人的看法是短期内我们还会继续用Transformer做主力架构但会在工程上加入更多针对性优化比如用更聪明的batching策略、混合精度、蒸馏、量化以及针对长文本的切分和召回方案。另一个我比较关注的方向是轻量化部署。NLP项目从实验到上线差距最大的往往不是模型效果而是模型能不能在有限算力下稳定跑起来。所以我会在项目早期就想清楚推理需求而不是等模型训练完再去压缩。如果你也在做实际NLP项目建议从第一天就把推理效率和长文本处理纳入架构选型。