用verl做SFT微调,这些坑你一定要避开
注意:本文不是手把手教程,而是踩过 dozens 次OOM、训练崩断、收敛诡异、显存爆炸后整理的实战避坑指南。如果你正准备用 verl 跑 SFT,别急着敲
torchrun——先看完这7个真实发生过的致命陷阱。
1. 坑一:把“支持HuggingFace模型”当万能钥匙,结果tokenizer对不上
verl 文档里写得清清楚楚:“与 HuggingFace 模型轻松集成”,但没人告诉你——“轻松”的前提是,你用的 tokenizer 必须和模型原始训练时完全一致,连 padding token 都不能错一个字。
我们团队第一次跑 Qwen2.5-0.5B-Instruct 时,直接from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")加载 tokenizer,训练启动后 loss 稳定在 12.7 不动,验证集 accuracy 始终为 0.0。查了3小时日志,最后发现:
- Qwen 官方仓库中,该模型的
pad_token_id是151643(特殊占位符),而 HuggingFace Hub 上同名模型的默认pad_token_id是0 - verl 的
SFTDataset在做 dynamic padding 时,会严格按 tokenizer.pad_token_id 插入填充,一旦填错,整个 batch 的 attention mask 就全乱了 - 模型根本没看到有效响应,只在学“怎么预测一堆 pad token”
正确做法:
from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct", trust_remote_code=True) # 强制对齐官方设定 if not hasattr(tokenizer, "pad_token") or tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token tokenizer.pad_token_id = tokenizer.eos_token_id # 验证关键ID print(f"pad_token_id: {tokenizer.pad_token_id}, eos_token_id: {tokenizer.eos_token_id}")补充提醒:
- Llama 系列要用
add_eos_token=True; - DeepSeek-Math 要手动设置
tokenizer.add_bos_token = False(它不加 BOS); - 所有自定义数据集加载前,务必用
tokenizer.decode(tokenizer.encode("测试"))看一眼是否还原一致。
2. 坑二:micro_batch_size_per_gpu 设成 8,结果 OOM 爆得比烟花还快
文档里写着“支持高吞吐”,示例配置也大方地写了micro_batch_size_per_gpu: 8。但没人告诉你——这个值不是看显存剩余量估算的,而是要结合 sequence length、model dtype、是否启用 liger、甚至 GPU 型号的 shared memory 大小来反推。
我们在 A100 80GB 上跑 Qwen2.5-0.5B,设micro_batch_size_per_gpu=8+max_length=2048,启动即报CUDA out of memory,nvidia-smi显示显存只用了 52GB,却提示 OOM。
原因在于:verl 默认开启use_remove_padding: false,意味着即使 batch 内各序列长度不同(比如有的 320,有的 2048),也会统一 pad 到 max_length。实际显存占用 ≈8 × 2048 × hidden_size × dtype_bytes,远超理论值。
正确做法:
先关掉 padding,用真实长度计算:
# 启动前先探查数据真实分布 python -c " import pandas as pd df = pd.read_parquet('$HOME/data/gsm8k/train.parquet') lens = [len(t) for t in df['question'].str.cat(df['answer']).apply(lambda x: tokenizer.encode(x))] print('P95 length:', sorted(lens)[int(0.95*len(lens))]) "根据 P95 长度设
max_length,再设micro_batch_size_per_gpu:P95 长度 推荐 micro_batch 是否必须开 liger ≤ 512 12–16 否 513–1024 6–8 建议开启 1025–2048 2–4 必须开启 然后在 config 中强制启用动态去 padding:
model: use_remove_padding: true # 关键! use_liger: true # 配套使用 data: max_length: 1024 # 不是 2048!
3. 坑三:LoRA 微调时 target_modules 写成 "all-linear",结果训完模型不会说话
verl 支持model.target_modules: all-linear这种快捷写法,听起来很省事。但我们训完 DeepSeek-Math-7B,发现模型对数学题的回答全是"I don't know"或空字符串。
调试发现:all-linear会把 embedding 层和 lm_head 层也加上 LoRA adapter。而这两个层直接影响词表映射和输出 logits 分布,一旦被低秩扰动,整个输出概率就塌缩了。
正确做法:
- 绝对不要用
all-linear,显式指定目标模块:model: target_modules: ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"] - 对于 Qwen2.5,额外加上
lm_head(它和 transformer 层解耦):model: target_modules: ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj", "lm_head"] - 验证是否生效:训练前加一行 debug:
from verl.trainer.fsdp_sft_trainer import FSDPSFTTrainer trainer = FSDPSFTTrainer(...) print("LoRA modules:", [n for n, m in trainer.model.named_modules() if "lora" in n.lower()])
4. 坑四:多轮对话数据用单字段 key,结果模型只会答第一轮
verl 示例里给的是"question"和"answer"两个字段,但多轮对话(如 ShareGPT 格式)是嵌套结构:
{ "conversations": [ {"from": "human", "value": "1+1等于几?"}, {"from": "gpt", "value": "等于2。"}, {"from": "human", "value": "那2+2呢?"}, {"from": "gpt", "value": "等于4。"} ] }如果还按data.prompt_key: conversations直接喂,verl 会把整个 list 当成 prompt 字符串,tokenize 后变成一堆不可读 token,loss 瞬间飙到 20+。
正确做法:
- 用
data.prompt_dict_keys和data.response_dict_keys指定路径:data: prompt_dict_keys: '["conversations", "0", "value"]' # 第一轮 human response_dict_keys: '["conversations", "1", "value"]' # 第一轮 gpt # 注意:verl 不支持自动拼接多轮,需预处理为单轮格式 - 更推荐方案:预处理时拼成 instruction 格式(verl 最擅长的模式):
然后配置:# preprocessing.py def format_multiturn(item): convs = item["conversations"] # 拼成:[INST] 1+1等于几? [/INST] 等于2。\n[INST] 那2+2呢? [/INST] 等于4。 text = "" for i, msg in enumerate(convs): if msg["from"] == "human": text += f"[INST] {msg['value']} [/INST]" else: text += f" {msg['value']}\n" return {"text": text.strip()}data: prompt_key: text response_key: text # 自监督式,让模型学续写
5. 坑五:梯度检查点(gradient checkpointing)开着,但 loss 曲线锯齿大得像心电图
文档说enable_gradient_checkpointing: true能省显存,我们开了,结果训练 loss 在 3.2–8.7 之间疯狂震荡,validation loss 完全不下降。
根本原因:verl 的 gradient checkpointing 实现(基于torch.utils.checkpoint)默认不保存中间 activation 的 RNG state。每次重计算时 dropout mask 重新采样,导致同一 batch 的两次 forward 结果不一致,梯度方向剧烈抖动。
正确做法:
- 关闭 dropout(最简单):
model: enable_gradient_checkpointing: true # 关键:禁用所有 dropout dropout_prob: 0.0 attn_dropout_prob: 0.0 - 或升级到 verl ≥ 0.3.2(修复了 RNG state 保存),并显式启用:
model: enable_gradient_checkpointing: true use_reentrant: false # 必须设为 false,启用非 reentrant 模式
6. 坑六:用 FSDP2 策略,却忘了关掉 CPU offload,结果训练慢如蜗牛
FSDP2 是 verl 的高性能策略,但文档里那句“支持 CPU offload”极具迷惑性。我们在 4×A100 集群上开启:
model: strategy: fsdp2 fsdp_config: cpu_offload: true结果吞吐量从 2800 tokens/s 掉到 420 tokens/s,htop显示 CPU 持续 100%,nvidia-smi却只有 30% 利用率。
真相是:CPU offload 会把部分参数频繁在 GPU↔CPU 间搬运,而 verl 的 HybridEngine 数据流设计依赖 GPU 内存零拷贝。一旦 offload,每步 forward 都触发 PCIe 传输,带宽成瓶颈。
正确做法:
- FSDP2 模式下,绝对禁止
cpu_offload: true和offload_params: true - 如需省内存,改用
fsdp_config.shard_strategy: FULL_SHARD+use_orig_params: false - 或直接换 LoRA(更轻量,且 verl 对 LoRA 的 FSDP 优化更成熟)
7. 坑七:从 checkpoint 恢复训练,resume_path 指向错了一级,模型直接退化
verl 支持trainer.resume_mode: resume_path,我们照着文档把路径设成:
--resume_from_path ./checkpoints/gsm8k-sft/global_step_1000结果训了 2 个 epoch 后,loss 回到初始值,生成文本变回胡言乱语。
排查发现:verl 的 checkpoint 保存结构是:
./checkpoints/ └── gsm8k-sft/ └── global_step_1000/ ├── mp_rank_00_model_states.pt ├── optimizer_states.pt └── rng_state.pth而resume_from_path必须指向global_step_1000/这一层目录,不是global_step_1000文件名。少一个/,verl 就当成新训练启动,覆盖原有权重。
正确做法:
- 启动前用
ls -l确认路径末尾有/:ls -l ./checkpoints/gsm8k-sft/global_step_1000/ # 必须看到上面列出的 .pt 文件 - 命令行中明确加
/:torchrun ... \ trainer.resume_mode=resume_path \ trainer.resume_from_path=./checkpoints/gsm8k-sft/global_step_1000/ \ ... - 更稳妥:用 Python 脚本验证 checkpoint 可读性:
import torch ckpt = torch.load("./checkpoints/gsm8k-sft/global_step_1000/mp_rank_00_model_states.pt", map_location="cpu") print("Model keys:", list(ckpt["model"].keys())[:3])
总结:SFT 不是调参,是排雷
用 verl 做 SFT,表面是改几个 YAML 参数,实则是和框架底层的数据流、内存管理、分布式通信机制持续博弈。这7个坑,每一个都曾让我们停摆半天以上——它们不是边缘 case,而是高频发生的“文档没写但生产必遇”问题。
记住这三条铁律:
- tokenizer 是第一道防线:永远用
trust_remote_code=True+ 手动校验 pad/eos ID; - batch size 是显存守门员:不看 P95 长度就设 micro_batch,等于主动申请 OOM;
- checkpoint 恢复是原子操作:路径差一个字符,前面所有训练就归零。
避开这些坑,你才能真正释放 verl 的性能潜力——而不是在 debug 中耗尽耐心。
获取更多AI镜像
想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。