🧮 Transformer / LLM 架构 & 手算反向传播
先用一张全景图看清一个 token 如何在 LLM 里流动,再把每个模块(注意力、FFN、LayerNorm、输出 softmax)拆成能用笔算出数字的最小例子。所有数值例子都用 d=2、序列长度=2 的迷你规模,且已用 NumPy 逐一对拍验证——你可以照着一步步复算,落到具体的梯度矩阵上。
LLM 全景数据流 —— 一个 token 的旅程
现代大语言模型(GPT 系)是纯解码器(decoder-only)的 Transformer:一叠相同的 Block 摞起来。先建立"数据在哪些形状之间变换"的直觉,后面每一步反传都是在给这些箭头配一条反向的梯度箭头。
① 分词 Tokenize
"你好世界" → [id₁, id₂, …]int[L]
② 词嵌入 + 位置编码
每个 id 查表成向量 + 位置信息[L, d_model]
③ 多头自注意力 + 残差 + LayerNorm
token 之间互相"看",交换信息[L, d_model]
④ 前馈网络 FFN + 残差 + LayerNorm
逐 token 独立地做非线性变换[L, d_model]
⑤ 输出投影 → logits → softmax
映射到词表,得到下一个 token 的概率[L, vocab]
⑥ 交叉熵损失 L
与真实的下一个 token 比对,得到标量损失
dX 简记上游传来的梯度 ∂L/∂X,它和 X 形状永远相同——这是手算时最好用的对形状检查法。输入 —— 分词、词嵌入、位置编码
分词与词嵌入
文本先被分词器切成整数 id 序列。嵌入矩阵 E 是一张 [vocab × dmodel] 的查找表:token id 就是行号,取出对应那一行作为该 token 的向量。它是可学习参数,反传时只有被用到的那几行会收到梯度(其余行梯度为 0)。
位置编码
注意力本身不含顺序信息(打乱 token 结果不变),所以要额外注入位置。经典正弦编码给每个位置 pos、每个维度 2i / 2i+1 一个固定值:
最终每层的输入是 hi = xi + PEi。现代模型多改用可学习位置嵌入或 RoPE(旋转位置编码),思想一致:让模型知道"谁在前谁在后"。
自注意力 —— 核心机制(带数值前向)
自注意力是 Transformer 的心脏:让每个 token 根据"相关性"从其它 token 那里加权取信息。三步——投影出 Q/K/V → 打分并 softmax → 用权重对 V 加权求和。
公式
输入 H([L × d])先经三个权重矩阵投影成 Query、Key、Value:Q = HWQ,K = HWK,V = HWV。然后:
为什么除以 √dk? 点积的方差随维度 dk 线性增长,值过大会把 softmax 推到"几乎 one-hot"的饱和区,梯度趋近 0。除以 √dk 把方差拉回 1 附近,保住梯度。因果掩码(causal mask):GPT 里每个 token 只能看自己和左边,做法是把 QKT 上三角(未来位置)设成 −∞,softmax 后自然为 0。
数值前向:2 个 token、d = 2
为了后面能手算反传,这里先把一组具体数字的前向算出来。设已经投影好的(取 W 使得):
注意力前向 · 四步
多头注意力 · FFN · 残差 · LayerNorm
多头注意力(Multi-Head)
把 dmodel 切成 h 份,每份独立做一次上面的注意力(各有自己的 WQ/WK/WV),再把 h 个输出拼接,过一个输出投影 WO。直觉:不同的头关注不同的关系(有的看语法、有的看指代)。反传时各头彼此独立,拼接处只是把梯度按维度切开分给各头。
前馈网络 FFN
逐 token 独立的两层 MLP,中间维度通常放大到 4d,激活用 ReLU 或 GELU:
注意力负责"token 之间"混信息,FFN 负责"token 内部"做非线性加工——两者交替,是 Transformer 表达力的来源。
残差连接 + LayerNorm
每个子层都包一层 残差 与 LayerNorm。残差 y = x + Sublayer(x) 让梯度有一条"高速公路"直达底层(∂y/∂x 含一个恒等项 I,缓解梯度消失)。LayerNorm 对每个 token 向量做标准化再缩放平移:
| 结构 | 公式 | 反传时的意义 |
|---|---|---|
| Post-LN(原版) | LN(x + Sublayer(x)) | 残差在 LN 内部,深层训练不稳,需 warm-up |
| Pre-LN(现代主流) | x + Sublayer(LN(x)) | 残差通路上无 LN,梯度更稳,GPT/LLaMA 都用它 |
输出 —— 投影到词表、softmax、自回归生成
最后一层 Block 的输出 h([L × d])经输出投影 Wout([d × vocab],常与嵌入表共享权重)得到 logits z = h Wout,再 softmax 成概率分布:
训练时用交叉熵把预测分布拉向真实的下一个 token(teacher forcing,所有位置并行算损失)。推理时自回归:每步取概率分布采样一个 token,拼回输入,再预测下一个,如此循环——温度/top-k/top-p 都是在这一步对 p 做采样调节。
反传三大基石 —— 记住这三条就够用
整个 Transformer 的反传,拆到底只是这三种局部导数的反复拼装。把它们背下来,任何模块都能手推。
基石一:softmax + 交叉熵 ⇒ ∂L/∂z = p − y
这是深度学习里最优雅的结果。真实标签 one-hot 为 y,则损失对 logits 的梯度就是"预测概率减真实概率":
推导(关键一步):L = −Σk yk log pk,而 ∂log pk / ∂zi = δki − pi,代入并利用 Σk yk = 1 即得 pi − yi。软饱和的指数和对数在这里恰好抵消,所以数值稳定、形式简单。
基石二:线性层 Y = XW
已知上游 dY,则三个梯度(注意都靠"转置对齐形状"记忆):
形状助记:X 是 [n×din]、W 是 [din×dout]、dY 是 [n×dout]。要凑出 dW([din×dout])只能 XT(din×n)·dY(n×dout);要凑出 dX([n×din])只能 dY(n×dout)·WT(dout×din)。形状唯一确定了乘法顺序,几乎不用背。
基石三:逐元素运算(激活、加法、⊙)
| 前向 | 反向(dX 或 dW) | 说明 |
|---|---|---|
| y = ReLU(x) | dx = dy ⊙ [x>0] | 正的位置梯度直通,负的位置置 0 |
| y = a + b(残差) | da = dy,db = dy | 加法把梯度原样复制给两条支路 |
| y = a ⊙ b | da = dy⊙b,db = dy⊙a | 逐元素乘,梯度交叉相乘 |
| C = A·B(矩阵乘) | dA = dC·Bᵀ,dB = Aᵀ·dC | 基石二的一般形式,注意力反传全靠它 |
手算 ① —— 输出层的完整数值反传
这是最适合入手的一段:从损失 L 出发,把梯度推到输出投影 Wout 和隐藏向量 h。全部数字可复算。
设定
隐藏向量 h = [1, 2](d=2),词表大小 3,输出投影:
前向 → 损失 → 反向,六步到底
dh = 0.11970.1197
手算 ② —— 注意力的反向传播
接第 2 章的数值前向(Q=K=I、V=[[1,2],[3,4]])。设上游传来的梯度 dO = I(单位阵,便于手算),把它一路推回 V、A、S、Q、K。这是全篇最难但最值得动手的一段。
注意力反传的四条公式
③ 逐行过 softmax:dSi = Ai ⊙ (dAi − (Ai·dAi))
④ dQ = dS·K / √dk dK = dST·Q / √dk
③ 是整段的难点:softmax 的雅可比是 diag(a) − a aT,作用到 dAi 上化简后就是这条"减去自身加权平均"的式子。它保证 dSi 每行和为 0(softmax 的输出被约束在概率单纯形上,梯度必须与"全体同增"方向正交)——这是手算时最好的自查点。
从 dO = I 反推到 dQ / dK / dV
LayerNorm 与 FFN 的反向传播
FFN 反传(就是基石的串联)
FFN y = W2·ReLU(W1x+b1)+b2 反传,从上游 dy 依次:
dz1 = da ⊙ [z1>0] (ReLU 反传,z₁=W₁x+b₁)
dW1 = xTdz1, dx = dz1·W1T
全是第 5 章三条基石的机械套用,没有新东西——这也是为什么"背熟三基石"能覆盖整个网络。
LayerNorm 反传(唯一需要单独记的公式)
LN 里 μ、σ 都依赖整个向量,所以某个分量的梯度会牵动所有分量,公式比线性层复杂。设 x̂ = (x−μ)/√(σ²+ε),维度为 d,则:
dxi = 1√(σ²+ε) ( dx̂i − 1dΣjdx̂j − x̂i·1dΣjdx̂jx̂j ) 已用数值梯度对拍验证
怎么理解那三项? 第一项是"直接梯度";第二项减去均值方向的分量(因为 x 整体平移不改变 x̂);第三项减去缩放方向的分量(x 整体伸缩不改变 x̂)。LN 把这两个自由度"归一化掉了",反传就要把对应方向的梯度扣除——几何上就是把 dx̂ 投影到与 𝟙 和 x̂ 都正交的子空间。
一个 Transformer Block 的完整反传顺序
把前面所有零件按倒序串起来。以 Pre-LN 结构 x → x+Attn(LN(x)) → +FFN(LN(·)) 为例,梯度从顶部 dy 流回底部 dx 的完整路径:
⑧ 上游梯度 dy 到达 Block 顶部
来自上一层(或第6章的 dh)[L,d]
⑦ 残差分流:dy 同时给"直连"和"FFN 支路"
da_res = dy;进入 FFN 反传的也是 dy(基石三·加法复制)
⑥ FFN 反传(第8章)→ 再过 LN 反传
dW₂,dW₁ 收梯度;穿过 LN 得该支路对输入的贡献
⑤ 汇合:dx' = da_res + (FFN支路传回的梯度)
残差处梯度相加[L,d]
④ 残差分流 → 注意力反传(第7章)→ LN 反传
dW_O,dW_Q,dW_K,dW_V 收梯度
③ dx = 直连梯度 + 注意力支路梯度
这就是 Block 对其输入的梯度,传给下一层(更靠底)
①② 传回嵌入表:只有被用到的 token 行累加梯度
dE[id_i] += dx_i;位置嵌入同理
① 残差 = 加法 = 梯度复制:每个子层的梯度都走"直连"和"支路"两条,最后相加——这保证了底层也能拿到强梯度。
② 反传严格是前向的倒序:前向最后做的(输出 softmax)反传最先算,前向最先做的(嵌入)反传最后更新。
③ 每个权重矩阵的梯度都是"输入激活ᵀ × 上游梯度"(基石二),所以前向时必须缓存各层激活——这正是训练比推理吃显存得多的原因。
# 前向:每一步都被 autograd 记进计算图(缓存激活) logits = model(tokens) # 第0~4章的前向 loss = F.cross_entropy(logits, targets) # 第4章的 L # 反向:一行触发第5~9章的全部手算,倒序自动完成 loss.backward() # dz=p-y 起,链式推回每个 .grad # 参数更新:W ← W - η·dW optimizer.step(); optimizer.zero_grad()
dW,就是 loss.backward() 之后存进 param.grad 的那个张量。手推一遍再对照框架,"自动微分"就不再是黑盒。