📍 路标:本篇位于《从零构建 Transformer》系列 第 8/16 章 · 架构篇
系列主线:注意力思想 → 自注意力 → 多头 → 位置编码 → 编解码架构 → 手撕实现 → 预训练模型 → 实战微调 → 知识收官
进度:▸ 基石篇(1-5) ▸ 架构篇(6-10·本篇) ▸ 预训练篇(11-13) ▸ 实战篇(14-16)

摘要:上一章我们把编码器(Encoder)内部的"残差、LayerNorm、前馈"三块承重墙拆了个底朝天;这一章轮到另一半——解码器(Decoder)。解码器比编码器多两样东西:一张**"不许偷看未来"的掩码**,和一种**"去查编码器记忆"的交叉注意力**。文章先把这两样东西的来龙去脉讲透:为什么生成第 t 个词时不能看第 t+1 个词?那张"上三角遮罩"到底长什么样、为什么必须填 -1e9 而不是 0?交叉注意力里"解码器问、编码器答"的 Q/K/V 到底从哪来、为什么它们的长度可以不同?然后用 9 张图、若干段最简 NumPy demo,把掩码矩阵的三角结构、掩码前后权重的天壤之别、交叉注意力的形状推演、语义对齐热力图,以及一个完整 Decoder 单层的前向,全部亲手算一遍,最后讲清"训练能并行、推理必须串行"这个面试必考题。全文配 9 张图,每张都能用一个最简单的 NumPy demo 亲手复现。

上一篇(三十六):Encoder 内部——残差连接、LayerNorm 与前馈网络


引言:编码器读懂了,解码器却"不能剧透"

上一章结束时我留了一句话:编码器内部我们吃透了,但 Transformer 还有另外半边——解码器。它比编码器复杂,多了两样东西:一张"不许偷看未来"的掩码,和一种"去查编码器记忆"的交叉注意力。

这两样东西,恰恰是 Transformer 能生成文本(翻译、写诗、写代码、聊天)的关键。你可能已经隐约知道它们存在,但一旦真去读源码,脑子里会冒出三个挥之不去的问号:

  1. 为什么生成第 t 个词时,不能看第 t+1 个词? 我不就是提前把整句话写好了吗,凭什么不能看?
  2. 那张"掩码"到底长什么样? 书上说"上三角遮成 -inf",可为什么是 -1e9 这种吓人的负数?填个 0 不行吗?
  3. 交叉注意力凭什么"跨语言"? 解码器在翻译中文,编码器读的是英文,两个句子长度都不一样,它们的向量怎么"对齐"到一张注意力矩阵上?

这三个问号,就是本章的三根主线。我们不满足于"背下结论",而是每一个都用最简单的 NumPy 代码亲手算一遍,让答案从数字里自己浮出来。

🎯 本章目标

  1. 讲清为什么解码器"不能剧透",以及"右移一位 + 遮罩"这套组合拳怎么落地;
  2. 手撕 lookahead mask:用 np.triu 造出上三角矩阵,亲眼看到未来位置被遮死;
  3. 用一个"0 vs -1e9"的对比实验,弄懂为什么掩码必须填负无穷级别的大负数;
  4. 手撕交叉注意力:Q 来自解码器、K/V 来自编码器,且长度可以不同
  5. 把交叉注意力的形状从头推到尾,理解"输出行数 = 目标句长度"这个关键结论;
  6. 用热力图实锤"生成哪个词,就重点查哪个源词";
  7. 组装一个完整的 Decoder 单层,跑通前向、验证 Shape 一路不变;
  8. 讲清"训练能并行、推理必须串行"的底层原因(teacher forcing vs 自回归)。

一、先看全景:解码器里多了哪两样东西?

不卖关子,先把解码器单层的"剖面图"拍在桌上:

在这里插入图片描述

对比上一章的编码器单层(自注意力 → 残差 → LN → FFN → 残差 → LN),解码器单层多了两处变化:

  • 第一处(图 1 的①):把"自注意力"换成了"掩码自注意力"。公式、Q/K/V 的来源都和编码器自注意力一模一样——Q、K、V 全都来自解码器自己的输入 Y;唯一的区别是多了一张上三角遮罩,把"未来位置"全部盖住。图 1 右上角那个红色小三角,就是这个遮罩的示意;
  • 第二处(图 1 的②):在掩码自注意力之后、FFN 之前,多插了一层"交叉注意力"。它的 Q 来自解码器自己(“我下一步该说什么?”),但 K、V 却来自编码器的记忆 Z(“源句里有什么能回答我?”)——就是图 1 里那条紫色虚线箭头。

