DAPO: An Open-Source LLM Reinforcement Learning System at Scale

1. 出发点 (Motivation)

2025 年初 OpenAI o1 + DeepSeek R1 把"long-CoT RL"推上了 LLM 范式革命的中心 — 但实际算法和训练 recipe 几乎全是黑盒。R1 技术报告只给了 framework 级别的描述,社区想复现 Qwen2.5-32B 上的 47% AIME24 时全都卡住,普遍只能拿到 30 分左右。DAPO 作者团队 (字节 + 清华 AIR) 的诊断:

论文的 4 个 named 技术合起来叫 DAPO (Decoupled clip + Dynamic sAmpling Policy Optimization)。Qwen2.5-32B base → 50% AIME24,只用 R1-Zero 50% 的 step:

AIME 2024 curve
Fig. 1 — DAPO 在 Qwen2.5-32B base 上的 AIME 2024 学习曲线。avg@32 (紫,solid) 在 ~5000 step 达到 50%,pass@32 (青,triangle-down) 超 70%,cons@32 (蓝,triangle-up) 在 60% 附近震荡。DeepSeek-R1-Zero-Qwen-32B (右侧蓝色 horizontal line) 用了 10000 step 才到 47%。DAPO 50% step 数,超 3 pt。

2. 方法 (Method)

核心思想 (类比)

把 long-CoT RL 训练想成"教学生做一道很长的证明题":

这 4 个修复没一个是惊天动地的算法 — 都是"现有方法里某个不显眼的角落里有 bug"。但合在一起,30 → 50 分。

2.1 GRPO 起点 + 公式

DAPO 基于 GRPO (Shao et al. 2024)。GRPO vs PPO 的核心区别:不用 value 网络,直接 group-wise 标准化做 advantage。对一个 prompt \(q\) 采样 \(G\) 个回复,advantage 是:

注意所有 token 共享同一个 sequence-level advantage \(\hat A_i\)。GRPO 的 clipped surrogate (PPO style):

其中 \(r_{i,t}(\theta) = \pi_\theta(o_{i,t}|q, o_{i,\lt t}) / \pi_{\theta_{\text{old}}}(o_{i,t}|q, o_{i,\lt t})\)

关键观察: GRPO 的内层 \(\frac{1}{|o_i|}\sum_t\)sample-level token-mean,外层 \(\frac{1}{G}\sum_i\) 是 sample 上平均。这两层平均的组合,就是 Token-Level Loss 要修的东西 — 见 §2.4。

额外:DAPO 去掉了 KL 项 (\(\beta = 0\))。逻辑:RLHF 里要约束 model 不偏离原模型 (因为想保留通用对齐);但 long-CoT reasoning 训练里 policy必然大幅偏离 base (要学新的"思考"模式),KL 约束反而阻碍。

额外:rule-based reward 而不是 reward model\(R(\hat y, y) = 1\) if answer 正确 else \(-1\)。避免 reward hacking。

2.2 Clip-Higher (raise the upper bound)

Clip-Higher effect
Fig. 2 — w/o Clip-Higher 的 entropy 在 ~500 step 后跌到接近 0 (青线,右图),AIME 准确率停在 0.22 左右 (青线,左图)。w/ Clip-Higher (紫线) entropy 长期保持在 0.4-0.5,AIME 涨到 0.38+。

问题: PPO/GRPO 默认 \(\varepsilon = 0.2\),新概率被 clip 到 \([\pi_{\text{old}} \cdot 0.8,\ \pi_{\text{old}} \cdot 1.2]\)这个 clip 对"高概率 exploitation token"和"低概率 exploration token"的影响不对称:

结果:exploitation token 持续被强化,exploration token 永远涨不起来 → entropy 崩溃。Fig 3a 实测:被上界 clip 的 token 平均概率 \(\lt 0.2\) — 印证了"上界主要在卡低概率 token"。

Up-clipped probability + acc=1 samples
Fig. 3 — (a) 被 upper-clip 的 token,平均概率长期 < 0.2 (大多 0.10-0.15),说明 upper clip 主要在卡低概率 exploration token 的提升;(b) avg@32 = 100% 的 prompt 比例从 0 涨到 0.6+,意味着越往后训练有效 batch 越少 — 这正是 §2.3 Dynamic Sampling 要解决的问题。

