跳转到正文

16.8 动手:veRL 代码生成 RL 实验

上一节讲 OPD 时,我们把 teacher 当成密集奖励来源。本节回到 RLVR 的路线,换一个更硬的场景:代码生成

代码题和数学题一样有一个关键优势:答案不是靠人主观打分,而是可以运行测试来验证。能通过测试,就给正奖励;不能通过,就给低奖励或零奖励。和数学 RLVR 相比,代码任务的 reward 更硬——不仅看输出对不对,还要把模型生成的代码真正跑起来。

本节用 veRL 在代码生成任务上跑通 PPO 训练。8.7 节已经在 GSM8K 数学题上用过 veRL,本节换到代码题,最大的变化是 reward 函数:数学只需要抽取最终数字做对比,代码必须把模型输出执行一遍。

本节参考了火山引擎的 veRL Code Sandbox 教程[1],具体参考了以下内容:

  • 训练配置:Eurus-2-RL-Data 数据集(仅 code 样本)+ Qwen2.5 系列模型 + PPO(GAE advantage 估计)的整体方案。
  • 数据处理:filter 超长 prompt、随机采样 1000 条训练数据的流程。
  • Reward 设计思路:把模型生成的代码当独立程序,跑 stdin/stdout 测试算通过率(详见下文 Reward 函数设计)。
  • 评测方法与数据:使用 EvalScope 在 GSM8K、HumanEval、LiveCodeBench 上的评测流程,以及 RL 训练前后的对比数据。

火山引擎原始教程使用 VKE 集群 + SandboxFusion 云沙箱做大规模分布式训练。本节把这些方案适配到本地 GPU 环境:用子进程隔离代替云沙箱,用单卡/多卡脚本代替集群部署,保留相同的算法逻辑和参数配置。完整的工业级代码 Agent 实验放在 10.5 用 rLLM 训练 DeepCoder Agent,那里更关注 AgentFlow 和 sandbox cookbook;本节更关注如何把代码 verifier 接进 veRL 训练框架。

Mermaid diagram

为什么代码生成适合 RLVR

普通聊天任务很难定义"正确答案"。同一句回复,可能有人喜欢简洁,有人喜欢详细,Reward Model 也可能被模型钻空子。

代码任务简单很多。比如题目要求写一个 two_sum(nums, target)

python
def two_sum(nums, target):
    ...

我们可以准备测试:

python
assert two_sum([2, 7, 11, 15], 9) == [0, 1]
assert two_sum([3, 2, 4], 6) == [1, 2]
assert two_sum([3, 3], 6) == [0, 1]

模型写得再漂亮,如果测试不过,reward 就低。模型解释得再长,如果没有给出可执行代码,reward 也低。这种反馈比"看起来像正确答案"的文本打分可靠得多。

代码 RLVR 的 reward 通常有三层:

层级检查什么典型 reward
格式检查是否提取到代码块、函数名是否存在0.0–0.2
编译/语法检查能否 import 或执行0.0–0.3
单元测试通过多少测试用例0.0–1.0

最重要的是第三层。前两层只是让训练早期不至于完全没有信号。

环境准备

硬件要求

本节配置针对单张 GPU(24GB 显存,如 RTX 3090 / 4090 / A5000)或多卡环境:

模型参数量训练方案显存需求
Qwen2.5-Coder-0.5B0.5B全参 + vLLM~18 GB(单卡)
Qwen2.5-Coder-1.5B1.5BLoRA + vLLM~20 GB(单卡)
Qwen2.5-Coder-7B7B全参训练~80 GB(A100 单卡或多卡)

和 8.7 节一样,PPO 需要同时加载 Actor、Critic(可训练)和 Reference(冻结),加上 vLLM 推理引擎,所以显存压力比纯 SFT 大。0.5B 代码模型 + 全参训练是最安全的单卡起点。

安装 veRL

如果已经按 8.7 节安装过 veRL,可以跳过。否则:

bash
# 创建环境
conda create -n verl python==3.10 -y
conda activate verl

# 安装 PyTorch(CUDA 12.x)
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121

# 安装 veRL
git clone https://github.com/volcengine/verl.git
cd verl
pip install -e .

# 安装 vLLM(推理引擎)
pip install vllm==0.8.3

