文章

基于 verl 的 Qwen3.5-0.8B GRPO 记录

基于 verl 的 Qwen3.5-0.8B GRPO 记录

本文旨在完整记录一次利用 verl 对 Qwen3.5-0.8B 进行 GRPO 训练的流程,内容涵盖环境配置、任务定义、数据准备、过程监控、结果分析等

实验环境

  • python 3.11
  • torch 2.10.0+cu128
  • vllm 0.18.0
  • transformers 5.3.0.dev0
  • ray 2.56.0
  • verl 0.9.0.dev0
  • GPU 1x NVIDIA 4090 (CUDA 13.2)

环境安装与准备

首先创建独立的 Python 3.11 环境。当前 verl main 分支会用到 enum.StrEnum,Python 3.10 在 Ray worker import 阶段会报错。

1
2
conda create -n verl-qwen35 python=3.11 -y
conda activate verl-qwen35

然后安装 verl 源码。这里使用源码安装,方便跟上 Qwen3.5 相关示例脚本和最新配置。

1
2
3
git clone --depth 1 https://github.com/verl-project/verl.git
cd verl
pip install -e '.[math]'

Qwen3.5 对依赖版本比较敏感。普通 transformers==4.57.x 不能识别 model_type: qwen3_5,需要安装 Qwen3.5 示例中对应的 transformers commit。vLLM 使用 0.18.0,同时补上 verl 运行时会用到的 TransferQueue

1
2
3
4
5
6
pip install 'vllm==0.18.0' 'TransferQueue==0.1.8'

pip install --no-deps \
  'git+https://github.com/huggingface/transformers.git@cc7ab9be508ce6ed3637bba9e50367b29b742dc6'

pip install 'huggingface-hub>=1.3.0'

安装后可以简单检查版本:

1
2
3
4
5
6
7
8
python - <<'PY'
import torch, transformers, vllm, ray

print("torch", torch.__version__, "cuda", torch.version.cuda, torch.cuda.is_available())
print("transformers", transformers.__version__)
print("vllm", vllm.__version__)
print("ray", ray.__version__)
PY

模型从 ModelScope 下载。

1
2
3
4
5
pip install -U modelscope

modelscope download \
  --model Qwen/Qwen3.5-0.8B \
  --local_dir ./models/Qwen3.5-0.8B

数据先用 GSM8K。verl 已经提供了预处理脚本,会把原始数据整理成训练需要的 parquet 文件。

1
2
3
4
cd verl

python examples/data_preprocess/gsm8k.py \
  --local_dir ./data/gsm8k

生成结果包括:

1
2
./data/gsm8k/train.parquet
./data/gsm8k/test.parquet

任务定义

GSM8K 是小学数学文字题数据集,每条样本包含一道自然语言题目和一段带推理过程的标准答案。对于 reward,模型只要在回答末尾按约定格式输出最终数字,就可以用规则函数判断对错。

原始 GSM8K 样本大致长这样:

1
2
3
4
{
  "question": "Natalia sold clips to 48 of her friends in April, and then she sold half as many clips in May. How many clips did Natalia sell altogether in April and May?",
  "answer": "Natalia sold 48/2 = <<48/2=24>>24 clips in May.\nNatalia sold 48+24 = <<48+24=72>>72 clips altogether in April and May.\n#### 72"
}

verl 的预处理脚本会做两件事。第一,把题目包装成 chat 格式的 prompt,并追加一句格式要求:Let's think step by step and output the final answer after "####". 第二,从标准答案中抽取 #### 后面的数字,放进 reward_model.ground_truth,供后续规则奖励使用。

处理后的 parquet 样本可以理解为下面这种结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
  "data_source": "openai/gsm8k",
  "prompt": [
    {
      "role": "user",
      "content": "Natalia sold clips to 48 of her friends in April, and then she sold half as many clips in May. How many clips did Natalia sell altogether in April and May? Let's think step by step and output the final answer after \"####\"."
    }
  ],
  "ability": "math",
  "reward_model": {
    "style": "rule",
    "ground_truth": "72"
  },
  "extra_info": {
    "split": "train",
    "index": 0,
    "question": "Natalia sold clips to 48 of her friends in April, and then she sold half as many clips in May. How many clips did Natalia sell altogether in April and May?",
    "answer": "Natalia sold 48/2 = <<48/2=24>>24 clips in May.\nNatalia sold 48+24 = <<48+24=72>>72 clips altogether in April and May.\n#### 72"
  }
}

