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 | 照着答案学 |
| 普通 KD | teacher 回答或 teacher logits | teacher token / logits | 照着老师的路学 |
| RLVR | student 当前 policy rollout | verifier 的 outcome reward | 自己试,最后判对错 |
| OPD | student 当前 policy rollout | teacher 对 student state 的 token-level scoring | 自己走,老师逐步点评 |
这里的关键词是 on-policy。在 OPD 里,prompt 为
teacher 看到的是 student 已经生成出来的 prefix,而不是 teacher 自己生成的 prefix。OPD 要让 student 在这些 state 上靠近 teacher:
其中
Thinking Machines Lab 文章的核心思想
Thinking Machines Lab 的文章强调了一个很实用的实现方式:对 student 采样出来的 token 序列,teacher 只做 logprob scoring。也就是:
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.* 下,关键开关是:
distillation.distillation_loss.use_policy_gradientSupervised OPD
当 use_policy_gradient=False 时,verl 直接把 per-token distillation loss 当监督损失反传:
distillation_loss = agg_loss(
loss_mat=distillation_losses,
loss_mask=response_mask,
loss_agg_mode=loss_agg_mode,
)最典型配置是:
distillation.distillation_loss.loss_mode=forward_kl_topk
distillation.distillation_loss.topk=128
distillation.distillation_loss.use_policy_gradient=falseforward_kl_topk 使用 teacher top-k token 的 logprobs 近似 forward KL:
优点是保留 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:
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。
推荐配置通常是:
distillation.distillation_loss.loss_mode=k1
distillation.distillation_loss.use_policy_gradient=true
distillation.distillation_loss.policy_loss_mode=vanillak1 可以粗略理解为 sampled token 上的:
PG OPD 用它的负值做 advantage。源码里必须 .detach(),因为 policy gradient 里的 reward 不应该继续对 student logprob 求普通监督梯度;否则 teacher signal 会被破坏。
k1、k3、forward_kl_topk 怎么选
verl 的 loss_mode 分两族:
| loss mode | 需要 teacher 返回什么 | 更适合 |
|---|---|---|
forward_kl_topk | teacher_ids + teacher_logprobs,shape 里有 top-k | supervised OPD |
k1, k2, k3, kl, low_var_kl, abs, mse | sampled token 的 teacher logprob | estimator / PG OPD |
源码里也有保护:
use_policy_gradient=False且loss_mode=k1会报错,因为直接反传 k1 时 teacher logprob 对 student 参数没有有效梯度。use_policy_gradient=True且loss_mode=forward_kl_topk会警告,因为 PG 更新只推动 sampled token 的log pi(y_t),会浪费 top-k 里非 sampled token 的分布信息。
所以一个入门经验是:
想用 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 后处理阶段就开始了:
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 的 DataPrototeacher 端的 sampling params 很关键:
{
"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_logprobs | teacher 对序列 token 的 logprob;top-k 模式下是 (seq_len, K) |
teacher_ids | top-k token ids;single-sample estimator 下通常宽度是 1 |
old_log_probs | rollout 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 绑定成:
self.loss_fn = partial(
distillation_ppo_loss,
config=actor_config,
distillation_config=distillation_config,
)distillation_ppo_loss() 有两种调用形态:
student_logits is not None:top-k 模式下作为 logits processor,在完整 logits 还没释放时计算forward_kl_topk,写回model_output["distillation_losses"]、student_mass、teacher_mass等。student_logits is None:最终 loss 阶段,读取distillation_losses或用 estimator 计算 per-token loss,再按use_policy_gradient决定监督反传还是 policy-gradient。
伪代码:
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_node | teacher 资源池大小 |
distillation.teacher_key | 多 teacher 路由字段,默认 data_source |
distillation.teacher_models.<name>.key | 某个 teacher 对应的数据路由值 |
distillation.teacher_models.<name>.model_path | teacher 模型路径 |
distillation.teacher_models.<name>.num_replicas | teacher inference replica 数 |
distillation.teacher_models.<name>.inference.name | teacher 用 vLLM 或 SGLang 等 rollout backend 做 scoring |
distillation.distillation_loss.loss_mode | 选择 forward_kl_topk、k1、k3 等 |
distillation.distillation_loss.topk | teacher 返回多少个 top-k logprobs |
distillation.distillation_loss.use_policy_gradient | supervised OPD 还是 PG OPD |
distillation.distillation_loss.use_task_rewards | 是否同时保留 PPO/GRPO 的任务 reward loss |
distillation.distillation_loss.distillation_loss_coef | distillation loss 和 task loss 混合时的权重 |
多 teacher 有一个坑:默认 teacher_model entry 在添加其他 teacher entry 时会被 pop 掉。多 teacher 配置应显式命名,例如 teacher_model1、teacher_model2,并保证它们的 key 和 distillation.teacher_key 对应字段匹配。
OPD 源码阅读路径
建议按这个顺序读:
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,再看:
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.mdfully async 关注的是系统调度:rollouter 和 trainer 解耦、MessageQueue、staleness、partial rollout、参数同步。它不改变 OPD 的基本思想:样本仍来自 student,teacher 仍做 token-level scoring。
最小伪代码
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_logprobs、teacher_ids、prompt_logprobs、topk、old_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