Qwen2.5-1.5B-distill 模型的 RL 优化(一):代码训练的数据准备和基础工程搭建
- 2025-07-28 08:00:00
作者 | Kangkang 编辑 | 大模型之心Tech
原文链接:https://zhuanlan.zhihu.com/p/1918765619614057424
点击下方卡片,关注“大模型之心Tech”公众号
>>点击进入→大模型没那么大Tech技术交流群
本文只做学术分享,如有侵权,联系删文,自动驾驶课程学习与技术交流群事宜,也欢迎添加小助理微信AIDriver004做进一步咨询
项目代码: https://github.com/wizard-III/ArcherCodeR
模型地址: https://huggingface.co/wizardII/ArcherCodeR-1.5B
数据地址: https://huggingface.co/datasets/wizardII/ArcherCodeR-Dataset
训练日志: https://wandb.ai/wangjkpkucs-peking-university/ArcherCodeR?nw=nwuserwangjkpkucs
近期一直在进行基于 Qwen2.5-1.5B-distill 模型的代码推理能力优化实验。计划将这段时间的“炼丹”心得总结分享出来,同时也借此梳理前期的工作。
预计会拆分成 3-4 篇 内容来写:
第一篇 将介绍在 数据和代码层面 的一些基础优化工作。由于 DeepCoder-1.5B 发布较早,我们前期主要以其为基准模型进行对比和改进,嗯 ... 然后发现了其不少优化空间。 后续几篇 将探讨 GRPO 算法 以及在 训练策略 上的一些思考和优化,阐述如何一步步提升模型性能至最终版本(具体内容待整理后发布)。
另外,注意到近期大家在代码推理任务上的工作多集中在 Qwen2.5-7B/14B/32B-distill 等更大规模模型上(如 NVIDIA 的 AceReason-Nemotron和天工的Skywork-OR1)。我原计划是想补充下 7B 模型 的对比实验,但受限于计算资源,目前难以完成。后续如果资源允许,会再补充相关结果。(至于 14B 和 32B 规模 ,则远超我这边当前可用资源,跑不起 )。
先上在 1.5B 模型上的对比实验结果~
模型效果
Model | LCB (8/1/24-2/1/25)(Pass@1) | LCB (8/1/24-2/1/25)(Pass@4) |
---|---|---|
pass@1
指标为采样 4 次的平均值;为确保评测一致性,对所有对比的开源模型我 统一 使用相同的评测脚本和参数设置进行了重新评估( temp=0.8
,max_gen_length=32k
)。同一模型多次评测结果的波动范围通常在 ±0.5 以内;DeepCoder 模型多次评测结果均稳定在 23 左右,未能复现其报告的性能。 NVIDIA 的 Nemotron-Research-Reasoning-Qwen-1.5B 模型的表现则略优于其报告值。推测差异可能源于NVIDIA同学使用的评测脚本中部分参数的设置,导致其报告结果偏低。
注:目前 GitHub 上的代码 暂仅支持到 ArcherCodeR-1.5B-DAPO 的训练 。其他优化策略的实现将在后续逐步整理并同步更新。
训练数据
1. 数据源
当前研究兴趣主要集中于强化学习(RL)算法内在机制的理解与优化上,所以并没有在数据优化上投入过多精力,均直接采用现有开源数据集,仅对其进行基础的整理与过滤工作。所用训练数据来源如下:
DeepCoder-Preview-Dataset Deepmind/code_contests Open-r1/codeforces
其中,DeepMind的code_contests
数据集与Open-r1里新发布的codeforces
数据集均是在原始数据基础上 重新生成了大量测试用例(test cases) ,这有助于 缓解假阳性(false positive)问题 。这两个数据集与先前使用的DeepCoder数据存在 大量重复的prompt 。在遇到重复情况时,我们优先选用code_contests
或codeforces
中的数据 。
2. 格式统一
Deepcoder 中的代码奖励模型(code reward)为每个数据源单独实现了处理逻辑分支, 设计上存在冗余 。 鉴于 这些数据的测试用例特点, 我们完全可以将其统一归为stdin/stdout
和function-call
两种格式 。 采用这种划分方式 ,无论是数据预处理阶段还是后续代码奖励模型的实现, 都能得到大幅简化 。两个格式的数据只在 ground_truth 的格式上有所区别:
stdin/stdout 格式
# ground_truth 示例, 包含inputs/outputs两个key,value类型为list
{
"inputs":
[
"3\n((()))\n(())()\n()())",
"3\n((()()\n(())()\n()(()",
"3\n((()))\n(())))\n()())",
],
"outputs":
[
"YES\nYES\nNO\n",
"NO\nYES\nNO\n",
"YES\nNO\nNO\n",
]
}
function-call 格式
# ground_truth 示例,只包含functional一个key,value类型为string
{
"functional": "
def check(candidate):
assert candidate(["7868190130M7522", "5303914400F9211", "9273338290F4010"]) == 2
assert candidate(["1313579440F2036", "2921522980M5644"]) == 0
assert candidate(["3988132605O4995"]) == 0
assert candidate(["0121327775O7247"]) == 1
assert candidate(["9788507693F1165"]) == 0
assert candidate(["8389394175M8829"]) == 1
assert candidate(["0496428209M7933"]) == 1
check(Solution().countSeniors)"
}
3. 重复问题
Deepcoder 的原始训练数据规模为 24k+ 。但在处理过程中,发现其中存在大量重复数据。部分重复源于不同数据源对特殊字符(如数学公式)的处理方式不一致;另一部分则仅因空格或换行符等细微差异导致,实质为同一题目。
例如 ,以下两个 prompt 的唯一差异在于末句:1 ≤ a_{i} ≤ 10^9
与 1 ≤ ai ≤ 109
。二者实为同一题目,后者疑似格式处理错误丢失了指数符号 ^
。
Question: Sonya was unable to think of a story for this problem, so here comes the formal description.
You are given the array containing n positive integers. At one turn you can pick any element and increase or decrease it by 1. The goal is the make the array strictly increasing by making the minimum possible number of operations. You are allowed to change elements in any way, they can become negative or equal to 0.
-----Input-----
The first line of the input contains a single integer n (1 ≤ n ≤ 3000) — the length of the array.
Next line contains n integer a_{i} (1 ≤ a_{i} ≤ 10^9).
Question: Sonya was unable to think of a story for this problem, so here comes the formal description.
You are given the array containing n positive integers. At one turn you can pick any element and increase or decrease it by 1. The goal is the make the array strictly increasing by making the minimum possible number of operations. You are allowed to change elements in any way, they can become negative or equal to 0.
Input
The first line of the input contains a single integer n (1 ≤ n ≤ 3000) — the length of the array.
Next line contains n integer ai (1 ≤ ai ≤ 109).
经去重后,原始 24k+ 数据中仅剩 12k+ 有效样本。( Deepcoder的数据预处理阶段的去重是咋做的? )
此外 ,我尝试使用 Deepcoder 开源训练数据复现其实验结果,但始终无法达到报告的 25.1 (最高收敛于 23 左右,与我实测其开源模型的表现基本吻合)。emm ..... 其实之前开源的 DeepScaler 也一直没能跑出 43.1 。
4. 假阴/假阳性的问题
这个问题在 NVIDIA 的 AceReason-Nemotron 那篇文章里已经讲的很好了。

