1. 这四件事是怎么组成一次参数更新的很多人一看到“大模型训练”就紧张觉得门槛高到不行又是并行又是算力又是几个T的显存。但实际上你把训练这件事拆到最小单元它始终是在重复同一个动作取一批数据跑一遍模型看预测和真实结果的差距算出每一步该往哪个方向调整然后挪一下参数。这个动作里藏着四个关键部件——梯度下降、反向传播、mini-batch、计算图。它们不是四个孤立的概念而是一条流水线上的四个环节。我举个最简单的例子。假设我们要拟合一个线性模型 y wx b数据只有三个点。第一步我们把三个点放进模型算出三组预测值这个过程叫前向传播第二步拿预测值和真实标签求一个损失常用的就是均方误差然后把损失对 w 和 b 的偏导数算出来——这个“算偏导”的过程不会真的去对 w 做数值试探而是沿着一条已经构建好的依赖关系从输出端一层层回推这就是反向传播第三步根据梯度更新 w 和 b即 w w - lr × ∂L/∂w这是梯度下降至于“三个点一起进去”还是“一个一个进去”就是 batch 策略问题对应到实际训练里就是 mini-batch。大模型训练的复杂不在于这四个概念本身变难了而在于规模的放大把每个环节的约束都放大了。7B 参数的模型光参数文件用半精度存就有 14GB一次前向要算出所有层的中间结果激活值又要吃几十 GB 显存反向传播要在这些中间结果上逐层递推梯度得在分布式节点之间同步优化器的动量状态还要额外占内存。于是你没有机会用最朴素的实现方式必须为每一环做工程上的取舍。为了把这条链路看清楚我给每个部件定位一下部件在训练里的角色如果没有它会怎样计算图记录前向运算的依赖关系让导数可以自动算每个参数求导都得手推公式或数值逼近不可行反向传播高效复用前向中间结果计算所有参数梯度只能做数值差分70亿参数意味着跑140亿次前向梯度下降用梯度决定参数的更新方向和幅度模型永远停留在初始权重学不到任何东西mini-batch在噪声、吞吐、显存之间找平衡要么更新过于频繁震荡要么一次显存爆掉这篇是系列的第一篇我会把这些基础概念讲透并且明确告诉你在大模型场景下它们各自的变化形态。理解这一段之后再去看混合精度、分布式数据并行、梯度累积这些进阶话题你会有完全不同的体感。2. 梯度下降从“下降”直觉到优化器选型2.1 大规模下梯度下降为什么难调梯度下降的直觉很朴素把损失函数看成一个地形面每个参数对应一个维度你在当前位置算出梯度也就是最陡的爬升方向取负号就变成最陡的下降方向沿着它走一小步。问题在于大模型的损失地形远远不是一个平滑的碗它是高维空间中一个非常粗糙、崎岖、充满大量局部结构的曲面。你在这个面上走一小步这一小步到底有多大直接决定了你是能滑进一个低洼地带还是直接跳过谷底冲上对面的坡。学习率就是这一小步的尺度。设参数为 w梯度为 g更新量就是 lr × g。如果你把学习率设成 1而梯度量级在 0.1 左右那么每步参数会大幅跳动损失经常表现为反复震荡甚至发散如果设成 1e-5更新量太小训练几十个 epoch 参数也没有明显漂移看起来就是“loss 卡住不动”。我自己早期训练一个 7B 模型的开源权重做持续预训练时第一版直接沿用单卡小模型的 3e-4 学习率。结果前几百步 loss 曲线倒是很漂亮一路往下掉到了 3000 步左右突然开始剧烈抖动接着出现第一次 NaN。查了半天才发现是学习率偏大加上某些层梯度量级集中在比较大的一批参数上更新冲出了数值安全区间。这属于典型的“学到了但学过头了”。2.2 为什么大模型几乎不用纯 SGD纯随机梯度下降的更新公式是 w w - lr × g它对所有参数一视同仁。但对于大模型来说不同参数所处的误差面形态差别非常大。靠近输出层的参数梯度通常相对稳定靠近输入层、尤其是 embedding 层梯度有时会非常稀疏多数位置长期不更新。对所有这些参数用同一个学习率就等于让你用跑马拉松的步速去跑一百米冲刺必然有人在扯后腿。Adam 这类自适应方法做的核心事情就是给每个参数单独维护一个“尺度感”。它有两个动量一阶动量 m 记录梯度的指数移动平均反映梯度的大致方向二阶动量 v 记录梯度平方的指数移动平均反映梯度变化的幅度。更新公式写出来是m_t β1 × m_{t-1} (1 - β1) × g_t v_t β2 × v_{t-1} (1 - β2) × g_t² 参数更新量 -lr × m_t / (√v_t ε)这样每个参数步长大约会被“归一化”到同一量级稀疏参数不会因为梯度太小而憋着不动高频参数也不会因为梯度太大而每次乱跳。β1 通常取 0.9β2 取 0.999ε 是 1e-8 这个量级的防除零常数。这就是为什么“Adam”在大模型训练里几乎是默认选项。2.3 AdamW 和 warmup实战里绕不开的两个变体社区里最终胜出的方案基本是在 Adam 基础上做两个修正一是把权重衰减和梯度更新解耦这就是 AdamW二是给学习率加一个预热和衰减的调度曲线。先说解耦。早期很多实现把 L2 正则直接加在 loss 里等效于在每次更新时让参数多乘一个衰减因子。但在 Adam 的框架下这种“耦合”方式会因为每个参数都有各自的归一化尺度而变得不精确。AdamW 的做法是梯度还是由反向传播算出优化器先按 Adam 规则算更新量然后再单独对权重乘一个衰减系数。它看起来只是改动了一点公式顺序但对 7B 以上规模的模型效果差异明显收敛稳定性更好很难被绕过。再说 warmup。大模型的初始化参数通常处于损失面上一个非常陡峭的区域初始梯度可能特别大。如果一上来就按照最大学习率冲锋很容易触发数值爆炸。预热的意思就是让学习率在开始的几千步里从 0 或者一个很小的值逐渐攀升到目标值等模型走出“陡峭区”再给满学习率。之后再用余弦退火把学习率缓缓降下来帮助收敛到更平缓的极小值附近。有人问过我学习率怎么设才靠谱我给一个很粗略但有参考价值的经验值7B 级别的模型用 AdamW 做持续预训练或大规模微调目标学习率在 1e-4 到 3e-4 之间warmup 步数在总步数的 1% 到 3% 之间。但注意这必须配合你的 batch size 来权衡batch 越大通常可以承受的学习率越大。这里面的关系等我们讲完 mini-batch 会更清楚。3. 反向传播链式法则如何在百万参数规模下存活3.1 一个两层网络的导数回推逻辑反向传播的核心原理是微积分里的链式法则复合函数的导数等于内部函数导数的乘积。深度学习模型本质上就是一层一层的函数嵌套。假设网络是 f(x) g(h(x))损失是 L(f)那么 L 对 x 的导数就是 ∂L/∂f × ∂f/∂h × ∂h/∂x。这个式子看起来简单但它意味着要算任意一个中间变量的梯度你需要把它之后所有层的局部导数乘起来。我习惯用一个非常朴素的两层例子来理解。设第一层输出 a W₁x b₁经过激活函数得到 h act(a)第二层输出 s W₂h b₂最后算损失。反向传播从损失开始先算出 ∂L/∂s这是最后一层的局部梯度然后沿着计算链回推∂L/∂h ∂L/∂s × ∂s/∂h ∂L/∂s × W₂。得到 h 的梯度之后再继续求 a 的梯度时会用到激活函数在 a 处的导数∂L/∂a ∂L/∂h × act(a)。整个过程一气呵成每一层只需要自己局部的导数不需要重新计算后面层的任何东西。这正是反向传播巧妙的地方前向传播时把每一层的输出都存下来反向传播时这些中间结果被反复复用。如果不用这个策略想算模型对某个参数的偏导只能把参数稍微拨动一下再跑一遍前向看损失变化多少。对 7B 模型数值差分法需要跑 140 亿次独立的前向传播这已经不是“慢”的问题而是根本不可行。反向传播只需要一次前向、一次反向代价是把所有中间激活值留在内存里这是显存压力的主要来源。3.2 激活值显存为什么反向传播反而更吃显存训练和推理的显存占用逻辑完全不同。推理只需要存参数和当前层的输入输出用完就可以丢训练因为反向传播要复用中间结果几乎所有层的输入、输出、激活函数输出都得在内存里待命。我把这个量级拉出来算一笔账你才能真切理解为什么大模型训练一定要花式省显存。假设一个 7B 量级的模型典型配置是 32 层、隐层维度 4096、序列长度 2048。前向过程里每一层大约要保留若干个与序列长度和隐层维度相关的激活张量。粗略估算单层激活量大约等于 2 × batch_size × seq_len × hidden_size。代入常见的 batch_size 8单层激活量 2 × 8 × 2048 × 4096 ≈ 1.34 亿个数值。fp16 下每个数值占 2 字节单层约 268MB。32 层加起来就是 8.6GB这还没算中间的其他状态量。这还是相对保守的估算。模型深度增加、隐层维度变大、batch 再往上加激活值轻松突破 20GB。为什么你训练大模型时经常出现“单看参数不大但一前向就 OOM”的现象就是激活值在悄悄吃显存。这也解释了后面计算图章节要讲的“重计算”技术为什么会被大规模采用——能不能省掉一部分中间结果反向传播时再重算本质是在用算力换显存。3.3 梯度消失和爆炸深层网络的经典事故链式法则还有一个非常要命的副作用它是连乘结构。如果每个局部导数的绝对值小于 1几十层乘下去梯度会趋向于 0这就是梯度消失如果大于 1连乘会爆炸梯度数值会迅速膨胀最终在更新时把参数冲飞loss 变成 NaN。早期神经网络训练不起来很大程度上就是在和这件事斗争。现代大模型能训练得起来主要靠几个架构层面的设计残差连接把每一层的输入直接加到输出上让梯度多出一条“近路”不会每层都被非线性压缩层归一化把每层的激活值拉回合理范围避免数值尺度在层间累积激活函数的选择也会影响梯度传播比如某些激活函数的梯度既不饱和也不会让数值爆炸。工程上你还会看到梯度裁剪把梯度的 L2 范数限制在一个阈值内如果超过就等比缩放。这属于最后的保险丝避免单步更新因为个别异常梯度变成灾难。我实际排查 NaN 的步骤基本是第一步看学习率和 warmup 配置第二步打开梯度裁剪第三步怀疑数据里有没有混入异常样本第四步检查混合精度的 loss scaler 是不是出了问题。大部分情况下问题出在前两项。4. mini-batch吞吐量、噪声与有效 batch size4.1 为什么不能一次喂全部数据如果严格按梯度下降的数学定义我们应该把整个训练集都放进目标函数算出一个真正的全局梯度再更新一次。但大模型的训练集动辄几千亿 token全量计算一次的代价高到不可能而且全局梯度并不意味着更好——它对噪声特别敏感容易陷入尖锐的极小值泛化效果反而不如带随机性的更新。单样本更新即纯 SGD每一步都用一个样本的梯度更新参数震荡剧烈。优点是每一步计算量小缺点是方向太随机收敛路径又长又曲折而且没法利用现代 GPU 的矩阵并行优势。因此实际操作中取的折中方案就是 mini-batch每次用一小批样本比如 64 条、256 条或者更多前向和反向都按整个 batch 的张量来算一次更新方向是这批样本梯度的平均。mini-batch 的“噪声”其实是一种隐性的正则化能力。全量梯度的方向太确定容易让模型过早锁定到尖锐极值附近而小批次梯度的随机波动能让模型有更多机会跳出窄而深的陷阱找到平坦而泛化的谷底。这个现象在 CV 和语言模型里都有大量实证。但噪声也不能太大否则参数一直在原地布朗运动损失曲线掉不下去。4.2 batch size 和学习率必须联动很多新手会犯一个错误batch size 翻倍了学习率不动结果发现训练曲线斜率和之前差不多但总步数变少了——看起来训练快了实际收敛效果反而下降。正确的直觉是batch 越大每个梯度方向的估计越准方差越小因此你可以走更大的步长而不至于跑偏。这个关系在简单场景下可以量化。设单样本梯度的方差是 σ²batch size 为 B 时平均梯度的方差约为 σ²/B。梯度下降了方差更新步长可以适当增大。社区里常用的经验是线性缩放规则batch size 从 B₁ 扩大到 B₂ 时学习率从 lr₁ 调整为 lr₁ × (B₂/B₁)。当然这只是起点大模型训练还受显存、通信效率、loss 尺度等因素影响实际需要微调。我自己调过一个案例一个 3B 模型batch 从 128 扩到 512学习率从 2e-4 线性放大到 8e-4前 2000 步的收敛速度明显提升loss 曲线没有出现震荡。但如果你一下从 128 跳到 2048学习率也按比例放大到 3.2e-3大概率会冲爆。所以缩放规则只在一定范围内成立跨一个数量级之后建议先做小规模消融。4.3 梯度累积让“物理 batch”和“逻辑 batch”分开显存是你扩大 batch size 时最先撞到的墙。一个 8 的 batch 已经把激活值吃掉十几 GB如果想把逻辑 batch 做到 1024难道真的把 1024 条样本一起喂进模型显存根本装不下。工程上的解法叫梯度累积每处理一小块数据就做一次前向和反向但先不更新参数只把梯度累加起来等到累满 K 个小块把 K 份梯度平均一下再一次性更新参数。这时的“有效 batch size”等于 micro-batch size × 梯度累积步数。比如 micro-batch 设为 8累积 64 步逻辑 batch 就是 512。需要注意的地方是累积梯度时要把每份梯度除以累积步数否则总的梯度尺度会被放大 K 倍等效学习率凭空扩大损失容易出现阶梯状跳变。另外梯度累积并不能完全等同于“一次跑大 batch”——每个 micro-batch 的激活值释放和重新分配会带来显存抖动而且累积过程中参数不变batch 之间可能存在分布漂移。但对于大多数场景它是一个非常实用的近似。4.4 分布式训练中的 micro-batch 切分真正训练 7B 以上模型时mini-batch 的概念还会被再切一刀。数据并行把全局 batch 按卡数均分每张卡算自己那份的梯度然后通过梯度同步让每张卡拿到全局平均梯度。这时候每张卡内部的 batch size 被称为 micro-batch它决定显存占用所有卡上的 micro-batch × 卡数 × 累积步数才是全局有效 batch size。我见过一个常踩的坑明明设置了很大的总 batch但每张卡的 micro-batch 设得太小比如只有 1 或 2导致每个 micro-batch 的梯度噪声极大激活函数进入饱和区的概率升高。表面看训练还在跑但 loss 曲线毛刺特别多收敛也慢。经验上micro-batch 至少取 4 到 8 比较稳妥除非受显存限制必须压到更低否则不建议为了“显示更高的全局 batch”而牺牲每个 micro-batch 的质量。5. 计算图框架替你记住了哪些中间量5.1 从手推公式到自动求导严格来说反向传播只是“算法”而计算图是“数据结构”。你用手推公式也能做反向传播但对一个 50 层的 Transformer 类模型手推每层参数的偏导公式会让人崩溃。主流的深度学习框架都采用自动微分的思路你在代码里写的前向运算会被框架转换成一个有向无环图——节点是张量操作边是数据依赖关系——然后反向传播算法在这张图上逆序遍历把每一层的梯度算出来。这就是“计算图”三个字的真正价值你不需要自己写求导规则只需要定义前向计算逻辑框架自动帮你把导数算好。一个常见的误解是反向传播等于某种特殊的网络结构其实不是它只是建立在计算图上的通用算法。你写一个最简单的 y x × w框架也会在内部为它构建一个包含乘法节点的计算图反向时自动算出 ∂y/∂x w、∂y/∂w x。5.2 动态图和静态图的取舍计算图有动态图和静态图两种构建方式。动态图的特点是“边执行边建图”你写 Python 代码每执行一行运算图就实时增长一段。这种模式对研究人员特别友好可以随时打印中间结果、写 if 分支、加调试打印训练循环像写普通程序一样自然。代价是框架很难对整张图做全局优化因为图在执行之前是未知的。静态图则是先定义一个完整的计算结构然后整体编译优化再执行。它更容易做算子融合、内存规划推理场景的性能优势明显但调试体验差一些写控制流也不如 Python 直觉。两种方案长期并存近来主流动态图框架也在吸收静态图优势比如在训练循环外包一层编译优化把 Python 层面的调度开销降下来。对大模型训练来说动态图是绝对主力。原因很实际你要频繁实验不同架构、不同 loss、不同优化器配置动态图的灵活性直接转化成开发效率。但如果你要做大规模推理部署静态图的全局优化能力更值钱。这个权衡决定了你在不同阶段会优先选哪种方案。5.3 叶子节点和参数梯度管理计算图里有一个概念叫叶子节点它指的是没有父节点的输入变量。模型的权重参数比如 Linear 层的 weight 和 bias通常就是叶子节点。前向传播时框架会记录从叶子节点到输出节点的完整路径反向传播时梯度会逐层回传最终累加在这些叶子节点的 gradient 属性上。这里有一个非常容易忽略的细节反向传播默认是把梯度累加到已有梯度上的而不是覆盖。如果你的优化器在每次 step 之后不把梯度清零下一次反向传播的梯度就会叠加上来导致参数更新量越来越大。常见的错误是在写了zero_grad()的地方漏了执行损失会变成奇怪的波浪形态。更隐蔽的是某些层可能因为分支结构被访问了多次梯度会被累加多次这有时是有意的有时则是 bug需要结合计算图的拓扑仔细判断。5.4 重计算大模型里最常用的显存优化手段存储所有中间激活值这是反向传播能高效运行的前提但也是显存的主要消耗点。于是有了一个非常直观的优化思路不存所有中间结果只存一部分“检查点”反向传播时如果遇到缺失的中间值就重新执行一遍前向把它算出来。这就是激活重计算也常被称作梯度检查点。实现上通常是每隔若干层设置一个检查点比如每 4 层一个。前向传播时只保留检查点的输出其他层算完即丢反向传播到缺失层时从最近的检查点重新做一次局部前向把该层输入算出来再继续反向。这样激活值显存从 O(层数) 降到 O(层数/重计算间隔)对深层模型效果非常显著代价是反向阶段多了一部分计算量整体训练时间大约多花 20% 到 40%。我在实际训练一个 7B 模型时显存 OOM 的第一反应不是把 batch 开小而是先打开重计算。因为减小 batch 会改变梯度噪声特性和学习率匹配度而重计算只是用额外算力换取显存不改变数学上等价的性质。唯一要注意的是重计算间隔的选取间隔太大省显存但慢间隔太小省得有限一般从每 4 层一个检查点开始调观察显存和吞吐的平衡。6. 一次训练步的真实顺序与异常排查6.1 单步训练的完整事件序列把前面四块拼起来一次完整的大模型训练 step 大概是这样的从数据加载器取一个 micro-batch经过 tokenizer 变成输入张量放进 GPU。执行前向传播数据在计算图上逐层流动计算图记录依赖关系中间激活值被按需缓存。计算 loss。如果开了混合精度loss 通常还需要乘一个 scaling factor防止梯度在低精度下被截断成 0。执行反向传播框架沿计算图逆序遍历梯度逐层回传最终累加到每个参数的梯度上。如果配置了梯度同步各卡的梯度通过集合通信做平均。做一次梯度裁剪防止范数爆炸。优化器 step拿着裁剪后的梯度按 AdamW 之类的规则更新参数同时更新动量状态。zero_grad 清零参数梯度准备下一步。如果累计步数达到预设值先做一次真实的 optimizer step再把累积梯度清掉。这套流程最大的特征是前向和反向共享计算图梯度累积逻辑和优化器状态更新相互独立以及学习率调度器通常按总步数推进不受梯度累积影响。6.2 异常现象和对应环节的排查对照训练大模型时你会反复和这么几个现象打交道现象最可能的环节常规处理loss 直接 NaN 或 Inf前向数值不稳定 / 梯度爆炸 / 学习率过大打开梯度裁剪降低学习率检查输入数据是否有异常值loss 逐渐发散然后 NaN优化器 step 过大或 weight decay 异常降低峰值学习率检查学习率调度确认 AdamW 参数loss 长时间卡住不动学习率太小或梯度消失增大学习率检查是否有zero_grad遗漏确认激活函数正常显存 OOM激活值缓存太多打开重计算降低 micro-batch或缩小序列长度训练吞吐骤降梯度同步通信成本过高增大 micro-batch 减少通信次数或调整并行策略loss 曲线周期性尖峰梯度累积时没有除以累积步数检查梯度平均逻辑确认缩放因子每次排查我都会先问一句这个问题是出在“梯度怎么算的”、“梯度怎么用的”还是“数据怎么喂的”大模型训练链条长大多数 bug 都藏在某个容易被忽略的连接处而不是某个高深的原理里。6.3 一个值得养成的调试习惯如果你刚开始做大模型训练我建议在真正跑一个大模型之前先用一个极小的模型、极小的数据集把上面这条链路完整跑通。比如 2 层、128 维的小网络训练数据就 32 条然后分别验证你的梯度是否和数值差分吻合、梯度累积是否等价于大 batch、打开重计算后 loss 曲线是否与不打开完全一致。这听起来有点像“多此一举”但等你真的在 7B 模型上训练时很多问题极难定位而小模型足够快足够让你把每个环节的数学等价性验证清楚。我自己的经验是花在“小模型验证”上的时间永远比在 7B 模型上反复调试损失的时间便宜得多。就我个人感受而言大模型训练的困难不在任何一个单一环节而在环节之间的耦合。梯度下降、反向传播、mini-batch、计算图每一个单独拿出来都算不上难题但把它们按真实训练流程串起来之后任何一个不合理设置都会以奇特的方式暴露到 loss 曲线上。能把这条流水线的每个环节都理解到“知道它为什么存在”的程度再去看各种显存优化和并行方案你的判断会准确得多。