Skip to content

OPD:On-policy Distillation

OPD 很容易和 SFT、KD、RLVR 混在一起。先记一句话:OPD 是让 student 自己生成轨迹,然后让 teacher 在 student 已经走到的每个 token state 上给出 token-level 指导。

Thinking Machines Lab 的 On-Policy Distillation 文章把这个想法讲得很直接:SFT/KD 的监督很密,但数据状态通常来自固定数据或 teacher;RL 是 on-policy,但奖励往往稀疏。OPD 想把两者拼起来:采样来自 student policy,监督来自 teacher policy。

先补一点先验

LLM post-training 里常见三类信号:

  • SFT:给模型一条标准答案,让它按 token 学会“下一个词应该是什么”。
  • KD:用更强 teacher 的回答或 logits 训练 student,信号比硬标签更软。
  • RLVR:student 自己生成答案,verifier 给整条答案打分,例如数学题对错。

它们各自有缺口。SFT/KD 常见问题是 exposure bias:训练时 student 看到的是固定答案或 teacher 轨迹,但推理时 student 会走到自己的中间状态。RLVR 没有这个状态错位,因为样本来自 student 自己,但 reward 常常只在最后给一个 0/1,token 级别的指导很少。

OPD 的直觉是:让 student 先走自己的路;teacher 不重写整篇答案,而是在 student 走到的每一步告诉它“如果是我,在这个上下文里下一个 token 会怎么分布”。

和 SFT / KD / RLVR 的区别

方法轨迹从哪里来训练信号小白理解
SFT人工或合成固定数据标准答案 token照着答案学
普通 KDteacher 回答或 teacher logitsteacher token / logits照着老师的路学
RLVRstudent 当前 policy rolloutverifier 的 outcome reward自己试,最后判对错
OPDstudent 当前 policy rolloutteacher 对 student state 的 token-level scoring自己走,老师逐步点评

这里的关键词是 on-policy。在 OPD 里,prompt 为 x,student rollout 为 yπθ(|x),第 t 个状态是:

st=(x,y<t)

teacher 看到的是 student 已经生成出来的 prefix,而不是 teacher 自己生成的 prefix。OPD 要让 student 在这些 state 上靠近 teacher:

LOPD=Ex,yπθ[1|y|tD(πθ(|st),ν(|st),yt)]

其中 πθ 是 student,ν 是 teacher。D 可以是分布级 KL,也可以是 sampled token 上的 KL estimator。

Thinking Machines Lab 文章的核心思想

Thinking Machines Lab 的文章强调了一个很实用的实现方式:对 student 采样出来的 token 序列,teacher 只做 logprob scoring。也就是:

python
tokens = student.generate(prompt)
teacher_logprobs = teacher.compute_logprobs(prompt, tokens)
student_logprobs = student.compute_logprobs(prompt, tokens)
reverse_kl = student_logprobs - teacher_logprobs
advantages = -reverse_kl.detach()
loss = policy_gradient_loss(student_logprobs, advantages)

这和“让 teacher 生成新答案再 SFT”不一样。teacher 在这里是裁判和打分器,不是数据生成器。student 采到了某个 token,如果 teacher 也觉得这个 token 在当前 state 下概率高,那么它得到较好的 token-level signal;如果 teacher 觉得这个 token 很差,signal 就会惩罚它。

文章还强调 OPD 的 reward 是 dense 的:每个 token 都可以有 teacher score,而不是整条答案最后才有一个 outcome reward。这也是它对 RLVR 的主要补位。

verl 的两种 OPD:supervised vs policy-gradient

verl 当前把两类 OPD 都放在 distillation.distillation_loss.* 下,关键开关是:

text
distillation.distillation_loss.use_policy_gradient

Supervised OPD

use_policy_gradient=False 时,verl 直接把 per-token distillation loss 当监督损失反传:

python
distillation_loss = agg_loss(
    loss_mat=distillation_losses,
    loss_mask=response_mask,
    loss_agg_mode=loss_agg_mode,
)

最典型配置是:

text
distillation.distillation_loss.loss_mode=forward_kl_topk
distillation.distillation_loss.topk=128
distillation.distillation_loss.use_policy_gradient=false

