1. 为什么值得花时间跑通 verl 这套训练框架
强化学习训练框架这两年更新得很快,从最早的 Stable-Baselines3 到后来的 RLlib、TRL,再到今天要聊的 verl,每一代都在解决上一代遗留的痛点。verl 是字节跳动开源的一套面向大语言模型后训练场景的强化学习框架,核心定位很明确:把 PPO、GRPO 这类策略优化算法在大规模模型上跑得又稳又快。我第一次接触它是因为手头有个 7B 模型要做偏好对齐,用 TRL 跑 PPO 时显存炸得厉害,换到 verl 之后同样的硬件配置能撑起更大的 batch,训练吞吐大概提升了三成左右。
这篇文章面向的是已经了解强化学习基本概念、想动手把 verl 跑起来的同学。你不需要是分布式训练专家,但至少要能看懂 PPO 的损失函数长什么样,知道 actor、critic、reward model 各自在干什么。我会从环境准备一路讲到 GRPO 和 PPO 的实际配置差异,中间穿插我自己踩过的坑和排查思路。整套流程走下来,你应该能在一个单机多卡或者多机环境里把训练任务拉起来,并且知道每个关键参数为什么这么设。
verl 最吸引我的地方在于它的解耦设计。传统 RLHF 流水线里,actor 模型、critic 模型、reward 模型、reference 模型经常被硬编码在一起,改一个就得动全身。verl 用了一套基于 Ray 的分布式编排,把训练和推理拆成独立的 worker group,通过共享内存和 NCCL 通信做数据交换。这意味着你可以让 actor 用 FSDP 训练,同时让推理部分用 vLLM 做 rollout,两边互不干扰。这种架构在工程上很优雅,但初次上手时配置项确实多,容易看花眼。
另外要提一句,verl 对 GRPO 的支持是我决定深入用的直接原因。GRPO 去掉了 critic 模型,用组内归一化的奖励作为优势估计,显存占用直接砍掉一大块。对于我这种只有 8 卡 A100 的选手来说,能不能省掉一个和 actor 同规模的 critic,往往决定了实验能不能跑起来。后面我会专门用一节讲 GRPO 的配置和它跟 PPO 的取舍。
2. 环境准备与依赖安装的完整路径
2.1 硬件与基础软件的前置检查
在动手装 verl 之前,先把硬件和驱动层面的事情理清楚,否则后面报错会很难定位。我推荐的最低配置是单机 8 卡 A100 80G,或者 8 卡 H100 更好。如果只有 4 卡,7B 模型用 GRPO 加 LoRA 也能跑,但 batch size 会压得很小,训练稳定性会打折扣。显存方面,PPO 全参数微调 7B 模型大约需要每卡 60G 以上,GRPO 能降到 40G 左右,这个数字会随序列长度和 micro batch 变化。
软件栈的版本匹配是第一个大坑。verl 依赖 PyTorch、CUDA、Ray、vLLM、FlashAttention 这几个核心组件,它们之间的版本兼容性很敏感。我实测下来比较稳的组合是:CUDA 12.1、PyTorch 2.3.1、Ray 2.35、vLLM 0.5.4、FlashAttention 2.6.3。如果你用更新的 CUDA 12.4,vLLM 那边可能需要重新编译,会多花不少时间。驱动版本建议 535 以上,用nvidia-smi确认一下。
nvidia-smi # 确认 Driver Version >= 535, CUDA Version >= 12.1 python -c "import torch; print(torch.__version__, torch.version.cuda)"注意:不要用 conda 默认源里的 pytorch,那个版本通常不带对应 CUDA 的编译支持。老老实实去 PyTorch 官网拿 pip 安装命令。
2.2 创建隔离环境与安装核心依赖
我习惯用 conda 建一个干净环境,避免和系统里的包打架。Python 版本选 3.10,这是目前各大框架兼容性最好的版本。
conda create -n verl python=3.10 -y conda activate verl pip install torch==2.3.1 torchvision==0.18.1 torchaudio==2.3.1 --index-url https://download.pytorch.org/whl/cu121 pip install ray==2.35.0 vllm==0.5.4 flash-attn==2.6.3flash-attn 的安装比较特殊,它需要编译,而且编译过程吃内存。如果你机器内存小于 64G,建议加上MAX_JOBS=4限制并行编译任务数,否则容易 OOM。
MAX_JOBS=4 pip install flash-attn==2.6.3 --no-build-isolation装完这些之后,再克隆 verl 仓库并安装。verl 本身是纯 Python 包,安装很快。
git clone https://github.com/volcengine/verl.git cd verl pip install -e .安装完成后跑一下自带的单元测试,确认基础环境没问题。
pytest tests/test_ppo_trainer.py -x -q如果这一步能过,说明核心依赖都装对了。我遇到过 flash-attn 编译成功但 import 时报符号错误的情况,那通常是 CUDA 版本和编译时用的版本不一致,重新装一遍就好。
2.3 数据集与模型权重的准备
verl 的训练数据格式是 parquet,每条样本包含 prompt 和可选的 reward 相关字段。官方示例里用的是 GSM8K 数学题数据集,我建议第一次跑通就用它,因为数据量适中、奖励规则明确。你可以从 HuggingFace 上下载,也可以用 verl 脚本里自带的预处理工具。
python examples/data_preprocess/gsm8k.py --local_save_dir ~/data/gsm8k模型权重方面,actor 和 reference 通常用同一个基座模型,比如 Qwen2.5-7B-Instruct。critic 在 PPO 里需要一个带 value head 的版本,verl 支持从基座模型自动初始化 value head。reward model 可以用现成的,比如 Skywork-Reward 或者自己训一个。GRPO 模式下不需要 critic,reward 直接用规则函数算,省事很多。
实操心得:第一次跑通时不要急着换自己的数据,先用 GSM8K 把整条链路验证一遍。我见过太多人一上来就用自己的业务数据,结果分不清是数据格式问题还是框架配置问题,排查成本翻倍。
3. 核心概念拆解:PPO 与 GRPO 在 verl 里的实现差异
3.1 PPO 的四模型协作机制
PPO 在 verl 里涉及四个模型角色:actor、critic、reference、reward。actor 负责生成回复并计算策略梯度,critic 估计状态价值用于计算优势函数,reference 提供 KL 散度约束防止策略跑偏,reward 给出最终奖励信号。这四个模型在训练循环里各司其职,verl 通过 Ray 的 remote actor 机制把它们分配到不同的 GPU 组上。
优势函数的计算是 PPO 的核心。verl 默认用 GAE(广义优势估计),需要 critic 对每个 token 位置输出 value。这里有个细节:value 的维度是 [batch, seq_len, 1],而 reward 通常只在序列末尾给一个标量。verl 会把末尾 reward 广播到整个序列,再结合 KL 惩罚项和 GAE 的 lambda 参数做逐步累加。这个计算过程在core_algos.py里的compute_gae_advantage_return函数里,值得读一遍源码。
KL 散度的处理方式直接影响训练稳定性。verl 支持两种模式:一种是把它作为 reward 的惩罚项,另一种是作为独立的 loss 项。前者更常见,实现上是在每个 token 的 reward 里减去kl_coef * (logp_actor - logp_ref)。kl_coef 默认 0.01,如果发现策略更新太激进、输出开始胡言乱语,可以调到 0.05 甚至 0.1。
3.2 GRPO 的组内归一化优势估计
GRPO 的思路和 PPO 差别很大。它不给每个 token 算 value,而是对同一个 prompt 采样一组回复(比如 8 条),用这组回复的奖励均值作为基线,每条回复的奖励减去均值再除以标准差,得到归一化的优势。这样就不需要 critic 模型了,显存和计算量都省了一大截。
在 verl 里配置 GRPO 只需要把adv_estimator设成grpo,同时把 critic 相关的配置去掉。采样组大小由rollout.n控制,我一般设 8 或 16。组太小的话优势估计方差大,组太大的话 rollout 成本高。GSM8K 这种任务上 n=8 就够用了。
GRPO 的奖励函数需要自己写,通常是一个 Python 函数,输入是生成的文本和标准答案,输出是 0 到 1 之间的分数。verl 支持在配置里指定 reward 函数的路径,也支持用 reward model。规则奖励的好处是零成本、可解释,坏处是只能处理有明确对错的任务。如果你的任务偏主观,还是得训 reward model。
3.3 两种算法的选型对照
| 维度 | PPO | GRPO |
|---|---|---|
| 是否需要 critic | 需要 | 不需要 |
| 显存占用(7B 全参) | 约 60G/卡 | 约 40G/卡 |
| 采样效率 | 每条 prompt 采 1 条 | 每条 prompt 采 n 条 |
| 优势估计 | GAE + value | 组内归一化 |
| 适用场景 | 通用偏好对齐 | 有明确奖励信号的任务 |
| 训练稳定性 | 对超参敏感 | 相对鲁棒 |
选型上我的建议很直接:如果你的任务能用规则算奖励,优先 GRPO,省资源且调参少。如果奖励只能靠模型打分,或者你需要精细的 token 级信用分配,那就上 PPO。两者在 verl 里切换的成本很低,改几个配置项就行,所以完全可以都跑一遍对比效果。
4. 单机多卡跑通 PPO 训练的实操流程
4.1 配置文件的结构与关键参数
verl 的配置用 Hydra 管理,主配置文件在verl/trainer/config/ppo_trainer.yaml。这个文件分几大块:data、actor_rollout_ref、critic、reward_model、trainer。我建议不要直接改主配置,而是复制一份到自己的实验目录,用--config-path指定。
data: train_files: ~/data/gsm8k/train.parquet val_files: ~/data/gsm8k/test.parquet train_batch_size: 256 max_prompt_length: 512 max_response_length: 512 actor_rollout_ref: model: path: Qwen/Qwen2.5-7B-Instruct actor: ppo_mini_batch_size: 64 ppo_micro_batch_size: 8 kl_loss_coef: 0.01 rollout: name: vllm n: 1 temperature: 1.0 gpu_memory_utilization: 0.6 critic: model: path: Qwen/Qwen2.5-7B-Instruct ppo_mini_batch_size: 64 ppo_micro_batch_size: 8 trainer: n_gpus_per_node: 8 total_epochs: 15 save_freq: 50几个参数需要重点解释。train_batch_size是每次迭代处理的 prompt 总数,ppo_mini_batch_size是 PPO 更新时的小批次大小,ppo_micro_batch_size是前向传播时的微批次。这三个的关系是:train_batch_size 决定 rollout 阶段采多少数据,mini_batch 决定一次梯度更新用多少,micro_batch 决定单卡一次前向能塞多少。显存不够时优先降 micro_batch,它对显存影响最直接。
gpu_memory_utilization是 vLLM 的显存占用比例,设 0.6 意味着 vLLM 用 60% 显存做 KV cache。这个值不能太高,否则训练部分没显存了;也不能太低,否则 rollout 速度上不去。8 卡 A100 上我一般设 0.5 到 0.6。
4.2 启动脚本与分布式配置
verl 用 Ray 做分布式调度,启动方式有两种:单机直接用python -m跑,多机需要先起 Ray 集群。单机 8 卡的启动命令大概是这样:
export CUDA_VISIBLE_DEVICES=0,1,2,3,4,5,6,7 export RAY_DEDUP_LOGS=0 python -m verl.trainer.main_ppo \ --config-path ~/configs/ppo_gsm8k.yaml \ trainer.n_gpus_per_node=8 \ trainer.nnodes=1 \ actor_rollout_ref.rollout.tensor_model_parallel_size=2 \ actor_rollout_ref.actor.fsdp_config.fsdp_size=4这里有两个并行度参数容易搞混。tensor_model_parallel_size是 vLLM 推理时的张量并行度,fsdp_size是训练时的 FSDP 分片数。8 卡机器上,我通常让 vLLM 用 2 路张量并行(占 2 卡),FSDP 用 4 路分片(占 4 卡),剩下 2 卡给 critic 和 reward。具体怎么分要看模型大小和显存,原则是让每个 worker group 都有足够的卡,同时避免通信开销过大。
注意:Ray 默认会把所有 GPU 都纳入资源池,如果你只想用部分卡,记得在启动 Ray 时用
CUDA_VISIBLE_DEVICES限制,否则会出现资源争抢。
4.3 训练日志解读与关键指标监控
训练启动后,控制台会打印每个 iteration 的指标。重点看这几个:actor/pg_loss、actor/kl、critic/vf_loss、reward/mean、response_length/mean。pg_loss 是策略梯度损失,正常应该在 0 附近小幅波动;kl 是策略和 reference 的散度,如果持续上涨说明策略跑偏了;reward mean 是最终目标,应该稳步上升。
我一般会同时开 TensorBoard 看曲线。verl 默认把日志写到outputs/目录下,用tensorboard --logdir outputs就能看。reward 曲线如果出现先升后降,通常是 kl_coef 太小导致策略过拟合到 reward model 的偏好上,这时候把 kl_coef 调大或者降低学习率。
还有一个容易忽略的指标是response_length/mean。如果这个值突然暴涨,说明模型学会了用长回复刷奖励,这时候需要在 reward 里加长度惩罚,或者设一个 max_response_length 硬截断。我在 GSM8K 上遇到过模型把答案重复写三遍来凑奖励的情况,加了重复检测的惩罚项才压下去。
5. 切换到 GRPO 的配置改动与效果对比
5.1 从 PPO 迁移到 GRPO 的最小改动集
从 PPO 切到 GRPO,配置上主要改三处:把adv_estimator改成grpo,删掉 critic 相关配置,把rollout.n调大。reward 部分如果原来用 reward model,可以保留,也可以换成规则函数。
algorithm: adv_estimator: grpo actor_rollout_ref: rollout: n: 8 temperature: 1.0 # critic 整块删掉或注释改完之后启动命令基本不变,但显存占用会明显下降。我在 8 卡 A100 上跑 7B 模型,PPO 时每卡显存峰值约 62G,GRPO 降到 41G 左右。省下来的显存可以用来加大 batch size 或者加长序列,对训练效果有直接帮助。
5.2 规则奖励函数的编写要点
GRPO 配规则奖励是最省事的组合。verl 支持在配置里指定一个 Python 函数作为 reward_fn,函数签名是def reward_fn(data_source, solution_str, ground_truth, extra_info),返回一个浮点数。以 GSM8K 为例,核心逻辑是从模型输出里提取\boxed{}中的答案,和标准答案比对。
import re def gsm8k_reward(data_source, solution_str, ground_truth, extra_info=None): match = re.search(r'\\boxed\{([^}]+)\}', solution_str) if not match: return 0.0 pred = match.group(1).strip() gt = ground_truth.strip() return 1.0 if pred == gt else 0.0这个函数看起来简单,但有几个细节要注意。正则要能处理嵌套括号和空格,答案比对前要做归一化(去空格、统一大小写、处理分数格式)。我一开始没做归一化,导致1/2和0.5被判为错误,奖励信号噪声很大。另外,如果模型输出里没有 boxed 答案,直接给 0 分,不要给部分分,否则模型会学会输出一堆废话来碰运气。
5.3 两种算法的实测效果对照
我在 GSM8K 上做了对照实验,同样的 Qwen2.5-7B 基座,同样的数据,PPO 和 GRPO 各跑 15 个 epoch。PPO 的最终准确率约 72%,GRPO 约 70%,差距在噪声范围内。但 GRPO 的训练时间少了约 35%,显存峰值低了 30% 以上。从性价比角度看,GRPO 明显更划算。
| 指标 | PPO | GRPO |
|---|---|---|
| 最终准确率 | 72.1% | 70.3% |
| 单 epoch 耗时 | 42 min | 27 min |
| 显存峰值 | 62G | 41G |
| 调参难度 | 高 | 中 |
| 奖励曲线平滑度 | 波动大 | 较平滑 |
PPO 的优势在于它理论上能做更精细的信用分配,如果你的任务需要区分一条回复里哪些 token 贡献了奖励,PPO 的 value 网络能提供这个信息。GRPO 把整条回复当成一个整体来对待,粒度粗一些,但在大多数有明确对错的任务上够用了。
6. 常见报错与排查技巧实录
6.1 显存相关报错的处理顺序
显存 OOM 是最常见的报错,排查顺序应该是:先降ppo_micro_batch_size,再降ppo_mini_batch_size,然后降max_response_length,最后考虑换更小的模型或者上 LoRA。我见过有人一上来就换模型,其实只要把 micro_batch 从 8 降到 4 就能解决。
vLLM 的显存报错比较特殊,它会在启动时预分配 KV cache,如果gpu_memory_utilization设太高,训练部分就没显存了。报错信息通常是CUDA out of memory但发生在 rollout 阶段。这时候把gpu_memory_utilization降到 0.4 到 0.5 之间试试。
实操心得:在 8 卡机器上,我习惯留 1 到 2 卡专门给 vLLM 做推理,训练用剩下的卡。这样两边互不干扰,显存规划更清晰。配置上通过
tensor_model_parallel_size和fsdp_size来控制卡数分配。
6.2 训练不收敛的典型症状与对策
训练不收敛的表现有好几种。reward 一直不涨,可能是学习率太小或者奖励信号太稀疏;reward 涨了又跌,通常是 kl_coef 太小;loss 变成 NaN,多半是梯度爆炸,需要加梯度裁剪。verl 默认的梯度裁剪阈值是 1.0,如果还炸,降到 0.5。
还有一种隐蔽的情况是 reward 涨但实际效果变差,这叫 reward hacking。模型学会了钻奖励函数的空子,比如输出特定格式但内容错误。对策是加多样性惩罚、长度惩罚,或者用多个奖励函数做集成。我在 GSM8K 上遇到过模型输出\boxed{42}但推理过程完全胡扯的情况,后来加了一个推理步骤的格式检查才缓解。
6.3 分布式通信相关的疑难杂症
多机训练时最容易出通信问题。NCCL 报错通常和网络配置有关,检查NCCL_SOCKET_IFNAME是否指向正确的网卡,NCCL_IB_DISABLE是否设成了 1(如果没有 InfiniBand)。Ray 集群的节点发现也有坑,ray start --address要指向 head 节点的正确 IP 和端口。
| 报错关键词 | 可能原因 | 解决方向 |
|---|---|---|
| NCCL timeout | 网卡配置错误 | 检查 NCCL_SOCKET_IFNAME |
| Ray actor died | 显存 OOM | 降 batch size |
| CUDA error: misaligned address | flash-attn 版本不匹配 | 重装 flash-attn |
| Connection refused | Ray head 未启动 | 先起 head 再起 worker |
| NaN loss | 梯度爆炸 | 降学习率、加梯度裁剪 |
我遇到过一次 Ray actor 反复重启的问题,日志里只显示actor died unexpectedly,最后发现是某个 worker 的显存被其他进程占了。用nvidia-smi逐个节点检查,杀掉残留进程就好了。所以训练前养成清空 GPU 的习惯,能省很多排查时间。
7. 我个人的调参经验与后续扩展方向
跑通 verl 只是第一步,真正花时间的是调参。我的经验是先把学习率固定在 1e-6,把 kl_coef 和 batch size 调稳,再动学习率。学习率太大容易崩,太小收敛慢,1e-6 到 5e-7 之间是比较安全的区间。GRPO 对学习率的敏感度比 PPO 低一些,可以用 2e-6 起步。
奖励函数的设计比算法选择更重要。我现在的做法是先用规则奖励跑一版 baseline,看看模型能到什么水平,再决定要不要上 reward model。很多时候规则奖励已经够用,上 reward model 反而引入额外的不确定性。如果非要用 reward model,记得定期用人工评估校准,防止 reward 漂移。
后续扩展的话,verl 支持多轮对话和工具调用场景,配置上需要改 data 的格式和 rollout 的逻辑。我还没深入这块,但看文档和示例代码,扩展性做得不错。另外它和 Megatron 的集成也在推进中,如果手头有更大的模型要训,可以关注这个方向。
最后分享一个小技巧:verl 的日志默认输出到控制台,信息量很大但不好检索。我习惯在启动命令后面加2>&1 | tee train.log,把日志同时写到文件,方便后面用 grep 查关键指标。再配合一个简单的 Python 脚本解析日志画曲线,比 TensorBoard 更灵活。这套组合拳打下来,实验迭代速度能快不少。