至于残差、LayerNorm、FFN,都是上一章的老朋友,照搬即可。所以这一章的任务非常聚焦:把"掩码自注意力"和"交叉注意力"这两样新东西,从原理到代码拆到不能再拆。

💡 为什么解码器需要这两样,而编码器不需要? 一句话:编码器读的是"已经写完整"的源句,解码器写的是"正在逐字憋"的目标句。 因为目标句是"边生成边长"的,所以(1)生成第 t 个词时,第 t+1 个词还不存在——你必须用掩码禁止它"偷看";(2)光看自己不够,得回头查源句——所以需要交叉注意力。这两个"所以",就是本章全部内容的根。


二、为什么解码器"不能偷看未来"?

2.1 先想清楚:解码器到底在做什么任务?

在讲掩码之前,必须先弄明白解码器的任务定义。很多人到这里会迷糊,就是因为没把任务想清楚。

解码器的目标,不是"把一句话变出来",而是**“给定前面的词,预测下一个词”**。以翻译 “I love cats” → “我 爱 猫” 为例:

  • 看到 <BOS>(开始标记),预测出"我";
  • 看到 <BOS> 我,预测出"爱";
  • 看到 <BOS> 我 爱,预测出"猫";
  • 看到 <BOS> 我 爱 猫,预测出 <EOS>(结束标记),生成终止。

所以解码器的输入不是"我 爱 猫"本身,而是右移一位的目标句:<BOS> 我 爱(少了最后一个"猫",多了开头一个 <BOS>)。为什么要右移?因为模型的任务是"用前 t 个词预测第 t+1 个词",那么"第 t+1 个词"就必须不在输入里,否则就是抄答案了。

现在,最关键的问题来了:当解码器在生成第 2 个位置(“爱”)时,它能看的输入是 <BOS> 我——这两个词在它左边;而"爱"本身以及"猫"都在它右边,是"未来的词"。 如果让自注意力像编码器那样"每个词都能看所有词",那么"爱"这个位置就能直接看到"猫"——这等于考试时偷看答案:模型不用学"如何从上下文预测",而是学会了"把答案原样背下来"。

这会导致一个致命后果:训练时 loss 很低(因为它偷看了答案),但推理时模型面对的是真实场景——未来的词根本不存在,于是彻底崩盘。 这种"训练推理不一致"的坑,是生成模型里最经典、最要命的一个。

💡 一句话记住:编码器读的是"完整的书",可以随便翻;解码器写的是"刚写了一半的书",只能看已经写出来的那半——剩下的那半,必须用掩码盖住。

2.2 具体怎么"盖"?一张上三角矩阵

道理懂了,怎么在代码里"盖"?答案是一张上三角矩阵(lookahead mask,前瞻掩码)。我们用 np.triu 把它造出来:

import numpy as np

S = 4                                   # 假设目标句 4 个位置
mask = np.triu(np.full((S, S), -1e9), k=1)   # 上三角(k=1)填 -1e9,其余填 0
print(mask)

真实输出:

[[ 0.e+00 -1.e+09 -1.e+09 -1.e+09]
 [ 0.e+00  0.e+00 -1.e+09 -1.e+09]
 [ 0.e+00  0.e+00  0.e+00 -1.e+09]
 [ 0.e+00  0.e+00  0.e+00  0.e+00]]

这张矩阵就是"防偷看"的全部机关。怎么读它?把它和"谁在看谁"对应起来:

  • 第 i 行第 j 列,表示"位置 i(正在生成的词)去看位置 j(被看的词)"的权限;
  • 对角线及以下(j <= i)是 0,意思是"看得见"——每个词可以看自己,也可以看它左边的词;
  • 对角线以上(j > i)是 -1e9,意思是"被遮死"——未来的词,一律不许看。

拿第 1 行(位置 1,即第 2 个词)举例:[0, 0, -1e9, -1e9],意思是"我可以看位置 0 和位置 1(自己和左边),但不能看位置 2、3(右边、未来)"。把这张矩阵画成热力图,直观得不能再直观:

在这里插入图片描述

图 2 左右两半是同一张矩阵的两种画法:左边标"遮/见",右边标实际数值 -1e9/0。记住这个"上三角"的形状——它就是解码器自注意力区别于编码器自注意力的唯一区别。

