图 1:本文封面,同一个 Transformer 块的两种 LN 位置。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。Pre-LN 与 Post-LN 的差别,字面看只是把 LayerNorm 挪了一格,但这一格决定了深层 Transformer 能不能稳定训练——它是理解现代大模型结构的一把钥匙。
先把两种结构的公式钉死。
一个 Transformer 子层(自注意力或 FFN)配残差连接,两种摆法:
- Post-LN:输出 = LN(x + Sublayer(x))。先做残差相加,再过归一化。2017 年的 Transformer 原论文(arXiv:1706.03762)用的就是这个结构。
- Pre-LN:输出 = x + Sublayer(LN(x))。先归一化,再进子层,残差相加时不碰 LN。
差别只有一行,但反向传播走的是完全不同的路。
Post-LN 的问题:梯度每层都要穿过 LN。
Post-LN 结构里,反向梯度从输出往输入传,每过一层都要穿过一次 LayerNorm 的雅可比矩阵。2020 年的论文《On Layer Normalization in the Transformer Architecture》(arXiv:2002.04745)用平均场理论证明了:初始化时 Post-LN 靠近输出层的参数梯度期望很大,直接上大学习率就会训练发散。这就是早期 Transformer 训练必须用学习率 warm-up(先小步预热再爬坡)的原因——不是玄学,是在躲初始化阶段的梯度尖峰。层数越深,问题越严重,所以原始 Post-LN 结构很难直接堆到几十上百层。
Pre-LN 的关键:残差变成恒等高速通道。
Pre-LN 把 LN 挪进残差分支内部,主干上只剩纯粹的加法:输出 = x + 分支(x)。反向传播时,梯度可以沿着这条「恒等通道」从输出层直达输入层,不被任何归一化缩放。同一篇论文证明 Pre-LN 初始化时梯度表现良好,因此可以干脆去掉 warm-up 直接训,论文实验里省去 warm-up 的 Pre-LN 在多种任务上达到了与基线相当的效果,还省下了大量调参时间。层数堆到上百层依然稳定——这就是今天主流开源大模型几乎清一色 Pre-LN(或其变体)的原因。
那 Post-LN 为什么还有人惦记:效果上限略高。
稳定是有代价的。Pre-LN 的恒等通道让每层的实际「有效深度」打了折扣——直观理解是深层分支的贡献容易稀释在残差流里,经验上同等规模下 Pre-LN 收敛后的最终效果常常略逊于训得动的 Post-LN。于是这道题的标准结论是:Post-LN 效果上限高但训练难(要 warm-up、深度受限),Pre-LN 稳定、好堆层、训练省心;大模型时代深度和稳定性压倒一切,选择了 Pre-LN。后续还有 Sandwich-LN、DeepNorm 等变体试图两边都要,属于这条线的延伸。
用代码看梯度行为差多少。
下面这段 Node.js 22.15.0 程序(node ln.js)搭了两座简化残差塔(tanh 子层 + 固定权重),用有限差分数值测量「输出对输入的梯度范数」,对比两种结构随深度增加的表现:
const D = 4;
let s = 7;
const rand = () => (s = (s * 1103515245 + 12345) % 2147483648) / 2147483648;
function ln(v) { // 简化 LayerNorm(无仿射参数)
const m = v.reduce((a, b) => a + b) / v.length;
const sd = Math.sqrt(v.reduce((a, b) => a + (b - m) ** 2, 0) / v.length + 1e-5);
return v.map(x => (x - m) / sd);
}
const W = Array.from({length: D}, () => Array.from({length: D}, () => rand() - 0.5));
const sublayer = v => W.map(r => Math.tanh(r.reduce((a, x, j) => a + x * v[j], 0)));
function stack(x, L, pre) {
let v = [...x];
for (let i = 0; i < L; i++) {
v = pre ? v.map((a, j) => a + sublayer(ln(v))[j]) // Pre-LN:残差是恒等通道
: ln(v.map((a, j) => a + sublayer(v)[j])); // Post-LN:每层穿过 LN
}
return v;
}
function gradNorm(x, L, pre) {
const f = xx => stack(xx, L, pre).reduce((a, b) => a + b * b, 0);
const eps = 1e-4;
let g = 0;
for (let j = 0; j < D; j++) {
const xp = [...x]; xp[j] += eps;
const xm = [...x]; xm[j] -= eps;
g += ((f(xp) - f(xm)) / (2 * eps)) ** 2;
}
return Math.sqrt(g);
}
const x = [0.5, -0.3, 0.8, 0.1];
for (const L of [1, 6, 12, 24]) {
console.log(`堆叠 ${String(L).padStart(2)} 层:输入侧梯度范数 Post-LN = ${gradNorm(x, L, false).toFixed(4)},Pre-LN = ${gradNorm(x, L, true).toFixed(4)}`);
}
实测输出:
堆叠 1 层:输入侧梯度范数 Post-LN = 0.0047,Pre-LN = 3.5273
堆叠 6 层:输入侧梯度范数 Post-LN = 0.0000,Pre-LN = 28.4210
堆叠 12 层:输入侧梯度范数 Post-LN = 0.0000,Pre-LN = 55.5273
堆叠 24 层:输入侧梯度范数 Post-LN = 0.0000,Pre-LN = 109.4523
Post-LN 的输入侧梯度只堆了几层就衰减到近乎消失(显示为 0.0000),浅层参数几乎收不到学习信号;Pre-LN 的梯度沿恒等通道直达输入,24 层依然保持健康的量级。玩具实验不能复刻论文里的全部结论,但「梯度要不要逐层穿 LN」造成的方向性差异一目了然。
图 2:两种结构的梯度路径对比。
面试现场的答题结构。
先写两行结构公式,指明差别只在 LN 与残差相加的先后;再讲反向传播:Post-LN 梯度逐层穿 LN、初始化时输出侧梯度大、需要 warm-up、深度受限;Pre-LN 残差是恒等通道、可去 warm-up、可堆百层;最后补 trade-off:Post-LN 效果上限略高,Pre-LN 胜在稳定,主流大模型选了后者。你训练时有没有被梯度炸过?当时是调 warm-up 还是改结构解决的?欢迎聊聊。