
如果你也经历过这种场景——训练脚本一跑就是两天loss 卡在一个区间来回震荡日志里每一轮都长得差不多最终模型的指标就是上不去——那Model-Optimizer这个名字你多半会感兴趣。它是我最近整理的一个内部项目核心是把模型训练中最容易被低估的环节优化器选型、参数配置、学习率调度、梯度处理做成一套可以复用、可以对照排查的组件。这篇文章把这些天踩过的坑和总结下来的思路完整写出来适合正在调模型收敛速度、或者觉得“换了个优化器居然差别这么大”的同行参考。1. 模型优化器到底在优化什么1.1 训练的本质在损失函数上走下山模型的训练过程本质上是在一个高维损失函数曲面上寻找低点。这个曲面有多复杂训练过真实模型的人都有体会梯度方向在局部看是可靠的但一旦把视野拉远就会遇到平坦区域、陡峭峡谷、鞍点、局部极小点交织在一起的情况。优化器做的事情就是决定“每一步怎么走”。同样是拿到当前点的梯度可以原样往下走可以累积历史方向形成动量可以根据参数自身的更新幅度调整步长也可以对不同的参数区别对待。这些微小的差异在几张图上也许不明显但在几万步训练之后差距会被放大到足以影响模型是否收敛、收敛得多快、最终精度多少。我在设计Model-Optimizer的时候首先明确了一个原则不把优化器当成一个“黑盒”。每次训练前我会先在损失曲面的简单子集上做一个 50 步的小实验只看优化器的更新轨迹和梯度方向的一致性。这件事看起来增加了几分钟工作量却能在正式训练之前发现大量问题比如梯度过大导致的震荡、学习率与权重衰减互相打架、动量缓冲区的统计量偏差。1.2 优化器不是学习率的附庸不少人一谈起调参第一反应就是改学习率优化器好像是“挂在那里不用管”的东西。实际不是。学习率只是优化器内部更新规则里的一个系数它和动量系数、二阶矩估计、权重衰减耦合在一起单独调整学习率而忽视其他参数往往会得到错误的结论。举一个非常常见的例子用 Adam 训练一个文本分类模型初始学习率设为 3e-5 和 1e-4可能最终精度差距不大但如果把 beta1 从 0.9 改成 0.99同时不调整学习率训练曲线会明显变慢因为一阶矩的平均窗口变长相当于给梯度加了更大的惯性。又比如 Adam 的二阶矩估计初值导致前几步更新偏大配合较大的学习率很容易在第一步就跳出有效区域。Model-Optimizer因此把优化器的一组参数视为一个整体配置来管理而不是散落在训练脚本里的几个 magic number。每个实验都记录完整的优化器配置快照包括每一处默认值是被显式指定还是沿用默认这样出问题的时候才能定位到真正的变量。1.3 Model-Optimizer 项目的切入点这个项目最初只是为了解决一个很具体的痛点团队里每个人都有自己写的一套训练循环每个人对优化器的理解不一样有人用 SGD 加了动量有人用 Adam 把 epsilon 改成 1e-4有人用 AdamW 但没调权重衰减。模型结构类似结果却很难横向对比。后来我把它做成一个独立的模块定义统一接口允许下面接各种优化器实现。训练脚本里只需要声明任务类型、参数量级、显存上限Model-Optimizer会自动给出推荐配置并生成一份“为什么这样推荐”的说明。这个设计最大的收益不是自动化而是把经验固化下来同一个任务、同一个 batch size、同样的数据分布换人跑也能得到一致的训练行为。2. 主流优化器的进化脉络与选型2.1 从 SGD 到动量法给梯度加惯性普通 SGD 的更新规则极其简单param - lr * grad。在凸问题和小数据集上这个简单的规则有很好的收敛性保证。但在深度网络里曲面曲率变化剧烈纯 SGD 容易在峡谷两侧来回震荡走得很慢。动量法解决的就是这个问题。它维护一个梯度的指数滑动平均v momentum * v - lr * grad然后参数按v更新。这样当某些维度上的梯度方向反复变化时动量项会互相抵消当某个方向持续一致时更新速度会逐步累积。实践中我用 SGD Momentum 训练图像分类模型大的 momentum 值比如 0.9 到 0.99 区间往往比裸 SGD 稳定很多而且对学习率没那么敏感。但动量法也有自己的麻烦它对所有参数使用同一个学习率稀疏特征对应的参数更新量少容易被“平均”淹没。如果任务里有大量稀疏特征比如推荐系统里的 id 类特征纯 SGD 家族的收敛速度通常不尽如人意这也是自适应方法流行的原因。2.2 Adam 的一阶矩与二阶矩自适应带来了什么Adam 可以理解为动量法加逐参数自适应学习率。它维护一阶矩m和二阶矩v分别估计梯度均值与梯度平方的均值。每个参数的更新量等于lr * m_hat / (sqrt(v_hat) epsilon)。从直觉上说梯度变化剧烈的维度会被缩小步长梯度平稳的维度会获得相对更大的信任。Adam 在 NLP、多模态、生成模型上表现稳定尤其是 Transformer 类结构几乎成了默认配置。但它的问题也很真实。第一前几步因为二阶矩从 0 开始估计偏差较大虽然 Adam 做了偏差修正但在学习率选择不当的时候早期更新还是会偏大。第二Adam 把权重衰减直接加到梯度上而不是在参数更新时解耦处理这会让 L2 正则的效果大打折扣。第三它需要维护一阶矩和二阶矩两份状态显存开销增加明显。我在Model-Optimizer的早期版本里甚至想完全绕开 Adam后来发现没必要。实践中最稳妥的做法是先按 Adam 跑通基线再根据模型的规模和任务特性要么切到 AdamW要么考虑 LAMB 这类用于大批量的变体。2.3 AdamW、LAMB、LARS大模型训练下的修正AdamW 看起来只是修正了权重衰减的位置实际影响却很大。原始 Adam 实现中权重衰减项参与梯度计算再被一阶矩和二阶矩处理相当于每步的衰减强度会被动态缩放AdamW 则把权重衰减直接从参数中减去与梯度无关。这个“解耦”让每一轮真实有效的正则幅度更可控大批量训练时效果更稳。大模型预训练几乎全部采用 AdamW我实测同样的模型从 Adam 换到 AdamW固定随机种子最终 loss 能低不少。LAMB 在 AdamW 的基础上做了一层逐层的自适应缩放让不同 Transformer 层的更新幅度保持一致从而支持更大的 batch size在百万级 batch 的预训练任务中很常见。LARS 则多为图像预训练使用尤其是 ResNet 类结构的超大批量训练它按层的权重范数与梯度范数之比去缩放更新。Model-Optimizer在配置推荐里会依据任务类型做区分图像对比学习任务偏好 LARS文本预训练任务偏好 LAMB 或 AdamW常规微调任务则保留 AdamW。2.4 不同业务场景的选型速查表为了减少每次重新纠结的时间我整理了一张简化版的选型表它不覆盖所有场景但足够支撑大多数训练任务。任务类型推荐优化器学习率参考关键注意点传统图像分类SGD Momentum0.01 ~ 0.1配合 cosine 退火容易调出最佳精度微调 Transformer 模型AdamW1e-5 ~ 5e-5权重衰减取 0.01注意 warmup 比例大规模 NLP 预训练AdamW / LAMB1e-4 ~ 5e-4大批量时优先 LAMB关注梯度 global norm对比学习 / 自监督LARS / LAMB0.3 ~ 1.0LARS 默认需要配合较大的 batch size 才有优势稀疏特征推荐模型Adam / AdaFactor1e-3 左右稀疏参数组单独设学习率关注 embedding 更新量强化学习策略网络Adam1e-4 ~ 3e-4需额外加上梯度裁剪状态值估计的 loss 容易爆炸这张表不是绝对的。每个任务的数据量、模型深度、batch size都会影响最优配置更靠谱的做法是拿两次实验对比而不是直接信任某篇文章里的“默认值”。Model-Optimizer内部也沿用这个思路它只给出推荐起点然后在日志里显眼地提示要做 at least two runs。3. 实操把 Model-Optimizer 接进训练循环3.1 一个可替换的优化器接口设计项目里最核心的是一套与具体框架解耦的优化器封装。它不重新实现底层更新公式而是统一管理参数分组、梯度处理、学习率调度和状态记录。下面是我反复调整后留下的简化版本思路比代码本身更重要。import torch from torch.optim import Optimizer from torch.optim.lr_scheduler import LambdaLR class ModelOptimizer: def __init__(self, params, optimizer_typeadamw, lr1e-3, weight_decay0.01, betas(0.9, 0.999), eps1e-8, grad_clip1.0): self.params list(params) self.lr lr self.grad_clip grad_clip decay_params [p for p in self.params if p.requires_grad and p.dim() 2] no_decay_params [p for p in self.params if p.requires_grad and p.dim() 2] param_groups [ {params: decay_params, weight_decay: weight_decay}, {params: no_decay_params, weight_decay: 0.0}, ] if optimizer_type adamw: self.optimizer torch.optim.AdamW(param_groups, lrlr, betasbetas, epseps) elif optimizer_type sgd: self.optimizer torch.optim.SGD(param_groups, lrlr, momentum0.9) elif optimizer_type lamb: from optimizers.lamb import LAMB self.optimizer LAMB(param_groups, lrlr, betasbetas, epseps) else: raise ValueError(funsupported optimizer: {optimizer_type}) def step(self): if self.grad_clip is not None: torch.nn.utils.clip_grad_norm_(self.params, max_normself.grad_clip) self.optimizer.step() def zero_grad(self): self.optimizer.zero_grad(set_to_noneTrue)这段代码真正有用的地方在于参数分组。对 Transformer 模型来说bias 和 LayerNorm 里的 weight 不应该做权重衰减这是一个特别容易踩的细节。很多默认实现会对所有参数一视同仁导致 LayerNorm 的 scale 被逐步压小最终模型表达能力受损但表面上 loss 又没明显变化直到看 attention 输出分布才意识到问题。3.2 学习率策略要与优化器一起调优化器决定了每一步更新方向学习率调度则决定每步的“腿长”。我最常用的组合是 linear warmup 配合 cosine decay这种组合在预训练和微调里都稳定。def build_scheduler(optimizer, warmup_steps, total_steps): def lr_lambda(step): if step warmup_steps: return (step 1) / warmup_steps progress (step - warmup_steps) / max(1, total_steps - warmup_steps) return 0.5 * (1.0 math.cos(math.pi * progress)) return LambdaLR(optimizer, lr_lambda)warmup 的比例怎么定取决于优化器和模型规模。我用 Adam 训练小型模型时warmup 占 5% 就够但训练大型模型时早期梯度的方差很大warmup 至少要占到总步数的 1% 到 10%。如果换用 LAMB 这种自适应缩放明显的优化器warmup 反而是避免第一步直接飞掉的关键大批量下 LAMB 的更新尺度对学习率非常敏感没有 warmup 时我见过训练 loss 直接变成 NaN。另一个容易被忽略的点是优化器状态与学习率调度器的状态一起保存。恢复 checkpoints 时如果只恢复模型参数和优化器状态忘了恢复 scheduler 的 step 计数那么 warmup 会重新执行一遍学习率曲线出现一个诡异的“下降再上升”训练行为完全偏离预期。Model-Optimizer里把 optimizer 和 scheduler 的状态打进同一个 checkpoint恢复接口一次性做完整这个设计减少了大量无效调试时间。3.3 梯度裁剪、权重衰减、EMA 的交互关系梯度裁剪与优化器的关系比表面看起来更微妙。对 Adam 这类自适应优化器梯度裁剪的作用不像对 SGD 那么直接因为 Adam 已经通过二阶矩对更新尺度做了归一化但裁剪仍然能避免个别异常样本破坏二阶矩估计。实践上我把 grad_clip 放在 optimizer.step 之前统一处理clip 的 max_norm 初始值设为 1.0再根据训练中真实的 gradient norm 分布去调整。权重衰减的数值也不能拍脑袋。很多代码默认 weight_decay0.01但对小模型而言0.01 可能过强导致模型欠拟合。我经验上先看验证集指标再试 0.001 和 0.05 两个极端初步确定量级后再细调。注意 AdamW 里的 weight_decay 与学习率是解耦的所以不会因为学习率衰减而自动变小这也是它和 L2 regularization 在训练动态上的关键差异。EMA指数移动平均是另一个常见的模型增强手段。它不属于优化器本体但在训练过程中维护参数滑动平均对最终的推理模型往往有 0.2 到 0.5 个百分点的提升。我在实现中把 EMA 更新放在 optimizer.step 之后用独立的 momentum 因子比如 0.999。要特别注意 EMA 参数不应该随着优化器一起保存否则 checkpoint 体积变大而且推理时如果误用训练参数而不是 EMA 参数结果会莫名其妙变差。4. 训练过程中的常见问题与排查心得4.1 loss 一直不降先别急着骂优化器接手一个新任务时我最常见的误区是一看 loss 不降就立刻换优化器。后来总结下来真正的问题往往出在三个地方数据 pipeline 有问题、学习率不合适、模型输出层初始化有缺陷。遇到 loss 不降先固定优化器不变用同一个配置跑一个小规模实验比如只拿 100 个 batch观察 loss 的前 100 步曲线。如果曲线是缓慢下降的说明更新方向基本正确只是速度不够可以尝试增大学习率如果曲线完全水平不再下降先检查标签是否有噪声、loss 是否计算错了、每个 batch 的数据是否相似如果曲线先上升再快速下降可能学习率偏大或者 warmup 不够。优化器本身出问题的概率反而比这些基础问题低得多。我还养成了一个习惯把每次实验的 loss 曲线、gradient norm、param norm 全部记录下来抽样对比。Model-Optimizer里内置了一个 tiny callback每个指定步数打印一次这部分信息。只看 loss 很难定位问题但 loss 与 grad norm 一起看就能判断是更新方向冲突还是步长过冲。比如 loss 震荡但 grad norm 持续很大说明学习率过高loss 下降缓慢且 grad norm 很小说明可能卡在平坦区优化器的作用空间有限。4.2 Adam 在大模型下的显存压力与低比特优化器优化器状态在训练大模型时是一个不容忽视的显存大户。以 Adam 为例每个参数要额外保存一阶矩和二阶矩两个 float32 张量相当于参数本身的 8 倍字节数。一个 70 亿参数的模型仅优化器状态就需要约 56GB 显存这还没算模型参数、梯度和中间激活。很多大模型训练场景为了省显存会优先考虑低比特优化器。Adafactor 用近似方式降低了二阶矩的存储把逐元素状态变成按行列的低秩分解显存节省明显但在接近收敛时的行为偏保守。近两年我实测过一些 8 位 Adam 实现它把二阶矩量化成 int8配合动态缩放存储显存可以压到原来一半以下大多数任务训练精度和 fp32 Adam 接近但也出现过个别数据集上收敛不稳定。Model-Optimizer的处理是对这三个选项做显存模拟根据参数量、batch size、激活显存估算告诉你在当前显卡上能不能跑得下再决定使用哪种优化器。4.3 优化器对比实验的公平性设计想比较不同优化器最忌讳只改优化器类型而不控制其他变量。比如 SGD 需要的学习率常常大于 Adam如果都用同一个学习率结论自然不公。我在做对比实验时习惯先为每种优化器各做一次粗粒度学习率扫描找到各自的最优区间再在同一最优配置下对比。这样虽然是两倍甚至三倍的计算量但结论可靠得多。另外不要忽略随机种子、数据顺序和初始化方式。模型初始化不同优化器优劣可能完全反转。我的固定做法是先固定随机种子跑出基线再让所有被比较的优化器在同样的数据顺序和同样的初始化状态下进行训练。有些优化器对数据顺序极敏感尤其是 batch size 很小的时候如果对比实验里数据顺序不一致结果里的大部分差异其实是噪声。4.4 问题速查表把这段时间遇到的高频问题整理如下遇到类似情况时可以直接对照。现象优先排查方向我常用的调整loss 一开始直接变成 NaN学习率过大 / 数据里有异常值降低学习率增加 warmup开启梯度裁剪loss 震荡严重不下降学习率偏高 / batch size 太小按 0.3 倍系数降学习率或增大 batch sizeloss 下降极慢特征未归一化 / 优化器一阶矩动量过强检查输入分布降低 beta1 或改用 AdamW微调阶段过拟合明显权重衰减太弱 / 训练步数太多增大 weight_decay 到 0.05 附近早停大批量训练性能下降学习率未随 batch size 调整线性缩放学习率或换 LAMB显存不足Adam 状态量太大使用低比特优化器或 Adafactor调整 batch size恢复 checkpoint 后 loss 曲线异常scheduler 状态丢失 / 未完整保存检查 optimizer 与 scheduler 的 step 是否一致这张表里没有银弹。每个现象背后可能同时存在多个原因我的建议是一次只调整一个变量并且保留每次实验的完整配置记录。Model-Optimizer的做法是把这些排查经验编码为规则提示比如检测到前段时间 loss 均值波动超过某个阈值就自动在日志里建议降学习率并且提示“先检查数据再调整优化器”。最后再分享一个我自己习惯的小动作每轮实验结束后我会用五行文字总结这次训练里“改动前的配置、改动后的配置、观测到的行为变化、我的下一步假设”。Model-Optimizer本身也从一开始的“换优化器工具”逐渐长成了一本可执行的经验笔记。你现在如果正在被某个模型的收敛问题卡住不妨先别急着换优化器把训练曲线、梯度范数和优化器状态这三样东西完整记录下来很多时候答案已经写在日志里了。