💡 "上三角"为什么叫 lookahead(前瞻)? 因为 np.triu 取的是"上三角"(upper triangle),它的形状正好是把"未来"(每行右侧的位置)盖住,所以叫"前瞻掩码"——前瞻,就是"往前看未来"。

2.3 掩码加在哪?加在 softmax 之前

有了这张矩阵,它该加到哪一步?答案是:加在 softmax 之前的"打分矩阵"上。回忆第 3 章缩放点积注意力的完整流程:

Attention ( Q , K , V ) = softmax ( Q K ⊤ d k ) V \text{Attention}(Q,K,V)=\text{softmax}\left(\frac{QK^\top}{\sqrt{d_k}}\right)V Attention(Q,K,V)=softmax(dk QK)V

拆成三步:先算打分矩阵 S = Q K ⊤ / d k S = QK^\top / \sqrt{d_k} S=QK/dk ,再对每一行做 softmax 得到权重,最后右乘 V V V 加权求和。掩码,就加在第一步和第二步之间——把打分矩阵里"未来位置"对应的分数,直接拉成负无穷:

scores = (Q @ K.T) / np.sqrt(d_k)   # ① 打分
if mask is not None:
    scores = scores + mask           # ② 加掩码:未来位置变成 -1e9
weights = softmax_rows(scores)       # ③ softmax:未来位置权重被压成 0

为什么"加一个 -1e9"就能让权重变成 0?这是下一节要重点拆的机制,也是本章第一个"不模糊、必须算清楚"的地方。


三、手撕掩码自注意力:未来位置权重怎么被"压成 0"

3.1 先做实验:加掩码前后,权重天壤之别

不空谈,直接用代码验证。构造 4 个词的序列,重点看**位置 1(第 2 个词)**在加掩码前后,对 4 个位置的注意力权重有什么变化:

import numpy as np

def softmax_rows(M):
    e = np.exp(M - M.max(axis=1, keepdims=True))
    return e / e.sum(axis=1, keepdims=True)

def scaled_dot_product_attention(Q, K, V, mask=None):
    d_k = K.shape[-1]
    scores = (Q @ K.T) / np.sqrt(d_k)
    if mask is not None:
        scores = scores + mask
    weights = softmax_rows(scores)
    return weights @ V, weights

np.random.seed(7)
S, d = 4, 4
Qs = np.random.randn(S, d) * 0.5
Ks = np.random.randn(S, d) * 0.5
Vs = np.random.randn(S, d) * 0.5
mask = np.triu(np.full((S, S), -1e9), k=1)   # 上三角掩码

_, W_no = scaled_dot_product_attention(Qs, Ks, Vs)          # 不加掩码
_, W_ma = scaled_dot_product_attention(Qs, Ks, Vs, mask)    # 加掩码

print("不加掩码——位置 1 对 4 个位置的权重:", W_no[1].round(4))
print("加掩码  ——位置 1 对 4 个位置的权重:", W_ma[1].round(4))

真实输出:

不加掩码——位置 1 对 4 个位置的权重: [0.3122 0.1285 0.3913 0.1681]
加掩码  ——位置 1 对 4 个位置的权重: [0.7085 0.2915 0.     0.    ]

对比两条结果,天壤之别:

  • 不加掩码:位置 1 对位置 2、3(未来)的权重是 0.3913、0.1681——它"偷看"了未来,而且偷看得还不少(合起来超过一半);
  • 加掩码:位置 2、3 的权重被强制压成 0,位置 1 只能把注意力全部放在位置 0 和位置 1(自己)身上,权重重新分配成 0.7085 和 0.2915。

把这两条画成柱状图,对比一目了然:

在这里插入图片描述

看整张加掩码后的权重矩阵,会发现它变成了一个漂亮的下三角

[[1.     0.     0.     0.    ]
 [0.7085 0.2915 0.     0.    ]
 [0.3326 0.3739 0.2935 0.    ]
 [0.291  0.1666 0.3458 0.1966]]

每一行的非零权重都只出现在"自己及左边"的位置上,右上角全是 0——这就是"每个词只能看过去,不能看未来"的物理实现。

🎤 一句话总结掩码自注意力:它和普通自注意力唯一的区别,就是在 softmax 之前,往打分矩阵里加了一张上三角的 -1e9 矩阵。加了之后,未来位置的权重被 softmax 压成 0,于是每个词"只能看左边,不能剧透右边"。

3.2 为什么是 -1e9?填 0 为什么不行?

这是新手最容易卡住、也最能暴露"是否真懂"的一问。很多人第一反应是:“我直接把未来位置填 0 不就行了?”——不行。 我们用一个小实验证明。