训练时,模型看到的是 prompt,生成一段带推理过程的回答。默认 reward 函数会从模型输出最后一段中匹配 #### 数字,再和 ground_truth 比较。匹配正确给 1.0,没有按格式输出或数字错误给 0.0

比如下面这个输出可以拿到奖励:

1
2
3
Natalia sold half as many clips in May, so she sold 24 in May.
Altogether she sold 48 + 24 = 72 clips.
#### 72

而下面这个即使文字解释接近,也会因为格式不符合 strict 规则而拿不到奖励:

1
Natalia sold 72 clips altogether.

训练配置

verl 的配置主要通过 Hydra override 传入。为了复现实验,最方便的做法是把命令整理成一个脚本,例如放在项目目录下的 scripts/run_qwen35_0_8b_gsm8k_grpo.sh。下面这份配置使用单张 4090:每步取 8 个 prompt,每个 prompt 采样 8 条 response,最大 response 长度为 1024,训练 48 step;同时开启 TensorBoard 记录,并每 24 step 保存一次 checkpoint。为保证长时间任务的稳定,推荐配合 screen 或 tmux 使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
#!/usr/bin/env bash
set -euo pipefail

###########################
# user-adjustable
###########################
export CUDA_VISIBLE_DEVICES=0
export CUDA_DEVICE_ORDER=PCI_BUS_ID
export WANDB_MODE=disabled
export TOKENIZERS_PARALLELISM=false
export RAY_DEDUP_LOGS=0

PYTHON_BIN=${PYTHON_BIN:-python}
VERL_DIR=${VERL_DIR:-/path/to/verl}
MODEL_PATH=${MODEL_PATH:-/path/to/models/Qwen3.5-0.8B}
TRAIN_FILE=${TRAIN_FILE:-/path/to/data/gsm8k/train.parquet}
TEST_FILE=${TEST_FILE:-/path/to/data/gsm8k/test.parquet}

PROJECT_NAME=${PROJECT_NAME:-GRPO-Qwen3_5}
EXPERIMENT_NAME=${EXPERIMENT_NAME:-Qwen3_5-0_8B-GSM8K-b8n8-r1024-s48-tb-save24}
OUTPUT_DIR=${OUTPUT_DIR:-/path/to/outputs/${EXPERIMENT_NAME}}

TRAIN_SAMPLES=${TRAIN_SAMPLES:-1024}
VAL_SAMPLES=${VAL_SAMPLES:-256}
TRAIN_BATCH_SIZE=${TRAIN_BATCH_SIZE:-8}
ROLLOUT_N=${ROLLOUT_N:-8}
MAX_PROMPT_LENGTH=${MAX_PROMPT_LENGTH:-512}
MAX_RESPONSE_LENGTH=${MAX_RESPONSE_LENGTH:-1024}
TOTAL_STEPS=${TOTAL_STEPS:-48}
TEST_FREQ=${TEST_FREQ:-12}
SAVE_FREQ=${SAVE_FREQ:-24}
LOGGER=${LOGGER:-'["console","tensorboard"]'}

ROLLOUT_GPU_MEM_UTIL=${ROLLOUT_GPU_MEM_UTIL:-0.9}
LOG_PROB_MAX_TOKEN_LEN=${LOG_PROB_MAX_TOKEN_LEN:-2048}
ROLLOUT_MAX_MODEL_LEN=${ROLLOUT_MAX_MODEL_LEN:-1792}

ray stop --force || true
mkdir -p "${OUTPUT_DIR}"
cd "${VERL_DIR}"

###########################
# parameter arrays
###########################
DATA=(
  algorithm.adv_estimator=grpo
  algorithm.use_kl_in_reward=False
  data.train_files="${TRAIN_FILE}"
  data.val_files="${TEST_FILE}"
  data.train_max_samples=${TRAIN_SAMPLES}
  data.val_max_samples=${VAL_SAMPLES}
  data.train_batch_size=${TRAIN_BATCH_SIZE}
  data.max_prompt_length=${MAX_PROMPT_LENGTH}
  data.max_response_length=${MAX_RESPONSE_LENGTH}
  data.filter_overlong_prompts=True
  data.truncation=error
  data.shuffle=False
  data.dataloader_num_workers=0
)