针对假阴性问题的处理:
在前期统一测试用例格式时,我们已经剔除了部分格式不规范的数据。然而,训练数据中仍可能存在难以通过简单规则识别的脏数据(例如,观察发现某些样本的测试数据与题目描述不匹配),这些数据仍会导致假阴性问题。
另一方面,自研的代码沙盒本身也可能存在缺陷,导致错误判断,进而引发假阴性。例如,部分样本的测试用例过长,超过了训练中设置的超时限制(timeout),即使模型生成的结果正确,也会被误判为错误(但出于训练效率考虑,timeout 也不宜设置过大)。
我的解决方案很简单:先使用一个能力更强的模型(这里采用的是 Qwen3-30B-A3B,这个模型性价比还行,推理起来没那么耗资源,穷啊,有条件的同学也可选用 Deepseek-R1/Qwen3-235B-A22B 等更强大的模型),对训练数据中的每个 prompt 进行 8 次采样。随后,使用训练所用的代码奖励模型(code reward)对采样结果进行打分。如果 8 次采样结果均未通过测试,则存在三种可能:
数据本身存在问题(错误的测试用例); 代码奖励模型无法处理该数据(如测试用例过长等); prompt 难度过高,强模型也无法正确解答。
无论何种原因,此类数据的训练价值都不高,因此我们将其直接过滤。
针对假阳性问题的缓解:
理论上,解决假阳性的最佳方法是如相关论文所述——精心构造包含复杂边界条件或极端输入约束的强测试用例,以确保错误的解法无法通过测试,从而消除“奖励误报”(false positive reward)。但个人精力有限,未自行生成此类精细测试用例,而是选择利用开源数据集(deepmind/code_contests和open-r1/codeforces)对测试用例进行增强,以期在一定程度上缓解假阳性问题(但无法完全消除)。
后续数据筛选步骤:
过滤简单样本: 使用训练阶段的热启模型(warm-started model)对剩余训练数据再次进行 8 次采样,过滤掉所有采样结果均正确的简单数据。 防止数据泄露: 对 lcb v5 的 279 条测试数据执行 n-gram 去重,确保训练集与测试集间不存在泄露风险。
经过上述逐层筛选,最终保留了 6,753 条训练数据,构成我们的最终训练集( ArcherCodeR-Dataset )。
训练代码
之前训练时一直使用的是较旧版本的 verl 代码。最近梳理工作时顺便 fork 了最新的 verl 代码,并将之前所做的修改集成到了这个新版本上(对应 verl 版本的 commit)。本部分主要说明在 fork verL 代码后,对其 DAPO 训练模块进行的几项关键修改:
1. 实现 Code Reward
首先,实现了code_reward
功能,相关代码在这里:
训练阶段: 使用 firejail
创建沙盒环境执行代码并获取奖励分数;评测阶段: 从 LCB 官方代码库中提取核心评测逻辑进行打分(确保与最终评测标准一致)。
完成code_reward
实现后,在main_dapo.py
中替换下默认的get_custom_reward_fn
函数。