回忆 softmax 的定义:对第 i 行的打分 [s0, s1, s2, ...],第 j 个位置的权重是:

w j = e s j ∑ k e s k w_j = \frac{e^{s_j}}{\sum_k e^{s_k}} wj=keskesj

关键在这个 e s j e^{s_j} esj只要 s j s_j sj 是有限的数, e s j e^{s_j} esj 就一定是正数,softmax 出来的权重就一定大于 0。 填 0 只是让那个位置的分 “s_j” 变成 0,但 e 0 = 1 > 0 e^0 = 1 > 0 e0=1>0,权重照样非零——等于没遮。

只有把分数拉到负无穷 e − ∞ = 0 e^{-\infty} = 0 e=0,权重才真正归零。来看代码:

scores = np.array([[1.0, 2.0, 3.0]])      # 某行对 3 个位置的打分

# 错误做法:用 0 遮第 3 个位置
w_zero = softmax_rows(scores)              # 0 根本没改变 scores
print("用 0 遮第3个位置:", w_zero.round(4))

# 正确做法:用 -1e9 遮第 3 个位置
scores_masked = scores.copy()
scores_masked[0, 2] += -1e9
w_inf = softmax_rows(scores_masked)
print("用 -1e9 遮第3个位置:", w_inf.round(4))

真实输出:

用 0 遮第3个位置:   [[0.09   0.2447 0.6652]]
用 -1e9 遮第3个位置: [[0.2689 0.7311 0.    ]]

看清楚差异:

  • 用 0 遮:第 3 个位置权重是 0.6652——不仅没遮住,它反而还是最大的那个(因为原始打分 3.0 最高);
  • 用 -1e9 遮:第 3 个位置权重是 0——被彻底压死。

为什么?因为 softmax 会把打分映射到"指数"上:exp(3.0) ≈ 20exp(0) = 1exp(-1e9) ≈ 0。只有那个趋近负无穷的 -1e9,它的指数才趋近 0,softmax 出来的权重才趋近 0。画成图对比更直观:

在这里插入图片描述

💡 "负无穷"在代码里长什么样? 真正的负无穷是 -np.inf,但很多实现里习惯用 -1e9(负十亿)这种"足够小"的有限负数代替。为什么不用真的 -inf?因为有些硬件/框架对 inf 的处理不友好,而 -1e9 在 softmax 里 exp(-1e9) 已经小到可以当作 0,效果等价、还更安全。理解了这一点,你就再也不会把掩码里的 -1e9 看成"神秘魔法数字"了。


四、交叉注意力:解码器"问",编码器"答"

4.1 它和自注意力的根本区别:Q 和 K/V 来自两个地方

掩码自注意力还是"自己和自己对话"——Q、K、V 都来自解码器自己的输入 Y。但解码器要做的,不是"自言自语",而是"对着源句写翻译"。所以它还需要一层交叉注意力(Cross-Attention),让解码器能"查"编码器的记忆。

交叉注意力的关键,全在 Q、K、V 三个矩阵的来源上:

  • Q(问题)来自解码器自己:代表"我当前这一步,到底想表达什么、想找什么信息";
  • K、V(键和值)来自编码器记忆 Z:代表"源句里每个词能提供什么信息"。

用代码写出来,就是这么三行(M 是解码器当前状态,Z 是编码器记忆):

Q = M @ Wq   # Q 来自解码器自己
K = Z @ Wk   # K 来自编码器记忆
V = Z @ Wv   # V 来自编码器记忆

然后套用同一个注意力公式 A = softmax ( Q K ⊤ / d k ) V A = \text{softmax}(QK^\top / \sqrt{d_k})V A=softmax(QK/dk )V。注意——公式一个字没变,变的只是 K、V 的来源。这就是第 6 章总结的那句"变的是原料,不变的是配方"。

💡 为什么叫"交叉"注意力? 因为信息流是"交叉"的:解码器的"问题"(Q)和编码器的"答案"(K、V)来自两个不同的网络,一个在右半边、一个在左半边,注意力把它们"交叉"地连接起来。自注意力里 Q、K、V 来自同一个网络,所以叫"自"。

4.2 一个极其重要、却常被忽略的点:Q 和 K/V 长度可以不同

交叉注意力里藏着一个自注意力里不会出现的现象:Q 的长度(目标句词数)和 K/V 的长度(源句词数)可以不一样。