**DAPO 修复:**分离上下 clip 边界

实验设 \(\varepsilon_{\text{low}} = 0.2\) (维持原值,防止低概率 token 被压到 0)、\(\varepsilon_{\text{high}} = 0.28\)这一个超参的微调就让 entropy 不再 collapse。

2.3 Dynamic Sampling (filter dead groups)

问题 (Fig 3b): 训练后期,acc=1 的 prompt 占比从 0 涨到 ~0.6。这些 prompt 的整个 group reward 都是 1,group-std = 0,advantage = 0,贡献 0 gradient。有效 batch 在悄悄变小

DAPO 修复: 在 batch 级别过采样,过滤掉 group 内 reward 全 0 或全 1 的 prompt,直到攒够目标 batch size。形式化加在 DAPO 目标里:

(每个 prompt 的 \(G\) 个 output 里必须至少有 1 个对、至少有 1 个错。)

实现层面 — verl 的 filter_groups.enable=True:用 gen_batch_size(例:1536) 采样,过滤 → 目标 train_batch_size(例:512)。如果一次过滤后<不够,继续采,最多 max_num_gen_batches 次。

Dynamic sampling progress
Fig. 6 — 看似"多采样应该慢",但 w/ Dynamic Sampling (紫) 在 ~2300 step 就达到 0.42,而 w/o (青) 跑到 6000+ step 才匹配。每个 step 信息量更大,总训练 time 反而更短。

2.4 Token-Level Policy Gradient Loss (long-CoT 关键)

问题: GRPO 的 loss aggregation 是 sample-level:

意味着 每个 sample 贡献相同的 loss 权重,不管它是 100 token 还是 5000 token。在 long-CoT 场景,这有两个问题:

**DAPO 修复:**改成 token-level mean — 所有 batch 里的 token 一视同仁:

长 sample 自动在 loss 里占更大权重(它有更多 token 贡献)。

没有这个修复(Fig 4 in paper):entropy 在长序列出现后失控,response length 也持续上涨到不健康水平。这是 long-CoT 跟 short-CoT(RLHF instruction-following)的本质区别 — 长度差异从"几十倍"变成"几百倍",sample-level mean 直接失效

2.5 Overlong Reward Shaping (truncation noise)

问题: 训练设 max length(如 20480 token)。超出的样本默认直接给 -1 reward。但很多被截断的样本推理本身是对的,只是写得长 — 一个 sound reasoning 因为长度被打 -1,模型学到的是"长 = 错",这是 reward noise。

**DAPO 解法 1 — Overlong Filtering:**直接 mask 掉被截断样本的 loss(让它不贡献 gradient)。简单粗暴但有效。Fig 5 显示这就能稳住 entropy。

Overlong filtering effect
Fig. 5 — (a) w/o overlong filtering AIME 在 3500 step 后开始震荡下降;(b) w/o overlong filtering 的 entropy 在 3500 step 后炸到 4-5 (gibberish 模式);带 filtering 后 entropy 接近 0 并稳定。不处理截断 reward → 训练后期崩。

**DAPO 解法 2 — Soft Overlong Punishment (Eq 13):**不是 0/1 切断,而是 length-aware 软惩罚。设 \(L_{\max}\) = max 输出长度(20480),\(L_{\text{cache}}\) = buffer 长度(4096):

所以:

这个 reward 加在原 correctness reward (±1) 上 — 信号"模型,你可以写长,但越过 16k 就开始算笔账"。

2.6 完整 DAPO 目标 + 算法

把 4 个技巧塞进同一个目标 (Eq 10/11/12):

对照标准 GRPO:

Algorithm 1: DAPO
Algorithm 1 — 完整 DAPO 训练循环。Line 6-8 是 Dynamic Sampling 的实现:filter → if 没满,continue (回 Line 2 重采样)。Line 11 用 Eq 8 (含 Clip-Higher) 更新 policy。

2.7 与代码对照

DAPO 训练 recipe 在 volcengine/verl recipe/dapo/。核心算法实现在 verl/trainer/ppo/core_algos.pyverl/workers/reward_manager/dapo.py。下面引的是当前 main 分支的代码 — 命名跟 paper 直接对得上。