代码沙盒的迭代历程(开发心路,可忽略)
v0:初探与挫折
最初着手代码任务时,第一考虑的是要确保训练阶段的奖励机制(
reward
)与评测标准完全对齐。因此,第一个版本的沙盒直接改造自LCB 官方仓库的核心评测代码。这版本的沙盒,用来跑评测是没有问题,但在训练过程中频繁遭遇 Segment Fault 错误,导致进程崩溃。该方案最终被放弃。
v1:转向 Firejail 与评测偏差
鉴于 LCB 方案的稳定性问题,转而探索基于firejail的方案(参考了 code-r1/deepcoder 的实现)。然而,deepcoder 中的代码奖励 (code reward) 实现过于复杂冗余,针对不同数据来源设计了多种打分方式,完全没必要。我们在其基础上 大幅简化 ,开发了自己的firejail沙盒版本。
用 firejail 做的沙盒,在训练时运行稳定。但 评测结果 与使用官方 LCB 代码进行的 离线测试始终无法对齐 ,存在偏差。
v2:分而治之的最终方案
为解决训练稳定性与评测准确性的矛盾,最终采用了 分阶段策略 :
训练阶段: 使用稳定高效的
firejail
沙盒。评测阶段: 使用官方 LCB 代码,确保结果与最终评测标准严格一致。
2. Reward Manager 优化
刚迁移至新版 VerL 代码后,发现相比之前的代码, DAPO 训练过程 异常缓慢 。经性能分析(Profiling)后发现, 其核心瓶颈在于 Reward 打分机制:VerL 当前的 DAPO 实现是单进程迭代方式进行 Reward 计算!
为解决此性能瓶颈,我们 将 Reward 计算重构为并发模式 。具体实现参考并借鉴了现有成熟方案(prime 和 skywork)在其基础上进行了适配性修改,很好理解,此处不再赘述。相关修改代码位于:https://github.com/wizard-III/ArcherCodeR/blob/main/verl/workers/reward_manager/wizard.py
好奇问下 VerL 中 DAPO 实现是出于什么考量还在使用迭代方案的呢?
3. 动态采样下的 数据利用效率优化
当前 GRPO 类的算法在训练中普遍采用动态采样策略,即每次采样后需过滤掉所有响应得分全为 0 或全为 1 的 Prompt。然而,这种过滤可能导致剩余的有效训练样本不足,从而无法进行接下来的训练的问题。
现有方案分析:
Verl (DAPO) 的解决方法:
增大 gen_batch_size
(用于采样的 Prompt 数量),使其为目标训练批次大小 (train_batch_size
) 的 N 倍,确保过滤后剩余的 Prompt 数量仍大于train_batch_size
。多轮采样: 若过滤后 Prompt 数量仍不足 train_batch_size
,则进行多轮生成,并将结果拼接直至达到目标数量。
核心代码如下:
prompt_bsz = self.config.data.train_batch_size
if num_prompt_in_batch < prompt_bsz:
print(f"{num_prompt_in_batch=} < {prompt_bsz=}")
max_num_gen_batches = self.config.algorithm.filter_groups.max_num_gen_batches
if max_num_gen_batches <= 0 or num_gen_batches < max_num_gen_batches:
print(f"{num_gen_batches=}. Keep generating...")
progress_bar.update(1)
continue
else:
raise ValueError(f"{num_gen_batches=} >= {max_num_gen_batches=}." + " Generated too many. Please check if your data are too difficult." + " You could also try set max_num_gen_batches=0 to enable endless trials.")
else:
# Align the batch
traj_bsz = self.config.data.train_batch_size * self.config.actor_rollout_ref.rollout.n
batch = batch[:traj_bsz]
问题: 该方案存在显著的 采样数据浪费 。通常,无效 Prompt(全0/全1)占比有限(例如约 20%)。即使设置 gen_batch_size = 2 * train_batch_size,也可能导致高达 60% 的采样数据被丢弃。考虑到 RL 训练中推理采样成本最高昂,此浪费代价巨大。( 注:若真有很高比例的全为0/1的无效数据,应优先进行离线过滤 )。
RLLM (DeepCoder/DeepScaler) 的实现:
首先我们要理解一点—— Verl 里多卡训练 (world_size) 的数据分配机制,对每个 step 的 prompt 数量是没有直接要求的,只需要保证过滤后的 response 数量足够每张训练结点分配就行,即 最终的 response 的数量是 world_size 的整数倍即可 。 所以 RLLM 里的解决方案是:
方法: 利用多卡训练 (
world_size
) 的数据分配机制,对 prompt 过滤后的 Response 进行处理:若 Response 数量 >
world_size
,则向下取整至最接近的倍数。若不足一个
world_size
,则跳过当前批次 (continue
)。优势: 这样设计的话,最多只会舍弃 world size -1 数量的 response。
如果训练集群不大,例如 8张/16张卡,再考虑到每个 Prompt 通常也采样 8 或 16 次,因过滤后 Response 数量不足
world_size
或者 不是 world_size 整数倍而被舍弃的情况 极少发生 。相比 Verl 的实现,该方案 显著提高了采样数据的利用率 。(PS :动态采样的方案早于 DAPO 就已见于 DeepScaler 代码里了 )。
核心代码如下:
if self.config.trainer.rejection_sample:
# If no valid samples remain, skip this batch and get a new one
if not valid_mask.any():
continue
# Filter batch to keep only valid samples
batch = batch[valid_mask]
batch = dataprotoitem_to_dataproto(batch)
# Round down to the nearest multiple of world size
num_trainer_replicas = self.actor_rollout_wg.world_size
max_batch_size = (batch.batch['input_ids'].shape[0] // num_trainer_replicas) * num_trainer_replicas
if not max_batch_size:
# give up, you got everything either all wrong or right.
continue
size_mask = torch.zeros(batch.batch['input_ids'].shape[0], dtype=torch.bool)
size_mask[:max_batch_size] = True
batch = batch[size_mask]
batch = dataprotoitem_to_dataproto(batch)
进一步优化
RLLM 的方案是当有效 response 数量不是world_size
整数倍时,直接舍弃余数(即使余数接近完整批次)在小规模集群(world_size
较小)下已足够高效。然而,在超大规模集群(如 512 或 1024 卡,world_size
极大)场景下,仍存在因向下取整导致部分有效 Response 被浪费的风险。
优化策略:
引入数据补齐机制,当余数 ≥ world_size/
N(可配置)时,通过随机重复采样补足完整批次。这样在512/1024卡的大集群场景下,避免 RLLM 方案中对 world_size 余数部分数据的浪费。默认50%的最小剩余比例阈值,支持按集群规模动态调整。
优势 :通过这一改动,使采样策略具备跨规模适应能力,既能保持小集群的高效特性,又能针对万卡级超大规模集群自动优化数据利用率,对采样数据100%利用。
优化后的代码如下:
注:因为这次跑DAPO实验的时候,还没把这段代码加进来,在新版代码里这个改动还没经过验证,所以这部分先不加到github里。
num_trainer_replicas = self.actor_rollout_wg.world_size
current_size = batch.batch['input_ids'].shape[0]
# 1. 处理小规模有效数据(动态重复采样)
if current_size < num_trainer_replicas:
# 仅当剩余数据比例超过阈值时补齐(例如1/2)
if current_size >= num_trainer_replicas // 2:
repeat_times = math.ceil(num_trainer_replicas / current_size)
batch = batch.repeat(repeat_times=repeat_times, interleave=False)
# 补齐后直接取所需的最小批次
batch = batch[:num_trainer_replicas]
else:
continue # 数据不足直接跳过
# 2. 处理非整数倍数据(大规模集群优化)
current_size = batch.batch['input_ids'].shape[0] # 更新当前大小
if current_size >= num_trainer_replicas:
# 计算完整批次数量
num_full_batches = current_size // num_trainer_replicas
remaining = current_size % num_trainer_replicas
# 当余数超过阈值时重复采样补齐
if remaining > 0 and remaining >= num_trainer_replicas // 2: # 阈值可配置
# 计算需要补充的样本数
num_needed = num_trainer_replicas - remaining
# 从有效数据中随机重复采样(避免样本偏移)
supplemental_indices = torch.randint(
low=0,
high=current_size,
size=(num_needed,)
)
# 拼接剩余数据和补充数据
batch = DataProto.concat([batch, batch.select_idxs(supplemental_indices)])
# 最终截断到完整批次
max_batch_size = (batch.batch['input_ids'].shape[0] // num_trainer_replicas) * num_trainer_replicas
batch = batch[:max_batch_size]
else:
continue # 理论上不会触发
4. 训练参数
训练脚本:run_dapo_qwen2.5_1.5b_code.sh 主要改动(相较于 Verl 仓库中的 DAPO 示例脚本):
# 1. 采样批次大小:
# 基于前述采样策略调整(无需扩大采样)。
gen_prompt_bsz=$((train_prompt_bsz * 1))
# 2. 超长响应处理:
# 此配置在32K训练中实际无影响(在旧版代码中有监控指标,训练过程中未出现超长响应)。
enable_overlong_buffer=False
+trainer.enable_overlong_filter=True
# 3. GRPO 参数优化:
# 此为针对 1.5B 模型的经验性调优参数,旨在提升训练的稳定性与收敛速度;
# 对比实验表明,此配置(clip_ratio_high=0.22, ppo_epochs=3)相较 clip_ratio_high=0.28, ppo_epochs=1 的设定,性能稳定提升超过 1.0 个百分点;
# 注意:该参数值可能不具备普适性,应用于其他模型时需重新验证。
clip_ratio_high=0.22
ppo_epochs=3
评测代码
上文有讲到,前期实验阶段,我们曾使用 firejail
沙盒环境运行模型评测。然而,评测指标始终无法与使用官方代码得出的结果对齐,这给训练过程分析带来了较大困扰。最终,我们回归使用 LCB 官方评测代码。
下方脚本使用训练完成的模型进行离线评测,其结果 完全对齐 于使用 LCB 官方评测代码的运行结果。
代码地址:run_eval.sh
使用方法: 根据需求修改以下参数:
nnodes=2
tp_size=1
# Vllm 相关参数
n_samples=4
temperature=0.8
max_prompt_length=$((1024 * 2))
max_response_length=$((1024 * 32))
# 路径参数
base_dir=.
model_path=${base_dir}/output/WizardCodeR-1.5B-DAPO
output_dir=${model_path}/output
data_dir=${base_dir}/data/test
dataset=livecodebench_v5
此外,我也 fork 了 LCB 官方代码库并稍作修改,使其支持直接使用自研模型生成的预测结果文件进行离线评测。若对上述第一种评测方式存疑,可使用评测过程中生成的 .parquet
结果文件,通过此修改版官方代码进行二次验证。
代码地址:https://github.com/wizard-III/LiveCodeBench
使用方法:
# 按官方流程配置 Python 虚拟环境
cd LiveCodeBench
uv venv --python 3.11
source .venv/bin/activate
# 安装依赖
uv pip install -e .
# 首次运行需从 Hugging Face 下载 lcb_v5 测试数据,耗时比较长
python lcb_runner/evaluation/compute_code_generation_metrics.py --eval_file $file_path
实验结果
实验环境:
硬件配置: 2 台服务器,每台配备 8 张 NVIDIA H800 GPU(共计 16 卡) 训练总时长: 约 80 小时(其中模型评测耗时约 10 小时) 训练步数: 270 steps
代码迁移至新版 Verl 框架后, 尚未对训练流程进行速度优化 ,预计仍有不少的加速潜力。
使用前述数据集和代码进行代码推理能力的优化实验,模型在 LiveCodeBench v5 (LCB v5) 评测集上能够 稳定达到 pass@1 26% 以上 的性能。(最近这次实验在线评测的结果跑到了 27.2%)
下图展示了最近一次实验在 Weights & Biases (wandb) 日志中记录的 avg pass@1 指标变化趋势图:

另外,大家是否遇到过类似问题:使用保存的
ckpt
文件进行离线评测时,结果通常比verl
训练时的在线评测略低。例如,上面 DAPO step230 的在线评测结果为 27.24,而离线评测结果为 26.70。 最终汇报时,我们均以离线评测结果为准。
关于日志分段说明: 日志显示实验分三次运行,但这并非计划中的分阶段训练,而是由于训练过程中意外中断后重启所致。(注:近期将训练代码迁移至 Verl 最新版本后,偶尔会出现内存突增导致崩溃的问题。并不能稳定复现,暂时不清楚是什么原因导致的。)
分阶段训练对比: 我曾尝试模仿 DeepCoder 的做法,采用序列长度递增(8K -> 16K -> 32K)的分阶段训练策略。然而,实验结果证明: 直接进行 32K 序列长度的完整训练,不仅最终模型效果更优,且总耗时更短。 虽然 8K/16K 阶段的单次迭代速度更快,但多阶段切换拉长了整体流程,其综合效率反而不及单阶段 32K 训练。
总结
综上,就是我近期在代码推理模型训练方面所做的基础性工作,主要包括训练数据准备、训练/评测代码优化以及训练参数调优。可以看到,经过初步优化,模型在 LCB 上的得分已突破 26+,达到了当前 SOTA 水平。
先行分享这部分工作,主要是为了便于与后续优化策略的效果进行清晰对比,后面的优化实验也都将在同一份数据上进行,以消除数据上的影响。我们常看到一些论文为了包装效果,将数据优化、参数调优和算法改进混在一起呈现,但实际上提升可能主要来自数据和参数调整,所谓的算法创新点鸟用没有。
另外需要说明的是,在训练数据和沙盒环境这两块上仍有进一步优化的空间,尤其在数据层面,好好优化下应该还可以把指标刷的更高些。不过,如前所述,目前的研究兴趣点更侧重于深入理解 GRPO 算法的本质并对其进行优化,因此暂不在此处投入过多精力。
下一篇工作将重点介绍对 GRPO 算法的分析与改进。
大模型之心Tech知识星球交流社区
我们创建了一个全新的学习社区 —— “大模型之心Tech”知识星球,希望能够帮你把复杂的东西拆开,揉碎,整合,帮你快速打通从0到1的技术路径。
星球内容包含:每日大模型相关论文/技术报告更新、分类汇总(开源repo、大模型预训练、后训练、知识蒸馏、量化、推理模型、MoE、强化学习、RAG、提示工程等多个版块)、科研/办公助手、AI创作工具/产品测评、升学&求职&岗位推荐,等等。
星球成员平均每天花费不到0.3元,加入后3天内不满意可随时退款,欢迎扫码加入一起学习一起卷!

- 点赞 (0)
-
分享
微信扫一扫
-
加入群聊
扫码加入群聊