📍 路标 :本篇位于《从零构建 Transformer》系列 第 9/16 章 · 架构篇 。
系列主线:注意力思想 → 自注意力 → 多头 → 位置编码 → 编解码架构 → 手撕实现 → 预训练模型 → 实战微调 → 知识收官 。
进度:▸ 基石篇(1-5) ▸ 架构篇(6-10·本篇) ▸ 预训练篇(11-13) ▸ 实战篇(14-16)
摘要 :从第 2 章到第 8 章,我们把注意力打分、多头、位置编码、残差、LayerNorm、前馈网络、掩码、交叉注意力这些"零件"一件一件拆开验算过了。这一章做总装 :把全部零件拧成一台完整的 Transformer,用纯 NumPy 手写从"源句 token id + 目标句 token id"到"词表概率分布"的整条前向传播,一台机器、一个函数、一次跑通。文章先用一张总装图说清这台机器"吃什么、吐什么",然后按"嵌入+位置编码 → Encoder 块 → Decoder 块 → 输出层"四个工位逐一安装并打印中间张量形状,最后给出五道"体检"证明代码没写错:①逐层 Shape 全程贯通;②注意力权重行和为 1、掩码位置权重恒为 0;③LayerNorm 输出均值≈0、方差≈1;④全零权重退化测试(所有参数置零后 logits 必须全 0、概率必须均匀);⑤与纸面手算结果逐位对拍。
上一篇(三十七):Decoder 内部------掩码自注意力与交叉注意力
引言:零件磨好了,该总装了
从第 2 章开始,我们像一个流水线上的老师傅,把 Transformer 的零件一件一件车出来,还每一件都上过机床验过尺寸:
- 第 2-3 章:缩放点积注意力------ Q K ⊤ / d k QK^\top/\sqrt{d_k} QK⊤/dk 打分、softmax 归一、加权求和;
- 第 4 章:多头注意力------切分、并行、拼接、投影;
- 第 5 章:正弦位置编码------给词打上"顺序"的标签;
- 第 7 章:残差连接、LayerNorm、前馈网络------哪一层都离不开的"承重墙";
- 第 8 章:掩码自注意力 + 交叉注意力------解码器独有的两样武器。
可零件堆在桌上,机器并没有出现。你可能会问三个问题,正是这一章要解决的:
- 这些零件到底按什么顺序装? 编码器里先放注意力还是先放 FFN?解码器的交叉注意力插在哪一层之间?顺序错了,机器就散架;
- 装好之后,一台机器到底"吃"什么、"吐"什么? 从一堆 token id 到最后能选词的概率分布,中间每一步张量的 shape 是怎么一路贯通、最终"变出"词表的?
- 我怎么知道我装对了? 这是最要命的一问------机器能跑不代表跑得对。这一章会给出一套不用任何深度学习框架就能执行的"体检程序",把错误照出原形。
第 1-8 章我们都在"拆",这一章我们要"拼"。而且像前面每章一样:不背结构图,用代码亲手装;不轻信结论,用数字自己验。
另外先回答一个可能冒出来的疑问:为什么这章坚持用纯 NumPy,而不是直接用 PyTorch 的 nn.Transformer? 两个原因。一是系列定位------本系列第 1-10 章就是用 NumPy 把每一个张量算明白,框架会把"维度怎么排、掩码怎么广播、残差加在哪"全部藏起来,你反而学不到;二是调试价值------纯 NumPy 里每一行都能打印、每一步都能手算对拍,机器出了"静悄悄的错误"也能用数学模型兜住。等第 11 章开始跑真实数据、做预训练时,我们再无缝切到 PyTorch。先徒手造一次轮子,才知道轮子为什么是圆的。
🎯 本章目标
- 用一张总装图说清完整 Transformer 前向的五个阶段(嵌入、编码器、解码器、输出层、softmax);
- 手写"嵌入查表 + 位置编码相加",看懂 token id 是如何变成一个可训练向量的;
- 把第 3、4 章的多头注意力封装成"通用零件",支持任意 (B,S,d) 输入、任意来源的 Q/K/V、任意形状的掩码;
- 组装 Encoder 块(自注意力→残差→LN→FFN→残差→LN)与 Decoder 块(掩码自注意力→残差→LN→交叉注意力→残差→LN→FFN→残差→LN),并验证堆叠多层后 shape 始终不变;
- 手写输出层 Linear + softmax,理解"为什么最后要把 d 维映射到词表大小";
- 用一个小假数据(batch=2、源句 5 词、目标句 4 词、d=8、h=2、词表 12、N=2 层)把整条前向跑通,打印每一层 shape;
- 用五道"体检"(shape 贯通、权重行和、掩码清零、LN 统计性质、全零退化)从数学上证明前向没有写错;
- 看懂"未训练模型的输出 = 接近均匀分布的废话",从而理解下一章训练的必要性。
一、先看全景:这台机器"吃什么、吐什么"
安装之前,先把整台机器的图纸贴在墙上。

