跑过大模型训练的人,多少都经历过这样的尴尬时刻:模型能跑起来,loss曲线也像模像样往下走,但一旦中途出点幺蛾子——loss突然变成NaN、显存OOM、深层梯度莫名消失——你只能蹲在屏幕前瞎试。我有一段时间就是纯调包状态,换数据集、调学习率、加正则,动作行云流水,可问题该来还是来,因为压根不清楚这些操作在底层动了什么。
后来被一次训练故障逼着把底层计算逻辑完整啃了一遍,才慢慢把整个训练过程梳理成三个可以独立审视的视角:递推、递归、三明治。这三个词听起来有点唬人,其实可以朴素理解成——数据是怎么一步一步变成预测的、预测又是怎么一层一层变回梯度的、网络结构到底为什么长成现在这个样子。搞明白这三件事之后,再回去看训练过程中的显存占用、梯度爆炸、模型不收敛,很多问题就不再是玄学,而是可定位、可复现、可解决的工程问题。
这篇东西不打算讲那些花里胡哨的分布式并行策略,也不准备堆一堆论文公式,就围绕这三个词,把大模型训练最底层的那点逻辑掰开揉碎讲清楚。适合那些已经能跑通训练脚本、但总觉得心里没底的同学。
1. 别急着打开训练脚本:这三个词到底对应训练的哪一环
先把这个框架安放好。递推、递归、三明治这三个词不是随便凑出来的,它们分别回答了大模型训练中的三个关键问题。
1.1 数据怎么变成预测、预测怎么变回梯度
递推回答的是"数据怎么变成预测"。神经网络的前向传播,本质上就是一个逐层接力的过程:输入过一个线性层,经过激活函数,再过一个线性层,再经过激活函数……每一层的输出就是下一层的输入。这种"每一步都依赖上一步结果"的计算模式,在数学上就是典型的递推关系。你不需要什么高深理论,打开训练脚本,那一段循环式的模型forward代码,就是递推的具象化。
递归回答的是"预测怎么变回梯度"。反向传播的基本原理是链式法则,要计算第一层参数的梯度,必须先把最后一层到第二层的梯度全部算完。这就像一个需要从后往前回溯的过程——先有后面的结果,才能推出前面的结果,而这个过程里每一步都在"调用更前面一层的梯度计算"。用程序员的视角看,它天然就是一段递归逻辑:要算第i层的梯度,就得先递归地去算第i+1层的梯度。
三明治回答的是"网络结构为什么长这样"。打开任何一个大模型的网络结构,你会发现里面的基本模块高度相似:残差链接、归一化层、多头注意力、前馈网络,一层层夹在一起,就像汉堡里的面包、肉饼、蔬菜、面包。这种"夹层"结构不是随便设计的,它直接决定了梯度能不能顺畅地从前向后流动、模型能不能叠到几十层甚至上百层。
1.2 用排队报数、族谱查询和汉堡包理解三者的区别
要是觉得上面还是有点抽象,我用三个生活场景再拆一遍。
递推就像排队报数。队首第一个人喊"1",后面每个人听到前一个人的数字后加1再喊出去,一路传到队尾。前向传播就是这样一个单向的、由前到后的计算过程,每个节点只需要接收上一个节点的结果,不需要回头看任何东西。
递归就像查族谱。你想知道自己往上五代的祖先是谁,得先问爸爸,爸爸问爷爷,爷爷问曾祖父,一层层往上问,问到源头之后再把结果一层层传回来。反向传播就是这样,想要第一层参数的梯度,得先知道第二层的梯度,而第二层又依赖第三层,一路递归到输出层才算完。
三明治就像多层汉堡。面包片、生菜、肉饼、芝士、面包片,按照特定顺序叠加在一起。Transformer的每一个block也类似,残差路径像贯穿始终的"面包层",注意力和前馈网络就是夹在里面的"肉饼"和"芝士",而LayerNorm则负责给每一层"调调味",让信号流动更稳定。
把这三个类比记住,后面所有细节都跑不出这个框架。
2. 递推:前向传播就是一场逐层接力的计算游戏
前向传播可能是训练里最"无聊"的部分,但恰恰是理解显存占用、推理速度、KV Cache等一系列工程问题的起点。我建议你先把前向计算看成一串递推式,而不是一堆层对象。
2.1 从线性层开始:把前向计算写成递推式
假设网络一共有L层,输入是x_0,那么第i层的输出可以写作:
x_{i+1} = f_i(x_i) = activation(W_i · x_i + b_i)
这里i从0取到L-1,x_0是原始输入,x_L就是最终的输出。你看到的什么多层感知机、Transformer、CNN,剥掉外衣之后,核心都逃不过这串递推式。所谓"深度",不过就是把这个式子重复了很多遍。
为什么要强调这是一个递推关系?因为递推关系的最大特点是每一步只依赖前一步的结果,这让计算过程非常适合写成循环语句。你可以用一个for循环从头跑到尾,跑完第i层就把中间结果x_i丢掉也行、存下来也行,完全取决于你后续要不要用它。前向计算的时间开销和空间开销也因此可以精确预估——这一点在后面讲显存估算时会特别有用。
训练框架里那些看着复杂的模块封装,什么"残差连接""层归一化""多头注意力",本质上都只是在为这个递推式提供不同的f_i函数。你不需要把每个函数都背下来,但你必须清楚:不管f_i内部多复杂,它输出的x_{i+1}一定会作为x_{i+2}的输入继续往下传,这个单向接力的大方向永远不变。
2.2 语言模型里的递推更明显:逐token生成
如果说普通神经网络的前向递推还需要你稍微想象一下,那自回归语言模型的生成过程就是递推的"完全体"。
大模型生成文本时,核心逻辑是预测下一个token:给定已经生成的前t个token x_1, x_2, ..., x_t,模型预测x_{t+1}的概率分布。等x_{t+1}被采样出来之后,它又作为新的已知条件,参与预测x_{t+2}。这个"每走一步都依赖之前所有步结果"的模式,用公式写出来就是:
x_{t+1} = g(x_1, x_2, ..., x_t)
从生成第一个token到生成第N个token,整个过程就是一条不断往后延伸的递推链。而且这条链有个特点:越往后,要处理的历史信息越多。那是不是意味着生成第N个token时要重新把所有历史token全部计算一遍?如果不做任何工程优化,确实是这样,计算量会随序列长度平方级增长,这也是为什么需要KV Cache。
2.3 KV Cache:递推过程的"草稿纸"为什么不扔
KV Cache是我觉得理解递推价值的最佳案例。
Transformer里有一个核心计算是注意力:某个token的query要跟序列里所有token的key做匹配,再用匹配权重对value做加权求和。生成第t个token时,前t-1个token的key和value其实已经算过了。如果每次生成都重新算一遍这些历史token的key和value,那就是O(t²)的重复劳动,非常浪费。
KV Cache的思路很朴素:既然前向递推每一步都会产生中间结果(每个token的key和value),而这些中间结果在生成后续token时还会反复用到,那就不扔,直接存下来,放到显存里当"草稿纸"。生成第t个token时,只需要计算当前token自己的key和value,历史的KV直接读缓存,计算量从O(t²)降到了O(t)。
这其实跟递推求斐波那契数列的道理一模一样。如果你定义f(n) = f(n-1) + f(n-2),坏写法是每个n都从头递归算一遍,好写法是保存已经算过的f(n-1)和f(n-2),每次只算一次加法。KV Cache就是前向递推里的"记忆化"。
我见过不少初学者问:为什么推理时显存占用会随着对话长度一直涨?答案就在这里——递推过程中积累的KV Cache没被清理,长度越长,草稿纸越多。按常见7B模型来估算,每个token的KV缓存大约在几百KB量级,对话到几千token时,光KV Cache就是GB级别。理解递推,你就能解释这类看起来很"玄"的现象。
3. 递归:反向传播是一场从后往前追问的族谱清算
前向递推把输入变成了损失值,但模型不会自己变好,真正让参数更新的是反向传播。这部分的底层逻辑不是"递推"而是"递归",因为它的方向完全反过来了——从输出层一路往回追,追到第一层为止。
3.1 链式法则为什么天然是递归
回忆一下微积分里的链式法则。假设损失函数是L,网络输出是h_L,第一层输入是h_0,那么损失对第一层参数的梯度可以展开成:
dL/dθ = dL/dh_L · dh_L/dh_{L-1} · ... · dh_2/dh_1 · dh_1/dθ
注意看这个式子:要算出最前面的dL/dh_1,你必须先知道后面的dL/dh_2、dL/dh_3,一直到dL/dh_L。这个依赖关系是严格从后往前的——前面梯度的计算必须等待后面梯度的结果。这就是递归,不是递推。
如果你用伪代码写反向传播,它会非常像一个递归函数:
def backward(i): if i == L: return dL / dh_L # 递归出口:最后一层的梯度由损失直接算出 grad_next = backward(i + 1) # 先递归算出后一层的梯度 return grad_next * (dh_{i+1} / dh_i) # 再乘上当前层的导数,回传你会发现,框架里的自动求导引擎做的工作,本质上就是维护这样一张计算图:前向递推时记录每个节点的计算关系,反向时从这个递归函数出发,把每个中间节点的梯度算一遍。这也是为什么前向时那些中间结果不能随便扔——递归回溯时需要用到它们。
3.2 递归深度带来的梯度消失与爆炸:一句话本质
理解了反向传播是递归之后,梯度消失和梯度爆炸这两个老大难问题就有了一个非常清晰的解释。
每一次递归调用都要在梯度上乘以一个"导数因子"dh_{i+1}/dh_i。如果这个因子的值平均小于1,那么递归几十层之后,乘积会按指数级缩小,浅层梯度趋近于0,参数几乎不动,这就是梯度消失。反过来,如果这个因子平均大于1,乘积按指数级放大,浅层梯度变成天文数字,参数一步跳飞,这就是梯度爆炸。
说白了,梯度消失和梯度爆炸不是两种独立的问题,而是同一个递归过程在两个不同方向上的极端表现。网络越深,递归层级越多,指数效应越明显,问题就越严重。
这也是为什么早期神经网络很难做到几十层,而现在的Transformer可以轻松上百层——结构变了。
残差连接h_{i+1} = h_i + f_i(h_i)带来的改变是:反向传播时梯度多了一条"直通路径"。因为h_{i+1}对h_i的导数变成了1 + df_i/dh_i,梯度回传时至少有一个恒等项1能穿过每一层,不会出现"每一层都乘一个小于1的系数"这种灾难性连乘。递归仍然在展开,但展开过程中多了一条"高速通道",浅层梯度不再容易被抹平。
3.3 梯度裁剪、初始化、学习率:都在调节递归清算的尺度
既然递归展开的缩放因子决定了训练稳定性,那训练里常用的一系列技巧,本质上都是在控制这个缩放因子的尺度。
梯度裁剪是最直接的干预。如果某一轮递归算出来的梯度总范数过大,说明链式乘积里的某些因子非常大,这时候直接把梯度整体缩放到一个安全阈值内(比如最大范数设为1.0),相当于强行按住递归爆炸的势头。很多新手不理解为什么梯度裁剪有用,总觉得"裁剪了梯度不就是篡改真实梯度吗?"没错,但比起让参数一步飞到溢出,用一个有界的梯度替代爆炸梯度,损失函数仍然能持续下降,而且稳定得多。
参数初始化控制的是递归展开的"起点"。如果初始权重太大,每一层线性变换的导数因子就会偏大,深层递归的连乘结果很容易爆炸;如果初始权重太小,前向信号和反向梯度又都会衰减。所以不管Xavier、He初始化还是大模型常用的标准差缩放,本质上都是在为"递推前向和递归反向"找一个平衡的起始尺度。
学习率则是整个递归清算之后最后一步的参数调整幅度。梯度大小只是方向信息,真正让参数走多远取决于学习率。当递归展开的梯度偏大时,过大的学习率会让参数更新幅度完全超出预期,引发训练震荡;当梯度被裁剪、缩放因子被控制住之后,学习率才可能安全地调大。
我排查训练不稳定问题时,第一件事永远是打印每一层梯度的L2范数,一个列表看下来,基本就能判断是浅层梯度消失了,还是中后层梯度爆炸了,再决定用梯度裁剪、调初始化还是改学习率。这套思路比盯着loss瞎猜高效得多。
4. 三明治:Transformer块的结构为什么像夹层汉堡
说完递推和递归这两个"动词",接下来看"名词"——Transformer块到底长什么样。之所以单独用一章讲结构,是因为结构直接决定了递推前向和递归反向能不能在深层网络里顺畅跑通。
4.1 解剖一个block:注意力、前馈、残差和归一化的摆放顺序
一个标准的Transformer块,内部大致是这样一个结构(以最经典的Post-LN写法为例):
- 输入进入多头注意力子层,注意力的输出与输入做残差相加
- 残差相加的结果过一个LayerNorm
- 然后进入前馈网络子层,前馈输出再与上一步结果做残差相加
- 最后再过一个LayerNorm,输出给下一层
简化成式子:
h = x + Attention(LayerNorm(x)) out = h + FFN(LayerNorm(h))
你会发现,这就是一个典型的"三明治":残差路径像贯穿始终的底层面包,多头注意力和前馈网络是两层"肉饼",LayerNorm是洒在每层之间的"调味层"。很多大模型的block就是这半个三明治再加另外半个,叠在一起构成完整的一块。
为什么要这样设计?核心目的有两个:一是让前向递推的信息流动更充分,二是给反向递归提供多条梯度路径。残差链接提供了"恒等映射"选项——如果某个子层学不到有用的变换,信息可以直接从残差路径溜过去,不必被迫经过一个可能有损的变换。
4.2 Pre-LN和Post-LN:夹层顺序为什么影响训练稳定性
很多同学在调模型时发现,把LayerNorm放在子层前面或者后面,训练稳定性差别很大,但不知道原因。这里的关键在于:归一化操作的位置改变了梯度递归回传的路径。
Post-LN(先子层后归一化)是原版Transformer的写法。它的特点是每次残差相加后都做归一化,输出分布比较稳定,但问题是LayerNorm本身参与梯度回传,反向递归时要经过LN的导数,而LN的导数跟输入数据的分布有关。在深层网络里,这种依赖容易导致梯度出现较大波动,训练深模型时更容易发散。
Pre-LN(先归一化后子层)把式子变成了h = x + Attention(LayerNorm(x))。归一化被挪到子层之前,残差路径上不再经过LN,梯度回传时残差部分直接跳过归一层,递归路径更"干净"。这也是为什么很多开源大模型采用Pre-LN变体——它让深层的梯度更容易稳定流动,代价是理论上表达能力略有损失,但在实践中通常利大于弊。
如果你的模型在训练中频繁出现loss震荡、深层梯度消失,试试把Post-LN改成Pre-LN,或者反过来,有时候问题会神奇地解决。这不是玄学,而是改变递归梯度路径之后带来的实际差异。
4.3 DeepNorm和Sandwich-LN:为了夹得更稳做的工程化调整
除了Pre-LN和Post-LN,还有几个在工程里常见的"三明治"变体,值得了解一下,因为你可能在某个开源代码里看到它们。
DeepNorm的设计很巧妙:它在残差连接上额外乘一个缩放系数α,配合特定的初始化策略,让深层模型在Post-LN风格下也能稳定训练。本质上是把残差路径的"透传率"稍微调低了一点,避免深层网络中残差累积过强导致的不稳定。
Sandwich-LN则是在Attention之后、FFN之前额外插入一个LayerNorm,让每个子层前后都被归一化"夹住"。它比标准结构的归一层更多,训练时更稳定,但会略微增加计算开销。不少追求训练稳定性的模型会采用这种结构。
我不建议你盲目跟风改结构,但如果你在训练一个比较深的模型时反复遇到不收敛,可以按照"Pre-LN -> Sandwich-LN调换 -> 调整残差缩放系数"的顺序做A/B实验。很多时候问题不是数据不行,而是三明治层的摆放顺序不适合当前网络深度。
另外多说一句,有些同行会把"预训练、指令微调、偏好对齐"这个完整流程也叫"三明治",那是训练流程层面的事,和这里的结构三明治是两码事,别混淆了。
5. 一个完整的训练step,三重逻辑是怎么咬合在一起的
把三个视角分别过了一遍之后,现在把它们拼回一个完整的训练流程里。你会发现,一个看似简单的step,其实同时运转着递推、递归和三明治三层逻辑。
5.1 从一张batch到一次参数更新:完整走一遍
假设你刚把一批数据喂进模型,接下来30秒内发生的事可以拆成四段。
第一段是三明治结构上的前向递推。输入经过embedding变成向量,然后穿过滤层、注意力、前馈、归一层,一层层往上走。每一步都在复用上一层的输出,这就是递推;而递推穿过的每一个Transformer块,都是三明治结构。
第二段是损失计算。模型最后一层输出的logits跟真实标签对比,算出一个标量损失。到这里,前向递推结束。
第三段是递归反向传播。框架从损失出发,先算输出层的梯度,然后逐层往前递归回溯,每一层都要利用前向递推时保存的中间结果。如果中途哪一层梯度爆炸或消失了,整个训练就会立刻异常。
第四段是优化器更新。梯度算完,优化器根据当前学习率和动量状态更新所有参数,然后新一轮前向递推开始。从整个训练过程看,参数每更新一次,就等于递推关系在前向推进一步——只不过这里递推的对象变成了参数本身。
你会看到,训练循环本质上就是:在三明治结构上不断做前向递推,在反向传播时不断做递归清算,然后更新参数,再来一轮。三个逻辑不是三选一,而是同时在一个step里协同运转。
5.2 显存到底被谁吃了:从三层逻辑估算一次
显存OOM是训练大模型的头号问题。其实只要理解了前面的逻辑,显存的去处是可以算清楚的,不需要靠猜。
显存消耗可以分成两部分:模型状态和激活值。
模型状态包括权重、梯度、优化器状态。用Adam优化器时,每个参数需要保存:fp32主权重一份、一阶动量一份、二阶动量一份,梯度一份。纯fp32训练下,这部分大约是每个参数16字节;混合精度训练下,再加上fp16权重和fp16梯度各一份,大约每个参数12字节。一个7B参数量的模型,光模型状态就需要84GB到112GB,这就是为什么7B模型全量精调需要80GB以上的单卡,根本原因不在激活值,而在优化器状态。
激活值是前向递推过程中产生的中间结果。每一层都要把输入、输出、注意力计算过程中的中间矩阵存下来,因为反向递归回溯时需要用到。激活值大小跟batch size、序列长度、隐藏维度直接相关。比如一个7B模型,batch size为1、序列长度4096时,激活随层数累积轻松超过几个GB,如果再乘上大batch,OOM几乎无法避免。
理解激活值的来源之后,你就能看懂为什么激活重算(activation checkpointing)这个技巧有效了:它在前向递推时故意不保存部分中间结果,等到反向递归真正需要时,重新做一次前向把它们算回来。相当于拿一倍额外计算量换掉了大部分激活显存,是典型的"递推状态的丢失与重演"。
还有梯度累积,逻辑是:本来一个batch太大放不进显存,那就拆成几个小batch,每个小batch正常做前向和反向,但不更新参数,只把梯度累加起来,攒够一个大批次的梯度后再更新。这可以想象成在反向递归外面又套了一层"加法递推"——每来一个小batch,就把梯度往总梯度里加一笔,加够了再统一清算。理解这一层逻辑,你自然就明白为什么梯度累积能模拟大batch效果,但BatchNorm相关的模型用起来要格外小心。
5.3 一个典型训练故障的排查示范
拿一个我实际遇到过的情况举例。训练到第3000步,loss从1.8开始往上弹,然后直接变成NaN。
很多人第一反应是"学习率太大了,调小一点"。但我想建议的是,先用三重逻辑把问题定位得更精确一点。
第一层排查,检查前向递推是否异常。在第3000步之后打印各层输出的均值和方差,看看是不是某一层的输出出现了非常大或非常小的数值,甚至出现了inf。如果是前向数值溢出,问题可能出在分母、softmax、位置编码等前向计算环节,跟学习率关系不大。
第二层排查,检查反向递归是否异常。如果前向输出正常,loss是正常算出来的,但参数更新后loss却突然崩了,那要去看每一层梯度的L2范数。我遇到的这个具体案例,最后打印出来是倒数第5层的梯度范数比其他层大了一千倍,这是典型的递归展开中的爆炸,问题出在某一层权重初始化尺度不当,导致链式乘法中的某个因子过大。
第三层排查,检查三明治结构是否给了梯度足够多的直通路径。如果发现是浅层梯度消失,深层梯度正常,往往是残差路径被某些权重缩放压制了,这时候考虑改Pre-LN结构或者调整残差缩放系数。
排查不用完全按顺序走,但一定要先搞清楚异常发生在"前向递推环节"、"反向递归环节"还是"结构路径环节",再对症下药。很多同学一遇到NaN就去降学习率,改了半天发现没用,就是因为连问题发生的环节都没定位对。
下面这张表是我在排查时常用的速查表,分享给你:
| 故障现象 | 可能所在环节 | 常见对策 |
|---|---|---|
| loss直接变NaN,前向输出出现inf | 前向递推数值溢出 | 检查loss缩放、中间计算稳定性、输入数据异常 |
| loss缓慢升,梯度范数随训练飙升 | 反向递归梯度爆炸 | 梯度裁剪、降低学习率、调整初始化标准差 |
| 浅层梯度远小于深层梯度 | 递归连乘衰减 | 增加残差直通路径、换Pre-LN、检查残差缩放 |
| loss震荡但梯度范数正常 | 三明治结构+学习率不匹配 | 更换归一化位置、降低学习率、warmup拉长 |
| 显存OOM,训练刚启动就崩 | 前向递推中间结果堆积 | 激活重算、减小batch、梯度累积 |
6. 三重逻辑带给我的几个实在教训
最后分享一些我在实际训练中沉淀下来的经验,不一定是教科书里写的,但每一句都来自踩坑。
调试顺序永远先看梯度,再看loss。很多人习惯盯着loss曲线,但loss是前向递推的最终结果,它只能告诉你"出问题了",不能告诉你"哪里出了问题"。梯度是反向递归的产物,每一层的梯度范数像体检报告一样,能精确定位病灶在哪一层。我现在的习惯是训练脚本里定期打印每层梯度的L2范数,哪怕只是跑一次debug small模型,也能提前发现很多隐患。
显存占用不是玄学,是递推中间结果的记账。每次有人说"为什么我的显存比理论计算值高那么多",我都会反问一句:你前向递推过程中每一层的中间矩阵都留下来了吗?注意力分数矩阵、value矩阵、FFN中间层激活,这些都是显存吞噬者。把前向过程从头到尾过一遍,每一层存了什么、什么时候需要释放,账算清楚了,显存优化方案自然就有了。
理解三明治结构之后,很多"trick"就不再是trick了。比如为什么某些模型在微调时要在block之间额外加LayerNorm?为什么某些团队会用deep-norm的缩放系数?当你把网络看成一层层的三明治时,这些操作的本质都是在调整梯度递归回传时的路径长短和信号强弱。你甚至可以根据自己模型的深度和稳定性,主动尝试调整残差缩放系数,而不只是照搬开源代码。
最后一个小技巧:遇到训练不稳定的问题时,可以把学习率往小里调一个数量级,同时把warmup步数翻倍,再做一次3D调试——同时观察loss、梯度范数、激活分布这三条曲线上,哪一条先出现异常。这个"先看哪条曲线异常"的习惯,会直接告诉你问题出在递推、递归还是结构上。等你熟练之后,会发现大模型训练排障其实没有那么神秘,大部分问题都能归到这三个框里。