为什么?因为翻译时,源句和目标句长度天然不同——“I love cats” 是 3 个词,“我 爱 猫” 也是 3 个词(这里恰好一样),但换成 “I love my cute cats”(5 词)→ “我爱我可爱的猫”(7 词),长度就对不上了。自注意力里 Q、K、V 都是同一个序列,长度必然相等;交叉注意力里 Q 来自目标句、K/V 来自源句,两者长度允许不同

这个"长度不同",会带来一个连锁反应——注意力权重矩阵的形状。我们用代码验证:

tgt_len = 3     # 目标句(解码器)长度
src_len = 4     # 源句(编码器)长度
d = 5
np.random.seed(3)
Q = np.random.randn(tgt_len, d) * 0.5   # 解码器 Q:3 行
K = np.random.randn(src_len, d) * 0.5   # 编码器 K:4 行
V = np.random.randn(src_len, d) * 0.5   # 编码器 V:4 行

C, W = scaled_dot_product_attention(Q, K, V)
print("解码器 Q.shape =", Q.shape)          # (3, 5)
print("编码器 K.shape =", K.shape)          # (4, 5)
print("QK^T 得分矩阵 shape =", (Q @ K.T).shape)   # (3, 4)
print("交叉注意力输出 shape =", C.shape)     # (3, 5)

真实输出:

解码器 Q.shape = (3, 5)
编码器 K.shape = (4, 5)
QK^T 得分矩阵 shape = (3, 4)
交叉注意力输出 shape = (3, 5)

跟着输出,抓住两条关键:

  1. Q @ K.T 的形状是 (3, 4):3 行(目标词)× 4 列(源词)。每个目标词,都对源句里的 4 个词算一个"相关性分数",得到一个 3×4 的权重矩阵;
  2. 最终输出形状是 (3, 5):3 行(还是目标词的数量)× 5 列(d_v 维)。注意输出行数 = Q 的行数 = 目标句长度 3,而不是源句的 4。

第 2 点尤其重要:交叉注意力的输出行数,永远等于"问题方"(Q,也就是解码器)的长度,和"答案方"(K/V,也就是源句)的长度无关。 因为输出本质上是"对 V 的加权求和",每行的权重(长度为 src_len)去加权 V(src_len 行),结果这一行仍然只有 d_v 维;有多少个 Q(tgt_len 行),就有多少个这样的输出行。画成示意图:

在这里插入图片描述

图 5 里那条最醒目的话就是结论:输出行数 = 解码器(Q)的长度,与源句长度无关。

4.3 形状推演:一张 3×4 的权重矩阵是怎么来的

把交叉注意力的每一步形状串起来,就是下面这张推演图:

在这里插入图片描述

跟着图 6 从左到右走一遍,四步:

  1. Q (3, d_k) × K^T (d_k, 4):矩阵乘法的内维是 d_k,消掉后得到 (3, 4) 的得分矩阵;
  2. softmax(沿每一行,即沿 4 个源词方向):把每一行的 4 个分数,归一化成"和为 1"的 4 个权重。这一步是沿着源句方向归一化,得到权重矩阵 (3, 4)
  3. 权重矩阵 (3, 4) × V (4, d_v):内维是 4(源词数),消掉后得到输出 (3, d_v)
  4. 最终输出 (3, d_v)——行数 = 3 = 目标词数,列数 = d_v = 值向量的维度。

💡 softmax 沿哪个方向做? 这是交叉注意力里一个必须盯紧的细节。答案是:沿"源词"那个方向(每一行内部)做 softmax,让"一个目标词对 4 个源词"的权重加起来等于 1。这样每个目标词的注意力才是一份合法的"概率分布"。如果做错了方向(沿列做),语义就全错了——那会变成"4 个源词瓜分 3 个目标词",完全不是我们想要的。

我们验证一下"每行和为 1":

print("每行权重之和 =", W.sum(axis=1).round(6))

真实输出:

每行权重之和 = [1. 1. 1.]

每一行都严格等于 1——这就是"沿源句方向做了 softmax"的铁证。

4.4 热力图实锤:生成哪个词,就重点查哪个源词

形状推演只证明了"矩阵长什么样",还差最生动的一步:解码器生成"我"的时候,交叉注意力真的会重点看源句里的 “I” 吗? 我们用和上一章一样的方法,构造一组"语义对齐"的简易向量,把交叉注意力权重画成热力图,眼见为实。

E_src = np.array([[1.0, 0.05, 0.15],   # I
                  [0.05, 1.0, 0.05],   # love
                  [0.15, 0.05, 1.0]])  # cats
