On-Policy Distillation of Large Language Models — 复现、实验与深度分析
Student: Qwen3-1.7B-Base · Teacher: Qwen3-8B · Data: GSM8K · Hardware: 4× NVIDIA L20 48GB
本项目基于 verl 框架和 LlamaFactory 工具,复现并深入探索 On-Policy Distillation (OPD) 算法在大语言模型训练中的实际表现。
flowchart LR
A[传统离线 SFT] -->|"Teacher 固定输出<br>→ 存在分布偏移"| C{问题}
B[On-Policy 蒸馏] -->|"Student 自己 rollout<br>→ Teacher 在线评分"| C
C -->|"哪个更有效?"| D[🔬 系统性实验]
D --> E[📊 定量结论]
D --> F[💡 实践指南]
| 核心问题 | 描述 |
|---|---|
| ❓ OPD 的保护机制是否真的有效? | Top-K 过滤、交集策略、概率加权、KL 惩罚 |
| ❓ 离线 SFT vs 在线 OPD,谁更优? | 监督信号强度与分布匹配度的权衡 |
| ❓ 关键超参数是什么? | 通过消融实验定位核心因素 |
┌──────────────────────────────────────────────────────────┐
│ Ray Cluster (4 GPUs) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ GPU 0 │ │ GPU 1 │ │ GPU 2 │ │ GPU 3 │ │
│ │ ┌──────┐ │ │ ┌──────┐ │ │ ┌──────┐ │ │ ┌──────┐ │ │
│ │ │Actor │ │ │ │Actor │ │ │ │Actor │ │ │ │Actor │ │ │
│ │ │FSDP │ │ │ │FSDP │ │ │ │FSDP │ │ │ │FSDP │ │ │
│ │ └──────┘ │ │ └──────┘ │ │ └──────┘ │ │ └──────┘ │ │
│ │ ┌──────┐ │ │ ┌──────┐ │ │ ┌──────┐ │ │ ┌──────┐ │ │
│ │ │Ref │ │ │ │Ref │ │ │ │vLLM │ │ │ │TchRM │ │ │
│ │ │FSDP │ │ │ │FSDP │ │ │ │Roll │ │ │ │FSDP │ │ │
│ │ └──────┘ │ │ └──────┘ │ │ └──────┘ │ │ └──────┘ │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└──────────────────────────────────────────────────────────┘
| 实验 | 范式 | top_k |
strategy |
weight |
KL |
目的 |
|---|---|---|---|---|---|---|
| Exp 1 🏆 | 离线 SFT | — | — | — | — | 建立上界基线 |
| Exp 2 💥 | Naive On-Policy | 0 | only_stu | none | ❌ | 确认崩溃 |
| Exp 3 🎯 | OPD 核心 | 16 | intersection | student_p | 0.005 | 验证保护机制 |
| 4a1 🔍 | top_k=8 | 8 | intersection | student_p | 0.005 | 更严格过滤 |
| 4a2 🔍 | top_k=32 | 32 | intersection | student_p | 0.005 | 更宽松过滤 |
| 4b 🔍 | kl=0.001 | 16 | intersection | student_p | 0.001 | 弱 KL 惩罚 |
| 4c 🔍 | only_stu | 16 | only_stu | student_p | 0.005 | 无交集策略 |
| 机制 | 参数 | 作用 |
|---|---|---|
| Top-K 过滤 | LOG_PROB_TOP_K=16 |
只保留 Student 高置信度的 token,过滤低质量噪声信号 |
| 交集策略 | TOP_K_STRATEGY=intersection |
只保留 Student 和 Teacher 都高置信度的 token |
| 学生概率加权 | REWARD_WEIGHT_MODE=student_p |
按 Student 概率加权,避免 Teacher 主导 |
| KL 惩罚 | USE_KL=True, COEF=0.005 |
防止 Student 偏离 Reference 模型过远 |
Token 级奖励信号(OPD 的核心):
PPO 损失函数:
在 dp_actor.py 的 update_policy 方法中,top-k 分支下的 log_prob 变量未被捕获,导致 UnboundLocalError。
# ❌ 原版代码
if advantages.dim() == 3:
- entropy, _, _, topk_log_probs = self._forward_micro_batch(...)
+ entropy, log_prob, _, topk_log_probs = self._forward_micro_batch(...)
log_prob_for_loss = topk_log_probs
# 后面 kld = kl_penalty(logprob=log_prob, ...) ← 不再报错| 参数 | Exp 2 | Exp 3 | 优化效果 |
|---|---|---|---|
N_RESPONSES |
4 | 3 | rollout 减少 25% |
test_freq |
20 | 40 | 验证次数减半 |
| 每步时间 | ~120s | ~55s | ↓ 54% |
| 总耗时 | ~18h | ~12h | ↓ 32% |
| Throughput | 250-336 tok/s | ~310 tok/s | ↑ ~15% |
💥 现象: batch_size=32, Step 3 崩溃
CUDA OutOfMemory: 尝试分配 17.17 GiB
GPU 0: 30.30/44.52 GiB 已用
🔍 根因: Qwen3 词表大小 151,936
logits shape = (total_nnz, 151936)
log_softmax(logits, dim=-1) 需要 17 GB 额外显存
✅ 解决方案:
1. MINI_BATCH_SIZE 回退到 16(安全值)
2. 减少 N_RESPONSES=3 弥补吞吐量
3. 增加 test_freq=40 减少验证开销
⚠️ 额外踩坑:
expandable_segments:True 与 vLLM CuMemAllocator 不兼容
→ AssertionError: Expandable segments are not compatible
%%{init: {'theme': 'base', 'themeVariables': { 'lineColor': '#FF6B35', 'textColor': '#333' }}}%%
xychart-beta
title "各实验 reward/mean@8 对比"
x-axis ["Exp 1<br>SFT", "Exp 2<br>Naive", "Exp 3<br>OPD", "4a1<br>topk=8", "4a2<br>topk=32", "4b<br>kl=0.001", "4c<br>only_stu"]
y-axis "GSM8K Acc / Reward (%)" 0 --> 60
bar [55.27, 0.76, 0.86, 0.43, 0.38, 0.32, 0.45]
| 指标 | 值 |
|---|---|
| GSM8K Test Accuracy | 55.27% (729/1319) |
| No Answer Rate | 35.48% (468/1319) |
| 训练 Loss | 0.2768 → 0.215 |
| 验证 Loss | 0.3364 |
| 训练步数 | 318 (3 epoch) |
| 耗时 | 1h 36min |
✅ 成功建立上界基线。Teacher (8B) 生成的 CoT 轨迹有效,Student (1.7B) 可模仿学习。
| 指标 | 值 |
|---|---|
| reward/mean@8 | 0.76%(全程无上升) |
| KL Loss | 稳定 ~0.0057(未暴涨) |
| 输出长度 | 1000-1600(未膨胀) |
| 总步数 | 467 |
| 总耗时 | ~18h |
✅ 确认无保护机制的 Naive On-Policy 训练有效信号几乎为零。
| 指标 | Step 1 → 467 |
|---|---|
| reward/mean@8 | 0.39% → 0.86% |
| reward/best@8/mean | 2.02% → 3.93% |
| KL Loss | 0 → 0.0113 |
| Grad Norm | 5.56 → 3.72 |
| 每步时间 | ~55s |
| 总耗时 | 12h 11min |
时间分布(单步 ~55s):
| 阶段 | 耗时 | 占比 |
|---|---|---|
| generate_sequences (vLLM rollout) | 16.0s | 29% |
| compute_log_prob | 2.5s | 5% |
| compute_rm_score (Teacher forward) | 8.2s | 15% |
| compute_distillation | 2.4s | 4% |
| ref forward | 3.2s | 6% |
| adv computation | 0.6s | 1% |
| update_actor | 13.4s | 24% |
| 验证等其他 | ~9s | 16% |
| 消融 | reward/mean@8 | vs 基线 |
|---|---|---|
| 🥇 4c: only_stu | 0.45% | +0.06pp ✅ |
| 🥈 4a1: top_k=8 | 0.43% | +0.04pp ✅ |
| 🥉 Exp 3 基线 | 0.39% | — |
| 4a2: top_k=32 | 0.38% | -0.01pp |
| ❌ 4b: kl=0.001 | 0.32% | -0.07pp ❌ |
%%{init: {'theme': 'base', 'themeVariables': { 'lineColor': '#FF6B35', 'textColor': '#333' }}}%%
xychart-beta
title "消融实验 reward/mean@8 @ Step 80"
x-axis ["kl=0.001❌", "topk=32", "基线", "topk=8🥈", "only_stu🥇"]
y-axis "Reward (%)" 0 --> 0.5
bar [0.32, 0.38, 0.39, 0.43, 0.45]
SFT (55.27%) >>>>>>>>>>>>>>> OPD (0.86%) > Naive (0.76%)
差 64 倍的根因:
1. 📶 信号强度:监督信号(正确 token 序列)vs 相对信号(概率差 ~0.006)
2. 🎯 信用分配:每个 token 有明确目标 vs 中间 token 信号模糊
3. 🧠 模型容量:1.7B 小模型难以从微弱相对信号中学习推理
| 机制 | 效果 |
|---|---|
| ✅ KL 惩罚 | KL Loss 仅 0.011,Grad Norm 5.8→3.7 |
| ✅ Top-K 过滤 | 过滤约 27% 的低质量 token(overlap ~73%) |
| ✅ 概率加权 | 训练全程稳定,未出现崩溃 |
KL 惩罚 (0.005) >>> Top-K 阈值 ≈ 交集策略
kl=0.005 → 0.39% ✅ 基线
kl=0.001 → 0.32% ❌ 显著下降(-18%)
only_stu → 0.45% 🥇 最佳
intersection → 0.39% 📉 基线
对 1.7B 小模型,仅用 Student 自己的 top-k 过滤已足够
交集策略反而可能过滤掉有用信号
| 参数 | 推荐值 |
|---|---|
LOG_PROB_TOP_K |
8 |
TOP_K_STRATEGY |
only_stu |
REWARD_WEIGHT_MODE |
student_p |
USE_KL |
True |
KL_LOSS_COEF |
0.005 |
⚠️ 重要建议:OPD 从 SFT checkpoint 开始训练,而非 Base 模型,效果可能显著提升。
# OPD / RL 环境
conda create -n verl python==3.12
conda activate verl
cd verl/
USE_MEGATRON=0 bash scripts/install_vllm_sglang_mcore.sh
pip install math-verify
# SFT 环境
conda create -n sft python==3.11
cd LlamaFactory/
pip install -e .
pip install -r requirements/metrics.txt# 模型软链接
ln -sf /path/to/qwen3_1.7b model/qwen3-1.7b-base
ln -sf /path/to/qwen3_8b model/qwen3-8b
# GSM8K 预处理
python verl/examples/data_preprocess/gsm8k_local.py \
--local_save_dir datasets/gsm8k_verl# Exp 1:离线 SFT 基线
bash exp/run_exp1.sh
# Exp 2:Naive On-Policy
bash exp/run_exp2.sh
# Exp 3:OPD 核心算法
bash exp/run_exp3.sh
# Exp 4:消融实验
bash exp/run_exp4.sh
# Exp 3 + 4 一键运行
bash exp/run_all.sh# 生成预测
python exp/eval_gsm8k.py \
--model /path/to/checkpoint \
--input datasets/gsm8k_verl/test.parquet \
--output preds.jsonl
# 打分
python exp/grade_gsm8k.py \
--input preds.jsonl \
--output metrics.jsonOPD_exp/
├── verl/ # verl RL 框架(editable install)
│ └── verl/trainer/main_ppo.py # PPO/OPD 训练入口
├── LlamaFactory/ # LlamaFactory SFT 框架
│ ├── data/dataset_info.json # 数据集注册表
│ └── examples/ # 训练配置和脚本
├── model/ # 模型软链接
│ ├── qwen3-1.7b-base/ # Student (1.7B)
│ └── qwen3-8b/ # Teacher (8B)
├── datasets/ # 预处理数据
│ ├── gsm8k_verl/ # GSM8K (verl 格式)
│ └── test_data/ # 各类测试集
├── exp/ # 🧪 实验核心
│ ├── EXPERIMENT_LOG.md # 完整实验记录
│ ├── Exp1_操作记录.md # Exp 1 详细记录
│ ├── Exp2_详细记录.md # Exp 2 详细记录
│ ├── Exp3_详细记录.md # Exp 3 详细记录
│ ├── Exp4_详细记录.md # Exp 4 详细记录
│ ├── eval_gsm8k.py # GSM8K 评估
│ ├── grade_gsm8k.py # 打分
│ ├── run_exp*.sh # 一键运行脚本
│ ├── logs/ # 训练日志
│ └── checkpoint/ # 模型 checkpoint
├── outputs/ # SwanLab 输出
├── figs/ # 图片资源
├── scripts/ # 辅助脚本
├── on_policy_distillation.sh # OPD 训练脚本
├── grpo.sh # GRPO 训练脚本
└── 简历项目描述.md # 简历版本总结
| 项目 | 配置 |
|---|---|
| GPU | 4× NVIDIA L20 (48GB/卡) |
| CPU | 128 核 |
| 内存 | ~500 GB |
| 驱动 | NVIDIA 550.135, CUDA 12.4 |
| 环境 | 包 | 版本 |
|---|---|---|
| ppo (verl/OPD) | Python | 3.10.0 |
| torch | 2.8.0+cu128 | |
| vllm | 0.11.0 | |
| ray | 2.55.1 | |
| verl | 0.7.0.dev0 | |
| sft (LlamaFactory) | Python | 3.11.0 |
| torch | 2.6.0+cu124 | |
| transformers | 5.2.0 | |
| LlamaFactory | 0.9.5.dev0 |
⚠️ sft 环境注意事项:需手动设置 LD_LIBRARY_PATH 以正确加载 CUDA 库。
📚 参考论文: Rethinking On-Policy Distillation of Large Language Models: Phenomenology, Mechanism, and Recipe
🔗 原始仓库: thunlp/OPD
⭐ If you find this project helpful, please star the original repo!