从词嵌入到自注意力:Transformer核心链路全解析
很多人第一次接触 Transformer都是被注意力机制这四个字吸引过来的。我也不例外——最早啃那篇经典论文的时候直接从注意力章节开始结果被 Query、Key、Value 三个字母绕得头晕。后来我换了个学法从整条数据流的起点开始看才发现一切豁然开朗注意力机制的前置条件是你得先把输入变成向量而这个变成向量的过程正是词嵌入Word Embedding。这是我计划中的 Transformer 基础系列第一篇我打算把这条链路完整走一遍从词嵌入出发一步步走到注意力机制把中间每一个为什么都摊开讲清楚。如果你刚入门 NLP或者读论文卡在注意力上这篇应该能帮你把地基打牢。1. 为什么入门Transformer得先从词嵌入开始1.1 机器读不懂猫只能读数字语言模型处理的是文本但神经网络完全不认识字符。无论是中文的猫还是英文的cat在这些模型眼里都没有任何意义——它们只接受数值类型的输入。所以在任何一个 NLP 任务开始之前我们都必须先把文本中的词或子词映射成一组数字。这个映射的产物就是词嵌入向量。可能有人觉得这件事太基础了不值得专门讲。但恰恰是这个基础步骤决定了后面所有模块能做什么、不能做什么。你可以把词嵌入理解成给模型准备的原材料原材料如果本身就是散的、碎的后面的注意力机制再强大也很难从乱成一团的输入里捞出有用的信息。先把这个阶段理顺比直接背注意力公式更有价值。1.2 从one-hot到分布式表示的必然选择最简单粗暴的文本数值化方案是 one-hot 编码。假设词表里有 1 万个词我们给每个词准备一个 1 万维的向量只有自己那一位是 1其余全是 0。这样做确实能让模型看见词但问题也是致命的。第一是维度爆炸。真实场景里词表动辄几万甚至几十万如果每个词都用如此高维的稀疏向量表示计算和存储都是灾难。第二也是更要命的是 one-hot 向量之间完全没有任何语义关联猫和狗的 one-hot 向量内积是 0欧氏距离是常数模型根本看不出这两个词其实是相近的概念。换句话说one-hot 只告诉模型这是两个不同的词却无法告诉模型这两个词在语义上比较像。分布式表示Distributional Representation的思路则完全不同。它不再给每个词独占一个维度而是用一组实数值维度来描述词的属性不同词之间共享这些维度。比如猫的向量可能是 [0.8, -0.1, 0.6, ...]狗的向量可能是 [0.7, -0.2, 0.5, ...]它们在若干维度上的取值接近模型就能感知到这两个词在语义空间里是挨着的。这种共享维度的设计让词汇之间天然带上了相似度信息也为后续注意力机制中计算两个词有多相关打下了基础。1.3 词嵌入的本质一张训练出来的查找表在 Transformer 架构里词嵌入层本质上就是一张可学习的查找表。假设词表大小是 10000我们准备一个形状为10000 × 512的矩阵每一行对应一个词的向量。输入某个词的 id就去查矩阵的对应行得到它的 512 维向量。这个矩阵不是用某种规则手工设计出来的而是跟着整个模型一起训练出来的。import torch.nn as nn embedding nn.Embedding(num_embeddings10000, embedding_dim512) word_idx torch.tensor([42]) vector embedding(word_idx) # shape: [1, 512]训练过程中这个矩阵不断被梯度更新最终让语义相近的词在向量空间中的距离也相近。比如猫和狗的向量距离通常小于猫和桌子的距离。这个性质正是注意力机制能按需加权的底气——如果词本身都没有语义结构后面的相似度计算就成了无源之水。提示Embedding 层在 PyTorch 等主流框架里默认是随机初始化的通常是均值为 0、方差较小的正态分布。它并不依赖预训练向量而是作为待训练参数存在。2. 词嵌入的演进与常见误区2.1 Word2Vec时代留下的核心直觉讲到词嵌入绕不开 Word2Vec 时代留下的一个核心直觉一个词的含义由它所在的上下文决定。这句话听起来像废话但它其实颠覆了早期语言学的做法。早期很多人尝试手工设计词的语义特征比如是否是可数名词是否有生命费力又不讨好。Word2Vec 的思路是让模型根据一个词的邻居去预测它或者反过来根据一个词去预测它的邻居。训练完成后词向量自然编码了共现规律。在 Transformer 的结构里这个直觉被继承并进一步强化了。Transformer 没有像 RNN 那样顺序读取文本而是让每个词一次性看到整个序列。但即便是这样它依然遵循语义来自上下文的原则只不过它的上下文不再是滑动窗口内的几个词而是整个序列中的所有词。2.2 静态词嵌入的边界一词多义怎么办经典的静态词嵌入有一个明显的边界每个词只有一个固定向量。这意味着bank无论在银行语境还是河岸语境里都被对应到同一个向量。这种一词多义问题静态词嵌入是无法解决的。要解决这个问题就得让词的表示不仅取决于词本身还要取决于它所在的环境。换句话说每一个词在进入模型之后都应该根据当前语境动态调整自己的向量。这恰恰是注意力机制存在的理由之一通过让词与周围的词交互把上下文信息揉进每个词的表示里最终得到的是语境化的词向量。如果你理解了静态词嵌入的这个局限再看 Transformer 里注意力那一大堆公式就会明白它不是凭空冒出来的而是在补一个老问题。2.3 维度、初始化与更新的取舍词嵌入维度怎么选我自己做中小规模模型时常用 512大一点的用 768 或 1024。维度太小语义容量不够维度太大参数膨胀训练也会变慢。关键是看你下游任务的数据量数据量不大就别盲目上大维度否则很容易过拟合。关于初始化现在框架默认的随机初始化基本够用。有一个细节值得注意在训练初期Embedding 层更新非常快如果学习率调得太大容易出现某个词向量突然跑飞的情况。我习惯在训练初期用较小的学习率做热身warmup让 Embedding 层和后续网络都稳定下来这个做法在很多 Transformer 训练实践中都是标配。3. 注意力机制到底破解了什么难题3.1 RNN序列模型的顺序困境在 Transformer 出现之前处理序列数据的标准方案是 RNN 和它的变体 LSTM、GRU。RNN 的核心逻辑是逐步读入当前时刻的隐状态依赖上一时刻的隐状态所以必须按顺序处理整个序列。这个设计带来了两个痛点。第一个痛点是无法并行。句子有多长模型就要串行跑多少步训练效率非常低。第二个痛点是长距离依赖问题。序列中相隔很远的两个词信息要经过很多步才能传递到彼此中间一旦有遗忘或衰减早期的重要信息就可能丢失。你可以把它想象成一场传话游戏一句话从队伍一头传到另一头传的人越多最后的版本和原始的差距就越大。3.2 注意力的初形态软对齐与加权求和注意力机制的早期形态是在机器翻译任务里为解决长句翻译质量差而出现的。当时的做法是解码器每生成一个目标语言的词都要回头去看源语言句子里的所有位置计算出每个位置的重要程度然后按重要程度把源句的向量加权求和作为生成当前词的额外输入。这个操作听起来很复杂但核心只有两点一是打分评估当前需要的信息与源句每个位置的相关程度二是加权求和把相关位置的向量按权重混合起来。整个过程可以写成加权和 权重1×向量1 权重2×向量2 ...。这种软对齐机制比早期硬对齐只选某一个位置好用得多因为它允许模型在不同程度上同时参考多个位置。3.3 自注意力让句子内部先互相打一次分Self-Attention自注意力并不是一种全新的算法而是把注意力用在了同一个序列内部让序列里的每个词都和序列里的所有其他词互相打一次分看看彼此有多相关。举个例子句子是小猫坐在垫子上它很暖和。这里的它指代谁人类一眼就能看出指代小猫。但模型要判断这一点就需要让它去关注句子中所有位置发现小猫与它的关系最强于是给小猫那个位置的向量一个较大的权重把相关信息聚合到它的表示里。这就是自注意力在做的事情每个位置的输出都变成整个序列按相关性加权求和的结果。这样做的好处非常直接——序列中任意两个位置之间的依赖只需要一步计算就能建立不需要像 RNN 那样一步一步传递。正因为如此Transformer 才能实现大规模并行计算同时解决长距离依赖问题。4. 自注意力的完整计算链路从QKV到缩放点积4.1 Query、Key、Value的生活化类比图书检索自注意力的公式里有三个角色Query查询、Key键、Value值。我第一次看的时候完全无法把它们和文本联系起来后来用图书检索的类比才真正记住。想象你去图书馆找一本书。你心里有一个需求这就是Query——我要找一本讲深度学习入门的书。图书馆的每本书都有个目录标签这是Key——深度学习入门教材之类的关键词。你拿着自己的查询需求去和每本书的标签做匹配匹配程度高的书会被你抽出来。书的内容则是Value——真正被你看进脑子里的东西。最终的阅读收获不是某一本书的全部内容而是几本匹配度较高的书中内容的混合匹配度越高的书对你最终理解的影响越大。对应到 Transformer 里每个词都会生成自己的 Query、Key、Value 三个向量然后拿自己的 Query 去和序列里所有词的 Key 算匹配度再用匹配度作为权重对所有词的 Value 做加权平均。匹配度越高那个词对最终输出的贡献就越大。4.2 缩放点积注意力的四步计算在实际实现中自注意力的计算会先通过三个独立的线性层把输入向量投射成 Query、Key、Value。假设输入向量是x那么Q x * W_QK x * W_KV x * W_V这三个权重矩阵是模型训练出来的。得到 Q、K、V 之后缩放点积注意力按四步走计算 Query 和所有 Key 的点积得到注意力分数scores Q · K^T对分数做缩放scores scores / sqrt(d_k)其中d_k是 Key 向量的维度对缩放后的分数做 softmax得到和为 1 的权重分布用权重对所有 Value 加权求和output weights · V这四步就是整个自注意力机制的全部。你不需要记住复杂的推导只需要理解整个过程是在做一件事——根据相关性对信息做有选择的聚合。4.3 为什么非要除以根号dk数值稳定性的细节很多人会忽略sqrt(d_k)这一步甚至觉得它只是个小修正。实际上这个缩放对训练稳定性至关重要。原因要从点积的方差说起。假设 Q 和 K 的每个维度都是独立同分布的均值为 0、方差为 1那么两个向量做点积后结果的期望是 0方差却是d_k。也就是说维度越大点积的方差越大。方差变大会让一些点积结果绝对值变得非常大再送进 softmax 时绝大多数位置的梯度都会被压得极小出现梯度消失模型基本学不动。除以sqrt(d_k)之后点积的方差被拉回 1 附近softmax 的输入分布就温和多了。这个细节解释了为什么你会在很多开源代码里看到torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(d_k)——这个math.sqrt(d_k)不是摆设而是保证训练稳定的关键一环。5. 一个句子从词嵌入到注意力的完整数据流5.1 输入处理token化、嵌入与位置信息叠加现在我们走一遍真实的数据流。假设输入句子是小猫坐在垫子上。第一步是分词tokenization把句子拆成模型可处理的 token 序列。早期 NLP 用词级分词现在更常用子词分词比如 BPE 这类算法既能覆盖常见词又能处理生僻词。分词后每个 token 会拿到一个词表 id。通过前面说的 Embedding 查找表每个 id 被换成一个向量。但这里有个问题Embedding 本身不含位置信息可语言是讲究顺序的猫追狗和狗追猫意思完全不同。于是模型需要给每个位置加上一个位置编码。早期 Transformer 用的是正弦位置编码用不同频率的三角函数去生成一个与词向量同样维度的位置向量直接加到词嵌入上。后来很多模型改成可学习的位置编码或者用旋转位置编码RoPE在注意力计算中注入相对位置信息本质都是在解决让模型知道词与词的先后关系。5.2 多头注意力一次不够就并行多学几路了解了单头自注意力之后多头注意力就很好理解了。它做的事情是把 Q、K、V 的维度切成h份每一份独立地做一次自注意力得到h个子输出再把它们拼接起来经过一个线性层得到最终输出。为什么要多头因为相关性不是一个单一概念。有些头学会关注句法关系比如动词和它的主语有些头学会关注指代关系比如代词和它指代的名词有些头可能关注修饰关系。单头注意力只能捕获一种加权方式多头则让模型并行地从多个角度理解序列。在我实际经验里h8在d_model512时是一个很常用的配置每个头的维度是 64。多头注意力的实现有一个容易踩坑的地方切分维度时要在d_model的最后一项做 reshape转置之后再做矩阵乘法最后要记得把多头结果重新拼接起来。这个过程如果 permute 的顺序搞错矩阵形状会非常混乱。5.3 残差连接与层归一化让深层网络不掉血注意力输出之后并不是直接进下一层而是要先做一个残差连接再加一个层归一化。残差连接就是让输入直接加到输出上output x attn(x)。这个操作允许梯度在深层网络中有一个捷径可以回传帮助缓解深层模型训练时常见的退化问题。层归一化的作用则是对每个样本的特征维做归一化让数据分布保持稳定。它与 BatchNorm 不同BatchNorm 在一个 batch 里跨样本做归一化而 LayerNorm 在单个样本内部做归一化更适合序列变长、长度不固定的 NLP 场景。Transformer 的每个子层注意力层、前馈层基本都采用子层计算 → 残差连接 → 层归一化的结构。5.4 极简PyTorch实现从零写一个自注意力层理解了这些代码就很容易看懂了。下面是一个极简的缩放点积自注意力实现我用它做过不少 demo逻辑清晰import math import torch import torch.nn as nn import torch.nn.functional as F class SelfAttention(nn.Module): def __init__(self, d_model, d_k, d_v): super().__init__() self.w_q nn.Linear(d_model, d_k) self.w_k nn.Linear(d_model, d_k) self.w_v nn.Linear(d_model, d_v) def forward(self, x, maskNone): # x shape: [batch, seq_len, d_model] Q self.w_q(x) # [batch, seq_len, d_k] K self.w_k(x) V self.w_v(x) scores torch.matmul(Q, K.transpose(-2, -1)) # [batch, seq_len, seq_len] scores scores / math.sqrt(K.size(-1)) if mask is not None: scores scores.masked_fill(mask 0, float(-inf)) weights F.softmax(scores, dim-1) output torch.matmul(weights, V) # [batch, seq_len, d_v] return output注意mask参数这个在后面排坑时非常重要如果你把 padding 位置也参与注意力计算模型会把大量注意力浪费在没有意义的填充位上甚至导致训练不收敛。下一节我会专门讲这个问题。6. 实操中容易踩的坑与排查思路6.1 padding位置对注意力分数的干扰训练时我们经常会把一个 batch 内的句子 padding 到相同长度。如果不做任何处理padding 位置的那个纯 0 向量也会参与注意力计算得到 softmax 之后甚至可能分走一部分权重。这不仅浪费模型容量还会让真正信息密集的位置拿到的注意力变少。解决办法是构造一个 padding mask把 padding 位置对应的注意力分数设为负无穷这样 softmax 之后这些位置的权重会变成 0。代码也很简单# mask shape 与 scores 一致padding 位置为 0 scores scores.masked_fill(mask 0, float(-inf))我在核对一批模仿 Transformer 的开源代码时最常见的错误就是漏掉这个 mask或者 mask 形状与 score 矩阵不匹配。如果你发现模型训练曲线飘忽不定先检查 pad 位置是否被正确处理这个排查成本很低。6.2 位置编码的取舍绝对位置还是相对位置位置编码的选择会直接影响模型在外推长度上的表现。早期正弦位置编码在训练长度内表现不错但一旦推理时输入长度超过训练长度效果就明显下滑。原因在于它编码的是绝对位置超出训练区间的部分模型没有见过。后来很多模型改用旋转位置编码RoPE它通过旋转矩阵把位置信息注入 Q 和 K使注意力分数天然依赖两个位置之间的相对距离外推能力更强。如果你要做的任务对长文本特别敏感建议优先选择带相对位置信息的编码方案。如果是跑通实验、验证思路正弦或可学习位置编码完全够用没必要一上来追求复杂方案。6.3 Embedding层与输出层共享权重的小技巧有一个经常被忽略的省参数技巧在很多 Transformer 语言模型中输入端的 Embedding 层和模型末尾的输出层把向量映射回词表概率的那层会共享同一份权重。因为从语义上看输入 Embedding 负责把词变成向量输出层负责把向量变回词这两个方向的操作具有对称性共享权重不仅能减掉一大块参数量有时还能让训练更稳定。如果你自己搭模型可以考虑尝试这种共享设计但要注意共享之后输出层的输出维度必须严格等于词表大小否则形状对不上。这个技巧在中小规模模型上效果明显但也并不是所有任务都适合比如输出目标和输入词表不完全一致的任务就不适用。6.4 从调试视角看如何确认你的注意力真的学到了东西训练完模型之后怎么知道注意力机制真的在起作用我把注意力权重矩阵画成热力图观察里面是否有明显的结构化模式。比如机器翻译任务里某个目标词通常只对齐到源句中一两个位置如果热力图呈现出近对角线或稀疏聚焦的模式说明注意力学得比较健康如果热力图呈现出完全均匀的权重那就要怀疑模型是不是退化了。另一个常用方法是检查多头是否分化。训练良好的多头注意力里不同头关注的重点往往不同一个头可能集中看前面的词另一个头可能集中看当前位置附近的词。如果所有头学出来的模式几乎一模一样那说明多头没有充分利用可能需要调整初始化方式或增加训练步数。我在实际项目中反复感受到Transformer 的很多反直觉设计都源于两个朴素目标让信息在长序列中不丢失让计算能大规模并行。词嵌入解决的是如何让机器理解文字注意力机制解决的是如何让信息按需流动。把这两件事打通之后再看后面更复杂的结构你会觉得顺理成章。下一篇我会继续展开多头注意力的完整实现与 Encoder 层搭建那里还有更多值得抠的细节。