E_tgt = np.array([[1.0, 0.08, 0.10],   # 我
                  [0.08, 1.0, 0.08],   # 爱
                  [0.10, 0.08, 1.0]])  # 猫
S = E_tgt @ E_src.T
W = softmax_rows(S)
print(W.round(4))

真实算出的权重矩阵:

行=目标词(Q),列=源词(K/V):
我:  对 I=0.5324  love=0.2199  cats=0.2477
爱:  对 I=0.2284  love=0.5431  cats=0.2284
猫:  对 I=0.2477  love=0.2199  cats=0.5324

画成热力图:

在这里插入图片描述

看图 7,规律清晰得不能再清晰:主对角线(我→I、爱→love、猫→cats)一片深红,权重都超过 0.5;斜对角一片浅黄。 用一句话总结:

解码器生成第 t 个目标词时,交叉注意力会自动把注意力集中在源句里"语义最相关"的那几个词上——这就是"查笔记"的微观画面,也是 Transformer 能做翻译的根本原因。

💡 注意一个细节:真实训练好的模型,每一行通常不会像图 7 这样"干净"(只亮一个格子),而是会同时关注多个相关的源词,甚至还会关注一些语法性的功能词。图 7 之所以"干净",是因为我们故意造了强对齐的向量,好让规律肉眼可见。但"主对角线偏亮"这个骨架,在真实模型的交叉注意力里是真实存在的——这正是对齐/翻译任务里注意力的一种典型形态。


五、把三件套组装起来:一个完整的 Decoder 单层

零件拆完了,现在把"掩码自注意力 + 交叉注意力 + FFN"按图 1 的顺序组装成一个完整的 Decoder 单层。顺序是:

Y → 掩码自注意力 → +Y 残差 → LN → 交叉注意力 → +残差 → LN → FFN → +残差 → LN 输出 Y \xrightarrow{\text{掩码自注意力}} \xrightarrow{\text{+Y 残差}} \xrightarrow{\text{LN}} \xrightarrow{\text{交叉注意力}} \xrightarrow{\text{+残差}} \xrightarrow{\text{LN}} \xrightarrow{\text{FFN}} \xrightarrow{\text{+残差}} \xrightarrow{\text{LN}} \text{输出} Y掩码自注意力 +Y 残差 LN 交叉注意力 +残差 LN FFN +残差 LN 输出

全程只用 NumPy,复用前几章的零件。交叉注意力的 K/V 用一份模拟的"编码器记忆 Z"(第 7 章讲过编码器怎么产出它,这里当输入直接用):

import numpy as np

def softmax_rows(M):
    e = np.exp(M - M.max(axis=1, keepdims=True))
    return e / e.sum(axis=1, keepdims=True)

def scaled_dot_product_attention(Q, K, V, mask=None):
    d_k = K.shape[-1]
    scores = (Q @ K.T) / np.sqrt(d_k)
    if mask is not None:
        scores = scores + mask
    weights = softmax_rows(scores)
    return weights @ V, weights

def layer_norm(X, gamma, beta, eps=1e-5):
    mu = X.mean(axis=-1, keepdims=True)
    var = X.var(axis=-1, keepdims=True)
    return (X - mu) / np.sqrt(var + eps) * gamma + beta

def ffn(x, W1, b1, W2, b2):
    return np.maximum(0, x @ W1 + b1) @ W2 + b2

np.random.seed(42)
d_model, src_len, tgt_len = 4, 3, 3
d_ff = d_model * 4
Z = np.random.randn(src_len, d_model) * 0.5        # 编码器记忆(3 个源词)
Y = np.random.randn(tgt_len, d_model) * 0.5        # 目标句嵌入(3 个目标词)

Wq = np.random.randn(d_model, d_model) * 0.3
Wk = np.random.randn(d_model, d_model) * 0.3
Wv = np.random.randn(d_model, d_model) * 0.3
W1 = np.random.randn(d_model, d_ff) * 0.3;  b1 = np.zeros(d_ff)
W2 = np.random.randn(d_ff, d_model) * 0.3;  b2 = np.zeros(d_model)
gamma = np.ones(d_model);  beta = np.zeros(d_model)

mask_tgt = np.triu(np.full((tgt_len, tgt_len), -1e9), k=1)   # 目标句的上三角掩码