forward_kl_topk 使用 teacher top-k token 的 logprobs 近似 forward KL:

vTopK(ν)ν(v|st)[logν(v|st)logπθ(v|st)]

优点是保留 teacher 分布里多个高概率 token 的信息。缺点是它依赖 inference server 返回 top-k logprobs,不能拿到完整 vocab 分布,所以是 top-k 近似。

Policy-gradient OPD

use_policy_gradient=True 时,verl 把 distillation loss 的负值当 advantage/reward,再走 PPO-style policy loss:

python
distillation_loss, pg_metrics = policy_loss_fn(
    old_log_prob=old_log_prob,
    log_prob=log_prob,
    advantages=-distillation_losses.detach(),
    response_mask=response_mask,
    config=loss_config,
)

这正对应 Thinking Machines Lab 文中的做法:用 teacher 对 sampled token 的 logprob 构造 reverse-KL estimator,再把负 KL 当成 token reward。

推荐配置通常是:

text
distillation.distillation_loss.loss_mode=k1
distillation.distillation_loss.use_policy_gradient=true
distillation.distillation_loss.policy_loss_mode=vanilla

k1 可以粗略理解为 sampled token 上的:

logπθ(yt|st)logν(yt|st)

PG OPD 用它的负值做 advantage。源码里必须 .detach(),因为 policy gradient 里的 reward 不应该继续对 student logprob 求普通监督梯度;否则 teacher signal 会被破坏。

k1、k3、forward_kl_topk 怎么选

verl 的 loss_mode 分两族:

loss mode需要 teacher 返回什么更适合
forward_kl_topkteacher_ids + teacher_logprobs,shape 里有 top-ksupervised OPD
k1, k2, k3, kl, low_var_kl, abs, msesampled token 的 teacher logprobestimator / PG OPD

源码里也有保护:

  • use_policy_gradient=Falseloss_mode=k1 会报错,因为直接反传 k1 时 teacher logprob 对 student 参数没有有效梯度。
  • use_policy_gradient=Trueloss_mode=forward_kl_topk 会警告,因为 PG 更新只推动 sampled token 的 log pi(y_t),会浪费 top-k 里非 sampled token 的分布信息。

所以一个入门经验是:

text
想用 teacher top-k 分布做“软监督”:forward_kl_topk + use_policy_gradient=false
想贴近 Thinking Machines 的 PG OPD:k1 + use_policy_gradient=true
想用更低方差/不同 KL estimator:再比较 k3、low_var_kl 等

teacher_logprobs / teacher_ids 如何进入 verl

OPD 的数据流不是在 loss 函数里突然出现的。它从 rollout 后处理阶段就开始了:

text
RayPPOTrainer.init_workers()
  -> 如果 distillation.enabled=True:
       创建 MultiTeacherModelManager
       teacher_model_manager.get_client()
       把 teacher_client 交给 AgentLoopManager

AgentLoopWorker 生成 student rollout
  -> _agent_loop_postprocess(...)
  -> _compute_teacher_logprobs(...)
  -> AsyncTeacherLLMServerManager.compute_teacher_logprobs_single(...)
  -> LLMServerClient.generate(prompt_ids=prompt+response, sampling_params=...)
  -> 返回 teacher_ids / teacher_logprobs
  -> 拼进 rollout output 的 DataProto

teacher 端的 sampling params 很关键:

python
{
    "max_tokens": 1,
    "temperature": teacher_model_config.inference.temperature,
    "prompt_logprobs": topk_or_0,
}

这里 max_tokens=1 只是为了走 inference server 的接口。真正要的不是 teacher 继续生成内容,而是 (prompt + student_response) 这条已知序列上的 prompt logprobs。

字段形状可以这样理解:

字段内容
teacher_logprobsteacher 对序列 token 的 logprob;top-k 模式下是 (seq_len, K)
teacher_idstop-k token ids;single-sample estimator 下通常宽度是 1
old_log_probsrollout policy 生成这些 token 时的 logprob,PG loss/importance sampling 用
log_probs当前 student actor forward 后重新算出的 logprob
response_mask哪些 response token 参与 loss

loss 里发生了什么

OPD 启用后,ActorRolloutRefWorker.init_model() 会把 actor 的 loss 绑定成:

python
self.loss_fn = partial(
    distillation_ppo_loss,
    config=actor_config,
    distillation_config=distillation_config,
)

distillation_ppo_loss() 有两种调用形态:

  1. student_logits is not None:top-k 模式下作为 logits processor,在完整 logits 还没释放时计算 forward_kl_topk,写回 model_output["distillation_losses"]student_massteacher_mass 等。
  2. student_logits is None:最终 loss 阶段,读取 distillation_losses 或用 estimator 计算 per-token loss,再按 use_policy_gradient 决定监督反传还是 policy-gradient。

伪代码:

python
def distillation_ppo_loss(model_output, data, student_logits=None):
    if student_logits is not None:
        return compute_topk_loss(
            student_logits=student_logits,
            teacher_topk_log_probs=data["teacher_logprobs"],
            teacher_topk_ids=data["teacher_ids"],
        )

    distillation_losses = distillation_loss(
        student_log_probs=model_output["log_probs"],
        teacher_logprobs=data["teacher_logprobs"],
        teacher_ids=data.get("teacher_ids"),
        loss_mode=cfg.loss_mode,
    )

    if cfg.use_policy_gradient:
        distill_loss = policy_loss(
            log_prob=model_output["log_probs"],
            old_log_prob=data["old_log_probs"],
            advantages=-distillation_losses.detach(),
        )
    else:
        distill_loss = masked_average(distillation_losses, data["response_mask"])

    task_loss = ppo_loss(...) if cfg.use_task_rewards else 0
    return task_loss + cfg.distillation_loss_coef * distill_loss

配置怎么和源码变量对上

配置源码含义
distillation.enabled开启 teacher pool,并把 actor loss 切到 distillation_ppo_loss
distillation.nnodes / n_gpus_per_nodeteacher 资源池大小
distillation.teacher_key多 teacher 路由字段,默认 data_source
distillation.teacher_models.<name>.key某个 teacher 对应的数据路由值
distillation.teacher_models.<name>.model_pathteacher 模型路径
distillation.teacher_models.<name>.num_replicasteacher inference replica 数
distillation.teacher_models.<name>.inference.nameteacher 用 vLLM 或 SGLang 等 rollout backend 做 scoring
distillation.distillation_loss.loss_mode选择 forward_kl_topkk1k3
distillation.distillation_loss.topkteacher 返回多少个 top-k logprobs
distillation.distillation_loss.use_policy_gradientsupervised OPD 还是 PG OPD
distillation.distillation_loss.use_task_rewards是否同时保留 PPO/GRPO 的任务 reward loss
distillation.distillation_loss.distillation_loss_coefdistillation loss 和 task loss 混合时的权重

多 teacher 有一个坑:默认 teacher_model entry 在添加其他 teacher entry 时会被 pop 掉。多 teacher 配置应显式命名,例如 teacher_model1teacher_model2,并保证它们的 keydistillation.teacher_key 对应字段匹配。

OPD 源码阅读路径

建议按这个顺序读:

text
1. docs/algo/opd.md
   先看官方对 GKD OPD / PG OPD / MOPD 的解释。

2. verl/workers/config/distillation.py
   看配置校验:loss_mode、topk、teacher resource pool、teacher routing。

3. verl/trainer/ppo/ray_trainer.py
   找 MultiTeacherModelManager 如何被创建,以及 teacher_client 如何传给 AgentLoopManager。

4. verl/experimental/teacher_loop/teacher_model.py
   看 teacher inference replicas 如何被拆资源池并启动。

5. verl/experimental/teacher_loop/teacher_manager.py
   看 compute_teacher_logprobs_single 如何构造 prompt_logprobs 请求。

6. verl/experimental/agent_loop/agent_loop.py
   找 _compute_teacher_logprobs,确认 teacher_logprobs / teacher_ids 如何并入 rollout output。

7. verl/workers/engine_workers.py
   看 ActorRolloutRefWorker.init_model 如何绑定 distillation_ppo_loss。

8. verl/trainer/distillation/losses.py
   看 distillation_ppo_loss、compute_forward_kl_topk、k1/k3 estimator。