图 1 就是这一章要写的全部。它比第 6 章的总览图更"实":每个方框都标注了确切的 shape。把图 1 翻译成一句话:
输入两串 token id(源句 + 目标句),输出一张词表概率分布;中间所有的层,输入什么形状、输出什么形状,一个字节都不变。
"形状不变"这四个字不是巧合,而是设计使然------正是因为它不变,我们才能把同一个块无限堆叠(图上写的 ×N),想叠多深叠多深。输出层的"形状突变"(最后一维从 8 变成 12)则是机器唯一的"出入口",咱们到第六节专门讲它。
为了把这条线走通,我们把前向拆成五个工位,接下来一节装一个:
- 工位一:嵌入层 + 位置编码(把 token id 变成向量);
- 工位二:Encoder 块 ×N(把源句"读"成记忆 Z);
- 工位三:Decoder 块 ×N(对着 Z 生成目标句表示);
- 工位四:输出层 Linear(把 d 维映射到词表大小);
- 工位五:Softmax(得到概率分布,直接用于选词)。
先写全部零件共用的四行"基础工具"(前几章都各自写过,这里汇总成一个公共区,下文所有块都用它们):
python
import numpy as np
def softmax_rows(M): # 沿最后一维做 softmax
e = np.exp(M - M.max(axis=-1, keepdims=True))
return e / e.sum(axis=-1, keepdims=True)
def layer_norm(x, gamma, beta, eps=1e-5): # 千手撕的 LayerNorm
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): # 前馈网络 ReLU(xW1+b1)W2+b2
return np.maximum(0.0, x @ W1 + b1) @ W2 + b2
def sinusoidal_pe(max_len, d_model): # 第5章正弦位置编码
pe = np.zeros((max_len, d_model))
pos = np.arange(max_len)[:, None].astype(float)
i = np.arange(d_model // 2).astype(float)
omega = 1.0 / np.power(10000.0, 2.0 * i / d_model)
pe[:, 0::2] = np.sin(pos * omega)
pe[:, 1::2] = np.cos(pos * omega)
return pe
这四个函数我们都不陌生,但请记住它们的一个共同点:输入 (B,S,d),输出 (B,S,d),shape 一次都不变。这是整台机器"形状守恒"的第一块基石。
二、工位一:嵌入层 + 位置编码------token id 变成向量
机器吃的第一口饭是整数 ------token id。比如源句 [3, 1, 4, 1, 5],里面的 3、1、4、1、5 只是词表里的编号,本身不含任何语义。要让神经网络处理它,第一步永远是查表:把每个 id 换成词表中对应的一行向量。

图 2 左半边是查表:词表矩阵 Emb 形状是 (V, d),V 是词表大小、d 是向量维度。要得到 id=3 的向量,直接取第 3 行:emb[3],得到一个 (d,) 的向量。图 2 右半边是相加:查表出来的内容向量 表示"它是谁",位置编码里的位置向量 表示"它在地第几个位置",两者逐元素相加,得到机器真正吃进去的输入向量。
再强调一次为什么必须加位置编码:自注意力本身是"无视顺序"的(把词打乱,注意力权重不变),位置编码就是给每个位置打的"房间号"。这一步在代码里就是两行:
python
Src_V, Tgt_V, d = 8, 12, 8 # 源词表8、目标词表12、向量维度8
emb_src = np.random.randn(Src_V, d) # 源词嵌入矩阵 (8, 8)
emb_tgt = np.random.randn(Tgt_V, d) # 目标词嵌入矩阵 (12, 8)
pe = sinusoidal_pe(32, d) # 位置编码 (32, 8),最多支持32个位置
src_ids = np.array([[1, 2, 3, 4, 5], # 2 个源句,每句 5 个 token
[6, 7, 1, 2, 3]])
tgt_ids = np.array([[0, 1, 2, 3], # 2 个目标句,每句 4 个 token
[0, 4, 5, 6]])
x = emb_src[src_ids] + pe[:5][None, :, :] # 查表 + 位置编码,一次广播完成
y = emb_tgt[tgt_ids] + pe[:4][None, :, :]
真实输出:
src_ids = [[1, 2, 3, 4, 5], [6, 7, 1, 2, 3]]
emb 查表后 shape = (2, 5, 8)
加上位置编码后 shape = (2, 5, 8)
PE 前 4 行的第 0、1 列(查表示例):
[[ 0. 1. ]
[ 0.8415 0.5403]
[ 0.9093 -0.4161]
[ 0.1411 -0.99 ]]
逐个拆解这行代码:emb_src[src_ids] 是查表,输入 (2,5) 的 id,输出 (2,5,8) 的向量------最后一维 8 就是 d ;pe[:5][None,:,:] 把 (5,8) 的位置编码加一个空 batch 维变成 (1,5,8),与 (2,5,8) 相加时自动广播成每个 batch 都用同一份位置编码。PE 表第 0 行第 0、1 列是 [0, 1]------这正是 sin ( 0 ) = 0 \sin(0)=0 sin(0)=0、 cos ( 0 ) = 1 \cos(0)=1 cos(0)=1;第 1 行是 [0.8415, 0.5403]------ sin ( 1 ) ≈ 0.8415 \sin(1)\approx0.8415 sin(1)≈0.8415、 cos ( 1 ) ≈ 0.5403 \cos(1)\approx0.5403 cos(1)≈0.5403。一行代码、一张表、一个广播,这个工位就装完了。
💡 最容易错的点 :位置编码的
[:5]/[:4]必须与句子长度一致。如果源句 5 个词你却pe[:32],那每个位置拿到的是错位的位置向量,顺序信息就全乱了。位置编码是"按位置取前若干行",不是"整个表贴上去"。
三、把注意力做成"通用零件":多头注意力
第 3、4 章我们分别手撕过缩放点积注意力和多头注意力。现在要把它升级成一个通用零件------因为整台机器有三个地方要用它,而且用法各不相同:
- Encoder 的自注意力:Q≠K=V 全来自 x;
- Decoder 的掩码自注意力:Q=K=V 全来自 y,多一张掩码;
- Decoder 的交叉注意力:Q 来自 y,K、V 来自 Z。
三处调用的区别只在"肉"(输入张量)和"调料"(掩码),公式和形状完全一样。所以我们把第 3 章的缩放点积注意力先升级成支持 batch 的版本:
python
def scaled_dot_product_attention(Q, K, V, mask=None):
d_k = Q.shape[-1] # 键的维度
scores = (Q @ np.swapaxes(K, -1, -2)) / np.sqrt(d_k) # 打分:QK^T / sqrt(dk)
if mask is not None:
scores = scores + mask # 掩码加在 softmax 之前
w = softmax_rows(scores) # 归一化成权重
return w @ V, w # 加权求和,顺便返回权重
def multi_head_attention(Q, K, V, Wq, Wk, Wv, Wo, h, mask=None):
B, Sq, Sk = Q.shape[0], Q.shape[1], K.shape[1]
dk, dv = Wq.shape[1] // h, Wv.shape[1] // h # 每头维度
q = (Q @ Wq).reshape(B, Sq, h, dk).transpose(0, 2, 1, 3) # (B,h,Sq,dk)
k = (K @ Wk).reshape(B, Sk, h, dk).transpose(0, 2, 1, 3) # (B,h,Sk,dk)
v = (V @ Wv).reshape(B, Sk, h, dv).transpose(0, 2, 1, 3) # (B,h,Sk,dv)
scores = (q @ k.transpose(0, 1, 3, 2)) / np.sqrt(dk) # (B,h,Sq,Sk)
if mask is not None:
scores = scores + mask # 掩码自动广播
w = softmax_rows(scores)
out = w @ v # (B,h,Sq,dv)
out = out.transpose(0, 2, 1, 3).reshape(B, Sq, h * dv) # 拼回 (B,Sq,d)
return out @ Wo, w
这段代码值得用图再看一遍------它是整台机器里最容易懵的一块,也恰恰是新手面试必考的一块:

图 3 拆成四步看:①投影 :x 分别乘 Wq、Wk、Wv,得到 (B,S,d) 的 Q/K/V;②切分 :把每个张量的最后一维 d 切成 h 段(图上 h=2,蓝色是头 0、橙色是头 1),重排成 (B,h,S,dk),于是两个头互不干扰地并行 ;③每个头独立算注意力 :q @ k^T / sqrt(dk) 得 (B,h,Sq,Sk) 的权重矩阵;④拼接+投影:把 h 个头的输出按行拼回 (B,S,d),再乘 Wo 投影一次。
掩码怎么进去?看代码里 scores = scores + mask 这一行:scores 是 (B,h,Sq,Sk),mask 是 (Sq,Sk),numpy 的广播规则让一个二维掩码自动复制到每一对 (batch, head) 里------一张掩码,全班共享。这就是图 3 右下角小图说的"广播"。
跑一遍,验证两件最重要的事:输出形状不变 ,以及权重矩阵的两个数学性质(行和为 1;掩码位置权重为 0):
python
np.random.seed(0)
P = np.random.randn(8, 8) * 0.2 # 用一对随机权重当 Wq
Q = np.random.RandomState(1).randn(2, 4, 8) * 0.5 # 注意:Q 和 K/V 长度可以不同
K = np.random.RandomState(2).randn(2, 5, 8) * 0.5
V = np.random.RandomState(3).randn(2, 5, 8) * 0.5
out_mh, w_mh = multi_head_attention(Q, K, V, P, P, P, P, 2)
print('多头输出 out.shape =', out_mh.shape)
print('权重矩阵 w.shape =', w_mh.shape)
print('每行权重和的最大偏差 =', float(np.abs(w_mh.sum(axis=-1) - 1).max()))
mask4 = np.triu(np.full((4, 4), -1e9), k=1) # 第8章的上三角掩码
_, w_mk = multi_head_attention(Q, Q, Q, P, P, P, P, 2, mask4)
print('掩码后 位置1 看未来位置2、3 的权重 =', np.round(w_mk[0, :, 1, 2:], 6).tolist())
真实输出:
多头输出 out.shape = (2, 4, 8) (输入是(2,4,8),出来还是(2,4,8))
权重矩阵 w.shape = (2, 2, 4, 5) (B, h, Sq, Sk)
每行权重和的最大偏差 = 1.1102230246251565e-16
掩码后 位置1 看未来位置2、3 的权重 = [[0.0, 0.0], [0.0, 0.0]]
三行结论,每一行都是一个验证:
- 输出 (2,4,8) :Q 的 S=4、K 的 S=5,输入长度不同,但输出行数跟着 Q 走(4 行),最后一维是 8(d)------"输出行数等于查询长度",正是第 8 章交叉注意力的关键结论,现在它在通用零件里自动成立;
- 权重和偏差 1.1e-16:这就是计算机意义上的"完全等于 1"------softmax 的数学性质(每行权重和为 1)在批量、多头场景下依然严格成立;
- 未来位置权重 \[0,0,0,0]:第 8 章学过的"掩码把未来压成 0",在多头里每个头都被压得死死的(2 个头,所以是两行 0)。
加上第 8 章早已验证过的交叉注意力(Q 来自 y、K/V 来自 Z、长度可不同),这个"通用零件"现在谁都能喂、喂什么都行。正式动手组装块吧。
四、Encoder 块:第一个真正的"层"
有了零件,开始装第一只"层"------Encoder 块。第 7 章已经拆过它的内部结构:自注意力 → 残差 → LayerNorm → 前馈 → 残差 → LayerNorm。现在用我们的通用零件把它写成 8 行:
python
def encoder_block(x, P, h):
# P 是本层参数:Wq/Wk/Wv/Wo(注意力)、W1/b1/W2/b2(FFN)、gamma/beta(LN)
a, _ = multi_head_attention(x, x, x, P['Wq'], P['Wk'], P['Wv'], P['Wo'], h)
m = layer_norm(x + a, P['gamma'], P['beta']) # 残差 + LN
f = ffn(m, P['W1'], P['b1'], P['W2'], P['b2'])
return layer_norm(m + f, P['gamma'], P['beta']) # 残差 + LN
就 8 行。对照第 7 章的图,一行对一步,绝不跳步:

注意图 4 里两处残差 的加号位置:第一处是"x + 注意力输出",第二处是"m + FFN 输出"------永远把进子层之前的值加回来,这是第 7 章强调过的细节,堆层时尤其重要。
跑一遍 Encoder 块,看每一步的 shape:
python
x = emb_src[src_ids] + pe[:5][None, :, :] # (2,5,8),工位一的产品
P_enc = {'Wq': P, 'Wk': P, 'Wv': P, 'Wo': P, 'W1': np.random.randn(8,16)*0.2,
'b1': np.zeros(16), 'W2': np.random.randn(16,8)*0.2, 'b2': np.zeros(8),
'gamma': np.ones(8), 'beta': np.zeros(8)}
z_out, a, m, f = encoder_block(x, P_enc, 2)
print('输入 x :', x.shape)
print('①多头自注意力 :', a.shape)
print('残差+LayerNorm :', m.shape)
print('②FFN :', f.shape)
print('层输出(残差+LN):', z_out.shape)
真实输出:
输入 x : (2, 5, 8)
①多头自注意力 : (2, 5, 8)
残差+LayerNorm : (2, 5, 8)
②FFN : (2, 5, 8)
层输出(残差+LN): (2, 5, 8)
每一步都是 (2,5,8)------进多少、出多少,形状不变。这就是图 1 左侧那根"直线"的由来:Encoder 层再多,x 到 Z 永远五列八格。这五个 (2,5,8) 不是巧合,而是三个零件(注意力、LN、FFN)各自的 shape 保持性质叠加的结果。形状不变,所以我们可以大胆堆叠------第 4 章到第 8 章攒下的所有零件,在这一刻全部"咬合"上了。
五、Decoder 块:三明治式的组装
Encoder 装好,Decoder 块是同一套思路再加一层。第 8 章说过,解码器比编码器多两样东西:掩码自注意力 (不许偷看未来)和交叉注意力(去查编码器记忆 Z)。装成代码:
python
def decoder_block(y, z, P, h, mask):
a, _ = multi_head_attention(y, y, y, P['Wq_self'], P['Wk_self'],
P['Wv_self'], P['Wo_self'], h, mask)
m1 = layer_norm(y + a, P['gamma1'], P['beta1']) # 残差+LN
c, _ = multi_head_attention(m1, z, z, P['Wq_cross'], P['Wk_cross'],
P['Wv_cross'], P['Wo_cross'], h)
m2 = layer_norm(m1 + c, P['gamma2'], P['beta2']) # 残差+LN
f = ffn(m2, P['W1'], P['b1'], P['W2'], P['b2'])
return layer_norm(m2 + f, P['gamma3'], P['beta3']) # 残差+LN
对照图再读一遍:

图 5 里最值得盯住的两点:①和②都是多头注意力,但因为喂的"肉"和"调料"不同,职责完全不同 ------①在看自己(掩码自注意力),②在看源句(交叉注意力);掩码只加在①上 ,②查的是完整已知的源句,不需要遮。代码里看得一清二楚:只有第一个 multi_head_attention 调用传了 mask。
验证 Decoder 块的前向(掩码直接用第 8 章的上三角矩阵):
python
mask4 = np.triu(np.full((4, 4), -1e9), k=1)
y = emb_tgt[tgt_ids] + pe[:4][None, :, :] # (2,4,8)
P_dec = {'Wq_self': P, 'Wk_self': P, 'Wv_self': P, 'Wo_self': P,
'Wq_cross': P, 'Wk_cross': P, 'Wv_cross': P, 'Wo_cross': P,
'W1': np.random.randn(8,16)*0.2, 'b1': np.zeros(16),
'W2': np.random.randn(16,8)*0.2, 'b2': np.zeros(8),
'gamma1': np.ones(8), 'beta1': np.zeros(8),
'gamma2': np.ones(8), 'beta2': np.zeros(8),
'gamma3': np.ones(8), 'beta3': np.zeros(8)}
dout, da, dc, df, dm1, dm2 = decoder_block(y, z_out, P_dec, 2, mask4)
print('输入 y :', y.shape)
print('①掩码自注意力 out :', da.shape)
print('残差+LayerNorm :', dm1.shape)
print('②交叉注意力 out :', dc.shape)
print('残差+LayerNorm :', dm2.shape)
print('③FFN + 残差 + LN :', dout.shape)
真实输出:
输入 y : (2, 4, 8)
①掩码自注意力 out : (2, 4, 8)
残差+LayerNorm : (2, 4, 8)
②交叉注意力 out : (2, 4, 8)
残差+LayerNorm : (2, 4, 8)
③FFN + 残差 + LN : (2, 4, 8)
又是清一色的 (2,4,8)。注意一个细节:Decoder 的输出行数 4 是目标句的长度------即使交叉注意力在"查"长度为 5 的源句 Z,输出依然跟着 Q(目标句)走,4 行不变。这正是第 8 章"输出行数 = 目标句长度"结论的再一次落地。
六、工位四与五:输出层 + Softmax------把向量"翻译"成词
终于到了机器的"出口"。前面所有层都保持 (2,4,8),但最后我们必须从中选一个词出来------选词需要的是每个词有多大概率被选中,也就是一张 (2,4,词表大小) 的概率表。
于是输出层做两件事:
python
W_out = np.random.randn(8, 12) * 0.1 # 把 d=8 维映射到词表 12 维
b_out = np.zeros(12)
logits = y @ W_out + b_out # (2,4,8) → (2,4,12) 打分
probs = softmax_rows(logits) # 沿最后一维做 softmax → 概率
print('logits shape =', logits.shape, ' probs shape =', probs.shape)
真实输出:
logits shape = (2, 4, 12) probs shape = (2, 4, 12)
为什么是词表大小 12?因为目标词表有 12 个词,机器最后要在这 12 个候选里挑一个。为什么还要 softmax?因为 logits 是"原始打分",可能是负数、没有大小概念;softmax 之后才变成"和为 1 的概率分布",可以直接比较"哪个词最该说"。logits[j] 越大,probs[j] 越大。到这一步,整条前向就闭合了:从 token id 进,到概率分布出。
💡 一句话记住输出层:机器内部一直用 d=8 维说话("思考用高维"),只有在出口才把话"翻译"成词表 12 维("表达用词表")。这一层没有魔法,就是一个普通的全连接层。
七、总装:完整 Transformer 前向,一次跑通
零件齐了,正式总装。我们把工位一到工位五串成一个函数,参数全部放进一个字典 P 里(每层一套参数),用循环堆叠 N=2 层:
python
def init_params(seed=0, V_src=8, V_tgt=12, d=8, h=2, d_ff=16, n_layers=2, max_len=32):
rng = np.random.RandomState(seed) # 固定随机种子,可复现
def W(rows, cols, s=0.2):
return rng.randn(rows, cols) * s
P = {'d_model': d, 'h': h, 'n_layers': n_layers,
'pe': sinusoidal_pe(max_len, d),
'emb_src': W(V_src, d, 0.3), 'emb_tgt': W(V_tgt, d, 0.3),
'W_out': W(d, V_tgt, 0.1), 'b_out': np.zeros(V_tgt),
'mask_tgt': np.triu(np.full((max_len, max_len), -1e9), k=1)} # 全模型共享上三角掩码
for i in range(n_layers):
P['enc%d' % i] = {'Wq': W(d, d), 'Wk': W(d, d), 'Wv': W(d, d), 'Wo': W(d, d),
'W1': W(d, d_ff), 'b1': np.zeros(d_ff),
'W2': W(d_ff, d), 'b2': np.zeros(d),
'gamma': np.ones(d), 'beta': np.zeros(d)}
P['dec%d' % i] = {'Wq_self': W(d, d), 'Wk_self': W(d, d), 'Wv_self': W(d, d), 'Wo_self': W(d, d),
'Wq_cross': W(d, d), 'Wk_cross': W(d, d), 'Wv_cross': W(d, d), 'Wo_cross': W(d, d),
'W1': W(d, d_ff), 'b1': np.zeros(d_ff),
'W2': W(d_ff, d), 'b2': np.zeros(d),
'gamma1': np.ones(d), 'beta1': np.zeros(d),
'gamma2': np.ones(d), 'beta2': np.zeros(d),
'gamma3': np.ones(d), 'beta3': np.zeros(d)}
return P
def transformer_forward(src_ids, tgt_ids, P):
S_src, S_tgt = src_ids.shape[1], tgt_ids.shape[1]
h, nl = P['h'], P['n_layers']
x = P['emb_src'][src_ids] + P['pe'][:S_src][None, :, :] # 工位一
z = x
for i in range(nl): # 工位二 × 2
z, *_ = encoder_block(z, P['enc%d' % i], h)
y = P['emb_tgt'][tgt_ids] + P['pe'][:S_tgt][None, :, :] # 工位一(目标句)
mask = P['mask_tgt'][:S_tgt, :S_tgt] # 截出 4x4 掩码
for i in range(nl): # 工位三 × 2
y, *_ = decoder_block(y, z, P['dec%d' % i], h, mask)
logits = y @ P['W_out'] + P['b_out'] # 工位四
probs = softmax_rows(logits) # 工位五
return logits, probs
整台机器加起来不到 40 行(初始化 + 前向)。现在用我们的小假数据跑一遍,并且每一步都记录 shape------这就是本章最核心的验证物:逐层 Shape 流水账。
python
P0 = init_params(seed=0)
logits, probs = transformer_forward(src_ids, tgt_ids, P0)
# 略去细节:在 forward 内部每步把 (name, shape) 记进 reports
真实输出(完整流水账):
嵌入+位置编码(src) shape=(2, 5, 8)
Encoder 第1 层 shape=(2, 5, 8)
Encoder 第2 层 shape=(2, 5, 8)
嵌入+位置编码(tgt) shape=(2, 4, 8)
Decoder 第1 层 shape=(2, 4, 8)
Decoder 第2 层 shape=(2, 4, 8)
输出层 Linear shape=(2, 4, 12)
Softmax 概率 shape=(2, 4, 12)
logits shape = (2, 4, 12) probs shape = (2, 4, 12)
每行最大概率 = [[0.1254, 0.135, 0.1163, 0.138], [0.1258, 0.1346, 0.128, 0.1434]]
argmax 预测 = [[1, 2, 2, 2], [1, 2, 2, 2]]
这张流水账值得裱起来------它就是第 9 章在系列目录里承诺的"逐层 Shape 推演表"的原始数据。把它画成表格:

配上对应的完整图,数据流向一目了然:

看这串 shape 你会注意到一个规律,正是我们前面反复强调的:机器内部 (2,5,8)/(2,4,8) 一路不变,唯一一次"变形"发生在输出层(8→12)。这个规律让代码结构极其干净:循环 N 次调用同一个块,块内三个子层各自 shape 保持,层与层之间无缝衔接。如果你自己写的时候某一行 shape 变了,比如注意力输出变成了 (2,8,5),那一定是 reshape/transpose 的顺序错了------回看图 3 对一遍。
把这张流水账读成一张"面试答题卡" 。假设面试官指着图 7 问你:"为什么 Transformer 里中间所有层的 shape 都一样?"你可以用这张流水账组织答案:①每个子层(注意力、LN、FFN、残差)都是"形状保持映射"------(B,S,d) 进,(B,S,d) 出,这是它们的设计前提;②因此块可以任意叠 N 层而不需要任何"维度对齐"的代码;③整个模型唯一的"维度跳变"发生在输出层,从 d 变到词表大小,这才是"预测哪个词"的口径。如果面试官继续追问"如果中间某层输出变成 (2,5,12) 你会怎么排查"------答:先查多头注意力的拼接(transpose+reshape 的顺序),再查 FFN 的 W2 形状,这两处是最经典的"维度失守"现场。能用这场流水账把这三个问题答清楚,"Transformer 前向"这一题基本就过关了。
七点五、两件"小事":shape 为什么是 (B,S,d),以及这台机器有多少参数
在进入最终的"体检"之前,先把两个最容易"模模糊糊"的问题彻底钉死。
7.5.1 为什么整台机器只认 (B,S,d) 这一种排列?
很多新手手写 Transformer 时卡住的不是公式,而是"维度到底怎么排"------同样一组数字,排成 (B,S,d) 和 (B,d,S) 在数学上完全是两种东西。答案藏在注意力的公式里:打分矩阵是 Q K ⊤ QK^\top QK⊤。回忆矩阵乘法规则: ( m × k ) (m\times k) (m×k) 乘 ( k × n ) (k\times n) (k×n),最内侧的两个维度(k 和 k)必须匹配、并且会被"消掉" 。Q 是 (B,Sq,d)、K 转置后是 (B,d,Sk),内积恰好消掉 d,留下 (B,Sq,Sk)------"词与词互相对照"的得分。如果当初把张量排成 (B,d,S), Q K ⊤ QK^\top QK⊤ 消掉的就会是 S,剩下 (B,d,d)------变成"特征与特征互相对照",整台机器立刻失去意义。
更阴险的是,这种错误不会报错 :两个 (B,8,5) 照样能乘、shape 照样"一路不变"地给你输出,只有数值彻底乱掉。这正是为什么要做下一节的体检------尤其是全零退化测试,就是专门抓这种"静悄悄的错误"。一句话记住:S 是句子里词的个数、它要"对话",d 是特征、它要"被消掉",所以序列维必须待在倒数第二维------注意力、LayerNorm、FFN、输出层,全线统一,谁也不要例外。
7.5.2 这台机器有多少参数?一张表算清楚
参数数量可以直接从 shape 算出来,顺便做一次统计练习。以我们的小模型(d=8,h=2,d_ff=16,V_src=8,V_tgt=12,N=2 层)为例:
| 模块 | 参数来源(形状) | 数量 |
|---|---|---|
| 源词嵌入 emb_src | (8,8) | 64 |
| 目标词嵌入 emb_tgt | (12,8) | 96 |
| Encoder 每层:注意力 | 4 × (8,8) | 256 |
| Encoder 每层:FFN | 8×16+16 + 16×8+8 | 280 |
| Encoder 每层:LN | 2 × 2×8(γ、β) | 32 |
| Encoder 每层小计 | --- | 568 |
| Decoder 每层:注意力 | 8 × (8,8)(自×4 + 交叉×4) | 512 |
| Decoder 每层:FFN + LN | 280 + 3×2×8 | 328 |
| Decoder 每层小计 | --- | 840 |
| 输出层 W_out + b_out | 8×12 + 12 | 108 |
| 整台机器合计 | 64+96+2×568+2×840+108 | 3084 |
全机不到 4000 个参数。真实的 Transformer(d=512、N=6、词表 4 万)这个数是 6500 万起步,差了四个数量级------但结构完全一样。参数只决定这台机器"装了多少东西",不决定"装的方式";本章的逐层验证在小模型上成立,换成任何尺度都成立。这正是"形状守恒"设计的价值:人在局里,数字在局外,但规矩不分大小。
八、实操验证:五道"体检",证明代码没写错
机器跑通了,shape 也全对。但"跑通"不等于"算对"------如果 softmax 里除错了地方、或者残差加错了对象,shape 照样是一路不变,可数值是错的。所以这一节我们给这台机器做五道体检,每一道都从数学上锁定一个性质,任何一个性质不满足,都说明写错了。这就是"所有内容实操验证"的兑现:不是看代码"像不像对的",而是让数字自己说话。
体检 1:手算对拍------纸面推导 vs 代码输出
最强硬的验证,是拿一台能纸上算完的微型机器对拍。我们分别对拍注意力核心、位置编码、LayerNorm 三处。全部用纸笔推一遍,再和代码输出比。
① 注意力核心对拍。取 Q=K=I₂(单位矩阵)、V=\[10,20,30,40],d_k=2:
- 打分矩阵: Q K ⊤ = I 2 QK^\top = I_2 QK⊤=I2,除以 2 ≈ 0.7071 \sqrt{2}\approx0.7071 2 ≈0.7071;
- softmax 第 0 行: w 0 = e 0.7071 e 0.7071 + e 0 ≈ 2.0281 3.0281 ≈ 0.6698 w_0 = \dfrac{e^{0.7071}}{e^{0.7071}+e^0} \approx \dfrac{2.0281}{3.0281} \approx 0.6698 w0=e0.7071+e0e0.7071≈3.02812.0281≈0.6698, w 1 = 1 − 0.6698 = 0.3302 w_1 = 1-0.6698 = 0.3302 w1=1−0.6698=0.3302;
- 输出第 0 行: 0.6698 × 10 , 20 + 0.3302 × 30 , 40 ≈ 16.60 , 26.60 0.6698\times10,20 + 0.3302\times30,40 \approx 16.60, 26.60 0.6698×10,20+0.3302×30,40≈16.60,26.60。
跑代码:
python
Qm = np.eye(2)
Vm = np.array([[10.0, 20.0], [30.0, 40.0]])
out_m, w_m = scaled_dot_product_attention(Qm, Qm, Vm)
print('权重 w =', np.round(w_m, 4).tolist())
print('输出 =', np.round(out_m, 4).tolist())
真实输出:
权重 w = [[0.6698, 0.3302], [0.3302, 0.6698]]
输出 = [[16.6048, 26.6048], [23.3952, 33.3952]]
纸面推到 4 位是 0.6698/16.60、26.60,代码到 4 位给 0.6698/16.6048、26.6048------完全吻合(第 4 位的小差异只是手算时 exp 近似取位造成的,数学上同一个数)。
② 位置编码对拍 。 sin ( 0 ) = 0 \sin(0)=0 sin(0)=0、 cos ( 0 ) = 1 \cos(0)=1 cos(0)=1、 sin ( 1 ) ≈ 0.8415 \sin(1)\approx0.8415 sin(1)≈0.8415、 cos ( 1 ) ≈ 0.5403 \cos(1)\approx0.5403 cos(1)≈0.5403:
python
pe4 = sinusoidal_pe(4, 4)
print(np.round(pe4[:2, :2], 4).tolist())
真实输出:
[[0.0, 1.0], [0.8415, 0.5403]]
③ LayerNorm 对拍 。x=1,2,3:均值 2、方差 2/3,标准差 2 / 3 ≈ 0.8165 \sqrt{2/3}\approx0.8165 2/3 ≈0.8165,归一化后 − 1.2247 , 0 , 1.2247 -1.2247, 0, 1.2247 −1.2247,0,1.2247:
python
print(np.round(layer_norm(np.array([[1., 2., 3.]]), np.ones(3), np.zeros(3)), 4).tolist())
真实输出:
[[-1.2247, 0.0, 1.2247]]
三处对拍全部命中。当"手算"和"代码"给出同一个数,就可以相信这条链路在数学上没有写歪------因为注意力、位置编码、LN 正是整台机器的三个数学内核,其余部分都是它们的照搬与组合。
再对拍一个 3 词例子。怕你觉得 2 词例子"太小",我们手推一个 3 词的全流程。取 Q=K=I₃(单位矩阵,d_k=3),V 随便取 V=\[1,2,3,4,5,6,7,8,9]:
- 打分: Q K ⊤ = I 3 QK^\top = I_3 QK⊤=I3,除以 3 ≈ 1.7321 \sqrt{3}\approx1.7321 3 ≈1.7321,对角线上全是 0.5774,其余是 0;
- softmax 第 0 行:分子 e 0.5774 ≈ 1.7813 e^{0.5774}\approx1.7813 e0.5774≈1.7813,分母 1.7813+1+1,得 w=0.4711, 0.2645, 0.2645;
- 第 0 行输出: 0.4711 × 1 , 2 , 3 + 0.2645 × 4 , 5 , 6 + 0.2645 × 7 , 8 , 9 ≈ 3.38 , 4.38 , 5.38 0.4711\times1,2,3 + 0.2645\times4,5,6 + 0.2645\times7,8,9 \approx 3.38, 4.38, 5.38 0.4711×1,2,3+0.2645×4,5,6+0.2645×7,8,9≈3.38,4.38,5.38。
跑同一段代码(就是本章的 scaled_dot_product_attention):
真实输出:
权重矩阵 w = [[0.4711, 0.2645, 0.2645], [0.2645, 0.4711, 0.2645], [0.2645, 0.2645, 0.4711]]
输出 out = [[3.3801, 4.3801, 5.3801], [4.0, 5.0, 6.0], [4.6199, 5.6199, 6.6199]]
权重矩阵与手算逐位吻合,第 0 行输出 3.3801 与手算 3.38 一致。顺带一个彩蛋结论:当 Q=K=I 时,"每个位置只和自己最像",权重矩阵退化成对角线隆起的矩阵(主对角线 0.4711 明显大于两侧 0.2645)------对角线最大,说明自注意力在最"干净"的世界里,也会先相信离自己最近的位置。
体检 2:注意力权重的两个数学性质
体检 1 只验证了单个点,体检 2 验证整批:对随机数据跑完整多头注意力,检查每个头、每个位置的权重行。两个性质就是第 3 章公式的定义本身:
- 性质 A:每个位置对"所有被看的词"的权重之和必须等于 1(softmax 行和);
- 性质 B :加了上三角掩码后,"未来位置"的权重必须严格等于 0 (不是约等于,是精确 0,否则就是
exp(-1e9)不够小或者掩码加错位置)。
前面 demo2 的真实输出已经给过答案:
每行权重和的最大偏差 = 1.1102230246251565e-16 ← 1e-16 级别,计算机意义上的 1
掩码后 位置1 看未来位置2、3 的权重 = [[0.0, 0.0], [0.0, 0.0]] ← 两个头全部压死
性质 A 锁定 softmax 方向没写反(沿行做、没沿列做),性质 B 锁定掩码加在了打分矩阵上、且用的确实是 -1e9 大负数。这两条过了,注意力这个"心脏"就是健康的。
体检 3:LayerNorm 的统计性质
LN 的数学定义是"把最后一条维归一化成均值 0、方差 1,再乘 γ 加 β"(第 7 章)。所以全模型跑完,取解码器最后一层的输出,统计每个时间步的均值和方差,应该分别≈0 和≈1(差的量级取决于 eps=1e-5):
python
y2, *_ = decoder_block(y, z, P0['dec1'], 2, mask4) # 取最后一块的输出
mu = y2.mean(axis=-1) # 每个 (batch, 位置) 的均值
var = y2.var(axis=-1) # 每个 (batch, 位置) 的方差
print('|均值| 最大值 =', float(np.abs(mu).max()))
print('|方差-1| 最大值 =', float(np.abs(var - 1).max()))
真实输出:
|均值| 最大值 = 1.3e-08
|方差-1| 最大值 = 1.0e-05
方差差 1e-5 正是 eps 的量级------完全符合 LN 的定义。如果这里出现方差=0.7 之类,说明 LN 的均值/方差算错维度了(比如沿 batch 归一化成 "BatchNorm" 了)。
体检 4:全零权重退化测试------最狠的一招
这是五道体检里最"一票否决"的一道。它的思想极端而彻底:把模型所有可学习参数(嵌入、注意力、LN、FFN、输出层)全部置零,那么这台机器无论输入什么,输出都必须退化成"完全均匀"的概率。
为什么?因为全零时:注意力输出=0,残差后 x+0=x,LN 输出非零(归一化而已),FFN 输出=0,一步步传递下去,最后到输出层 y @ W_out + b_out = y @ 0 + 0 = 0------logits 必须全 0 。而 logits 全 0 时 softmax 里 exp ( 0 ) = 1 \exp(0)=1 exp(0)=1 对每个词都相等,于是概率必须精确等于 1/12。这是一个由模型结构决定的、必然成立的恒等式;只要代码里任何一个"不该加的加了、该扣的没扣"(比如忘了 b_out、或者把掩码当参数学),这个恒等式就会破。
python
for key in P0: # 把 P0 里所有可学习参数置零
if key in ('d_model', 'h', 'n_layers', 'pe', 'mask_tgt'):
continue
if isinstance(P0[key], dict):
for k2 in P0[key]:
P0[key][k2] = np.zeros_like(P0[key][k2])
else:
P0[key] = np.zeros_like(P0[key])
logits0, probs0 = transformer_forward(src_ids, tgt_ids, P0)
print('logits 最大绝对值 =', float(np.abs(logits0).max()))
print('probs 是否每一项都等于 1/12 :', bool(np.allclose(probs0, 1.0 / 12, atol=1e-9)))
print('probs[0,0,:4] =', np.round(probs0[0, 0, :4], 4).tolist())
真实输出:
logits 最大绝对值 = 0.0
probs 是否每一项都等于 1/12 : True
probs[0,0,:4] = [0.0833, 0.0833, 0.0833, 0.0833]
画成图,一眼看穿:

logits 最大绝对值 = 0.0、每一项概率都精确等于 1/12------恒等式成立,说明从输入到输出的链路每一环都扣对了。这一招不需要任何参考实现,全靠数学结构本身把关,是做手撕代码时最值得常备的"试金石"。
体检 5:未训练模型输出 = "均匀的废话"(并预告下一章)
最后一体检其实是"预期观测":用随机初始化的完整模型(seed=0 那套参数)跑一遍,看输出的概率分布长什么样。

真实输出:
probs[0,0,:](第1句第1个词的词表分布)= [0.0733, 0.1254, 0.1215, 0.0924, 0.0663, 0.0613, 0.1171, 0.0824, 0.0833, 0.0643, 0.0522, 0.0605]
最大概率 = 0.1434 均匀概率 1/12 = 0.0833
argmax 序列 = [[1, 2, 2, 2], [1, 2, 2, 2]]
看数据:12 个概率从 0.05 到 0.14 之间晃荡,最大也只到 0.1434------接近但没到均匀(0.0833),说明这台机器在每一个位置上都"毫无主见" ,argmax 挑出的词 id(1、2、2、2...)是随机的、无意义的。机器"通了",但"没学会"。这完全正常:前向只是机器在"空转",知识是训练一点点喂进去的。
8.6 前向正确 ≠ 能干活:边界划在哪里 。五道体检全部通过,只能说明三件事:①代码的数学实现与公式一致;②shape 链条没有断;③模型结构完整。它不能 说明模型"有用"------图 9 已经用数字证明:未训练模型的 argmax 输出和抛硬币没有本质区别。所以"前向正确"与"模型可用"之间隔着整整一章:前者是"发动机点火",后者要"燃料+磨合",燃料是数据,磨合是损失函数与优化器。这也解释了为什么我们的验证全部选在结构层面 (shape、行和、退化恒等式)而不是效果层面(准确率、BLEU)------效果要等学会之后才能谈。把这条边界记住,你就不会犯"前向跑通就以为模型会翻译了"的经典错误。
这个观测恰好把钩子抛给下一章:前向验证通过的机器,如何通过损失函数、优化器、学习率调度真正学到东西?这正是第 10 章"训练细节------标签平滑、AdamW 与学习率调度"要回答的。
九、常见坑与自查
- 形状混乱的三大元凶 :
reshape与transpose的顺序、多头切分时按"头优先"还是"维度优先"、Q/K/V 之间S的对齐。图 3 是这方面的唯一"地图",写错就对着它重排; - 残差加错对象:Encoder 两处、Decoder 三处,永远加"进入子层之前"的值(x、m1、m2),不是子层输出自己。漏一处,梯度流通会悄悄断掉;
- 位置编码切错长度 :
pe[:S_src]的S_src必须等于句子实际长度,否则顺序信息错位; - 掩码只加在解码器第一个子层 :交叉注意力查的是源句,不加掩码;掩码维度要截成 (S_tgt, S_tgt),用
mask_tgt[:S_tgt, :S_tgt],别拿整张 32×32; - 把输出层忘了 softmax:logits 是原始打分可以随便取负值,选词要用 softmax 后的 probs;
- 循环堆层时误共享参数 :每一层必须独立一套参数 (
enc0、enc1...),否则 N 层退化成一层; - "跑通但不验证":shape 对不代表数值对。本章的五道体检,尤其是全零退化测试,是新手手撕 Transformer 最容易漏、也最该学会的一招------它不需要任何参考实现就能抓出"隐性错误";
- 以为前向通过 = 模型可用:未训练模型的输出只是均匀分布的抖动(最大概率 0.1434 vs 均匀 0.0833),前向正确只是"发动机点着了",真正让它说话的是训练。
十、一键复现:两个脚本把本章所有数字和图片全跑出来
文章里的每一段"真实输出"和每一张图都不是手打的,而是由两个脚本生成的。想亲手验证(强烈建议),只需要两条命令:
python _verify_tx9.py # 重跑全文所有「真实输出」,逐条核对
python plot_tx9_shapes.py # 重新生成 assets/ 下 9 张配图
_verify_tx9.py是本章的"体检报告生成器":手算对拍(2 词与 3 词)、多头性质、Encoder/Decoder 块前向、完整 Shape 流水账、全零退化测试、随机初始化概率,全部按顺序打印。你会看到与文中"真实输出"逐字一致的数字------数字一致,代码就对;哪个数对不上,就回去查那一节;plot_tx9_shapes.py是 9 张图的"绘图车间":每张图一个函数、一键重画,全部输出到assets/tx9_*.png(Windows 用 SimHei 中文字体渲染;macOS/Linux 读者把脚本开头字体列表换成系统自带中文字体即可)。
两个脚本合计不过几百行,但把本章的所有可验证内容 都兜住了:想改 d、h、N 看 shape 怎么变?改 init_params 里的参数重跑就行;想换一句"源句"?把 src_ids 换成你喜欢的 token 序列。这就是"实操验证"的正确姿势------知识不是"听来的",是"跑出来的"。
小结
这一章我们干了件大事:把前 7 章磨出来的每一件零件,亲手装成了一台完整的 Transformer,并且让它端到端跑通。核心收获八条:
- 完整前向 = 五工位:嵌入+位置编码 → Encoder×N → Decoder×N → 输出层 → Softmax,一台机器、一个函数、一次跑通;
- 形状守恒定律:机器内部 (B,S,d) 一路不变,所有块都是形状保持的,唯一"变形"在输出层(d→词表大小);
- 多头注意力是通用零件:一个函数吃下三种用法(自注意力、掩码自注意力、交叉注意力),靠"喂谁 + 是否加掩码"区分;掩码 (Sq,Sk) 一行广播到 (B,h,Sq,Sk);
- Decoder 块 = Encoder 块 + 交叉注意力:三子层"掩码自注意力→交叉注意力→FFN",掩码只加第一处;
- 输出层把"思考维"翻译成"词表维":logits → softmax → (2,4,12) 的概率分布,每行和为 1;
- 逐层 Shape 流水账全绿:(2,5,8)→(2,5,8)→(2,5,8),(2,4,8)→(2,4,8)→(2,4,8)→(2,4,12),十条记录全部 ✅;
- 五道体检守护正确性:手算对拍(注意力 0.6698、PE 0,1/0.8415,0.5403、LN -1.2247,0,1.2247 全部命中)、权重行和=1(偏差 1e-16)、掩码未来权重精确 0、LN 均值≈0 方差≈1、全零退化(logits 全 0 → 概率精确 1/12),一道一道过;
- 未训练 = 均匀废话:随机初始化输出只在均匀分布附近抖动(0.05~0.14),证明"前向通了,知识还没来"。
至此,从原理到手撕到总装,Transformer 前向这座"发动机"我们不仅造出来了,还用数学体检确认它没装错。就像安装完一台新电脑,主板、内存、显卡、电源都装好了,开机自检也全绿------但系统里还没有操作系统。下一章我们装操作系统:损失函数、优化器、学习率调度------让这台机器真正"学会"说话。
十一、动手指南:五道练习题,把"看懂"变成"会写"
看完不等于会写。这五道题都是改一行就能做的小实验,做完你就是真的"从零实现过 Transformer 前向"的人了:
- 加厚 :把
init_params里的n_layers从 2 改成 4,重跑第七节的 Shape 流水账------你会发现 (2,5,8)/(2,4,8) 依旧分毫不动,堆叠的"形状守恒"得到第 4 次验证; - 加宽:把 d 从 8 改成 16(同时把 h 从 2 改成 4,注意 d/h 必须整除),重跑全部五道体检------注意力、LN、FFN 全部应继续通过,任何一个性质报错,说明你改的时候把维度搞乱了;
- 关掉位置编码 :把
init_params里的'pe'替换成pe*0,重跑 demo2------同一个 token 在不同位置会拿到完全一样的向量,机器"失去顺序感",你可以对比开关前后argmax输出是否漂移(大概率会漂,因为注意力不再区分位置); - 故意犯错 :把输出层
W_out的 shape 改成 (8,5),重跑前向------观察是"shape 直接报错"还是"静悄悄算错",体会"矩阵乘法只在最内维断言"这件事,加深对 (B,S,d) 布局的印象; - 手推升级 :用体检 1 的 3 词例子做模板,自己手推一个 5 词的例子(建议 Q=K=I₅、V 取等差矩阵),再把结果与
scaled_dot_product_attention的输出对比------手算越吃力,说明你对 softmax 那一步越该回去复习第 3 章。
这五题全部做完,你手上就有了一台"自己造的、验过身的"Transformer。下一章我们在这台机器上装操作系统。
下一篇(三十九):训练细节------标签平滑、AdamW 与学习率调度