**(a) Clip-Higher — 分离 \(\varepsilon_{\text{low}} / \varepsilon_{\text{high}}\):**论文 Eq 10 的 clip(r, 1-ε_low, 1+ε_high) 这一行。

repo_verl/verl/trainer/ppo/core_algos.py:L1278-L1346 — compute_policy_loss_vanilla

@register_policy_loss("vanilla")
def compute_policy_loss_vanilla(
    old_log_prob, log_prob, advantages, response_mask,
    loss_agg_mode="token-mean",        # ← Token-Level Loss (§2.4)
    config=None,
    rollout_is_weights=None,
):
    clip_ratio = config.clip_ratio
    clip_ratio_low  = config.clip_ratio_low  if config.clip_ratio_low  is not None else clip_ratio
    clip_ratio_high = config.clip_ratio_high if config.clip_ratio_high is not None else clip_ratio
    # ↑ 这两行就是 Clip-Higher (§2.2). DAPO 配 ε_low=0.2, ε_high=0.28

    negative_approx_kl = log_prob - old_log_prob
    negative_approx_kl = torch.clamp(negative_approx_kl, min=-20.0, max=20.0)
    ratio = torch.exp(negative_approx_kl)

    pg_losses1 = -advantages * ratio
    pg_losses2 = -advantages * torch.clamp(ratio,
                                           1 - clip_ratio_low,
                                           1 + clip_ratio_high)   # ← 分离 clip
    clip_pg_losses1 = torch.maximum(pg_losses1, pg_losses2)
    # ... (dual-clip for negative advantages omitted) ...
    pg_loss = agg_loss(loss_mat=pg_losses, loss_mask=response_mask,
                       loss_agg_mode=loss_agg_mode, **config.global_batch_info)
    return pg_loss, pg_metrics

L1314-L1315 是 paper Eq 10 的 \(\varepsilon_{\text{low}}, \varepsilon_{\text{high}}\) 实现。L1340-L1342 把它们用进 torch.clampDAPO 跟 vanilla GRPO 在这两行就分叉了 — 其余 PPO 数学完全一样。

**(b) Token-Level Loss — loss aggregation mode:**paper §3.3 的 token-mean 实现。

repo_verl/verl/trainer/ppo/core_algos.py:L1138-L1199 — agg_loss

def agg_loss(loss_mat, loss_mask, loss_agg_mode, dp_size=1, batch_num_tokens=None, ...):
    """Aggregate the loss across global batch."""
    if loss_agg_mode == "token-mean":                              # ← DAPO Token-Level Loss
        if batch_num_tokens is None:
            batch_num_tokens = loss_mask.sum()
        loss = verl_F.masked_sum(loss_mat, loss_mask) / batch_num_tokens * dp_size

    elif loss_agg_mode in ["seq-mean-token-sum", "seq-mean-token-sum-norm"]:
        seq_losses = torch.sum(loss_mat * loss_mask, dim=-1)
        seq_mask = (torch.sum(loss_mask, dim=-1) > 0).float()
        # ... seq-mean over sequences ...

    elif loss_agg_mode == "seq-mean-token-mean":                   # ← vanilla GRPO 默认
        seq_mask = torch.sum(loss_mask, dim=-1)
        seq_losses = torch.sum(loss_mat * loss_mask, dim=-1) / (seq_mask + 1e-8)
        seq_mask = (seq_mask > 0).float()
        loss = verl_F.masked_sum(seq_losses, seq_mask) / global_batch_size * dp_size
    else:
        raise ValueError(f"Invalid loss_agg_mode: {loss_agg_mode}")
    return loss

三个模式的数学差别就在归一化分母:

(c) Overlong Reward Shaping — Eq 13 在 reward manager 里:

repo_verl/verl/workers/reward_manager/dapo.py:L121-L131 — overlong penalty inside __call__

if self.overlong_buffer_cfg.enable:
    overlong_buffer_len = self.overlong_buffer_cfg.len           # 4096 in paper
    expected_len = self.max_resp_len - overlong_buffer_len       # 20480 - 4096 = 16384
    exceed_len = valid_response_length - expected_len            # >0 if in overlong buffer
    overlong_penalty_factor = self.overlong_buffer_cfg.penalty_factor   # 1.0
    overlong_reward = min(-exceed_len / overlong_buffer_len * overlong_penalty_factor, 0)
    reward += overlong_reward
    if self.overlong_buffer_cfg.log:
        reward_extra_info["overlong_reward"].append(overlong_reward)
        reward_extra_info["overlong"].append(overlong_reward < 0)

