Transformer 模型结构全景
第 2 课:Transformer 模型结构全景
第 1 课搞清楚了"为什么要 Transformer",这一课把 Transformer 的骨架拆开——Encoder 塔怎么理解输入、Decoder 塔怎么生成输出、两者之间怎么传递信息。
② 说清 Encoder 的 Self-Attention 和 Decoder 的 Masked Self-Attention 为什么不一样
③ 理解 Cross-Attention 怎么把 Encoder 的输出"喂"给 Decoder
先看任务本身:把「I love you」翻成「我爱你」,目标端要逐词产出。而"我"和"爱"这两个位置对输入的要求方向相反——一个要求"整句都已读过",一个要求"绝不能提前看到后面"。这就要求结构把这两种可见范围分开,也就是分成 Encoder 与 Decoder 两座塔。
就像同声传译岗位的分工:一个人先把整段话听完记牢,另一个人再逐句往外说——前者在论文里叫 Encoder,后者叫 Decoder。
| 要产出的词 | 必须看见 | 绝不能看见 |
|---|---|---|
| "我"(第 1 个) | I / love / you —— 整句 3 个源词 | — |
| "爱"(第 2 个) | I / love / you | "你"(自己还没生成) |
| "你"(第 3 个) | I / love / you | —(已是最后一个) |
| 阶段 | 若允许看未来 | 若禁止看未来 |
|---|---|---|
| 训练 | 学到 P(我 | I love you 全句) | 学到 P(我 | 只有前缀) |
| 推理 | 只能拿到前缀(未来词尚不存在) | 与训练一致 |
| 结果 | 训练与推理输入分布不一致 → 输出崩坏 | 一致 |
| 塔 | 可见范围 | 因为要满足 | 运行次数 |
|---|---|---|---|
| Encoder | 双向,每个词看全部词 | 源句侧"全可见" | 1 次 |
| Decoder | 单向,Masked(只看左边) | 目标侧"绝不看未来" | 逐词,3 次 |
| 塔 | 前向次数 | 层数 | 层计算次数 |
|---|---|---|---|
| Encoder | 1 | 6 | 1 × 6 = 6 |
| Decoder | 3(3 个词) | 6 | 3 × 6 = 18 |
| 合计 | — | — | 18 ÷ 6 = 3 倍 |
Decoder 每层比 Encoder 多一个 Cross-Attention(4 个 512×512 投影矩阵):单层从 3.15M 增到 4.20M,6 层共多 6.3M 参数。而 FFN 始终占每层参数的大头(67%)——Self-Attention 并不是参数最多的部件。
| 组件 | 参数量 | 占 Encoder 一层比例 |
|---|---|---|
| Multi-Head Self-Attention(4 个投影矩阵) | 4 × 512² ≈ 1.05M | 33% |
| FFN(两个线性层) | 2 × 512 × 2048 ≈ 2.10M | 67% |
| LayerNorm(2 个,各 512 缩放 + 偏置) | 2 × 1024 ≈ 2K | 可忽略 |
| Encoder 一层合计 | ≈ 3.15M | 100% |
| Decoder 一层(多 Cross-Attention) | 额外 4 × 512² ≈ 1.05M | — |
| Decoder 一层合计 | ≈ 4.20M | — |
| 6 层 Encoder + 6 层 Decoder | 6 × 3.15M + 6 × 4.20M ≈ 44.1M | — |
| + Embedding(与输出层共享权重)+ 位置编码(正弦函数,无参数) | V × 512 = 18.94M | — |
| 总计(V = 37000 BPE) | ≈ 44.1M + 18.9M ≈ 63M | 嵌入层与输出层共享权重,只计一份 |
上一张卡片看了双塔结构。现在拆开看 Encoder 一层内部:两个子层,每个子层都是"处理 → 加回 → 归一化"的三部曲:
就像一条流水线:所有工位先互相通气一遍(Self-Attention 做信息融合),每个工位再各自加工(FFN 做非线性变换)——论文里就是 Self-Attention → Add & Norm → FFN → Add & Norm 这个顺序。
为什么要"加回"再"归一化"?因为网络越深,数值尺度越会逐层放大、梯度也越会逐层衰减——这两步就是为深网络准备的稳定器。
子层 1:Self-Attention
输入 x → Multi-Head Self-Attention(x) → 残差加法 x + Attention(x)。最后再过一层 LayerNorm。
子层 2:FFN
LayerNorm 输出 → FFN(两个线性层 + ReLU) → 残差加法 → LayerNorm。
| 位置 | 词 | 向量(d_model=4) |
|---|---|---|
| pos1 | 我 | [1.0, 0.5, 0.2, 0.8] |
| pos2 | 爱 | [0.3, 1.2, 0.7, 0.1] |
| pos3 | 你 | [0.6, 0.4, 1.1, 0.9] |
| 位置 | x(原始) | + attention_out | = 求和 | → LayerNorm → 输出 |
|---|---|---|---|---|
| pos1 | [1.0, 0.5, 0.2, 0.8] | [0.3, 0.1, 0.2, 0.4] | [1.3, 0.6, 0.4, 1.2] | [1.109, -0.718, -1.239, 0.848] |
| pos2 | [0.3, 1.2, 0.7, 0.1] | [0.5, 0.3, 0.4, 0.2] | [0.8, 1.5, 1.1, 0.3] | [-0.285, 1.313, 0.4, -1.427] |
| pos3 | [0.6, 0.4, 1.1, 0.9] | [0.2, 0.1, 0.3, 0.5] | [0.8, 0.5, 1.4, 1.4] | [-0.577, -1.347, 0.962, 0.962] |
| 位置 | 输入 x' | → W1×x'+b1 → ReLU | → W2×(...)+b2 = ffn_out |
|---|---|---|---|
| pos1 | [1.109, -0.718, -1.239, 0.848] | [2.1, 0, 1.8, 0, 0.5, 3.2, 0, 1.1] | [0.4, 0.2, -0.1, 0.6] |
| pos2 | [-0.285, 1.313, 0.4, -1.427] | [1.5, 0, 2.0, 0, 0.3, 2.8, 0, 0.9] | [0.3, 0.5, 0.1, 0.4] |
| pos3 | [-0.577, -1.347, 0.962, 0.962] | [1.8, 0, 1.5, 0, 0.2, 2.5, 0, 0.8] | [0.2, 0.3, 0.0, 0.7] |
| 位置 | x'(子层1输出) | + ffn_out | → LayerNorm → Encoder 输出 |
|---|---|---|---|
| pos1 | [1.109, -0.718, -1.239, 0.848] | +[0.4, 0.2, -0.1, 0.6] | [1.509, -0.518, -1.339, 1.448] → [0.997, -0.64, -1.304, 0.947] |
| pos2 | [-0.285, 1.313, 0.4, -1.427] | +[0.3, 0.5, 0.1, 0.4] | [0.015, 1.813, 0.5, -1.027] → [-0.304, 1.457, 0.171, -1.325] |
| pos3 | [-0.577, -1.347, 0.962, 0.962] | +[0.2, 0.3, 0.0, 0.7] | [-0.377, -1.047, 0.962, 1.662] → [-0.634, -1.261, 0.62, 1.275] |
Self-Attention 做的是"信息融合"——各位置的词互相交流;FFN 做的是"信息加工"——每个位置独立地对融合后的信息做非线性变换。两者配合:先融合、再加工、每一层加深理解。
上一张卡片拆解了 Encoder 的内部四步。Decoder 一层 = 三个子层(比 Encoder 多一个),区别在哪?因为生成时它要同时干两件事:一边看自己已经写出的内容,一边去查已读完的输入,所以比 Encoder 多出一个 Cross-Attention。
就像做翻译时的两个动作:先回想自己已经译出的半句(Masked Self-Attention),再回头翻原文找对应说法(Cross-Attention)——这两个动作在论文里就是 Decoder 的 两个注意力子层。
子层 1:Masked Self-Attention — 和 Encoder 类似,但加了下三角 Mask(只看已生成的词)。
子层 2:Cross-Attention — Q 来自本层输入,K 和 V 来自 Encoder 输出。Decoder 用自己当前的表示去"查"Encoder 对整个输入句子的理解。
子层 3:FFN — 和 Encoder 完全一样
省掉 Cross-Attention 行不行?不行:第 1 步生成时 Decoder 手里只有 [BOS] 一个符号,它从没见过「I love you」这 3 个源词,凭什么把首词定成「我」而不是「你」?所以必须保留一个专门回看源句的子层——这就是 Cross-Attention 存在的理由。
每个子层后面都跟着 Add & Norm。
| Encoder 位置 | 编码后的向量 |
|---|---|
| I | [0.9, 0.5, 0.1, 1.1] |
| love | [0.5, 1.0, 0.6, 0.4] |
| you | [0.4, 0.4, 0.7, 1.2] |
| Q·K 得分 | Softmax | 加权 V |
|---|---|---|
| Q·K_I = 0.42 | exp(0.42)/Σ = 0.355 | 0.355 × V_I |
| Q·K_love = 0.35 | exp(0.35)/Σ = 0.331 | 0.331 × V_love |
| Q·K_you = 0.30 | exp(0.30)/Σ = 0.315 | 0.315 × V_you |
Self-Attention 是"自己和自己聊天"——同一序列内部的信息融合。Cross-Attention 是"译者和原文对话"——Decoder 用自己的理解去原文中找相关线索。这是 Encoder-Decoder 架构的精髓:Encoder 负责把原文变成一个"信息库"(K 和 V),Decoder 用自己的"问题"(Q)去库中检索。
前面我们看了 Encoder 和 Decoder 各自的子层结构,每个子层后面都跟着"Add & Norm"。这俩到底为什么这么重要?残差连接:x + F(x)。如果这层学不到有用的东西(F(x)接近零),信息直接穿过。反向传播时(∂/∂x)至少为 1——不会指数衰减。
LayerNorm:归一化每层的输出,让数值范围稳定。没有它,深层网络的激活值会越来越大或越来越小。
就像给水流修一条旁通管:主管道再窄,水也能从旁通管直接流到下游(残差连接 提供这条直通路),而阀门把水压稳在同一刻度(LayerNorm 负责这一步)。
| 层数 | 假设每层梯度乘子=0.85 | 传到第1层的梯度 |
|---|---|---|
| 12 | 0.85¹² | 0.14(衰减 86%) |
| 24 | 0.85²⁴ | 0.02(衰减 98%) |
| 48 | 0.85⁴⁸ | 0.0004(几乎为零) |
| 层数 | I + 0.85×I = 1.85 | 传到第1层的梯度 |
|---|---|---|
| 12 | 1.85¹² | 1607(不会消失!但太大了——需要 Norm) |
| 24 | 1.85²⁴ | 2.6e6(爆炸了!) |
| 步骤 | 公式 | 示例(向量 [2, 6, 4, 8]) |
|---|---|---|
| 减均值 | x_i - μ | μ=(2+6+4+8)/4=5 → [-3, 1, -1, 3] |
| 除标准差 | (x_i-μ)/σ | σ=√((9+1+1+9)/4)=√5≈2.24 → [-1.34, 0.45, -0.45, 1.34] |
| 缩放平移 | γ×norm + β | γ 和 β 是可学习参数,恢复表达能力 |
没有残差:24 层梯度衰减 98%,浅层几乎学不到。没有 LayerNorm:24 层梯度放大 2.6e6 倍,数值爆炸。两者配合让 6 层、24 层甚至 96 层都能稳定训练——这是 Transformer 从 6 层扩展到千亿参数的前提。
前面分析了残差和 LayerNorm 如何稳定深层训练。现在看另一个必须的组件——位置编码。在词向量加上位置编码后,同样一个"打"字,位置 2 和位置 5 拿到的向量就不一样了。
就像给同一句话里的每个词发一张带座位号的票:词本身(词向量)不变,加上座位号后,"打"坐在第二排和第五排就是两个不同的输入向量。
这张"座位号"就是论文里的 Positional Encoding(位置编码)。
| 维度 i | 2i/d | ω_i = 1/10000^(2i/d) |
|---|---|---|
| 0(偶数维,用 sin) | 0/4 = 0 | 1/10000⁰ = 1.0 |
| 1(奇数维,用 cos) | 0/4 = 0 | 1/10000⁰ = 1.0 |
| 2(偶数维,用 sin) | 2/4 = 0.5 | 1/10000^0.5 = 1/100 = 0.01 |
| 3(奇数维,用 cos) | 2/4 = 0.5 | 1/10000^0.5 = 1/100 = 0.01 |
| 位置 | dim0 (sin(1.0×pos)) | dim1 (cos(1.0×pos)) | dim2 (sin(0.01×pos)) | dim3 (cos(0.01×pos)) |
|---|---|---|---|---|
| pos=1 | sin(1)=0.84 | cos(1)=0.54 | sin(0.01)=0.01 | cos(0.01)=1.00 |
| pos=2 | sin(2)=0.91 | cos(2)=-0.42 | sin(0.02)=0.02 | cos(0.02)=1.00 |
| pos=3 | sin(3)=0.14 | cos(3)=-0.99 | sin(0.03)=0.03 | cos(0.03)=1.00 |
| "打"的词向量 | + 位置编码 | = 最终输入 | |
|---|---|---|---|
| 位置2 | [0.2, 0.9, 0.4, 0.1] | +[0.91, -0.42, 0.02, 1.00] | [1.11, 0.48, 0.42, 1.10] |
| 位置5 | [0.2, 0.9, 0.4, 0.1] | +[sin(5), cos(5), sin(0.05), cos(0.05)] | [不同的向量] |
不是给位置"贴标签",而是用多频率 sin/cos 生成向量。高频维度捕获局部差异(相邻位置),低频维度覆盖全局距离。这比可学习位置嵌入更灵活——理论上能用线性变换表示相对位置,但超出训练长度仍会退化(需位置插值等外推技巧),并非可任意外推。
上一张卡片给输入端的每个词都配好了位置编码,把它们送进了 Encoder。现在把视线移到链条的另一端——Decoder 算完最后一层,手里是一个 d_model 维的向量,接下来的问题是:怎么把它变回一个词?
答案是拿一个输出矩阵把向量投影到整个词表上,得到每个词的得分,再取概率最大的那个。整个词表是关键:词表有 37,000 个词,这个矩阵就得有 37,000 行——它和输入端那张「词→向量」的表,其实一样大。
这就像一本双向词典:查词(词→向量)和造句(向量→词)用的是同一套词条,只是翻的方向相反。
| 方向 | 操作 | 矩阵形状 | 参数个数 |
|---|---|---|---|
| 读(词→向量) | 查表 embedding | 37,000 × 512 | 37,000×512 = 18.94M |
| 写(向量→词) | 线性投影 + softmax | 512 × 37,000 | 37,000×512 = 18.94M |
| 方案 | 输入表 | 输出表 | 合计参数 |
|---|---|---|---|
| 各学一遍(不共享) | 18.94M | 18.94M | 2 × 18.94M = 37.89M |
| 共用一张(weight tying) | 18.94M | 同一份 | 18.94M |
| 省下 | — | — | 18.94M |
| 模型 | 词表大小 | 向量维度 | 一张表的参数 |
|---|---|---|---|
| 原论文 base | 37,000 | 512 | 37,000×512 = 18.94M |
| Llama 2 7B | 32,000 | 4,096 | 32,000×4,096 = 131M |
输入端问的是「这个词该用哪个向量表示」,输出端问的是「这个向量最像哪个词」——两个问题的答案,都写在同一个「词 ↔ 向量」的对应关系里。共用这张表(weight tying)既省参数(原论文省 18.94M,Llama 2 7B 口径是 131M),又让同一组权重同时收到「读」和「写」两端的梯度信号——原论文与 GPT-2 等都采用了它(注意:Llama 2 等一批模型并未使用 weight tying)。