上一篇我们刚把Decoder-only的骨架完整跑通,embedding、多头注意力、FFN、LayerNorm 和最后输出头的代码都已经躺在本地的仓库里。但说实话,模型结构这部分只是“看起来完整”,真正让参数变得有价值的,是接下来要讲的这一整套训练流程。这篇是系列第四篇,我默认读者已经能完成前向传播、会构造 batch、也看得懂 attention mask,所以不再重复模型定义代码,直接把火力集中在训练环节。
这一篇覆盖的内容:损失函数与优化器选型、单机训练循环的最小实现、梯度累积与混合精度、从单卡改造成多卡 DDP、训练过程中的日志观测与踩坑排查。最后我会给出一份小规模实验的完整训练记录,方便你对照自己的 loss 曲线。适合正在照着一二三篇搭模型、准备开始真正跑训练的人,也适合已经能跑通但总在训练阶段出各种诡异问题的朋友。
1. 训练前的最后准备:损失函数与优化器选型
1.1 交叉熵损失不是一行代码那么简单
写过大模型代码的人都会说“就一个 CrossEntropyLoss 的事”,但真的深入训练之后,会发现这里至少有三个坑。
第一个坑是维度。大部分框架的交叉熵实现,默认接受三类输入:未归一化的 logits、类别标签、以及可选的每个样本权重。语言模型输出 shape 是 [batch, seq_len, vocab_size],标签是 [batch, seq_len],直接丢进 nn.CrossEntropyLoss 会报错,原因是它默认输入是 [N, C],其中 C 是类别数。所以常见做法要么手动 reshape 成二维,要么把 ignore_index 传进去后自己拉平:
import torch import torch.nn as nn def ce_loss(logits, labels, pad_token_id): # logits: [batch, seq_len, vocab_size] # labels: [batch, seq_len] B, T, V = logits.shape loss = nn.CrossEntropyLoss(ignore_index=pad_token_id) return loss(logits.reshape(B * T, V), labels.reshape(B * T))第二个坑是 ignore_index。训练 LLM 时我们会对不同长度的样本做 padding,所有 pad token 对应的位置的预测结果不应该算进 loss。用 ignore_index 的好处是框架直接跳过这些位置,不用费心 mask。我在早期版本里见过有人自己构造 label mask 后乘到 loss 上,这样也能做,但梯度数值会被多除以一个非均匀的系数,等价于给不同 token 的损失手动加权重,结果总是跟预期差一点。直接用 ignore_index 是最省事的。
第三个坑是 label smoothing。这个技巧本身很简单:把 one-hot 标签稍微“抹平”一点,比如 ground truth 类别给 0.9,其他类别平均分剩下的 0.1。它的作用是抑制模型过度自信,通常能带来一点困惑度改善,也能让训练后期更稳。值得说清楚的是,label smoothing 和采样温度是两码事:前者是训练阶段的标签平滑,后者是解码阶段的概率重分配,别混在一起。我在小模型上习惯设置 0.1,如果发现训练数据很多、任务比较难,还会降到 0.05。
1.2 优化器选型:为什么大模型都选 AdamW 而不是 Adam
优化器这块,经验结论很一致:从头训练大语言模型,默认选 AdamW,几乎没人用纯 SGD。原因是语言模型的 loss 面非常崎岖,SGD 收敛太慢,且对学习率极其敏感。Adam 类方法用一阶矩和二阶矩的估计,相当于给每个参数维度自适应调节步长,在这种崎岖的面上实用得多。
AdamW 和 Adam 的区别,一句话总结:Adam 把 L2 正则直接加到梯度上,AdamW 把权重衰减单独拎出来,在参数更新时做。数学上看,后者更符合“权重衰减”的本意,因为 L2 正则会被 Adam 的自适应学习率破坏掉等价性。实践中,AdamW 的泛化表现通常更好,所以现在大模型训练基本默认它。
参数细节上,我建议这些初始值:
- betas = (0.9, 0.95) 或 (0.9, 0.98)。0.9 决定动量,0.95 以上可以减少 loss 后期震荡,但收敛初期会略慢。
- eps = 1e-8 或 1e-6。FP16/BF16 训练时,eps 太小可能导致除零或数值不稳定,我会用 1e-6 保平安。
- weight_decay 常用 0.1 或 0.01。对小模型,0.1 不一定最优,但它在很多公开配置里能跑出不错的结果,可以先按 0.1 试。
这里有个容易忽略的问题:如果你用某个优化器实现的默认参数,比如默认 eps=1e-8,然后打开混合精度,某些小梯度场景下二阶矩接近 0,更新被放大,容易不稳。我一般统一写成 eps=1e-6。严格说这不是最优解,只是降低玄学概率的务实做法。
1.3 学习率调度:warmup 不是可选配置
大模型训练里的学习率调度,绝大多数实践是 warmup + cosine decay,warmup 不是可选配置,几乎必须做。原因可以这么理解:训练开始时,模型参数是随机初始化的,Adam 的二阶矩估计也是空的,这时候如果直接给一个很大的学习率,模型会在最初几个 step 里大步幅乱跳,跳进一个不好的区域,后面再想拉回来很难。warmup 这段时间相当于让模型先“慢热”,把优化器的统计量稳定下来,再进入主力学习期。
具体数值上,我的经验区间:
- 模型参数 1 亿以下,max_lr 可以试着 5e-4 到 1e-3。
- 1 亿到 10 亿参数,max_lr 在 2e-4 到 5e-4 之间。
- 参数再往上,通常往 1e-4 甚至更低走。
warmup_steps 我喜欢用固定步数而非比例,比如 1000-2000 步,而不是 1%。这是因为在训练前期,batch 数量是受你数据和算力限制的,固定步数更可控。cosine decay 的 min_lr 一般设成 max_lr 的 10% 左右,最后的 loss 收敛会稍微好一点。
调度器还有一个跟 batch size 相关的联动:如果加大 batch size,学习率通常也应该往上调整。我不建议在没把单卡流程跑通前纠结这个,后面第 3 章会专门说。
2. 搭一个能跑的训练循环
2.1 最小训练循环的骨架
经历了那些“看起来很重要”的准备工作,现在真正进入训练环节。我先把一个最小的训练循环写出来,这一段代码我会直接放进项目里当模板。
import torch from torch.nn.utils import clip_grad_norm_ def train_one_step(model, batch, optimizer, scheduler, pad_token_id, grad_clip=1.0): model.train() input_ids = batch["input_ids"].to(device) labels = batch["labels"].to(device) attention_mask = batch["attention_mask"].to(device) logits = model(input_ids, attention_mask=attention_mask) loss = ce_loss(logits, labels, pad_token_id) optimizer.zero_grad() loss.backward() clip_grad_norm_(model.parameters(), grad_clip) optimizer.step() scheduler.step() return loss.item()别小看这个函数,几乎所有真正跑起来的训练脚本都是它的增强版。几个细节我要强调一下:
- optimizer.zero_grad() 必须在 loss.backward() 之前调用。我见过有人把 zero_grad 放在 loss 计算之后,代码也能跑,但梯度会从上一步累积,导致 loss 曲线特别诡异。
- clip_grad_norm_ 的位置在 backward 之后、step 之前。这个不是可选项,我强烈建议从第一天就加上,max_norm 先给 1.0。
- 一个 step 里 scheduler.step() 的次数要和 optimizer.step() 一一对应,不要自己数错。如果你在梯度累积场景里,后面我会给出准确位置。
实际训练时,你不会一个 batch 一个 batch 地手动打印,一般会写一个外层循环,按 step 数控制打印频率、保存频率、评估频率。这个骨架不要过度封装,能跑通、日志清晰就够用。
2.2 数据批次细节:padding、mask 和 batch 内长度控制
训练循环看着简单,数据批次细节才是决定你训练效率的大头。
先说 padding 问题。标准做法是每个 batch 内统一到该 batch 最大长度,用 pad_token_id 补齐。然后 labels 里对应位置也填 pad_token_id,计算 loss 时用 ignore_index 跳过。attention_mask 也要覆盖,否则 padding 位置会参与注意力计算,等于让模型白白关注这些无意义位置。
第二个大问题:直接用原始顺序切 batch,会出现一个 batch 里最短的样本只有 200 token,最长的有 1000 token,padding 浪费严重。一个简单技巧是:把数据按长度排序,每个 batch 内尽量让样本长度接近,然后按 batch 大小顺序取。这样做通常能让训练吞吐提升 20%-40%,尤其在处理长短差异很大的混合语料时。代价是 batch 的分布比完全随机稍微偏了一点,但语言模型训练对这点偏置不敏感。
第三个问题是 packing。有些实现会把多个短文档拼接到同一个 sequence 里,中间用 sep token 隔开。这样做的好处是每一条样本的长度都能接近模型的 max_seq_len,训练效率更高。代价是 attention mask 要额外处理,不能允许跨文档的注意力。这个优化适合算力紧张的人,我不建议新手一上来就上,先把 padding + ignore_index 这套搞明白更稳。
2.3 梯度累积:显存不够时的标准解法
单卡显存有限,但你又想把 batch size 拉大,标准解法是梯度累积。核心思想很简单:把 N 个 batch 的梯度都累加到一起,再做一次参数更新,等效于用 N 倍大的 batch 训练。
代码上,只需要把上面的 update 部分拆开:
accum_steps = 8 model.zero_grad() for i, batch in enumerate(data_loader): loss = compute_loss(model, batch) / accum_steps loss.backward() if (i + 1) % accum_steps == 0: clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() model.zero_grad()注意 loss 要除以 accum_steps,这样 N 次 backward 累加后的梯度,和直接用一个 N 倍大 batch 做 backward 的梯度量级一致。如果不除,等效于你的学习率被放大了 N 倍,几乎肯定会训崩。
再强调一个位置问题:scheduler.step() 只在真正 update 的那一步调用,而不是每个 micro batch 都调用。你要是把 scheduler 放到循环外,用训练数据量来控制,那更没问题。但我见过不少人把 scheduler.step() 放在内层,导致学习率衰减速度变成预期的 accum_steps 倍,曲线一般是“前面掉得快、后面微弱波动”,还以为模型有问题。
2.4 混合精度训练:BF16 优先,FP16 兜底
混合精度是纯工程优化,却直接影响训练能否跑完。
先说 FP16。FP16 的动态范围比较小,权重和梯度容易溢出到 inf 或 0,所以需要 GradScaler 动态调整 loss 缩放系数。框架的实现都是自动的,但实际问题仍然很多:loss scale 飙升、梯度 inf、NaN 等等。FP16 的问题不是模型结构问题,而是数值范围问题。
BF16 不一样,它的指数位跟 FP32 一样,所以动态范围很大,基本不会溢出。代价是尾数位少,精度差,但在深度学习训练里,这个精度通常够用。所以我现在训练中小模型时,优先用 BF16。代码里只需要把模型参数转成 bfloat16:
model = model.to(torch.bfloat16)如果你的环境里 BF16 支持有问题,再用 FP16 + GradScaler。判断依据很简单:跑完 500 步 loss 数值稳定,不出现 inf/nan,就可以放心用 BF16。另外注意:用混合精度训练时,评估阶段要记得把模型切回 fp32,或者用和训练一致的精度,否则指标会有偏差。
关于为什么强烈推荐框架自带的混合精度模块而不是手动 half(),原因是最容易踩的坑就是手动把整个模型转 half 后,某个组件在 forward 里用了不同的浮点类型,最后出现各种 dtype 不匹配报错。封装好的模块会自己处理梯度缩放和类型转换,你只要把 forward 和 backward 写好就行。
2.5 checkpoint 与断点续训:RNG 状态也得存
训练一个大一点的模型,中途断电、机器重启都是常见事。没有断点续训,你可能要白跑几十个小时。checkpoint 的保存内容,不只是 model.state_dict(),至少还要包含:
- 模型权重
- 优化器状态,尤其是 Adam 的 exp_avg 和 exp_avg_sq。不保存优化器,重跑时相当于从头算动量,曲线会有一阵子不一致。
- 调度器状态,确切记录当前已经走了多少 step。
- step / epoch 计数
- RNG 状态。这个东西看起来玄,但如果你在数据采样、dropout 里用了 random,不保存 RNG 状态会导致续训后数据顺序和 dropout 模式都对不上,本来应该相同的曲线会分叉。
代码结构大概是:
ckpt = { "model": model.state_dict(), "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict(), "step": step, "epoch": epoch, "rng_state": torch.get_rng_state(), } torch.save(ckpt, f"ckpt/step_{step}.pt")保存频率一般在几百到几千 step 一次,具体看你一次打印日志的间隔,建议保存间隔和日志间隔对齐。磁盘够就多留两个版本,只留最新的容易在版本偶尔损坏时没有退路。
3. 从单卡到多卡:分布式训练入门
3.1 DDP 到底做了什么
训练到一定规模,单卡就会嫌慢。多卡训练最常见的是 DistributedDataParallel,简称 DDP。
DDP 的核心逻辑不复杂:每一张卡都复制一份完整模型,各自拿不同的 batch 做 forward/backward。关键点在 backward 之后,需要把所有卡上的梯度做一次 all-reduce,也就是求平均,然后每张卡用这个平均梯度更新本地模型。因为初始权重相同、每步更新的梯度相同,所以多张卡上的模型始终保持一致。
为什么不去手动写 allreduce?因为里面细节太多了:通信量、梯度分桶、通信和计算重叠、异步启动的钩子……框架把这一切封装好,你只要正确地初始化,剩下的交给它。DDP 适合模型能完整放入单卡显存、但数据很多导致训练时间过长的场景。如果模型本身就放不下一张卡,那就属于模型并行、流水线并行或者各种 offload 的范畴,不是这一章能讲完的事。
一个容易误会的点是,DDP 并不会降低单卡显存需求,它只是把多张卡拼成更大的“总吞吐”。很多人以为 DDP = 省显存,实际完全不是。
3.2 把训练脚本改造成 DDP
假设你已经有一份能单卡跑的脚本,改造成 DDP 大概只需要四步:
from torch.nn.parallel import DistributedDataParallel as DDP import torch.distributed as dist def setup_dist(rank, world_size): dist.init_process_group("nccl", rank=rank, world_size=world_size) def main(): rank = int(os.environ["LOCAL_RANK"]) world_size = int(os.environ["WORLD_SIZE"]) setup_dist(rank, world_size) torch.cuda.set_device(rank) model = model.to(rank) ddp_model = DDP(model, device_ids=[rank]) # 数据 sampler = torch.utils.data.distributed.DistributedSampler(dataset) loader = DataLoader(dataset, batch_size=per_gpu_batch, sampler=sampler) for epoch in range(epochs): sampler.set_epoch(epoch) # 关键:保证每个 epoch shuffle 不同 ...保存 checkpoint 时要注意一个坑:如果每张卡都各存一份,会互相覆盖。正确做法是指定 rank 0 来保存,或者保存前先 dist.barrier() 同步。加载同样限制在 rank 0 上加载后 broadcast,或者每张卡直接读同一个文件。我一般用 rank 0 保存,反正进程结束后文件就固定了。
DistributedSampler 还有一个作用:它帮你维护一份不重叠的数据划分,每个进程只看到属于自己的子集。没有它,每个进程都会从同一个 data loader 里看到同样的一批数据,等于训练了 N 遍同样的数据,浪费而且无效。
3.3 batch size 与学习率怎么联动
从单卡改成多卡,一个核心问题:总 batch size 翻倍后,学习率怎么办?
理论上有两种常见策略:线性缩放,即总 batch 变成 k 倍,lr 也放大 k 倍;平方根缩放,lr 放大 sqrt(k) 倍。实践中,线性缩放在小范围更常用,但直接放大太多很容易崩。更稳的做法是:改总 batch 之后,先不调 lr,观察 loss 曲线;如果收敛速度没有明显变慢,就保持;如果 loss 偏小、步子迈不动,再按 50%-100% 尝试放大。
还有一个我特别想说的事:总 batch size 不应该盲目翻倍。LLM 训练经验上,batch size 翻倍后,要达到同样的 loss,需要的总步数可以减半,但总计算量不一定省。当你发现单卡训练时间太长时,先确认瓶颈在算力还是显存,再决定是否走 DDP。
3.4 多机训练与通信细节
多机训练的场景,主要是显存超过单机、或者算力池很大。DDP 支持多机其实只需要在两台机器上各跑同样的脚本,并正确设置 MASTER_ADDR、MASTER_PORT、RANK、WORLD_SIZE 等。后端一般选 nccl,但如果在某些不支持的环境里,gloo 也可用来兜底。
多机多卡最头疼的是通信。每个 step 都要做全量梯度的 all-reduce,通信量等于模型参数量的两倍左右。模型参数越大,网络带宽越容易成为瓶颈。这时候有几个实用注意点:
- 尽量保证机器之间的网络带宽充足,至少要万兆网络水平,千兆网下多机 DDP 的加速比会打骨折。
- 通信和计算是重叠的,框架会尽量掩盖一部分通信时间,但 batch 太小会导致通信占比增大,实际吞吐上不去。
- 如果多机 DDP 效率很差,就要考虑更重型的并行策略,比如流水线并行、张量并行。这些内容对大多数中小规模项目来说,暂时用不到。
我个人的建议是,能单机多卡,就先不上多机。先把 DDP 流程跑通,确认加速比正常,再考虑扩展。
4. 训练日志、指标观测与踩坑实录
4.1 训练日志到底该记哪些东西
训练不能只看 loss。我的日志表里至少有这几项:
| 指标 | 记录原因 | 异常信号 |
|---|---|---|
| loss | 主指标,反应收敛进度 | 长期不降、NaN |
| grad_norm | 反映梯度规模,辅助诊断 | 急剧涨跌、恒为0 |
| lr | 确认调度器是否正确 | 不符合预期曲线 |
| tokens_per_second | 吞吐,判断训练效率 | 波动大、持续低 |
| gpu_mem | 显存使用 | 接近上限、持续增长 |
| eval_loss | 泛化参考 | train下降但eval上升 |
grad_norm 是我特别喜欢看的一个指标。它等于所有参数梯度张量 norm 的平方和再开根号,能反映整个网络梯度抹平后的平均规模。正常情况下,它应该在一个相对稳定的范围里波动,比如 1 到 10。如果 grad_norm 突然飙到 100 以上,随后 loss 暴涨,基本就是梯度过大,需要降学习率或加强梯度裁剪。
吞吐指标用 tokens_per_second 而不是 step_per_second,是因为不同 batch 的填充长度不一样,只看 step 会误判训练速度。
4.2 loss 曲线怎么读
看 loss 曲线时,要区分三种典型形态。
第一种是“指数衰减后趋于平坦”,这是正常收敛形态。前期 loss 下降很快,几百步后能明显看到下降变慢,一万步后几乎是一条平缓的下降直线。这种形态下别急着调参,让它继续跑。
第二种是“train_loss 一直降,eval_loss 先降后升”,这是过拟合的典型信号。LLM 小模型在数据量不足时特别容易过拟合。解决方法优先是加数据、增加数据多样性,其次才是调 dropout、加大 weight_decay。对小规模项目,不要一上来就上数据增强那一套,先确认数据量是否足够支撑模型规模。
第三种是“loss 在某个步数之后开始剧烈震荡”,多半是学习率过高或数值不稳定。先看 log 里 lr 当前值、grad_norm 是否有特别高的 spike。如果 lr 还在 warmup 期间就开始震荡,多半是 max_lr 定大了。
还有一条很实用的经验:随机初始化模型的起始 loss 大约在 ln(vocab_size) 附近。比如 vocab_size=30000,初始 loss 应该在 10.3 上下。如果初始 loss 差得远,先检查有没有把 logits 直接喂进 softmax 之类的问题。
4.3 常见问题排查速查表
这部分是真正的“踩坑笔记”,我按频率从高到低整理。
| 现象 | 可能原因 | 检查顺序 |
|---|---|---|
| loss 是 NaN | 学习率过大、FP16 溢出、数据里有 NaN、embedding 初始化异常 | 1打印数据 2 查 lr 3 查 scale 4 换 BF16 |
| loss 几乎不动 | lr 太小、mask 没配好、梯度被 clip 成 0 | 1 查 grad_norm 2 查 lr 3 查 ignore_index |
| 显存 OOM | batch 过大、中间激活值过大、保存占内存 | 1 降 batch 2 开梯度累积 3 查缓存 |
| 训练速度慢 | padding 浪费严重、data loader 瓶颈、没有混合精度 | 1 看吞吐 2 按长度排序 3 开 BF16 |
| 多卡加速比低 | 通信成为瓶颈、batch per step 太小 | 1 看 GPU 利用率 2 提高 batch 3 查网络 |
| loss 下降但 eval 变差 | 过拟合 | 1 加数据 2 加 regularization 3 降模型规模 |
4.4 一个亲历的 NaN 排查案例
我在自己的模拟项目 X 里遇到过非常经典的一次 NaN 事故。当时用 FP16 训练一个 1.5 亿参数的小模型,前 500 步一切正常,loss 从 10.2 平滑降到 7.4。第 510 步左右,loss 突然变成 nan,之后全部是 nan。
排查过程:
- 先打印输入数据,确认没有 NaN 或异常值。
- 打印 logits 最大值,发现 forward 输出正常。
- 打开 GradScaler 的日志,发现 loss scale 在某些 step 降到非常低,然后某个小梯度被裁成 0,再往后梯度里出现 inf。
- 定位到问题在于模型某个模块在 FP16 下溢出。换用 BF16 后,同样流程完全稳定。
这类问题的通用结论:遇到 FP16 相关的数值问题,优先尝试 BF16,不要花一整天去调 loss scale 和 max norm。只要你用的计算设备支持 BF16,它就是更省心的选择。如果必须用 FP16,那就把学习率降一半,同时调大 grad_clip 的容忍度,但要有心理准备,FP16 的坑更深。
5. 一次小规模训练实验的完整复盘
5.1 实验配置与数据
为了验证整个系列文章的流程,我用一份约 5 亿 token 的中文混合语料训练了一个 1.2 亿参数的小模型。模型结构是标准 Decoder-only Transformer:12 层、768 维、12 头、max_seq_len=512。词汇表 3 万。这个配置放在今天听起来很小,但作为学习项目,正好能在一张 24GB 显存卡上跑完,时间还不至于等太久。
训练配置:AdamW,betas=(0.9, 0.95),eps=1e-6,weight_decay=0.1。max_lr=3e-4,warmup_steps=2000,cosine decay 到 3e-5。batch size=32,grad_accum=8,等效总 batch 256。BF16 混合精度。
5.2 训练过程中的核心数据
| 阶段 | loss | grad_norm 范围 | 备注 |
|---|---|---|---|
| 初始 500 步 | 10.2 -> 8.1 | 8~25 | warmup 期,loss 快速下降 |
| 5000 步 | 6.2 | 5~15 | 进入平稳下降 |
| 20000 步 | 4.3 | 3~10 | 曲线变平缓 |
| 最终步 | 3.1 | 2~8 | 还没到过拟合,数据有限 |
这个 loss 曲线本身不算优秀,但足以证明流程正确。整个训练消耗了大约 7 亿 token 的等价算力,约几十个小时。
5.3 复盘三个经验
第一个经验:warmup 期别急着评估。前 2000 步模型还在乱撞,eval loss 会比最终差很多,不要被它吓到。
第二个经验:数据质量比参数小改更有用。我在某次迭代里把数据清洗标准提高了一点,同样的训练步数,最终 loss 低了不少。这印证了数据准备才是一切的基础。所谓的“好模型很大程度来自好数据”,在中小模型上体现得尤其明显,因为模型容量有限,喂进去脏数据它会很努力地去“记住”噪声,而不是学语言规律。
第三个经验:checkpoint 要勤保存,但不要每次都存全量 optimizer。几十 GB 的 optimizer 状态写盘很慢。折中方案是每 2000 步存一个含 optimizer 的完整 checkpoint,每 200 步只存一个 model-only 的最小 checkpoint,用于快速恢复训练 loss 曲线和探路。这样既不会因为频繁保存拖慢训练,又能在意外中断时把损失控制在很小的范围内。
5.4 如何规划你自己的第一次训练实验
如果你正准备复现这套流程,我给一个具体的行动清单:
- 先选一个小模型,1 亿参数以下最佳,确保能在单卡上快速迭代。
- 用一小块干净数据,比如 2000 万 token 级别,先把 5000 步训练跑通,不要贪多。
- 每次只改一个变量。想调学习率就只动 max_lr,想调 batch 就只动 grad_accum,改完跑 1000 步看趋势,而不是顺手把调度器和优化器一起换掉。
- 记录一份自己的日志表,哪怕只是简单的 loss 和 grad_norm。有了历史数据,后面遇到问题才能对比“正常曲线”和“异常曲线”。
这一章给的不是标准答案,而是希望你能用这套方法少走弯路。当你自己的训练曲线和表里的量级差太多时,就该怀疑哪里出了问题。
6. 关于训练规模的几个经验判断
这个系列写到这里,很多朋友会问一个问题:我到底应该训练多大的模型、准备多少数据?这个问题没有唯一答案,但有几个经验判断可以帮你快速定位自己的资源到底适合什么规模。
第一,显存决定模型上限,也决定最大 batch。24GB 显存训练 1.2 亿参数模型,用 BF16 时单卡大概能塞下 batch size 32 这样的配置。如果你的模型到了 3 亿参数,同样的显存可能要把 batch 降到 16,或者干脆走梯度累积。显存不够不是末日,但会显著影响吞吐和迭代效率。
第二,数据量决定过拟合边界。一般来说,一个模型能用完多少有效信息,和数据规模强相关。1 亿参数的小模型,用上 5 亿 token 的干净语料,基本能训练到一个合理的点。如果你的数据只有几千万 token,那模型再大也容易过拟合,这时候优先扩数据,而不是换一个更大的模型。
第三,训练时间决定你能不能快速试错。理想的规模是:单次训练控制在 3 到 10 个小时内。这样一天能跑好几轮实验,你才有机会做有意义的“调参”。如果一次训练要跑几天,那么每次调参的试错成本太高,反而不利于学习。你可以通过减小数据量、缩减序列长度、提前终止等方法来把实验周期压缩下来。
第四,评估任务要提前想好。训练不是只要 loss 越低越好,最终要看模型能否完成你期望的能力。最好从一开始就准备一批固定的 prompt,每隔几千步跑一些生成样例,直观感受模型从“胡言乱语”到“有点逻辑”的变化。这个感受比任何指标都真实。
训练规模这件事,本质上是在有限的资源下做取舍。没有资源的时候,就少训练一点,但要把实验习惯养好,每一步都记录清楚,等到有机会上更大规模时,你才能真正用得上那些成熟做法。
7. 写在最后的实操心得
最后分享一点我这几年反复体会的东西。训练大语言模型,最大的敌人不是数学,也不是代码,而是“不知道当前系统处于什么状态”。loss 曲线、grad_norm、吞吐、显存、日志……这些观测手段不是可有可无的装饰,它们是你判断“下一步该干嘛”的唯一依据。我自己踩过最大的坑,就是在一个看起来正常的训练流程里跑了很久之后才发现数据 pipeline 有 bug,白白浪费大量算力。所以这篇里我把日志和排查放在很靠前的位置,真心建议你从第一天训练起就养成记录和评估的习惯。
另一个体会是,训练代码一定要能快速跑通。不要一开始就追求分布式、追求极致的吞吐,先用小数据、小模型把整个链路跑一遍,确认每一环都符合预期。等你的 baseline 稳定了,再去加混合精度、梯度累积、DDP 这些优化项。每加一项,观察一次曲线,确认没有引入新的问题,再继续往下走。
这个系列如果继续往下写,大概率会聊推理阶段的内容,包括解码策略、KV cache 以及生成质量评估。到时候欢迎你带着自己训练出来的模型一起来看,那会是整个系列里最有成就感的部分。