Transformer刚火起来的那几年,圈子里流传过一篇特别经典的图解文章,很多人的收藏夹里都有它。我当时第一次看完,感觉是“每个字都认识,连起来就懵”;隔了一段时间再翻,突然就通了。后来带过不少新人,发现大家卡住的点高度一致,无非是 QKV 到底是什么、为什么要除根号 dk、多头注意力又是干嘛的。这篇就把我消化那篇图解之后的理解,用大白话加具体数字重新讲一遍,配上实操里容易踩的坑。整篇不用公式吓人,核心就四个字:看图、算数。
1. 先搞懂Transformer到底想干什么
1.1 老选手的麻烦:一个字一个字往前传
在Transformer出现之前,处理序列问题的主力是循环神经网络那一类结构。你可以把它想象成一条传送带:每个时刻只处理一个输入,然后把手里的“状态”传给下一个时刻。比如翻译“我爱机器”,它先看“我”,更新一个状态;再看“爱”,结合刚才的状态再更新;最后看“机器”,把累积的信息用于预测输出。
这个设计看起来很自然,但有两个绕不开的麻烦。第一个是长距离依赖问题:顺序一长,最初的信息在传递过程中会被层层稀释,就像一群人传话,传到后排时早就走样了。第二个是并行度低:当前时刻必须等前面所有时刻算完才能开始,训练速度被锁死。GPU 再强,也得老老实实地排队。
所以业界一直想要一个更彻底的方案:能不能让所有位置同时看全局,而不是一个接一个地往后传?Transformer 就是对这个问题的一次颠覆性回答。
1.2 一句话理解Transformer核心
Transformer 的核心可以压缩成一句大白话:让输入序列里的每一个词,同时和所有词做一次“关联度打分”,然后用这些分数把所有词的信息加权汇总,得到每个词的新表示。
这个“关联度打分”的机制,就是自注意力。它是Transformer 的心脏。后面的多头注意力、位置编码、残差连接,全都是为了让这颗心脏跳得更稳。
我用一个生活化的例子帮你记住它。假设你在一个全是陌生人的房间里,想理解“我”这个词在当前句子里到底指谁、指什么,不能只看它自己,得环顾四周每一句话、每一个词,看看谁和“我”关系近,谁关系远,再结合上下文重新理解“我”。自注意力干的就是这件事,只不过它把“关系近”量化成了一个分数。
这篇博文要做的,就是把这个“环顾四周打分加权”的过程拆开揉碎,顺带把输入输出怎么流动、训练和推理又有啥区别讲清楚。适合完全没接触过注意力机制的小白,也适合看过理论但总感觉差一层窗户纸的读者。
2. 宏观地图:从输入到输出的完整流水线
2.1 Encoder-Decoder 到底是啥
Transformer 最初是为机器翻译设计的,所以天然分成两半:左边是编码器(Encoder),右边是解码器(Decoder)。你可以把编码器理解成一个“阅读理解”模块,负责把输入句子读成一组富含语义的向量;解码器是一个“写作”模块,负责拿着这些向量,一个一个词地往外写目标句子。
完整结构大致是这样:
- 输入句子先经过词嵌入,变成一个一个向量。
- 再叠加上位置编码,让每个词知道自己站在句子的第几个位置。
- 然后进入编码器的一块(Encoder Block),里面有自注意力层和前馈网络层,外面套着残差连接和层归一化。
- 编码器的输出会作为解码器里“交叉注意力”的输入之一。
- 解码器自己也有自注意力层,但多了一个掩码,防止它偷看未来的词。
- 最后经过一个线性层加 Softmax,输出下一个词的概率分布。
初看很乱,但记住一条主线:输入先去编码器做特征提取,输出端在解码器里一边盯着已生成的内容,一边参考输入信息,逐步预测下一个词。
2.2 张量形状变化:一张表看懂维度
理解 Transformer 最容易被劝退的地方,是维度。我建议你盯住三个数字:
- batch size(一批多少个句子)
- sequence length(一个句子多长)
- d_model(每个词用多少维向量表示)
比如一个 batch 是 32 句话,每句话 10 个词,词向量维度是 512,那么输入张量就是 (32, 10, 512)。
后面的操作其实都在变换最后这一维:
| 阶段 | 张量形状变化 | 干了什么 |
|---|---|---|
| 词嵌入 | (32, 10, 512) | 把词 ID 映射成稠密向量 |
| 位置编码 | (32, 10, 512) | 逐元素加到词向量上 |
| 自注意力 | (32, 10, 512) | 每个位置融合全序列信息 |
| 前馈网络 | (32, 10, 512) 先扩到 (32, 10, 2048) 再缩回 | 逐位置的非线性变换 |
| 编码器输出 | (32, 10, 512) | 作为解码器交叉注意力的 K 和 V |
| 解码器输出 | (32, 目标句长度, 512) | 预测目标序列的表示 |
| 线性层+Softmax | (32, 目标句长度, 词表大小) | 输出每个位置下一个词的概率 |
注意一个关键点:自注意力从头到尾不改变序列长度和维度。它做的是特征融合,而不是压缩或扩维。压缩和扩维是前馈网络和线性层负责的。
2.3 为什么Encoder可以并行,Decoder却不行
这是很多初学者困惑的点。编码器里,自注意力一次就能看到整个句子,10个词之间互相打分,理论上可以同时算,所以 GPU 能一窝蜂地并行处理。
解码器在训练时其实也可以部分并行——因为训练阶段你知道完整的目标句子是什么,可以一次性把整句喂进去,只是要用掩码遮住未来位置。但在推理阶段就不行了:你只知道已经生成的词,不知道下一个词是什么,只能先生成第 1 个词,再基于第 1 个词生成第 2 个,再基于前两个生成第 3 个,这就是自回归生成。
打个比方:编码器是站在山顶看完整幅地图,解码器是蒙着眼睛猜下一步往哪走,每走一步才多知道一点路况。
3. 自注意力机制:一场全员参与的打分游戏
3.1 Query、Key、Value 到底是什么
自注意力的核心是三个向量:Query(查询)、Key(键)、Value(值)。很多人一看到 QKV 就头大,其实拿检索来类比特别容易。
你在百度搜索“Transformer教程”的时候:
- Query 就是你输入搜索框的那几个字:“Transformer教程”。
- Key 是互联网上每篇文章的标签、标题、摘要,用来和 Query 做匹配。
- Value 是文章正文本身,是真正有用的内容。
搜索引擎拿到 Query 之后,先和所有 Key 算一下相关度,然后把相关度最高的几篇文章的正文(Value)取出来,按相关度加权拼在一起还给你。Transformer 里的自注意力如出一辙:一个词作为 Query,去和序列里所有词的 Key 比对,得到一堆分数,再用这些分数去加权所有词的 Value。
这里有个容易混淆的点:同一个词,它既当 Query,也当 Key,也当 Value。因为三个角色都是从这个词的原始向量通过不同的权重矩阵映射出来的。你可以理解成这个词有三张名片:一张用于“询问别人”、一张用于“被询问”、一张用于“提供内容”。
3.2 具体算一遍:三个词的例子
纸上谈兵没用,直接拿一个极简句子“我 爱 AI”来算。假设每个词的原始向量是:
- 我:
[1, 0] - 爱:
[0, 1] - AI:
[1, 1]
为了得到 Q、K、V,我们先设置三组权重矩阵。为了能口算,我用最简单的矩阵:
Wq = [[1, 0], [0, 1]] Wk = [[1, 0], [0, 1]] Wv = [[1, 1], [0, 1]]把每个词的向量分别乘上这三个矩阵,得到:
- “我”:Q=[1,0],K=[1,0],V=[1,1]
- “爱”:Q=[0,1],K=[0,1],V=[1,2]
- “AI”:Q=[1,1],K=[1,1],V=[2,3]
现在计算“我”这个词和所有词的注意力分数。先算 Query 和每个 Key 的点积:
- “我”和“我”:1×1 + 0×0 = 1
- “我”和“爱”:1×0 + 0×1 = 0
- “我”和“AI”:1×1 + 0×1 = 1
然后做 Softmax。Softmax 会把分数变成概率,而且会放大差距。为了简单,我们假设这三个数的 Softmax 结果是:[0.45, 0.10, 0.45]。最后加权求和 Value:
- 0.45×[1,1] + 0.10×[1,2] + 0.45×[2,3]
- = [0.45+0.10+0.90, 0.45+0.20+1.35]
- = [1.45, 2.00]
于是“我”的新表示变成了[1.45, 2.00]。仔细看,这个新向量里混进了“爱”和“AI”的信息。这就是自注意力干的活:每个词的新表示,是全体词之前的信息按相关度加权混合后的结果。
3.3 为什么要除以根号dk
真正实现时,点积算完后还要除以sqrt(d_k),其中 d_k 是 Key 向量的维度。这是很多教程一笔带过的地方,但我觉得这是全篇最巧妙的设计之一。
原因要从 Softmax 的脾气说起。Softmax 对输入数值的大小非常敏感:如果输入分数都很大,Softmax 之后概率分布会变得非常“尖锐”,接近 one-hot,也就是只挑一个词看,其他都忽略。这样模型就丧失了“兼顾上下文”的能力,梯度也容易消失。
举个数:假设 d_k=512,Key 和 Query 都是独立随机向量,每个元素约在 0 附近,那么点积的方差就大约是 512。想象你在投一个 512 面的骰子,点数之和的波动范围自然比 2 面骰子大得多。点积结果一波动,Softmax 输入就进入饱和区。
除以根号 d_k,本质上是把点积的方差重新拉回到 1 左右,让 Softmax 的工作区间保持在温和地带。这就好比把音量先调低一点再进功放,防止爆音失真。
如果不用这个缩放,训练初期会明显不稳,表现为 loss 震荡、收敛变慢。我自己调试时试过去掉缩放,模型虽然也能跑,但要多花不少时间去哄它收敛,所以这个细节千万别乱动。
3.4 从向量到矩阵:实际代码里怎么组织
实际实现当然不是一个个词循环算,而是把所有词的 Q、K、V 拼成矩阵,一次性算完:
# 伪代码:自注意力矩阵运算 # X: (seq_len, d_model) Q = X @ Wq # (seq_len, d_k) K = X @ Wk # (seq_len, d_k) V = X @ Wv # (seq_len, d_v) scores = Q @ K.T # (seq_len, seq_len) scores = scores / sqrt(d_k) weights = softmax(scores, dim=-1) output = weights @ V # (seq_len, d_v)如果你用 NumPy 对着上面那个三词例子复现,会得到和我手算一致的结果。我建议初学者都手动写一遍这个小矩阵乘,比看十遍文章都管用。其中scores矩阵的第 i 行第 j 列,就是第 i 个词对第 j 个词的注意力强度。
4. 多头注意力:一副眼镜不够,就戴多副
4.1 为什么一个注意力不够用
前面我们算了一轮注意力,得到一组加权结果。问题在于:一个注意力头只能表达一种“相关性理解”。但自然语言里的相关关系是多维的。比如“苹果”和“好吃”可能是口感上的相关,和“公司”可能是商业上的相关,和“手机”可能是产品线上的相关。
如果只用一个头,模型被迫把这几种相关混在一起,就像一副眼镜没法同时满足近视、散光、变色三重需求。多头注意力提供了解决办法:并行走多组 QKV 变换,每组只关注一种关系,最后再合并。
你可以理解为组了多个“搜索小组”,每个小组用不同的搜索引擎,有的偏重语法关系,有的偏重语义相近,有的偏重指代关系。汇总时各小组投票,从而得到更丰富的表示。
4.2 多头的计算流程
假设 d_model=512,头数 h=8,那么每个头分到的维度 d_k = d_model / h = 64。
- 输入 X 分别乘上 8 组 Wq、Wk、Wv,得到 8 组 Q、K、V。
- 每组独立计算自注意力,得到 8 个 (seq_len, 64) 的输出。
- 把这 8 个输出在最后一维拼接起来,得到 (seq_len, 512)。
- 再乘一个输出权重矩阵 Wo,把维度压回 512,完成融合。
用图来想:原始向量被切成 8 个“窄通道”,每个通道单独做一次信息交换,最后又重新拼成宽通道。这有一点像乐队里不同乐器声部先单独练习,再合到一起排练。
这里有个初学常见误区:多头并不是把每个词的向量切成 8 段,然后各自处理。它是用 8 组不同的权重矩阵,把原始向量映射到 8 个不同的子空间。你可以在 PyTorch 里观察每个头的输出,会发现它们关注的东西确实很不一样。
4.3 头数怎么定
头数不是越大越好。头数增加会摊薄每个头的维度,如果头数过多,每个头分到的容量太小,反而学不到有效模式。常见配置是 8 头、12 头、16 头。业界流行的几个大模型基本都在这个范围里。
我见过有人把头数从 8 调到 32,结果训练速度变慢,效果不升反降。原因就是每头只有 16 维,信息表达太局促。选头数时有个经验:头数乘以每个头的维度,大致等于 d_model。8×64=512、12×64=768、16×64=1024,都是常见组合。
5. 位置编码与残差连接:让模型知道顺序和防止退化
5.1 顺序感哪里来
自注意力有个天生的毛病:它看不出词的先后顺序。你让它处理“我打你”和“你打我”,在纯注意力眼里,“打”和两个词的距离几乎一样,语义却天差地别。因为注意力只算两两相关,和位置无关。
所以 Transformer 必须人为给每个词的位置信息,这就是位置编码。位置编码的向量和词嵌入向量维度相同,直接相加,模型才知道“这个向量代表的是第几个位置的词”。
位置编码有两种常见做法:一种是正弦余弦固定公式;另一种是把位置编码当作可学习的参数,让模型自己学。早期 Transformer 用前者,现在很多大模型用后者或者更复杂的旋转位置编码。
5.2 正弦位置编码的直觉
正弦位置编码公式长这样:
- PE(pos, 2i) = sin(pos / 10000^(2i/d_model))
- PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))
为什么用这个公式?核心好处是:它可以用三角函数的和差公式,把任意两个位置之间的相对位置关系,表示成当前两个位置编码的线性组合。也就是说,模型只要学到一组线性变换,就能“算出”相对位置。这比直接用整数 1、2、3 当位置信息要灵活得多。
你不需要记住公式,但要知道它的两个特点:
- 位置越靠后,波形的频率就越低,不同维度上的“步调”不一样。
- 它不引入额外参数,而且能处理比训练时更长的序列。
不过在处理超长文本时,经典正弦编码的效果也会下滑,所以后来大家才研究各种新的位置编码方案。这属于进阶话题,初学者先理解“为什么要加位置信息”最重要。
5.3 残差连接:给梯度留条高速路
Transformer 里的每个子层(自注意力、前馈网络)都套了一层残差连接:输出的不是子层计算结果,而是“输入 + 子层结果”。也就是:
output = LayerNorm(x + Sublayer(x))这样做最直接的好处是:万一某一层什么都没学到,至少做个“恒等映射”把输入原样传下去,不会比什么都不加更差。从梯度角度看,反向传播时梯度多了一条近路,可以跳过子层直接流回前面,有效缓解深层网络梯度消失。
很多初学者看结构图时,只盯着注意力那一块,忽略了旁边那条拐到后面的箭头。那条线恰恰是训练稳定的命根子。我试过去掉残差连接跑一个小模型,结果层数一加深就出现 loss 不降、梯度几乎为零的问题,加了残差马上就正常了。
层归一化在这里还有个细节:它放在残差相加之后,和原始论文一致。后来的很多模型改成 Pre-LN,也就是先归一化再进子层,训练更稳。这个差异对结果有实际影响,但不影响理解主流程,知道有这回事即可。
6. 解码器与训练推理:从抄答案到闭关答题
6.1 Masked自注意力:不许偷看未来
解码器里也有自注意力,但多了一层“掩码”。为什么?因为解码器的任务是逐词预测,预测第 t 个词时,只能看到第 1 到 t-1 个词,不能看到 t 之后的内容。否则就是拿着答案做题,训练出来没有任何实际生成能力。
实现上很简单:把注意力分数矩阵的上三角部分(未来位置的分数)设成一个极大的负数,比如 -1e9,这样 Softmax 之后这些位置的概率会趋近于 0,相当于“未来的词虽然躺在输入里,但注意力把眼睛蒙上了”。
训练时你可以把完整的目标句子喂给解码器,通过掩码实现并行。这一步和推理的区别很重要:
- 训练:目标句子已知,一次性并行输入,但每个位置只能看到前面的词。
- 推理:目标句子未知,一个一个词地生成,生成的词同时充当后续词的输入。
这就像考试辅导和正式考试的区别:训练时题目和答案都在手里,但要求你假装没看到后面的题;推理时才是真正闭卷一道一道做。
6.2 交叉注意力:解码器怎么参考输入
解码器除了自带的自注意力,还有一个叫“交叉注意力”的模块。它的 Query 来自解码器自身的输出,而 Key 和 Value 来自编码器的输出。意思很明确:解码器在生成每一个词时,都要去“原文里查资料”。
这里就又回到搜索类比:解码器当前已生成的内容是 Query,编码器提炼出的原文信息是 Key 和 Value。解码器先生成“我”,再查原文看下一个最可能接什么词,生成“爱”,再去查原文,如此反复直到生成结束。
整个解码器由多层这样的结构堆叠,每一层都做一次“自身回顾 + 原文查阅”。层数越多,对关系的捕捉理论上越细腻,代价是计算量成倍增长。
6.3 训练和推理的数据流差异
这个差异是面试和大厂新人培训最爱问的点。我把关键区别列成一张表:
| 对比项 | 训练阶段 | 推理阶段 |
|---|---|---|
| 输入 | 完整的目标句子 | 已经生成的词 |
| 并行度 | 高,整句喂入 | 低,逐词生成 |
| 损失计算 | 每个位置都在预测 | 只关心下一个词 |
| 掩码 | 需要 Mask | 天然只看已生成内容 |
| 速度 | 快,可以大批量 | 慢,延迟随长度累积 |
训练和推理如果共用一个模型,你需要在代码里对 Mask 参数做区分。新手常见错误是推理时忘了关掉训练 Mask 逻辑,导致生成效果异常;或者在训练时没加 Mask,让模型偷看了未来,训练指标虚高,实际生成时崩盘。
7. 图解视角:怎么把那张经典结构图变成自己的东西
7.1 拆图四步法
那篇经典图解之所以出名,是因为它真的能用图让人懂。但很多人收藏完就吃灰。我的建议是别干看,按四步走:
第一步,从左往右读数据流。看一个向量从底部输入开始,经过哪些方框,每一步形状怎么变。不看含义,只跟踪数据形状。
第二步,关注注意力那一块的三个箭头。找到 Q、K、V 三个分支分别从哪来、到哪去,明白“MatMul”在算谁和谁的点积。
第三步,把 Softmax 前面的“Mask(可选)”圈出来,思考它在什么时候生效,什么时候不生效。
第四步,看每条“残差捷径”是从哪一步开始拐到输出的。把这一步做好,你基本上能把结构图默画出来。
7.2 用极简实现验证理解
动手是验证理解最好的方式。下面这段代码是极简版单头自注意力,用 NumPy 实现,没有训练逻辑,纯粹展现前向计算:
import numpy as np def softmax(x, axis=-1): e = np.exp(x - np.max(x, axis=axis, keepdims=True)) return e / e.sum(axis=axis, keepdims=True) def self_attention(X, Wq, Wk, Wv): Q = X @ Wq K = X @ Wk V = X @ Wv d_k = K.shape[-1] scores = Q @ K.T / np.sqrt(d_k) weights = softmax(scores) return weights @ V, weights # 模拟:4个词,词向量维度3,d_k=3 X = np.random.randn(4, 3) Wq = np.random.randn(3, 3) Wk = np.random.randn(3, 3) Wv = np.random.randn(3, 3) out, attn = self_attention(X, Wq, Wk, Wv) print("注意力权重矩阵:", attn.shape) # (4, 4) print("输出形状:", out.shape) # (4, 3)拿到这份代码,你可以在 jupyter 里改改序列长度、维度,观察注意力矩阵每一行的权重分布。我当年就是这么“看透”自注意力的,比任何课程都直观。
7.3 推荐一条完整学习路径
如果你真的想彻底搞懂 Transformer,下面的路线可以照着走:
- 第 1 步:把这篇博文里 QKV 手算例子自己算一遍。
- 第 2 步:用上面 NumPy 代码改成多头版本。
- 第 3 步:用现成框架把结构图里的模块搭一遍,不看参考答案。
- 第 4 步:训练一个小翻译模型,观察训练和推理的表现差异。
- 第 5 步:去读相关经典论文,对照结构图逐段理解。
完成前四步,你已经超过一大堆只会调包的人。第五步是登堂入室的门槛,能坚持到那里的,基本就是真懂 Transformer 了。
8. 常见问题与避坑指南
8.1 Q、K、V的权重能不能共享
理论上可以,但实践中几乎没人这么做。如果共享,就相当于每个词的查询、被查询、内容提供完全用同一套映射,注意力会退化成“只看自身相似度”,表达能力大幅缩水。这就是为什么原始实现里一定有三份不同矩阵。
有个值得试验的变体是“共享 QK”,但那是为了特定效率需求做的妥协,不是常规操作。你遇到类似代码时,先想想作者的动机,别盲目照搬。
8.2 为什么注意力分数要经过Softmax而不是直接用
Softmax 能让权重非负且总和为 1,这样输出就是所有 Value 的加权平均,既稳定又易于解释。如果不做 Softmax,直接用原始点积去加权,很容易出现负权重或权重绝对值失控,数值不稳定。
顺带一提,Softmax 的温度参数(除以温度系数)可以控制注意力分布“软硬”程度。温度越低分布越尖锐,越高越平滑。这不是 Transformer 的最初设计,但后来很多改进工作会用到。
8.3 位置编码用固定还是可学习
早期那篇经典实现用的是固定正弦编码,稳定且不需要训练参数。后来随着模型规模增大,不少人改用可学习的位置编码,让模型根据训练数据自行调整位置表示。
两者各有优势,对新手来说不用纠结,先学会“要加位置编码”这件事。如果你想追新,可以去看旋转位置编码(RoPE),现在不少新一代模型用它来拓展长文本能力,原理也是给词向量加“旋转角”,比固定正弦编码更好地处理相对距离。
8.4 初学者最容易踩的四个坑
第一个坑:以为自注意力改变了序列长度。没有,它只改变每个位置的表示内容,长度保持不变。
第二个坑:分不清 d_model 和 d_k。d_model 是整个词的向量维度,d_k 是每个注意力头的维度。d_model 除以头数等于 d_k,这个关系一定要记牢。
第三个坑:训练和推理用同一套代码逻辑。训练时用 Mask 和完整句子,推理要求逐词生成,两者切换时要特别小心。
第四个坑:忽略残差连接。有人画图或者搭模型时觉得残差“只是加了一下”,其实有没有这条捷径,训练稳定性和最终效果差别巨大。
还有一个经验上的提醒:如果你自己从零搭 Transformer,千万别一上来就搞大模型、大批次。先用极小数据集,比如几十条样本,验证前向传播形状正确、loss 能下降,再逐步扩大范围,否则你会被各种维度不匹配的报错淹没。我见过太多人卡在形状报错上,其实把每个张量的维度打印出来,逐个对照本文那张表,五分钟就能定位问题。
Transformer 刚出来时,很多人觉得它复杂得像天书。但等你把它拆成“搜索打分、加权汇总、多头并行、位置编码、残差保护”这几块,会发现每一步都不难。个人体会是:最难的不是理解某个模块,而是把这些模块串成完整数据流时,脑子里的那根线不能断。拿着结构图,手里写一遍数字示例,再跑一遍极简代码,这比反复看视频有效得多。希望这篇帮你把那根线接上。