# 安装 Flash Attention
pip install flash-attn --no-build-isolation

数据准备

本节使用 Eurus-2-RL-Data 数据集,来自 PRIME-RL 项目,是一个专门为强化学习设计的数学 + 代码推理数据集。

注意(issue #53):Eurus-2-RL-Data 没有 entry_pointtests 这类顶层字段。它的真实结构是 veRL 原生格式,验证信息存在 reward_model 列里:

字段含义
promptchat 消息数组:[{"role":"system",...}, {"role":"user",...}]。system 是 PRIME 推理动作模板([ASSESS]/[ADVANCE]/…),user 才是题目
ability"math""code",本实验只取 code
reward_model{"ground_truth": <答案>, "style": "rule"}。code 样本的 ground_truth 是 JSON 字符串 {"inputs": [...], "outputs": [...]},即 stdin/stdout 测试对
data_source题目来源:codecontests / taco / apps / codeforces
extra_info{"index": ..., "split": ...}

也就是说,这些 code 样本是**"读 stdin、写 stdout"的竞赛编程题**,不是"实现某个函数签名"的题目——所以没有 entry_point,测试也不是 assert 语句,而是输入输出对。reward 函数要把模型生成的代码当独立程序运行,喂入输入、比对输出。

数据集已分好 split:train 48 万条(其中 ability=="code" 2.5 万条),validation 2048 条(其中 code 1024 条)。

处理数据的脚本见 code/chapter18_grpo/verl_code_rlvr/prepare_data.py,一键生成 veRL 需要的 parquet:

bash
conda activate test
python code/chapter18_grpo/verl_code_rlvr/prepare_data.py

脚本做的事:

  1. 过滤 code 样本ability == "code",得到 2.5 万条代码题。
  2. 重建 prompt:去掉 system 消息里的 PRIME 推理动作模板(对代码生成没有意义),只保留 user 的题目,重建为 chat 消息格式 [{"role":"system","content":"You are a competitive programming assistant."}, {"role":"user","content":"读 stdin 写 stdout 指令 + 题目"}]。⚠️ 不要用纯文本字符串——veRL 会对 prompt 做 apply_chat_template,字符串会被丢弃(见下方字段表注意事项)。
  3. 过滤 + 采样:过滤 prompt 超过 512 token 的样本(1 token ≈ 4 字符),然后随机采样 1000 条,存为 ~/data/eurus2/train1000.parquet;validation 直接存为 ~/data/eurus2/validation.parquet

处理完成后,train1000.parquet 的列就是 veRL 原生格式:

字段含义示例
promptchat 消息列表(system 指令 + user 题目)[{"role":"system","content":"You are a competitive programming assistant."}, {"role":"user","content":"Read the problem…"}]
reward_model{"ground_truth": I/O 测试 JSON, "style": "rule"}'{"inputs": [...], "outputs": [...]}'
data_source题目来源"codecontests" / "taco" / "apps"
ability"code""code"
extra_info{index, split}{"index": 0, "split": "dummy"}

为什么 prompt 必须是 chat 消息格式,而不是纯文本? veRL 的 RLHFDataset 会把 prompt 交给模型的 apply_chat_template。如果 prompt 是纯字符串,Qwen 的模板会直接丢弃内容,只生成 system + assistant 两个特殊 token(实测只有 24 个 token),模型根本看不到题目、reward 恒为 0。所以 prepare_data.py 重建 prompt 时用的是 [{"role": "system", ...}, {"role": "user", ...}] 结构。

训练时模型只看到 prompt,veRL 会把 reward_model.ground_truth 传给 reward 函数做验证。这就是代码 RLVR 的核心——reward 函数不评价文字风格,只评价代码能否跑通测试

Reward 函数设计

8.7 节的 GSM8K reward 只需要从模型输出中提取最终数字,做一次数值比较。代码任务完全不同:需要从 markdown 中提取代码块,放到隔离环境中执行测试,处理编译错误、运行异常和超时。

这是本节和 8.7 节最大的工程差异。下面逐模块讲解 reward 函数的设计。

从模型输出中提取代码

模型的输出通常是一段包含解释和代码的 markdown 文本。我们需要从中提取出 Python 代码部分:

python
import re

_CODE_BLOCK_RE = re.compile(r"```(?:python)?\n(.*?)```", re.DOTALL)


def extract_code(response: str) -> str:
    """从模型输出中提取 Python 代码块。

    模型通常输出类似这样的文本:
        "```python\nimport sys\n\nfor line in sys.stdin: ...```"
    我们只需要 ```python 和 ``` 之间的部分。
    如果模型没有用代码块格式输出,则把整个回答当作代码(兜底)。
    """
    match = _CODE_BLOCK_RE.search(response)
    if match:
        return match.group(1).strip()
    return response.strip()

如果模型没按格式输出代码块,extract_code 会把整个回答当作代码返回——但这通常会导致语法错误,reward 为 0。这本身就是一种训练信号,迫使模型学会用正确的格式输出代码。

运行 stdin/stdout 测试(I/O 验证)

这里是本节和 8.7 节最大的差异。Eurus-2-RL-Data 的 code 样本没有 tests(assert 语句)reward_model.ground_truth 是 JSON 字符串 {"inputs": [...], "outputs": [...]}——也就是把生成的代码当独立程序跑:对每个 input 喂入 stdin,比对 stdout 和期望 output。

subprocess 起一个真实子进程执行,比 exec 更安全:完整进程隔离,模型写的死循环、文件操作、网络请求都影响不到训练进程:

python
import json
import subprocess
import sys
import tempfile
from pathlib import Path


def run_io_tests(code: str, ground_truth_json: str, timeout_s: float = 10.0):
    """把 code 作为独立程序运行,用 ground_truth 里的 inputs/outputs 测试。

    返回 (pass_rate, 前几个测试的详细结果)。任何异常(语法错误、崩溃、
    超时、输出不匹配)都只影响对应用例,不会中断打分。
    """
    tests = json.loads(ground_truth_json)
    inputs, outputs = tests["inputs"], tests["outputs"]

    with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as f:
        f.write(code)
        tmp_path = f.name

    try:
        passed = 0
        for inp, expected in zip(inputs, outputs):
            try:
                proc = subprocess.run(
                    [sys.executable, tmp_path],
                    input=inp, capture_output=True, text=True, timeout=timeout_s,
                )
                got = proc.stdout.strip()
                if proc.returncode == 0 and got == expected.strip():
                    passed += 1
            except subprocess.TimeoutExpired:
                pass  # 超时(死循环/低效代码)只算这一题不过
        return passed / len(inputs)
    finally:
        Path(tmp_path).unlink(missing_ok=True)

超时设为 10 秒。大部分竞赛题的单测都能在 1 秒内完成,10 秒留足了余量。如果超时,说明模型可能写了死循环或极其低效的代码,只扣这一题的分。

包装成 veRL 的 reward 接口

veRL 的 RewardManager(verl/workers/reward_manager/naive.py)调用 reward 函数的签名是:

python
score = self.compute_score(
    data_source=data_source,   # 数据集 data_source 列
    solution_str=response_str, # 模型生成的完整回答
    ground_truth=ground_truth, # 数据集 reward_model["ground_truth"]
    extra_info=extra_info,     # 数据集 extra_info 列
)

所以 compute_score 要按这个签名写。返回 dict 时,veRL 以 "score" 作为 PPO 的主奖励,其余 key(pass_rateformat)会作为日志附加信息:

python
def compute_score(data_source, solution_str, ground_truth, extra_info=None):
    """veRL reward 入口函数。

    Args:
        data_source: 数据集来源(codecontests/taco/apps/codeforces)
        solution_str: 模型生成的完整回答(markdown 文本)
        ground_truth: reward_model["ground_truth"],code 样本是 I/O 测试的 JSON 字符串
        extra_info: 数据集 extra_info 列(本数据集只有 index/split,未使用)

    Returns:
        {"score": pass_rate, "pass_rate": pass_rate, "format": 是否提取到代码}
    """
    match = _CODE_BLOCK_RE.search(solution_str)
    format_ok = 1.0 if match else 0.0
    code = extract_code(solution_str)
    if not code:
        return {"score": 0.0, "pass_rate": 0.0, "format": 0.0}

    pass_rate, _ = run_io_tests(code, ground_truth)
    return {"score": pass_rate, "pass_rate": pass_rate, "format": format_ok}

完整代码

完整文件见 code/chapter18_grpo/verl_code_rlvr/code_reward.py。可以直接自检(不依赖训练环境):

bash
python code/chapter18_grpo/verl_code_rlvr/code_reward.py

输出示例:

正确代码 -> score=1.00 pass_rate=1.00 format=1
错误代码 -> score=0.00 pass_rate=0.00 format=1
无代码   -> score=0.00 pass_rate=0.00 format=0

这个 reward 函数的核心思想是:不评价文字风格,只评价代码能否跑通测试。模型写了再长的解释,如果代码跑不通,reward 就是 0。这种硬信号比 RM 的软分数可靠得多。

Prompt 模板

训练代码模型时,prompt 要尽量约束输出格式。早期不要让模型自由写长解释,否则 verifier 需要花很多精力抽取代码。

Eurus-2-RL-Data 的 code 样本是"读 stdin、写 stdout"的竞赛题,没有 entry_point/problem_statement 这种字段拆分。prepare_data.py 重建 prompt 时用 chat 消息格式(见 prepare_data.py 里的 CODE_GEN_SYSTEM / CODE_GEN_USER_TEMPLATE):

json
[
  {"role": "system", "content": "You are a competitive programming assistant."},
  {"role": "user", "content": "Read the problem below and write a Python solution that reads from stdin and writes to stdout.\nReturn only one Python code block, with no explanations.\n\nProblem:\n{problem}"}
]

其中 {problem} 是数据集 user 消息里的题目(保留 Input/Output 格式说明和示例)。相比文档早期的方案,这里去掉了 Function name: {entry_point}——因为这类题目不要求实现某个函数签名,而是要求程序自己读 stdin 并写 stdout。

为什么必须是 chat 格式? veRL 会把 prompt 交给 apply_chat_template。纯文本字符串会被 Qwen 模板直接丢弃(只留下 system + assistant 特殊 token),模型看不到题目。所以即使训练 base coder,也建议保持 chat 结构,让模板能正确拼出完整 prompt。关键是保持训练和评测模板一致。

单卡训练脚本

基于 8.7 节的 veRL PPO 脚本结构,适配代码生成任务。整体框架不变,关键差异有三处:数据集换成 Eurus-2-RL-Data(只取 code 样本)、reward 函数换成代码验证、max_response_length 从 256 增大到 512(代码回答通常比数学推理更长)。

脚本的设计思路和 8.7 节完全一致:所有参数通过环境变量设置默认值,需要调整时不用改脚本,直接在命令行覆盖就行。完整脚本见 code/chapter18_grpo/verl_code_rlvr/run_qwen_coder_ppo_single_gpu.sh

和 8.7 节 GSM8K 脚本相比,本节新增的关键配置是 Reward 接线——不配 custom_reward_function 的话 reward 根本不会生效(这是文档早期版本漏掉的):

bash
# ---- Reward 配置 ----
# 用 code_reward.py 做规则奖励(跑 stdin/stdout 测试),不训练 Reward Model
# 这是本节和 8.7 节最大的不同:reward 来自代码执行验证,而不是预训练的 RM
REWARD=(
    reward_model.enable=False
    custom_reward_function.path="$REWARD_FILE"
    custom_reward_function.name=compute_score
)

其中 $REWARD_FILE 默认指向和脚本同目录的 code_reward.pycustom_reward_function.name=compute_score 告诉 veRL 调用 code_reward.py 里的 compute_score 函数。启动训练时把 ${REWARD[@]} 加进 main_ppo 的参数列表:

bash
python3 -m verl.trainer.main_ppo \
    "${DATA[@]}" "${MODEL[@]}" "${ACTOR[@]}" "${ROLLOUT[@]}" \
    "${REF[@]}" "${CRITIC[@]}" "${REWARD[@]}" "${TRAINER[@]}" "$@"

脚本其余部分(数据、模型、Actor/Reference/Critic、Trainer 配置)和 8.7 节基本一致。

配置解读

和 8.7 节 GSM8K 的 PPO 配置相比,几个关键差异:

配置项GSM8K(8.7 节)代码生成(本节)原因
数据集GSM8K 数学题Eurus-2-RL-Data(仅 code 样本)代码任务需要可验证的测试用例
reward 函数gsm8k_rewardcode_reward代码需要提取 + 运行 stdin/stdout 测试
max_response_length256512代码回答通常比数学推理更长
基座模型Qwen2.5-0.5BQwen2.5-Coder代码生成用 coder 变体效果更好
reward 接线custom_reward_function代码 reward 是自定义函数,必须显式接线

其他参数(学习率、clip_ratio、GAE 等)和 8.7 节保持一致——它们是 PPO 的算法参数,不随任务类型变化。

和 8.7 节四模型结构的对应

和 8.7 节一样,PPO 训练涉及四个模型角色:

8.7 节角色本节对应说明
Actoractor_rollout_ref.actor.*可训练策略,生成候选代码并更新
Referenceactor_rollout_ref.ref.*冻结的 SFT 模型,计算 KL 约束
Criticcritic.*可训练价值函数,GAE 估计 advantage
RM/Rewardcode_reward.py:compute_score代码验证:提取 + 子进程跑 stdin/stdout 测试

关键区别是最后一行:8.7 节用数学答案匹配(抽取数字做数值比较),本节用代码执行验证(提取代码 → 子进程运行 → 比对输入输出)。reward 信号按测试通过率给 0~1 的分数,但代码 reward 的工程复杂度更高。

启动训练

直接运行脚本

bash
chmod +x run_qwen_coder_ppo_single_gpu.sh
bash run_qwen_coder_ppo_single_gpu.sh

通过环境变量覆盖参数

bash
# 换用 1.5B coder 模型
MODEL_PATH=Qwen/Qwen2.5-Coder-1.5B-Instruct \
TRAIN_BATCH_SIZE=64 \
PPO_MINI_BATCH_SIZE=16 \
bash run_qwen_coder_ppo_single_gpu.sh
bash
# 多卡扩展(8 卡)
NNODES=1 NDEVICES_PER_NODE=8 \
TRAIN_BATCH_SIZE=1024 \
PPO_MINI_BATCH_SIZE=256 \
ROLLOUT_TP=2 \
bash run_qwen_coder_ppo_single_gpu.sh

Ray 会在 main_ppo 内自动初始化。单卡场景下,所有 worker 在同一张 GPU 上交替执行;多卡时 Ray 自动分配,不需要手动管理集群。

训练输出

训练开始后,终端会输出关键指标:

[Step 1]  train | reward/score=0.03 | reward/pass_rate=0.03 | reward/format=0.15 | kl=0.000
[Step 5]  val   | reward/score=0.08 | reward/pass_rate=0.08
[Step 6]  train | reward/score=0.12 | reward/pass_rate=0.12 | reward/format=0.45 | kl=0.002
[Step 10] val   | reward/score=0.21 | reward/pass_rate=0.21

指标名用的是 reward/score(即 compute_score 返回字典里的 score 键,veRL 以它作为 PPO 主奖励),pass_rateformat 是额外的日志指标。

注意 format 指标通常比 pass_rate 先上升——模型先学会"按格式输出代码块",然后才逐渐学会"写出能通过测试的代码"。这是代码 RLVR 的典型训练动态。

训练指标分析

关键指标解读

指标健康信号危险信号
reward/pass_rate缓慢上升长期为 0 或突然暴涨
reward/format先于 pass_rate 上升一直很低(模型不输出代码)
kl缓慢增长持续飙升
actor_loss在 0.5~1.0 之间波动爆炸到 >10 或 NaN
response_length稳定或略微增长和 reward 同步暴涨

代码 RLVR 的典型训练曲线

阶段 1:学格式(step 1~10)pass_rate 接近 0,但 format 开始上升。模型正在学会"把代码放在 ```python 代码块里输出",但写出来的代码大部分还跑不通。kl 接近 0。

阶段 2:学写代码(step 10~40)pass_rate 开始稳步上升。模型已经稳定输出代码格式,开始学会写能编译的代码,然后是能通过部分测试的代码。这个阶段是 PPO 最有效的窗口。

阶段 3:边际收益递减(step 40+)pass_rate 增速放缓。剩余的错误通常是因为模型能力天花板——题目本身太难,模型参数量不够。

参考评测结果

以下是基于火山引擎官方实验(Qwen2.5-7B-Instruct-1M,Eurus-2-RL-Data 约 1000 条训练数据,130 steps PPO)的评测数据[1:1],使用 EvalScope 在三个 benchmark 上评测:

模型GSM8KHumanEvalLiveCodeBench
Qwen2.5-7B-Instruct-1M(原始)0.820.590.50
Qwen2.5-7B-Instruct-1M-step130(RL 后)0.830.590.53

可以看到:

  • LiveCodeBench 提升最明显(0.50 → 0.53),这是代码能力的直接体现——RL 训练让模型在动态编程题上表现更好。
  • GSM8K 小幅提升(0.82 → 0.83),说明代码 RL 训练也有一定的数学推理迁移效果。
  • HumanEval 保持不变(0.59),这个 benchmark 的题目相对固定,1000 条训练数据的覆盖范围有限。

经过 RL 训练后,模型数学推理步骤逻辑更加清晰,语言更简洁,更能按提示词要求输出答案格式。理论上增加训练步数和使用更多训练数据,还有进一步提升空间。

注意:上表数据来自火山引擎官方在多 GPU 环境上的实验结果。本节的单卡脚本模型更小、训练步数更少,具体数值会有差异,但训练动态和趋势一致。

模型评测

训练完成后,对 checkpoint 做独立评测,确认 PPO 训练确实带来了能力提升。

Checkpoint 合并

veRL 使用 FSDP 训练,保存的 checkpoint 是按 GPU 分片的。需要合并为标准 HuggingFace 格式:

bash
python scripts/model_merger.py merge \
    --backend fsdp \
    --local_dir /path/to/checkpoints/global_step_20/actor \
    --target_dir ./merged_model

EvalScope 评测

使用 EvalScope 做独立评测:

bash
# 安装 EvalScope
pip install evalscope

# 评测代码能力(HumanEval + LiveCodeBench)
evalscope eval \
    --model ./merged_model \
    --datasets humaneval livecodebench \
    --limit 100

# 评测数学推理(作为对照)
evalscope eval \
    --model ./merged_model \
    --datasets gsm8k \
    --limit 100

评估时注意:

  • 使用 test 集:不能用在训练集上评测,否则分数虚高。
  • 对比 baseline:同时评测 RL 前的原始模型,才能量化 PPO 带来的真实提升。
  • 多 benchmark 对照:只看 HumanEval 不够,LiveCodeBench 更能反映代码模型的实际能力。

从单卡扩展到多卡

理解了单卡配置后,扩展到多卡只需要修改几个关键参数:

参数单卡8 卡说明
NDEVICES_PER_NODE18GPU 数量
TRAIN_BATCH_SIZE1281024总 batch(FSDP 自动切分到各卡)
PPO_MINI_BATCH_SIZE64256同上
ROLLOUT_TP12vLLM 张量并行度
ROLLOUT_GPU_MEM_UTIL0.40.6多卡时每卡可以多用一点

学习率、clip_ratio、GAE 参数等不需要改——它们是算法参数,不随硬件规模变化。

和 10.5 DeepCoder 实验的关系

本节和 10.5 讲的是同一个大方向:用 sandbox reward 训练代码模型。区别在于关注点不同:

小节框架重点
9.7 本节veRL把代码 verifier 接进 PPO/GRPO 训练框架
10.5rLLM用 DeepCoder cookbook 跑完整 Agentic 实验

如果你想先跑通一个端到端案例,优先看 10.5。如果你已经熟悉 veRL,想把数学 RLVR 扩展到代码任务,就沿着本节的 data、reward、trainer 三个接口补齐。

实验检查清单

正式训练前,至少检查这些点:

  • 测试集不能出现在训练数据里。
  • reward 函数必须设置超时,避免死循环卡住 rollout。
  • reward 日志要记录三类错误:编译失败、运行失败、测试失败。
  • 不要只看训练 reward,要固定一份独立 eval set 看 Pass@1。
  • 如果加入格式奖励,权重不要超过测试通过奖励。

代码生成 RL 的好处是反馈硬、可复现;难点是工程边界更复杂。把 verifier 写稳,比调 PPO/GRPO 超参更重要。


参考文献
  1. 火山引擎,"veRL Code Sandbox 代码生成强化学习",https://www.volcengine.com/docs/6460/1756203 ↩︎ ↩︎

现代强化学习实战课程