MODEL=(
  actor_rollout_ref.model.path="${MODEL_PATH}"
  actor_rollout_ref.model.use_remove_padding=False
  actor_rollout_ref.model.enable_gradient_checkpointing=True
  +actor_rollout_ref.model.override_config.attn_implementation=eager
)

ACTOR=(
  actor_rollout_ref.actor.optim.lr=1e-6
  actor_rollout_ref.actor.ppo_mini_batch_size=${TRAIN_BATCH_SIZE}
  actor_rollout_ref.actor.ppo_micro_batch_size_per_gpu=1
  actor_rollout_ref.actor.ppo_max_token_len_per_gpu=${LOG_PROB_MAX_TOKEN_LEN}
  actor_rollout_ref.actor.use_kl_loss=True
  actor_rollout_ref.actor.entropy_coeff=0
  actor_rollout_ref.actor.kl_loss_coef=0.01
  actor_rollout_ref.actor.kl_loss_type=low_var_kl
  actor_rollout_ref.actor.use_torch_compile=False
  actor_rollout_ref.actor.strategy=fsdp2
  actor_rollout_ref.actor.use_dynamic_bsz=False
  actor_rollout_ref.actor.fsdp_config.fsdp_size=1
  actor_rollout_ref.actor.fsdp_config.reshard_after_forward=True
  actor_rollout_ref.actor.fsdp_config.offload_policy=True
  actor_rollout_ref.actor.fsdp_config.param_offload=True
  actor_rollout_ref.actor.fsdp_config.optimizer_offload=True
  actor_rollout_ref.actor.fsdp_config.ulysses_sequence_parallel_size=1
)

REF=(
  actor_rollout_ref.ref.strategy=fsdp2
  actor_rollout_ref.ref.log_prob_micro_batch_size_per_gpu=1
  actor_rollout_ref.ref.log_prob_max_token_len_per_gpu=${LOG_PROB_MAX_TOKEN_LEN}
  actor_rollout_ref.ref.use_torch_compile=False
  actor_rollout_ref.ref.fsdp_config.reshard_after_forward=True
  actor_rollout_ref.ref.fsdp_config.offload_policy=True
  actor_rollout_ref.ref.fsdp_config.param_offload=True
  actor_rollout_ref.ref.fsdp_config.ulysses_sequence_parallel_size=1
)

ROLLOUT=(
  actor_rollout_ref.rollout.name=vllm
  actor_rollout_ref.rollout.ignore_eos=False
  actor_rollout_ref.rollout.log_prob_micro_batch_size_per_gpu=1
  actor_rollout_ref.rollout.log_prob_max_token_len_per_gpu=${LOG_PROB_MAX_TOKEN_LEN}
  actor_rollout_ref.rollout.tensor_model_parallel_size=1
  actor_rollout_ref.rollout.gpu_memory_utilization=${ROLLOUT_GPU_MEM_UTIL}
  actor_rollout_ref.rollout.n=${ROLLOUT_N}
  actor_rollout_ref.rollout.enable_chunked_prefill=True
  actor_rollout_ref.rollout.max_model_len=${ROLLOUT_MAX_MODEL_LEN}
  actor_rollout_ref.rollout.max_num_seqs=64
  actor_rollout_ref.rollout.max_num_batched_tokens=8192
  actor_rollout_ref.rollout.free_cache_engine=True
  actor_rollout_ref.rollout.enforce_eager=False
  actor_rollout_ref.rollout.enable_prefix_caching=False
  actor_rollout_ref.rollout.agent.num_workers=1
  actor_rollout_ref.rollout.checkpoint_engine.update_weights_bucket_megabytes=512
  +actor_rollout_ref.rollout.engine_kwargs.vllm.limit_mm_per_prompt.image=0
  +actor_rollout_ref.rollout.engine_kwargs.vllm.limit_mm_per_prompt.video=0
)

TRAINER=(
  trainer.critic_warmup=0
  trainer.logger="${LOGGER}"
  trainer.project_name="${PROJECT_NAME}"
  trainer.experiment_name="${EXPERIMENT_NAME}"
  trainer.n_gpus_per_node=1
  trainer.nnodes=1
  trainer.balance_batch=False
  trainer.resume_mode=disable
  trainer.val_before_train=False
  trainer.save_freq=${SAVE_FREQ}
  trainer.test_freq=${TEST_FREQ}
  trainer.total_epochs=1
  trainer.total_training_steps=${TOTAL_STEPS}
  trainer.default_hdfs_dir=null
  trainer.default_local_dir="${OUTPUT_DIR}"
)