9. verl/trainer/distillation/fsdp/losses.py 和 megatron/losses.py
   看 top-k forward KL 在具体训练后端里怎么利用 logits。

如果读 fully async OPD,再看:

text
verl/experimental/fully_async_policy/fully_async_trainer.py
verl/experimental/fully_async_policy/fully_async_rollouter.py
verl/experimental/fully_async_policy/detach_utils.py
docs/advance/fully_async.md

fully async 关注的是系统调度:rollouter 和 trainer 解耦、MessageQueue、staleness、partial rollout、参数同步。它不改变 OPD 的基本思想:样本仍来自 student,teacher 仍做 token-level scoring。

最小伪代码

python
for prompts in dataloader:
    # 1. student on-policy rollout
    rollout = student_rollout_server.generate(prompts)

    # 2. teacher scoring, not teacher generation
    teacher_signal = teacher_server.logprobs(
        input_ids=concat(prompts, rollout.responses),
        prompt_logprobs=topk_or_0,
    )

    batch = merge(prompts, rollout, teacher_signal)

    # 3. actor forward on student
    model_output = student_actor.forward(batch)

    # 4. distillation loss
    distill = distillation_loss(
        student_log_probs=model_output.log_probs,
        teacher_logprobs=batch.teacher_logprobs,
        teacher_ids=batch.teacher_ids,
        response_mask=batch.response_mask,
    )

    # 5. supervised 或 policy-gradient OPD
    if use_policy_gradient:
        loss = policy_loss(advantages=-distill.detach())
    else:
        loss = masked_mean(distill)

    if use_task_rewards:
        loss = ppo_or_grpo_loss(batch) + coef * loss

    loss.backward()
    optimizer.step()

这段代码的精髓是:teacher 是 scoring path,不是 rollout path。

这页原本不适合学习的地方

  • 只说 OPD 是 on-policy distillation,但没有把 exposure bias、dense token-level reward、sparse outcome reward 这些先验讲清楚。
  • 没把 Thinking Machines Lab 文章里的 PG OPD 实现思路和 verl 的 use_policy_gradient=True 对齐。
  • teacher_logprobsteacher_idsprompt_logprobstopkold_log_probs 等字段之间关系不够清楚。
  • 没解释 forward_kl_topk 更适合 supervised OPD,而 k1 更适合 PG OPD。
  • 源码阅读路径过短,读者不知道 teacher scoring 是在 agent loop 后处理里并入 batch 的。
  • 没提醒 docs/advance/async-on-policy-distill.md 是旧式 async KD recipe 口径,和当前主线 distillation.* 配置不同。

本节参考与延伸阅读

  • Thinking Machines Lab:On-Policy Distillation,https://thinkingmachines.ai/blog/on-policy-distillation/
  • verl 官方文档:docs/algo/opd.md
  • verl 官方文档:docs/advance/async-on-policy-distill.md
  • verl 官方文档:docs/advance/fully_async.md
  • 示例:examples/on_policy_distillation_trainer/README.md
  • 示例脚本:examples/on_policy_distillation_trainer/run_qwen3_8b_fsdp.sh
  • 示例脚本:examples/on_policy_distillation_trainer/run_qwen3_8b_megatron.sh
  • 示例脚本:examples/on_policy_distillation_trainer/run_qwen3_vl_8b_fsdp.sh
  • 示例脚本:examples/on_policy_distillation_trainer/run_qwen3_8b_mopd_fsdp.sh
  • 源码:verl/workers/config/distillation.py
  • 源码:verl/trainer/distillation/losses.py
  • 源码:verl/trainer/distillation/fsdp/losses.py
  • 源码:verl/trainer/distillation/megatron/losses.py
  • 源码:verl/experimental/teacher_loop/teacher_model.py
  • 源码:verl/experimental/teacher_loop/teacher_manager.py
  • 源码:verl/experimental/agent_loop/agent_loop.py
  • 源码:verl/workers/engine_workers.py
  • 源码:verl/trainer/ppo/ray_trainer.py
  • 源码:verl/experimental/fully_async_policy/fully_async_trainer.py
  • 源码:verl/experimental/fully_async_policy/fully_async_rollouter.py

面向源码阅读的 verl 学习文档。