逐行对照 Eq 13:exceed_len\(|y| - (L_{\max} - L_{\text{cache}})\),-exceed_len / overlong_buffer_len 就是 \(-(|y| - (L_{\max} - L_{\text{cache}}))/L_{\text{cache}}\),min(..., 0) 保证只在超过 expected_len 时才施惩罚。简单 5 行实现 Eq 13。

**(d) Dynamic Sampling — Group Filtering:**不在 loss 里,在 trainer 的 rollout 阶段实现 (来自 docs/algo/dapo.md)。

verl/recipe/dapo/dapo_ray_trainer.py (paraphrased from docs/algo/dapo.md) — over-sample + filter loop

prompt_bsz = self.config.data.train_batch_size           # 512
num_prompt_in_batch = 0
num_gen_batches = 0
while num_prompt_in_batch < prompt_bsz:
    gen_batch = sample_gen_batch(size=self.config.data.gen_batch_size)   # 1536
    gen_batch = run_rollout(gen_batch)                                   # 16 rollouts per prompt
    gen_batch = compute_rewards(gen_batch)

    # Filter groups whose rewards are all the same (acc all 0 or all 1)
    keep_mask = ~all_same_per_group(gen_batch.rewards)
    filtered = gen_batch[keep_mask]
    batch = concat(batch, filtered)
    num_prompt_in_batch += count_prompts(filtered)

    num_gen_batches += 1
    max_num_gen_batches = self.config.algorithm.filter_groups.max_num_gen_batches
    if max_num_gen_batches > 0 and num_gen_batches >= max_num_gen_batches:
        raise ValueError(f"{num_gen_batches=} >= {max_num_gen_batches=}. Generated too many.")

# Align the batch to exactly train_batch_size * n_rollouts
traj_bsz = self.config.data.train_batch_size * self.config.actor_rollout_ref.rollout.n
batch = batch[:traj_bsz]

\(\mathrm{gen\_batch\_size} > \mathrm{train\_batch\_size}\) 是关键(例:1536 > 512),给过滤留余量。max_num_gen_batches 是安全闸 — 防止数据集太简单导致死循环重采。

3. 结论 (Key Findings)

主结果:

Table 1: progressive techniques
Tab. 1 — 4 个技术的累积贡献 (AIME 2024 avg@32)。Naive GRPO 30 → +Overlong Filtering 36 (+6) → +Clip-Higher 38 (+2) → +Soft Overlong Punishment 41 (+3) → +Token-level Loss 42 (+1) → +Dynamic Sampling = DAPO 50 (+8)。Dynamic Sampling 是单一最大涨幅(+8)。Overlong-related 改进 (filtering + soft punishment) 合起来也是 +9。Clip-Higher 单独贡献 +2 (但 entropy collapse 修复后才能让其他改进发挥作用)。

训练动态健康指标 (Fig 7 — DAPO 的训练长这样):

Training dynamics
Fig. 7 — DAPO 5000 step 的训练曲线。(a) 平均响应长度 从 ~700 token 持续涨到 ~4500 — healthy growth,代表 model 学会更长的 CoT。(b) Reward score 从 -0.8 稳定涨到 +0.4。(c) Entropy 先降到 0.35 (开始 commit) 再缓慢回升到 0.55 — 受控的探索回升。(d) Mean probability 0.78 → 0.86 → 回落到 0.74 — 跟 entropy 反向变化,说明 model 在前半段 commit 后半段重新探索。这 4 条曲线是健康 long-CoT RL 训练的"教科书形状"。

关键数字:

4. 实现细节 (Implementation Notes)

DAPO 不只是什么 work,而是把 recipe 拆解到可复现级别。整套训练代码、数据、checkpoint 全开源。下方列细节都从论文 §4.1 或 verl repo 配置文件提取:

5. 批判性总结 (Critical Assessment)

5.1 优点

5.2 不足 / 疑点

5.3 适用 vs 不适用

5.4 进一步阅读

讨论 / Comments

评论托管在本仓库的 GitHub Discussions, 需 GitHub 账号。