# ① 掩码自注意力(Q=K=V=解码器自己 Y,加掩码)
Q = Y @ Wq; K = Y @ Wk; V = Y @ Wv
m, _ = scaled_dot_product_attention(Q, K, V, mask_tgt)
m = layer_norm(Y + m, gamma, beta)                       # 残差 + LN
print("① 掩码自注意力 + 残差 + LN → m.shape =", m.shape)

# ② 交叉注意力(Q 来自解码器 m,K/V 来自编码器 Z,无掩码)
Q = m @ Wq; K = Z @ Wk; V = Z @ Wv
c, _ = scaled_dot_product_attention(Q, K, V)             # 注意:这里不加掩码
c = layer_norm(m + c, gamma, beta)                       # 残差 + LN
print("② 交叉注意力 + 残差 + LN → c.shape =", c.shape)

# ③ FFN + 残差 + LN
f = ffn(c, W1, b1, W2, b2)
out = layer_norm(c + f, gamma, beta)
print("③ FFN + 残差 + LN → out.shape =", out.shape)

真实输出:

① 掩码自注意力 + 残差 + LN → m.shape = (3, 4)
② 交叉注意力 + 残差 + LN → c.shape = (3, 4)
③ FFN + 残差 + LN → out.shape = (3, 4)

注意每一条输出都是 (3, 4)——输入是 (3, 4),经过掩码自注意力、交叉注意力、FFN,以及三处残差 + LayerNorm,一圈下来输出还是 (3, 4)。和编码器一样,解码器的每一层也是"形状保持"的,所以才能想叠几层叠几层。把这条流水账画成图:

在这里插入图片描述

⚠️ 两个极容易写错的细节(对照代码再看一遍):

  1. 掩码只加在"掩码自注意力"上,交叉注意力不加掩码。 因为交叉注意力看的是编码器的源句,源句是"完整的、已知的",不需要也不应该被遮——解码器当然可以看源句的所有词。真正要遮的,是目标句里"还没生成的未来词",所以掩码只出现在第一步;
  2. 三处残差加的"原始输入"各不相同:第一处加的是 Y(掩码自注意力之前的输入),第二处加的是 m(交叉注意力之前的输入),第三处加的是 c(FFN 之前的输入)。每次都加"进入子层之前"的那个值,而不是子层输出自己。

六、训练能并行、推理必须串行:teacher forcing vs 自回归

这是解码器相关面试里最爱考的一题,也是最容易答混的一题。答案的根,还是那句话——解码器写的是"边生成边长"的句子。但训练和推理是两个完全不同的场景,行为恰好相反:

在这里插入图片描述

6.1 训练时:teacher forcing,反而能并行

训练时,我们有完整的目标句答案(这是监督学习的基本前提)。于是我们可以把整句目标词 <BOS> 我 爱 猫 一次性喂进解码器,让 3 个位置同时做前向、同时预测、同时算 loss——这就是并行,速度快。

但问题来了:一次性喂整句,位置 2(“爱”)不就能看到位置 3(“猫”)了吗?这不就剧透了吗?

这正是掩码的用武之地! 训练时虽然喂了整句、位置是并行的,但我们用上三角掩码,保证"每个位置在计算注意力时,只能看到它左边的位置"——于是"爱"这个位置虽然物理上"眼前有猫",但注意力计算时猫被掩码遮成了 0,它实际上还是只能依据 <BOS> 我 来预测"爱"。并行 + 掩码,两全其美:既快,又不剧透。

这种"训练时把正确答案直接喂给模型,让它并行预测"的做法,叫 Teacher Forcing(老师强迫)——老师(正确答案)直接把下一步的输入塞给模型,强迫模型按正确路径学习。

6.2 推理时:自回归,只能串行

推理时,没有正确答案了——你让模型翻译,它得自己把答案"憋"出来。这时怎么办?

只能自回归(Autoregressive):一个一个词地生成。

  • t=1:输入只有 <BOS>,输出第一个词"我";
  • t=2:把"我"接到输入后面,变成 <BOS> 我,输出"爱";
  • t=3:输入 <BOS> 我 爱,输出"猫";
  • t=4:输入 <BOS> 我 爱 猫,输出 <EOS>,结束。

每一步只多一个词、只输出一个词,上一步的输出是下一步的输入——这就是"自回归"。它必须是串行的,因为第 t 个词的输出依赖第 t-1 个词的输出,这个依赖链无法并行。

💡 一句话记住:训练时"老师给答案"→ 喂整句 + 掩码 → 并行(快);推理时"自己憋答案"→ 逐词生成 → 串行(慢,但这是唯一的路)。掩码的意义,正在于让"训练时的并行"和"推理时的串行"在数学上严格一致——训练时位置 t 看到的上下文,和推理时位置 t 真实拥有的上下文,一模一样,都只有"左边那些词"。