REWARD=(
  reward.num_workers=1
)

TRANSFER_QUEUE=(
  transfer_queue.backend.SimpleStorage.num_data_storage_units=1
)

###########################
# launch
###########################
"${PYTHON_BIN}" -m verl.trainer.main_ppo \
  "${DATA[@]}" \
  "${MODEL[@]}" \
  "${ACTOR[@]}" \
  "${REF[@]}" \
  "${ROLLOUT[@]}" \
  "${TRAINER[@]}" \
  "${REWARD[@]}" \
  "${TRANSFER_QUEUE[@]}" \
  "$@"

运行时直接执行脚本即可:

1
bash scripts/run_qwen35_0_8b_gsm8k_grpo.sh

如果需要实时看曲线,可以另开一个终端启动 TensorBoard:

1
tensorboard --logdir tensorboard_log --port 16006

重要参数:

  • data.train_batch_size=8 表示每个 step 取 8 个 prompt
  • actor_rollout_ref.rollout.n=8 表示每个 prompt 采样 8 条回答,因此一次 rollout 会产生 64 条 response
  • data.max_response_length=1024 给模型留出更长的推理空间,同时也会显著增加 rollout 和 log prob 计算成本
  • actor_rollout_ref.model.use_remove_padding=False 是 Qwen3.5 上比较关键的一项。开启 remove padding 时,actor 侧 old log-prob 计算容易在 RoPE 相关 shape 上出错
  • actor_rollout_ref.actor.use_dynamic_bsz=False 也是同一类稳定性设置,先固定 batch 行为,避免在 smoke 和小规模观察实验里引入额外变量
  • max_num_seqs=64 对应 8 个 prompt 乘以 8 条 response
  • max_model_len=1792 需要覆盖 prompt 和 response 的总长度;配合 max_response_length=1024 时,log-prob 相关 token 上限也相应设为 2048
  • trainer.total_training_steps=48 控制总 step 数
  • trainer.test_freq=12 表示每 12 step 做一次验证
  • trainer.logger=["console","tensorboard"] 同时保留终端日志和 TensorBoard 曲线
  • trainer.save_freq=24 表示每 24 step 保存一次 checkpoint。checkpoint 保存后还会恢复 rollout engine,显存余量太紧时这里也可能成为新的不稳定点

GRPO 训练

通过观察日志,我们看到训练过程的关键信息。首先,训练启动后,verl 会先做配置校验,然后启动本地 Ray。

1
2
3
4
5
6
7
8
9
[validate_config] All configuration checks passed successfully!
Started a local Ray instance.

'max_response_length': 1024
'train_batch_size': 8
'train_max_samples': 1024
'val_max_samples': 256
'experiment_name': 'Qwen3_5-0_8B-GSM8K-b8n8-r1024-s48'
'total_training_steps': 48

这几行基本确认了本次 run 的规模:训练集采样 1024 条,验证集采样 256 条,每步 8 个 prompt,最大生成长度 1024,一共训练 48 step。日志里会完整打印一次配置树,实际排查时可以用它确认 override 有没有被默认值覆盖。

接着是数据集加载和 prompt 长度过滤。GSM8K 原始 train/test 分别是 7473/1319 条,本次实验只取其中一部分做观察。

1
2
3
4
5
6
7
8
9
10
11
12
Using dataset class: RLHFDataset
dataset len: 7473
selected 1024 random samples out of 7473
filter dataset len: 1024

Using dataset class: RLHFDataset
dataset len: 1319
selected 256 random samples out of 1319
filter dataset len: 256

train and validate dataloader initialized, train dataset size: 1024, val dataset size: 256
Total training steps: 48

这里的 filter dataset len 仍然是 1024/256,说明 max_prompt_length=512 没有过滤掉额外样本。后面的 Total training steps: 48 来自 trainer 初始化阶段,也可以作为最终训练步数的二次确认。

模型和 worker 初始化阶段会打印 actor/ref、FSDP 和 reward loop 的状态。

1
2
3
4
5
6
Qwen3_5ForConditionalGeneration contains 852.99M parameters
Before FSDP, device used/total (GB): 0.39/23.52
After FSDP, device used/total (GB): 3.00/23.52

actor and ref model engine initialized
reward loop manager initialized

这说明 actor 和 ref model 都已经建好,规则奖励也已经注册。

启动阶段还会看到一条 critic 相关 warning:

1
UserWarning: Disabled critic as algorithm.adv_estimator != gae.

这条 warning 和当前设置是匹配的。GRPO 使用 group 内相对奖励估计优势,本次配置里 algorithm.adv_estimator=grpo,所以不会启用 GAE critic。

rollout 由 vLLM 负责。日志里能看到 vLLM 服务的关键参数,以及 CUDA graph capture 的初始化过程。

1
2
3
4
5
6
7
'max_model_len': 1792
'max_num_seqs': 64
'gpu_memory_utilization': 0.9
'n': 8

Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 100%
Capturing CUDA graphs (decode, FULL): 100%

max_num_seqs=64 正好对应 8 prompt * 8 responsemax_model_len=1792 用来覆盖 prompt 和 response 的总长度。CUDA graph capture 是 vLLM 启动阶段的优化步骤,首次启动会多等一会儿,后续生成会受益。

这一段也会出现一些 warning

1
2
3
Default vLLM sampling parameters have been overridden ... max_tokens: 1024
Passing raw prompts to InputProcessor is deprecated ...
Only support config type ... but got qwen3_5. MFU will always be zero.
  • 第一条也能确认 vLLM 侧生成上限已经是 1024
  • 第二条是 vLLM 接口弃用提示,不影响这次训练
  • 第三条说明 verl 当前的 MFU 统计还没有覆盖 qwen3_5,因此日志里的 perf/mfu/actor: 0.0 不能用来判断训练有没有正常运行。

真正进入训练后,每个 step 会输出一行很长的指标。step 1 的代表性字段日志大致如下:

1
2
3
4
5
6
7
8
9
10
11
step:1
training/global_step:1
critic/rewards/mean:0.375
response_length/mean:467.84375
response_length/max:1024.0
response_length/clip_ratio:0.203125
timing_s/gen:89.0894
timing_s/old_log_prob:43.6307
timing_s/ref:43.0311
timing_s/update_actor:328.4308
perf/time_per_step:520.4508
  • critic/rewards/mean 是这一批 rollout 的规则奖励均值,可以粗略理解为本 batch 中答对的比例
  • response_length/clip_ratio 表示生成被长度上限截断的比例,step 1 约为 20.3%,说明 1024 的 response length 仍然会被一部分样本打满
  • timing_s/* 可以看每个阶段的耗时,本次实验里 actor update 是主要耗时来源。

训练结束时,日志先显示进度条到 48/48,然后打印最后一次验证结果。

1
2
3
4
5
6
7
8
Training Progress: 100%|...| 48/48 [5:44:00<00:00, 430.01s/it]

step:48
val-core/openai/gsm8k/acc/mean@1:0.609375
critic/rewards/mean:0.75
response_length/mean:280.859375
response_length/clip_ratio:0.03125
timing_s/testing:24.9256

最终验证集 acc/mean@10.609375,最后一个 train batch 的 reward mean 为 0.75。和训练初期相比,step 48 的截断比例降到 0.03125,生成长度也明显变短。

TensorBoard 图表

在 TensorBoard 中,我们可以随时追踪训练参数:

  • score 是规则函数给出的原始任务分数;在 GSM8K 里,回答的最终数字匹配标准答案就是 1.0,否则是 0.0
  • reward 是真正送进 RL 更新的奖励。在当前配置里没有额外 reward shaping,也没有把 KL 放进 reward 里,所以 critic/rewards/mean 通常和 critic/score/mean 一样。如果以后加入长度惩罚、格式奖励、KL penalty 或 reward model,score 和 reward 就可能不一样。
  • advantage 是把 reward 转成策略更新信号后的结果。GRPO 会在同一个 prompt 的多条 response 之间做相对比较,答得更好的 response 得到正 advantage,答得差的得到负 advantage。图里 critic/advantages/max 经常到 2.4749critic/advantages/min 经常到 -2.4749,说明同组 response 之间存在明显的好坏差异,这正是 GRPO 可以利用的训练信号。
  • return 在 PPO/GAE 语境里通常表示累计回报;但这次没有启用 critic/GAE,reward 又是整条 response 级别的规则奖励,因此日志里的 returns 更接近训练内部复用的回报张量。

本次配置里 algorithm.use_kl_in_reward=False,也没有额外 reward shaping,所以 critic/score/meancritic/rewards/mean 两条曲线完全重合。

algorithm.use_kl_in_reward=False 表示 KL 不会作为惩罚项直接扣到 reward 里;KL 约束仍然可以通过 actor loss 中的 actor/kl_loss 生效。

  • actor/pg_loss 是 policy gradient 部分的 loss,直接对应 GRPO 用 advantage 推动策略更新的项;
  • actor/kl_loss 反映当前策略和参考策略的偏离程度。它在训练后段逐步变大,说明模型确实在离开初始策略;但数值仍然不高,结合前面的 clip 指标接近 0,可以看作这次更新比较保守。
  • actor/loss 是 actor 总 loss,包含 KL 正则等项。因为本次 kl_coef=0.01,前期两条曲线几乎重合;随着 actor/kl_loss 从接近 0 增长到约 0.018,两者后期会出现很小的差异。
  • actor/entropy 粗略反映输出分布的分散程度。原始数据里它大多在 0.400.74 之间波动,没有出现快速塌缩到很低的情况。
  • actor/grad_norm 用来看梯度是否异常。这里主要在 12 附近波动,最高约 2.20,没有明显的梯度爆炸迹象。
  • response_length/mean 是每个 step 生成 response 的平均长度,本次大多在几百 token 范围内波动,平均约 527。它会直接影响 global_seqlen/mean 和训练耗时。
  • prompt_length/mean 是输入 prompt 的平均长度,范围大致是 74110。相比 response 长度,它的变化小很多,因此本次总 token 量主要由 response 侧决定。
  • global_seqlen/mean 是每个 step 的总 token 规模,平均约 3.9 万

因为这次是单卡训练,global_seqlen/min/max/meanbalanced_* 曲线基本重合。多卡训练时,每张卡拿到的样本长度可能不同,global_seqlen/min/max/mean 反映的是各个 rank 原始 token 负载的分布;如果 max 明显高于 min,说明某些 rank 被更长的序列拖慢。balanced_* 是做完 batch balance 之后的负载统计,用来观察重排后 token 是否更均匀;两者差距越小,通常说明跨卡负载越均衡。

  • ppl 即 perplexity,计算上可以理解为 exp(-平均 logprob)。同一批 token 上,perplexity 越低,表示模型给这些 token 的平均概率越高,也就是模型认为这些 token 越自然。
  • rollout_corr/training_ppl 是训练侧 actor 重新计算出来的 perplexity;rollout_corr/rollout_ppl 是 rollout 侧 vLLM 记录的 perplexity。它们看的是同一批 response,只是来自两套执行路径。
  • rollout_corr/ppl_ratio 近似表示 training_ppl / rollout_ppl。理想情况下它应该接近 1;大于 1 表示训练侧认为这些 response 稍微更不自然,小于 1 则相反。
  • rollout_corr/log_ppl_abs_diff 是两边 log perplexity 的平均绝对差。理想情况下它应该接近 0,比 ppl_ratio 更适合观察小量级差异。

training_ppl 的平均值约为 1.688rollout_ppl 约为 1.687ppl_ratio 的平均值约为 1.0005log_ppl_abs_diff 平均约为 0.0012。这说明 vLLM rollout 侧和 actor 训练侧的概率计算整体很近,这是我们希望的。因为 rollout 负责生成样本,actor 负责用这些样本计算 logprob 和更新参数;如果两边对同一批 token 的概率判断差很多,训练就会变成在一个明显偏离的分布上做更新,importance ratio、KL 和 advantage 估计都可能变得不稳定。轻则表现为 loss、clip ratio、KL 曲线异常抖动,重则说明 rollout 权重同步、dtype/实现差异或异步滞后已经影响训练可信度。

这张图没有放 rollout_corr/klk3_klchi2_tokenchi2_seq,但它们也是同一类一致性诊断。

  • kl 直接估计 E[log π_rollout - log π_training]
  • k3_kl 使用更稳定的小 KL 估计,形式近似为 E[r - log(r) - 1],其中 r = π_training / π_rollout
  • chi2_token = E[ρ_t^2] - 1 看 token 级 importance weight 的方差;
  • chi2_seq = E[(∏ρ_t)^2] - 1 看整条 response 级别的累积差异。理想情况下,KL 和 χ² 相关指标也都应接近 0

  • val-core/openai/gsm8k/acc/mean@1 是验证集上的最终答案准确率。这里每 12 step 验证一次,TensorBoard 中记录到 step 12、24、36,数值分别是 0.51170.53520.5703,整体是上升的。
  • val-aux/openai/gsm8k/reward/mean@1 是验证集上的规则奖励均值。在 GSM8K 里,reward 主要来自最终答案是否匹配,所以它和 acc/mean@1 基本对齐;这次两条曲线的数值完全相同。
  • timing_s/step 是单个训练 step 的总耗时,包含生成、logprob 计算、ref 计算、advantage 计算、actor 更新、权重同步等阶段。本次记录到的平均 step 耗时约 401s
  • timing_s/update_actor 是 actor 参数更新耗时,也是当前主要瓶颈。原始数据里它平均约 300s,占单 step 总耗时的大部分。

部署与推理

verl 训练保存下来的 actor checkpoint 通常是 FSDP 分片格式,不能直接当作普通 HuggingFace 模型目录交给 vLLM 或 Transformers 加载。因此部署前需要先把 checkpoint 合并成 HuggingFace 格式。这个步骤只是在整理权重格式,不会改变模型参数。

1
2
3
4
5
6
cd verl

python -m verl.model_merger merge \
  --backend fsdp \
  --local_dir ./outputs/qwen35_grpo/checkpoints/actor \
  --target_dir ./models/qwen35-0.8b-gsm8k-grpo

合并完成后,可以分别部署原始模型和训练后模型。下面用 vLLM 的 OpenAI-compatible server,端口和 GPU 按实际机器调整即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 原始模型
CUDA_VISIBLE_DEVICES=0 vllm serve ./models/Qwen3.5-0.8B \
  --served-model-name qwen35-0.8b-base \
  --host 0.0.0.0 \
  --port 8000 \
  --dtype float16 \
  --max-model-len 2048 \
  --gpu-memory-utilization 0.65 \
  --max-num-seqs 16 \
  --limit-mm-per-prompt '{"image":0,"video":0}'

# 训练后模型
CUDA_VISIBLE_DEVICES=1 vllm serve ./models/qwen35-0.8b-gsm8k-grpo \
  --served-model-name qwen35-0.8b-gsm8k-grpo \
  --host 0.0.0.0 \
  --port 8001 \
  --dtype float16 \
  --max-model-len 2048 \
  --gpu-memory-utilization 0.65 \
  --max-num-seqs 16 \
  --limit-mm-per-prompt '{"image":0,"video":0}'

简单检查服务是否起来:

1
2
curl http://localhost:8000/v1/models
curl http://localhost:8001/v1/models

推理可以直接走 OpenAI 兼容接口。下面的脚本从处理后的 GSM8K parquet 中取出一条样本,用样本里的 chat prompt 调模型;替换 base_urlmodel 即可测试原始模型或训练后模型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import pandas as pd
from openai import OpenAI

DATA_FILE = "./data/gsm8k/test.parquet"
SAMPLE_INDEX = 18
MODEL_NAME = "qwen35-0.8b-gsm8k-grpo"

client = OpenAI(
    base_url="http://localhost:8001/v1",
    api_key="EMPTY",
)

df = pd.read_parquet(DATA_FILE)
row = df.iloc[SAMPLE_INDEX]

messages = row["prompt"].tolist()
gold = row["reward_model"]["ground_truth"]

resp = client.chat.completions.create(
    model=MODEL_NAME,
    messages=messages,
    temperature=0,
    max_tokens=512,
)

print("question:", row["extra_info"]["question"])
print("gold:", gold)
print()
print(resp.choices[0].message.content)

这个例子里,标准答案是 7。题目要求计算 4 周里一共吃了多少打鸡蛋:每天 3 个,4 周是 28 天,一共 3 * 28 = 84 个鸡蛋,84 / 12 = 7 打。

原始模型在这个样例上犯了中间推理错误:它把一周当成了 5 天,因此最终答案变成了 5

1
2
3
4
5
6
There are 5 days in a week.
Eggs per week = 3 eggs/day * 5 days/week = 15 eggs/week.
Total eggs = 15 eggs/week * 4 weeks = 60 eggs.
Dozens = 60 / 12 = 5.

#### 5

训练后模型在同一道题上修正了这个步骤,先用 7 天计算每周鸡蛋数,再得到最终答案。

1
2
3
4
5
6
There are 7 days in a week.
Total eggs per week = 3 eggs/morning * 7 days = 21 eggs.
Total eggs in 4 weeks = 21 eggs/week * 4 weeks = 84 eggs.
Number of dozens = 84 / 12 = 7.

#### 7
本文由作者按照 CC BY 4.0 进行授权