七、常见坑与自查

  • 以为掩码是模型学出来的参数:不是。掩码是手工构造的固定矩阵np.triu 生成),不参与训练、没有梯度,就是个"权限表";
  • 掩码填 0 而不是 -1e9:错。0 遮不住(exp(0)=1>0,softmax 后权重仍非零)。必须用 -1e9-inf,让 exp(-1e9)≈0,权重才真正归零(回顾图 4);
  • 把掩码加到 softmax 之后:错。掩码要加在打分矩阵 scores 上、softmax 之前。加在 softmax 之后,权重已经归一化了,再改就没意义了;
  • 交叉注意力也加了掩码:错。交叉注意力看的是编码器源句,源句是完整已知的,不需要、也不应该遮。掩码只加在第一步的"掩码自注意力"上;
  • 把交叉注意力的 Q/K/V 来源搞混:Q 来自解码器(自己),K/V 来自编码器(记忆 Z)。三者的来源恰好是"两个网络"——这是交叉注意力和自注意力最本质的区别;
  • 以为交叉注意力输出行数 = 源句长度:错。输出行数 = Q(目标句)的长度,与源句长度无关。因为输出是"每个目标词对 V 加权求和",有多少个目标词就有多少行(回顾图 5、图 6);
  • softmax 做错方向:交叉注意力的 softmax 必须**沿源词方向(每一行内部)**做,让"一个目标词对 4 个源词"的权重和为 1。沿列做就全错了;
  • 把"右移一位"忘了:解码器输入是右移一位的目标句(前补 <BOS>),不是目标句本身。这是"用前 t 个词预测第 t+1 个词"这一任务定义的直接体现;
  • 三处残差加错对象:第一处加 Y、第二处加 m、第三处加 c——每次都加"进入子层之前"的值,而不是子层输出自己;
  • 以为训练和推理都是串行:训练时 teacher forcing 喂整句 + 掩码,是并行的;只有推理时自回归才是串行。两者在"每个位置只看左边"这件事上严格一致,这是掩码设计的精髓。

小结

这一章我们把 Decoder 内部的两样新东西——掩码自注意力与交叉注意力——彻底拆开,核心收获八条:

  • 解码器的任务:不是"变出句子",而是"用前 t 个词预测第 t+1 个词",所以输入是右移一位的目标句,且不能偷看未来
  • lookahead mask:一张上三角矩阵,对角线及以上填 -1e9、以下填 0,用 np.triu(..., k=1) 一键生成。第 i 行只能看第 0…i 列;
  • 掩码机制:把 -1e9 加在 softmax 之前的打分矩阵上,exp(-1e9)≈0,未来位置权重被压成 0。实测位置 1 对未来的权重从 [0.391, 0.168] 变成 [0, 0]
  • 为什么是 -1e9 不是 0exp(0)=1>0,0 根本遮不住(实测用 0 遮,未来权重仍有 0.6652);只有负无穷级别的大负数,softmax 后权重才真正归零;
  • 交叉注意力:Q 来自解码器(问题),K/V 来自编码器记忆 Z(答案),公式与自注意力完全相同,变的是原料;
  • 长度可不同:交叉注意力里 Q(目标句)和 K/V(源句)长度可以不同,权重矩阵形状是 (tgt_len, src_len),输出行数 = 目标句长度;
  • 语义对齐:热力图主对角线亮——生成"我"重点查 “I”(0.532)、生成"爱"重点查 “love”(0.543)、生成"猫"重点查 “cats”(0.532);
  • 训练 vs 推理:训练时 teacher forcing 喂整句 + 掩码,并行;推理时自回归逐词生成,串行。掩码保证两者"每个位置只看左边"严格一致。

到这里,编码器和解码器的内部构造,我们都吃透了。但你可能已经发现一个更大的问题:这些零件(嵌入、位置编码、注意力、残差、LayerNorm、FFN、掩码、交叉注意力)我们都各自写过了,却还没把它们真正组装成一个完整的、能端到端跑通的 Transformer 类。 下一章,我们就做这件"总装"的大事——把第 2 章到第 8 章的所有零件,组装成一个从输入到输出一路贯通的完整 Transformer,并用小假数据打印每一层的 Shape,验证整个模型没有任何维度错误。


下一篇(三十八):从零实现 Transformer 前向传播

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