# 第 23 课:PPO 训练链路
这一课先讲 PPO 算法本体,再讲 MiniMind 的 `train_ppo.py` 如何把算法落到 LLM 训练里。
本节要解决的不只是“源码里有哪些变量”,而是这条完整主线:
```text
PPO 是一个 on-policy actor-critic 算法;
它用旧策略采样 trajectory;
用 reward 和 critic 算 advantage;
用新旧策略概率比 ratio 更新 actor;
用 clip 限制更新幅度;
在 RLHF 场景里再用 reference KL 约束模型不要偏离 SFT 模型太远。
```
## 目录
- [0. 本节主线](#l23-mainline)
- [1. 本节要懂的 7 个原理](#l23-principles)
- [2. PPO 完整原理](#l23-complete-principle)
- [3. 源码阅读顺序图](#l23-reading-order)
- [4. MiniMind 源码走读](#l23-source-walkthrough)
- [5. 本节必须会写 / 暂时不要求](#l23-must-write)
- [6. 手写模块](#l23-handwrite)
- [7. 实验验证](#l23-experiment)
- [8. 阶段组装](#l23-stage-assembly)
- [9. 本节检查](#l23-check)
- [10. 下一课](#l23-next)
## 0. 本节主线
PPO 算法的主线是:
```text
初始化 actor policy pi_theta、critic value V_phi、reference policy pi_ref
-> 用当前 actor 采样 response trajectory
-> 保存采样时的 old_logprob
-> 用 reward model / rule 得到 response reward
-> 用 critic 估计 value
-> 用 reward 和 value 计算 advantage 与 return
-> 固定这批 rollout,多轮优化 actor/critic
-> actor 用 ratio = pi_theta / pi_old 做 clipped policy update
-> critic 用 return 做 value update
-> RLHF 额外用 reference KL 约束 actor
-> 更新后的 actor 再进入下一轮 rollout
```
MiniMind 源码里的落地链路是:
```text
RLAIFDataset 只提供 prompt
-> 当前 actor rollout 生成 response
-> rollout 时记录 old response token logprob
-> reward model 和规则给整条 response 一个标量 reward
-> 只在 response token 上建 mask 和 logp_pos
-> critic 给每个 response token 估计 value
-> 把整条 response reward 放到最后一个有效 token
-> GAE 从后往前算 advantages 和 returns
-> actor 重新 forward 得到 new response logprob
-> ratio = exp(new_logp - old_logp)
-> PPO clip policy loss + reference KL penalty 更新 actor
-> clipped value loss 更新 critic
-> 更新 rollout engine 里的 policy 权重
```
一句话:
```text
PPO 先用旧 actor 生成样本,再用 advantage 判断这些 token 应该被新 actor 提高概率还是降低概率,同时用 clip 防止新 actor 一步改得太猛。
```
这节和前两节的区别:
```text
DPO: 离线 chosen/rejected 偏好对,policy/ref 都在同一批静态数据上算 logprob。
PPO: 在线 rollout,数据来自当前 actor,old_logp 必须记录生成当时的策略概率。
```
## 1. 本节要懂的 7 个原理
| 原理 | 要理解什么 | 源码位置 |
|---|---|---|
| LLM PPO 的 RL 建模 | state 是 prompt+已生成前缀,action 是下一个 token,trajectory 是整段 response | `dataset/lm_dataset.py:195-224`, `trainer/rollout_engine.py:71-92` |
| PPO 是 on-policy 采样 | response 必须来自当前 actor,旧样本只能有限复用 | `README.md:1091-1100`, `trainer/train_ppo.py:89-101` |
| actor-critic 分工 | actor 生成和更新策略,critic 估计 value,reward model 给回报,reference 做约束 | `trainer/train_ppo.py:36-49`, `trainer/train_ppo.py:365-389`, `trainer/trainer_utils.py:160-177` |
| policy gradient 用 advantage 指方向 | advantage 大于 0 就提高 token 概率,小于 0 就降低 token 概率 | `trainer/train_ppo.py:136-151`, `trainer/train_ppo.py:196-199` |
| old/new logprob 形成 ratio | PPO 不直接最大化 logprob,而是比较新旧策略对同一 token 的概率比 | `trainer/rollout_engine.py:23-36`, `trainer/train_ppo.py:178-191` |
| clip surrogate 限制更新幅度 | ratio 被限制在 `1±epsilon` 附近,避免新策略离采样策略过远 | `README.md:1083-1089`, `trainer/train_ppo.py:191-199` |
| LLM 训练只作用 response token | prompt/padding 不进入 policy/value loss,`logp_pos` 对齐 next-token logits | `trainer/train_ppo.py:117-128`, `trainer/train_ppo.py:200-203` |
学完本节,你应该能画出:
```text
prompt -> rollout response -> old_logp/reward/value -> advantage/return -> new_logp -> ratio -> clipped actor loss + value loss
```
并且能解释下面这些张量的 shape:
```text
gen_out: [batch, prompt_len + response_len]
completion_ids: [batch, response_len]
old_resp_logp: [batch, response_len]
logp_pos: [batch, response_len]
resp_policy_mask:[batch, response_len]
advantages: [batch, response_len]
returns: [batch, response_len]
```
## 2. PPO 完整原理
### 2.1 先把 LLM 训练写成 RL 问题
PPO 不是一种“特殊的 cross entropy loss”。它首先是强化学习算法。
一般 RL 里有三个核心对象:
```text
state s_t: 当前状态
action a_t: 当前动作
reward r_t: 动作之后得到的奖励
```
放到 LLM 里:
```text
state s_t = prompt + response 前 t-1 个 token
action a_t = 第 t 个 response token
policy pi_theta(a_t | s_t) = 模型在当前位置输出这个 token 的概率
trajectory y = 一整段 response token
reward R(x, y) = reward model / rule 给 prompt-response 的分数
```
所以 LLM PPO 优化的不是某个固定标签,而是:
```text
让 actor 在 prompt x 下更容易生成高 reward 的 response y。
```
写成目标函数:
$$
J(\theta)
=
\mathbb{E}_{y \sim \pi_\theta(\cdot \mid x)}
\left[
R(x, y)
\right]
$$
这里最关键的一点是:
```text
response y 是模型采样出来的离散 token 序列,不能像普通 CE loss 那样直接对 token 选择过程求导。
```
这就是为什么 PPO 要走 policy gradient 路线。
### 2.2 policy gradient:用 logprob 给采样动作分配责任
policy gradient 的核心结论是:
$$
\nabla_\theta J(\theta)
=
\mathbb{E}\left[
\sum_t
A_t
\nabla_\theta \log \pi_\theta(a_t \mid s_t)
\right]
$$
先不用推导,先理解它的含义:
```text
如果某个 token 后面的结果比预期好,A_t > 0:
提高 log pi_theta(a_t | s_t)
如果某个 token 后面的结果比预期差,A_t < 0:
降低 log pi_theta(a_t | s_t)
```
`A_t` 叫 advantage。它不是原始 reward,而是:
```text
实际回报 - critic 预期回报
```
如果写成要最小化的 loss,最朴素的 policy gradient loss 是:
$$
\mathcal{L}_{\mathrm{PG}}
=
-\mathbb{E}\left[
\sum_t
A_t
\log \pi_\theta(a_t \mid s_t)
\right]
$$
这个式子能解释 PPO 的方向,但还不是 PPO。PPO 还要解决一个问题:策略每次不能改太猛。
### 2.3 为什么要 old policy 和 ratio
PPO 每轮先用当前 actor 采样一批 response。
采样那一刻的 actor 叫旧策略:
$$
\pi_{\mathrm{old}}
$$
更新时 actor 参数已经在变化,叫新策略:
$$
\pi_\theta
$$
同一个 token 在旧策略和新策略下各有一个概率:
$$
\pi_{\mathrm{old}}(a_t \mid s_t)
$$
$$
\pi_\theta(a_t \mid s_t)
$$
PPO 不直接用新策略 logprob,而是用新旧概率比:
$$
r_t(\theta)
=
\frac{
\pi_\theta(a_t \mid s_t)
}{
\pi_{\mathrm{old}}(a_t \mid s_t)
}
$$
在代码里通常用 logprob 更稳定:
$$
r_t(\theta)
=
\exp\left(
\log \pi_\theta(a_t \mid s_t)
-
\log \pi_{\mathrm{old}}(a_t \mid s_t)
\right)
$$
源码对应:
```python
log_ratio = mb_resp_logp - old_resp_logp[inds]
ratio = torch.exp(log_ratio)
```
`ratio` 的含义:
```text
ratio = 1: 新旧 actor 对这个 token 的概率一样
ratio > 1: 新 actor 更倾向这个 token
ratio < 1: 新 actor 更不倾向这个 token
```
为什么要保存 `old_logp`?
```text
因为 response 是 old actor 采样出来的。
如果只看 new_logp,就不知道新策略相对采样策略改了多少。
```
### 2.4 PPO clip:让 policy gradient 变成受限更新
如果只用:
$$
r_t(\theta) A_t
$$
当 `A_t > 0` 时,优化器会倾向把 `ratio` 推得越大越好。
当 `A_t < 0` 时,优化器会倾向把 `ratio` 压得越小越好。
这会让新策略离采样这批数据的旧策略太远,导致训练不稳定。
PPO 的核心处理是 clipped surrogate objective:
$$
\mathcal{L}^{\mathrm{clip}}(\theta)
=
-\mathbb{E}_t
\left[
\min\left(
r_t(\theta) A_t,
\mathrm{clip}(r_t(\theta), 1-\varepsilon, 1+\varepsilon) A_t
\right)
\right]
$$
这个公式的行为要分两种情况理解:
```text
如果 A_t > 0:
希望提高该 token 概率,所以希望 ratio 变大;
但 ratio 超过 1 + epsilon 后,不再继续给额外收益。
如果 A_t < 0:
希望降低该 token 概率,所以希望 ratio 变小;
但 ratio 低于 1 - epsilon 后,不再继续给额外收益。
```
源码是最小化 loss,所以把 `-min(...)` 写成等价的 `max(-..., -...)`:
$$
\mathrm{policy\_loss}
=
\mathbb{E}\left[
\max\left(
-A_t r_t,
-A_t \mathrm{clip}(r_t, 1-\varepsilon, 1+\varepsilon)
\right)
\right]
$$
MiniMind 再加上 reference KL penalty:
$$
\mathrm{actor\_loss}
=
\mathrm{policy\_loss}
+
\beta \mathrm{KL}(\pi_\theta, \pi_{\mathrm{ref}})
$$
### 2.5 critic 和 GAE:advantage 从哪里来
reward model 给的是整条 response 的分数,不是每个 token 的分数。
但 policy loss 要每个 response token 一个 `A_t`。
所以 PPO 引入 critic:
$$
V_\phi(s_t)
\approx
\mathbb{E}[\text{future return} \mid s_t]
$$
critic 的作用是估计:
```text
从这个 token 位置往后,平均能拿到多少回报。
```
有了 `V(s_t)` 后,先算 TD residual:
$$
\delta_t
= r_t + \gamma V(s_{t+1}) - V(s_t)
$$
再用 GAE 从后往前累计:
$$
A_t
= \delta_t + \gamma \lambda A_{t+1}
$$
最后 critic 的训练目标是:
$$
\mathrm{return}_t
=
A_t + V_{\mathrm{old}}(s_t)
$$
MiniMind 的做法是:
```text
token_rewards 全部为 0
-> 最后一个有效 response token 加上整条 response reward
-> critic 估计每个 response token 位置的 value
-> GAE 从后往前算每个 token 的 advantage
-> returns = advantages + old_values
```
这里要注意:
```text
reward 是整条 response 的标量;
advantage 是每个 response token 的训练信号。
```
### 2.6 LLM PPO 为什么还要 reference KL
经典 PPO 可以只讲 actor、critic、environment reward。
但 LLM RLHF 还多一个问题:
```text
reward model 不完美;
如果 actor 只追 reward,可能学到奇怪输出、格式投机、重复或偏离 SFT 行为。
```
所以 MiniMind 像常见 RLHF PPO 一样保留 reference model。
reference model 通常是训练前的 SFT 模型:
```text
pi_ref = frozen SFT policy
```
训练时额外惩罚 actor 偏离 reference:
$$
\beta \cdot \mathrm{KL}(\pi_\theta \| \pi_{\mathrm{ref}})
$$
MiniMind 的 `kl_ref_penalty` 用的是 token 级近似形式:
```python
torch.exp(ref_resp_logp - mb_resp_logp) - (ref_resp_logp - mb_resp_logp) - 1.0
```
写成数学形式,先定义第 `b` 条样本第 `t` 个 response token:
$$
\ell^{\theta}_{b,t}
=
\log \pi_{\theta}(a_{b,t} \mid s_{b,t})
$$
$$
\ell^{\mathrm{ref}}_{b,t}
=
\log \pi_{\mathrm{ref}}(a_{b,t} \mid s_{b,t})
$$
代码里的差值是:
$$
\Delta_{b,t}
=
\ell^{\mathrm{ref}}_{b,t}
-
\ell^{\theta}_{b,t}
=
\log
\frac{
\pi_{\mathrm{ref}}(a_{b,t} \mid s_{b,t})
}{
\pi_{\theta}(a_{b,t} \mid s_{b,t})
}
$$
单个 token 的 penalty 是:
$$
k_{b,t}
=
\exp(\Delta_{b,t})
-
\Delta_{b,t}
-
1
$$
也就是:
$$
k_{b,t}
=
\frac{
\pi_{\mathrm{ref}}(a_{b,t} \mid s_{b,t})
}{
\pi_{\theta}(a_{b,t} \mid s_{b,t})
}
-
\log
\frac{
\pi_{\mathrm{ref}}(a_{b,t} \mid s_{b,t})
}{
\pi_{\theta}(a_{b,t} \mid s_{b,t})
}
-
1
$$
带上 response mask 后,MiniMind 实际优化的是 masked mean:
$$
\mathrm{KL}_{\mathrm{ref}}
=
\frac{
\sum_{b,t} m_{b,t} k_{b,t}
}{
\sum_{b,t} m_{b,t}
}
$$
其中 `m_{b,t}` 对应源码里的 `resp_policy_mask`。
这个形式可以看作对:
$$
\mathrm{KL}(\pi_{\theta} \| \pi_{\mathrm{ref}})
$$
的 token 级采样估计。直观上,actor 给某个 token 的概率越偏离 reference,这个 penalty 越大。
这不是 DPO 里的 chosen/rejected reference logratio。这里的 reference 作用是:
```text
约束 actor 在 PPO 更新时不要离原始 SFT policy 太远。
```
### 2.7 PPO 完整算法伪代码
把上面连起来,PPO 的训练框架是:
```text
initialize actor pi_theta
initialize critic V_phi
initialize frozen reference pi_ref
initialize reward model R
for each training step:
prompts = sample prompts
with no_grad:
responses = actor.generate(prompts)
old_logps = log pi_old(response_tokens | prompts)
rewards = R(prompts, responses)
old_values = V_phi(prompts + responses)
advantages, returns = GAE(rewards, old_values)
for ppo_epoch in range(K):
for minibatch in rollout_batch:
new_logps = log pi_theta(response_tokens | prompts)
values = V_phi(prompts + responses)
ratio = exp(new_logps - old_logps)
policy_loss = clipped_surrogate(ratio, advantages)
kl_loss = KL(pi_theta, pi_ref)
value_loss = clipped_mse(values, returns)
loss = policy_loss + beta * kl_loss + vf_coef * value_loss
update actor and critic
sync latest actor into rollout engine
```
这个框架里最容易混的地方是:
```text
old_logps: 采样 response 时的 actor 概率,固定不变
new_logps: 当前正在更新的 actor 概率,每次 forward 会变
advantages: 这批 response token 好不好,由 reward 和 critic 决定
ratio: 新 actor 相对旧 actor 对同一 token 改了多少
clip: 限制 ratio,防止离 old actor 太远
```
### 2.8 PPO 公式到 MiniMind 变量映射
| PPO 符号 | 含义 | MiniMind 变量 |
|---|---|---|
| `s_t` | prompt + 已生成 response 前缀 | `gen_out` 中 response token 之前的上下文 |
| `a_t` | 第 `t` 个 response token | `completion_ids[:, t]` / `labels.gather(...)` |
| `pi_old` | rollout 时的 actor | `rollout_engine.policy_model` |
| `log pi_old(a_t|s_t)` | 生成时 token logprob | `old_resp_logp` |
| `pi_theta` | 当前正在更新的 actor | `actor_model` / `actor_unwrapped` |
| `log pi_theta(a_t|s_t)` | 更新时重新计算的 token logprob | `mb_resp_logp` |
| `r_t(theta)` | 新旧策略概率比 | `ratio = torch.exp(log_ratio)` |
| `R(x, y)` | 整条 response reward | `rewards` |
| `r_t` | token 级 reward | `token_rewards` |
| `V(s_t)` | critic value | `old_resp_values`, `mb_resp_values` |
| `A_t` | advantage | `advantages` |
| `return_t` | value target | `returns` |
| `epsilon` | PPO clip 范围 | `args.clip_epsilon` |
| `pi_ref` | frozen reference policy | `ref_model` |
| `KL(pi_theta, pi_ref)` | reference 约束 | `kl_ref_penalty` |
## 3. 源码阅读顺序图
建议按这个顺序读:
```text
1. dataset/lm_dataset.py:195-224
看 PPO 数据集为什么只返回 prompt。
2. trainer/train_ppo.py:365-389
看 actor、reference、critic、reward model、rollout engine 如何初始化。
3. trainer/rollout_engine.py:23-92
看 rollout 如何生成 response,并保存 old per-token logprob。
4. trainer/train_ppo.py:84-128
看一个 batch 如何从 prompt 变成 gen_out、completion_ids、mask、logp_pos。
5. trainer/train_ppo.py:130-151
看 critic、reference、reward 和 GAE 如何产生 advantages/returns。
6. trainer/train_ppo.py:164-203
看 PPO mini-batch 更新里的 ratio、clip、KL、value loss。
7. trainer/train_ppo.py:246
看 actor 更新后如何同步给 rollout engine。
```
和前几课的关系:
```text
第 21 课:reference model 和 token logprob
第 22 课:sequence logprob 和 DPO loss
第 23 课:PPO rollout、advantage、clip update
第 24 课:GRPO/CISPO,减少或替代 PPO 里的 critic/value 组件
```
## 4. MiniMind 源码走读
### 4.1 PPO 数据集只提供 prompt
#### 源码证据:RLAIFDataset
文件:`dataset/lm_dataset.py:195-224`
看它是为了理解:PPO 不是读取一个固定 answer 来训练,而是只拿 prompt 给 actor 生成。
代码摘录:
```python
class RLAIFDataset(Dataset):
def create_chat_prompt(self, conversations):
conversations = pre_processing_chat(conversations)
use_thinking = random.random() < self.thinking_ratio
return self.tokenizer.apply_chat_template(
conversations[:-1],
tokenize=False,
open_thinking=use_thinking,
add_generation_prompt=True
)
def __getitem__(self, index):
sample = self.samples[index]
prompt = self.create_chat_prompt(sample['conversations'])
return {
'prompt': prompt,
'answer': ""
}
```
这段代码说明:
- `conversations[:-1]` 表示不把最后的标准回复当训练标签。
- `add_generation_prompt=True` 让 prompt 结束在 assistant 待生成位置。
- `answer` 是空字符串,真正的 response 来自后面的 rollout。
理解到这里就够:
```text
PPO 的训练数据不是 labels,而是让 actor 生成 response 的上下文。
```
暂时不要看:
```text
thinking_ratio 的课程外效果;
真实 RLAIF 数据如何收集;
reward model 的训练过程。
```
### 4.2 四类模型的分工
#### 源码证据 A:CriticModel
文件:`trainer/train_ppo.py:36-49`
看它是为了理解:critic 不是输出 vocab logits,而是每个 token 一个 value。
代码摘录:
```python
class CriticModel(MiniMindForCausalLM):
def __init__(self, params):
super().__init__(params)
self.value_head = nn.Linear(params.hidden_size, 1)
def forward(self, input_ids=None, attention_mask=None, **kwargs):
outputs = self.model(input_ids=input_ids, attention_mask=attention_mask, **kwargs)
hidden_states = self.model.norm(outputs[0])
values = self.value_head(hidden_states).squeeze(-1)
return values
```
这段代码说明:
- critic 复用 MiniMind backbone。
- `value_head` 把 hidden state 映射成一个标量。
- 返回 shape 是 `[batch, seq]`,不是 `[batch, seq, vocab]`。
#### 源码证据 B:训练入口初始化四类组件
文件:`trainer/train_ppo.py:365-389`
看它是为了理解:PPO 同时需要 actor、reference、critic、reward model 和 rollout engine。
代码摘录:
```python
actor_model, tokenizer = init_model(lm_config, base_weight, device=args.device)
ref_model, _ = init_model(lm_config, base_weight, device=args.device)
ref_model = ref_model.eval().requires_grad_(False)
critic_model = CriticModel(lm_config)
critic_model.load_state_dict(state_dict, strict=False)
critic_model = critic_model.to(args.device)
reward_model = LMForRewardModel(args.reward_model_path, device=args.device, dtype=torch.float16)
rollout_engine = create_rollout_engine(
engine_type=args.rollout_engine,
policy_model=actor_model,
tokenizer=tokenizer,
device=args.device,
autocast_ctx=autocast_ctx,
)
train_ds = RLAIFDataset(args.data_path, tokenizer, max_length=(args.max_seq_len + args.max_gen_len), thinking_ratio=args.thinking_ratio)
```
这段代码说明:
- actor 是要训练的策略模型。
- reference 从同一个 base weight 初始化,但冻结。
- critic 从 base checkpoint 加载 backbone,再加 `value_head`。
- reward model 是外部打分器。
- rollout engine 持有当前 actor,用来生成 response。
#### 源码证据 C:reward model wrapper
文件:`trainer/trainer_utils.py:160-177`
看它是为了理解:源码中的 reward model 输出一个被截断到 `[-3, 3]` 的标量。
代码摘录:
```python
class LMForRewardModel:
def __init__(self, model_path, device="cuda", dtype=torch.float16):
self.tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
self.model = AutoModel.from_pretrained(model_path, torch_dtype=dtype, trust_remote_code=True)
self.model = self.model.to(device).eval()
@torch.no_grad()
def get_score(self, messages, response):
eval_messages = [
{"role": "user", "content": message_context},
{"role": "assistant", "content": response}
]
score = self.model.get_score(self.tokenizer, eval_messages)
return max(min(score, 3.0), -3.0)
```
这段代码说明:
- reward model 不参与反向传播。
- 输入是整理后的用户上下文和 actor response。
- 输出是整条 response 的标量分数。
### 4.3 rollout 生成 response,并记录 old logprob
#### 源码证据 A:计算每个 completion token 的 logprob
文件:`trainer/rollout_engine.py:23-36`
看它是为了理解:PPO 需要保存生成当时的 `old_resp_logp`。
代码摘录:
```python
def compute_per_token_logps(model, input_ids: Tensor, n_keep: int, attention_mask: Optional[Tensor] = None) -> Tensor:
logits = unwrapped(input_ids, attention_mask=attention_mask, logits_to_keep=n_keep + 1).logits[:, :-1, :]
per_token_logps = []
for logits_row, ids_row in zip(logits, input_ids[:, -n_keep:]):
per_token_logps.append(
torch.gather(logits_row.log_softmax(dim=-1), 1, ids_row.unsqueeze(1)).squeeze(1)
)
return torch.stack(per_token_logps)
```
这段代码说明:
- `n_keep` 是 completion 长度。
- logits 先去掉最后一个位置,因为最后一个 logits 没有下一个 token 标签。
- `input_ids[:, -n_keep:]` 是 completion token ids。
- `gather` 取出 actor 对自己生成 token 的 logprob。
注意:
```text
这里不是求 vocab 上所有 token 的概率,而是只取 response 真实 token 的 logprob。
```
#### 源码证据 B:Torch rollout 返回完整训练所需字段
文件:`trainer/rollout_engine.py:71-92`
看它是为了理解:rollout 的产物不只是文本,还包括 old logprob 和 completion ids。
代码摘录:
```python
output_ids = model.generate(
input_ids=prompt_ids.repeat_interleave(num_generations, dim=0),
attention_mask=attention_mask.repeat_interleave(num_generations, dim=0),
max_new_tokens=max_new_tokens,
do_sample=True,
temperature=temperature,
)
prompt_len = prompt_ids.size(1)
completion_ids = output_ids[:, prompt_len:]
full_mask = (output_ids != self.tokenizer.pad_token_id).long()
per_token_logps = compute_per_token_logps(self.policy_model, output_ids, completion_ids.size(1), attention_mask=full_mask)
return RolloutResult(output_ids, completion_ids, per_token_logps, completions, ...)
```
这段代码说明:
- `output_ids` 是 prompt 和 response 拼接后的完整序列。
- `completion_ids` 只保留 response 区域。
- `per_token_logps` 对应 completion token。
- PPO 更新时不能丢掉这些 old logprob。
### 4.4 只在 response token 上建 mask
#### 源码证据:response 位置和 mask
文件:`trainer/train_ppo.py:117-128`
看它是为了理解:PPO loss 只作用在有效 response token,不作用在 prompt 和 padding。
代码摘录:
```python
full_mask = (gen_out != tokenizer.pad_token_id).long()
labels = gen_out[:, 1:].clone()
B = len(prompts)
resp_labels = completion_ids
resp_idx = torch.arange(resp_labels.size(1), device=gen_out.device).unsqueeze(0)
logp_pos = prompt_lens.unsqueeze(1) - 1 + resp_idx
resp_pad_mask = rollout_result.completion_mask.to(args.device).bool()
resp_lengths = resp_pad_mask.sum(dim=1)
eos_mask = resp_labels.eq(tokenizer.eos_token_id) & resp_pad_mask
has_eos = eos_mask.any(dim=1)
eos_pos = torch.argmax(eos_mask.int(), dim=1)
resp_lengths = torch.where(has_eos, eos_pos + 1, resp_lengths).long().clamp(min=1)
resp_policy_mask = ((resp_idx < resp_lengths.unsqueeze(1)) & resp_pad_mask).float()
resp_value_mask = resp_policy_mask.clone()
```
这段代码说明:
- `labels = gen_out[:, 1:]` 是 next-token 标签。
- 第 `j` 个 completion token 的 logits 位置是 `prompt_len - 1 + j`。
- `resp_policy_mask` 同时处理 padding 和 eos 后 token。
- policy loss 和 value loss 都用 response mask。
容易错的地方:
```text
completion_ids 的第 0 个 token,不对应 logits 的 prompt_len 位置;
它对应 logits[:, prompt_len - 1],因为 logits 预测下一个 token。
```
### 4.5 reward 放在最后一个有效 response token
#### 源码证据 A:规则 reward + reward model
文件:`trainer/train_ppo.py:52-76`
看它是为了理解:MiniMind 的 reward 是整条 response 的标量,不是每个 token 的监督标签。
代码摘录:
```python
rewards = torch.zeros(len(responses), device=args.device)
...
rewards[i] += 0.5 if 20 <= len(response.strip()) <= 800 else -0.5
if '' in response:
rewards[i] += 1.0 if 20 <= len(thinking_content.strip()) <= 300 else -0.5
rewards[i] += 0.25 if response.count('') == 1 else -0.25
rewards[i] -= rep_penalty(answer)
score = reward_model.get_score(messages, answer)
reward_model_scores.append(score)
...
rewards += reward_model_scores
```
这段代码说明:
- reward 由启发式规则和 reward model 分数组成。
- reward 的 shape 是 `[batch]`。
- 它评价整条 response。
#### 源码证据 B:把 response reward 写到最后一个 token
文件:`trainer/train_ppo.py:136-138`
看它是为了理解:GAE 需要 token 级 reward,所以源码把整条回复分数放到末 token。
代码摘录:
```python
token_rewards = torch.zeros_like(old_resp_logp)
last_idx = resp_lengths - 1
token_rewards[torch.arange(B, device=args.device)[valid_resp], last_idx[valid_resp]] += rewards[valid_resp]
```
这段代码说明:
- `token_rewards` shape 和 `old_resp_logp` 一样,都是 `[batch, response_len]`。
- 除最后一个有效 response token 外,其它 token reward 为 0。
- 后续 GAE 会把末端 reward 从后往前传播到 earlier tokens。
### 4.6 GAE 计算 advantages 和 returns
#### 源码证据:从后往前递推 advantage
文件:`trainer/train_ppo.py:130-151`
看它是为了理解:PPO 的 policy loss 用 `advantages`,critic 的 value loss 用 `returns`。
代码摘录:
```python
values_seq = critic_for_rollout(input_ids=gen_out, attention_mask=full_mask)
old_resp_values = values_seq.gather(1, logp_pos) * resp_value_mask
...
gen_len = old_resp_values.size(1)
lastgaelam = torch.zeros(B, device=args.device)
advs_rev = []
for t in reversed(range(gen_len)):
nv = old_resp_values[:, t + 1] if t < gen_len - 1 else 0.0
delta = token_rewards[:, t] + args.gamma * nv - old_resp_values[:, t]
lastgaelam = delta + args.gamma * args.lam * lastgaelam
advs_rev.append(lastgaelam)
advantages = torch.stack(advs_rev[::-1], dim=1)
returns = advantages + old_resp_values
adv_mean = (advantages * resp_policy_mask).sum() / resp_policy_mask.sum().clamp(min=1)
adv_var = ((advantages - adv_mean) ** 2 * resp_policy_mask).sum() / resp_policy_mask.sum().clamp(min=1)
advantages = (advantages - adv_mean) * torch.rsqrt(adv_var + 1e-8) * resp_policy_mask
```
这段代码说明:
- critic 输出 `[batch, full_seq]`,再用 `logp_pos` 取 response 位置。
- GAE 从最后一个 response token 往前算。
- `returns = advantages + old_resp_values` 给 critic 作为目标。
- advantages 会在有效 response token 上标准化。
### 4.7 PPO update:ratio、clip、KL、value loss
#### 源码证据 A:重新 forward 得到 new logprob
文件:`trainer/train_ppo.py:171-181`
看它是为了理解:rollout 时保存 old logprob,更新时重新算 new logprob。
代码摘录:
```python
mb_values_seq = critic_unwrapped(input_ids=gen_out[inds], attention_mask=full_mask[inds])
mb_resp_values = mb_values_seq.gather(1, logp_pos[inds])
res = actor_unwrapped(input_ids=gen_out[inds], attention_mask=full_mask[inds])
mb_resp_logp = F.log_softmax(res.logits[:, :-1], dim=-1).gather(
2, labels[inds].unsqueeze(-1)
).squeeze(-1).gather(1, logp_pos[inds])
log_ratio = mb_resp_logp - old_resp_logp[inds]
```
这段代码说明:
- `old_resp_logp` 不参与当前 actor forward。
- `mb_resp_logp` 来自当前 actor。
- 两者相减得到 `log_ratio`。
#### 源码证据 B:PPO clip policy loss
文件:`trainer/train_ppo.py:191-199`
看它是为了理解:README 公式里的 `ratio`、`clip`、`KL` 在源码里如何落地。
代码摘录:
```python
ratio = torch.exp(log_ratio)
clipfrac = ((((ratio - 1.0).abs() > args.clip_epsilon).float() * resp_policy_mask[inds]).sum()
/ resp_policy_mask[inds].sum().clamp(min=1))
kl_ref_penalty = ((torch.exp(ref_resp_logp[inds] - mb_resp_logp) - (ref_resp_logp[inds] - mb_resp_logp) - 1.0)
* resp_policy_mask[inds]).sum() / resp_policy_mask[inds].sum().clamp(min=1)
policy_loss = ((torch.max(-advantages[inds] * ratio,
-advantages[inds] * torch.clamp(ratio, 1.0 - args.clip_epsilon, 1.0 + args.clip_epsilon))
* resp_policy_mask[inds]).sum() / resp_policy_mask[inds].sum().clamp(min=1)
+ args.kl_coef * kl_ref_penalty)
```
这段代码说明:
- `ratio` 比较新旧 actor 对同一批 response token 的概率。
- `clipfrac` 统计有多少有效 token 超过裁剪范围。
- `kl_ref_penalty` 约束 actor 不要离 reference 太远。
- `policy_loss` 只在 `resp_policy_mask` 有效位置求平均。
#### 源码证据 C:critic value loss
文件:`trainer/train_ppo.py:200-203`
看它是为了理解:critic 也用 clip,避免 value 估计一步跳太大。
代码摘录:
```python
value_loss = 0.5 * (torch.max((mb_resp_values - returns[inds]) ** 2,
(torch.clamp(mb_resp_values, old_resp_values[inds] - args.cliprange_value,
old_resp_values[inds] + args.cliprange_value) - returns[inds]) ** 2)
* resp_value_mask[inds]).sum() / resp_value_mask[inds].sum().clamp(min=1)
```
这段代码说明:
- `returns` 是 critic 的训练目标。
- `old_resp_values` 用来裁剪 critic 更新幅度。
- value loss 和 policy loss 使用同一片 response 区域。
### 4.8 更新 rollout engine 的 policy
#### 源码证据:训练后同步 actor
文件:`trainer/train_ppo.py:226-246`
看它是为了理解:PPO 是 on-policy,所以 rollout engine 必须定期拿到最新 actor。
代码摘录:
```python
if grad_accum_step % args.accumulation_steps == 0:
clip_grad_norm_(actor_model.parameters(), args.grad_clip)
clip_grad_norm_(critic_model.parameters(), args.grad_clip)
actor_optimizer.step()
critic_optimizer.step()
actor_scheduler.step()
critic_scheduler.step()
actor_optimizer.zero_grad()
critic_optimizer.zero_grad()
if step % args.save_interval == 0 or step == iters:
rollout_engine.update_policy(actor_model)
```
这段代码说明:
- actor 和 critic 都会更新。
- rollout engine 不能永远用旧 actor。
- 如果使用 SGLang,`update_policy` 会把新权重同步到推理服务。
## 5. 本节必须会写 / 暂时不要求
### 5.1 必须会写
本节的教学版核心不是完整训练脚本,而是下面 5 个小函数:
```python
def response_logp_positions(prompt_lens: torch.Tensor, response_len: int) -> torch.Tensor:
...
def masked_gae(
token_rewards: torch.Tensor,
values: torch.Tensor,
mask: torch.Tensor,
gamma: float,
lam: float,
) -> tuple[torch.Tensor, torch.Tensor]:
...
def ppo_policy_loss(
new_logps: torch.Tensor,
old_logps: torch.Tensor,
advantages: torch.Tensor,
mask: torch.Tensor,
clip_epsilon: float,
) -> torch.Tensor:
...
def reference_kl_penalty(
policy_logps: torch.Tensor,
ref_logps: torch.Tensor,
mask: torch.Tensor,
) -> torch.Tensor:
...
def clipped_value_loss(
values: torch.Tensor,
old_values: torch.Tensor,
returns: torch.Tensor,
mask: torch.Tensor,
cliprange_value: float,
) -> torch.Tensor:
...
```
它们分别对应:
```text
train_ppo.py:121-122
train_ppo.py:140-151
train_ppo.py:180-199
train_ppo.py:194-195
train_ppo.py:200-203
```
### 5.2 暂时不要求
本节先不要求手写:
```text
真实 reward model 加载;
SGLang rollout engine;
DDP 同步和 early_stop_kl 的通信细节;
checkpoint、scheduler、wandb/swanlab;
真实长文本采样质量;
reward hacking 的完整治理。
```
这些是工程外围或更大的 RLHF 话题。第 23 课先把核心张量链路讲清楚。
## 6. 手写模块
本节对应教学版建议文件:
```text
course/impl/core/ppo.py
```
### 6.1 补 `response_logp_positions`
接口:
```python
def response_logp_positions(prompt_lens: torch.Tensor, response_len: int) -> torch.Tensor:
...
```
对齐源码:
```text
trainer/train_ppo.py:121-122
```
行为要求:
```text
输入:
prompt_lens: [batch]
response_len: int
输出:
logp_pos: [batch, response_len]
规则:
第 j 个 response token 对应 logits[:, prompt_len - 1 + j]
```
### 6.2 补 `masked_gae`
接口:
```python
def masked_gae(
token_rewards: torch.Tensor,
values: torch.Tensor,
mask: torch.Tensor,
gamma: float,
lam: float,
) -> tuple[torch.Tensor, torch.Tensor]:
...
```
对齐源码:
```text
trainer/train_ppo.py:140-151
```
行为要求:
```text
输入:
token_rewards: [batch, response_len]
values: [batch, response_len]
mask: [batch, response_len]
输出:
advantages: [batch, response_len]
returns: [batch, response_len]
```
注意:
```text
advantages 要只在 mask 区域标准化;
padding 位置最终应为 0。
```
### 6.3 补 `ppo_policy_loss`
接口:
```python
def ppo_policy_loss(
new_logps: torch.Tensor,
old_logps: torch.Tensor,
advantages: torch.Tensor,
mask: torch.Tensor,
clip_epsilon: float,
) -> torch.Tensor:
...
```
对齐源码:
```text
trainer/train_ppo.py:180-199
```
行为要求:
```text
log_ratio = new_logps - old_logps
ratio = exp(log_ratio)
loss = masked mean(max(-advantages * ratio, -advantages * clipped_ratio))
```
这个函数只负责 PPO clipped surrogate。MiniMind 的完整 actor loss 还要加:
```text
kl_coef * reference_kl_penalty(...)
```
### 6.4 补 `reference_kl_penalty`
接口:
```python
def reference_kl_penalty(
policy_logps: torch.Tensor,
ref_logps: torch.Tensor,
mask: torch.Tensor,
) -> torch.Tensor:
...
```
对齐源码:
```text
trainer/train_ppo.py:194-195
```
行为要求:
```text
delta = ref_logps - policy_logps
penalty = exp(delta) - delta - 1
loss = masked mean(penalty)
```
对应公式:
$$
\Delta_{b,t}
=
\log \pi_{\mathrm{ref}}(a_{b,t} \mid s_{b,t})
-
\log \pi_{\theta}(a_{b,t} \mid s_{b,t})
$$
$$
\mathrm{reference\_kl\_penalty}
=
\frac{
\sum_{b,t} m_{b,t}
\left[
\exp(\Delta_{b,t}) - \Delta_{b,t} - 1
\right]
}{
\sum_{b,t} m_{b,t}
}
$$
理解重点:
```text
这不是 DPO 的 reference logratio;
这是 RLHF PPO 里防止 actor 偏离 frozen reference policy 的约束项。
```
### 6.5 补 `clipped_value_loss`
接口:
```python
def clipped_value_loss(
values: torch.Tensor,
old_values: torch.Tensor,
returns: torch.Tensor,
mask: torch.Tensor,
cliprange_value: float,
) -> torch.Tensor:
...
```
对齐源码:
```text
trainer/train_ppo.py:200-203
```
行为要求:
```text
unclipped = (values - returns) ** 2
clipped_values = clamp(values, old_values - cliprange_value, old_values + cliprange_value)
clipped = (clipped_values - returns) ** 2
loss = 0.5 * masked mean(max(unclipped, clipped))
```
## 7. 实验验证
本节实验不加载真实 reward model,也不启动 SGLang。它用随机 tiny MiniMind 模型构造一批 synthetic rollout,验证 PPO 核心张量链路。
实验文件:
```text
course/labs/trace_ppo_batch_flow.py
```
命令:
```bash
cd /home/sun/minimind
python course/labs/trace_ppo_batch_flow.py
```
这个实验验证:
```text
1. completion token 的 old logprob shape 是 [batch, response_len]。
2. logp_pos 使用 prompt_len - 1 + response_idx。
3. response mask 会去掉 padding/eos 后 token。
4. 标量 reward 会写到最后一个有效 response token。
5. GAE 输出 advantages/returns。
6. ratio、clipfrac、KL penalty、policy loss、value loss 都只在 response mask 上计算。
```
记录输出:
```text
gen_out.shape =
completion_ids.shape =
old_resp_logp.shape =
logp_pos =
resp_policy_mask =
advantages.shape =
returns.shape =
masked_adv_mean =
ratio_min =
ratio_max =
approx_kl =
policy_loss =
value_loss =
```
输出含义:
```text
ratio_min/ratio_max 接近 1,说明 old 和 new actor 此时还没真正更新;
approx_kl 接近 0,说明新旧策略几乎一致;
masked_adv_mean 接近 0,说明 advantages 在有效 response token 上做了标准化;
policy_loss 和 value_loss 是可反向传播的标量。
```
如果你后续补了 `course/impl/core/ppo.py`,可以新增对齐测试:
```text
course/impl/tests/test_ppo.py
```
测试内容:
```text
response_logp_positions 对齐 train_ppo.py 的 logp_pos;
masked_gae 对齐 train_ppo.py 的 GAE 递推;
ppo_policy_loss 对齐 train_ppo.py 的 clip policy loss;
reference_kl_penalty 对齐 train_ppo.py 的 reference KL penalty;
clipped_value_loss 对齐 train_ppo.py 的 value loss。
```
## 8. 阶段组装
PPO/GRPO 阶段的教学版最小闭环可以这样组装:
```text
CoursePromptDataset
-> actor.generate 得到 completion_ids
-> old_logps = response token logprob
-> reward_fn 给每条 response 一个标量 reward
-> critic 得到 response values
-> masked_gae 得到 advantages/returns
-> ppo_policy_loss 更新 actor
-> reference_kl_penalty 约束 actor 不要偏离 reference
-> clipped_value_loss 更新 critic
```
### 8.1 本节验收命令
```bash
cd /home/sun/minimind
python course/labs/trace_ppo_batch_flow.py
```
如果环境没有 GPU,也可以强制 CPU:
```bash
python course/labs/trace_ppo_batch_flow.py --device cpu
```
### 8.2 源码差异
教学版先省略:
```text
SGLang 服务;
真实 reward model;
多轮 rollout 采样;
DDP 通信;
checkpoint/resume;
大 batch 和梯度累积调度。
```
这些省略不改变 PPO 核心:
```text
old_logp -> new_logp -> ratio -> advantage -> clipped policy loss
reference_logp -> reference KL penalty
```
### 8.3 Portfolio 记录
完成本节后,可以在 `course/portfolio/experiments.md` 记录:
```text
PPO batch trace:
- 构造 synthetic prompt/completion。
- 用 tiny actor 计算 old response logprob。
- 用 tiny critic 计算 response value。
- 把 scalar reward 写到最后一个有效 response token。
- 用 GAE 得到 advantages/returns。
- 手算 ratio、clip policy loss、reference KL penalty 和 clipped value loss。
```
在 `course/notes/mistakes.md` 记录容易错的点:
```text
1. 把 old_logp 和 new_logp 混成同一个东西。
2. 忘记 completion 第 0 个 token 对应 logits 的 prompt_len - 1 位置。
3. 把 prompt token 也算进 policy loss。
4. 对 padding token 做 advantage 标准化。
5. 忘记 actor 更新后同步 rollout engine。
```
## 9. 本节检查
1. 在 LLM PPO 里,`state`、`action`、`trajectory`、`reward` 分别对应什么?
2. PPO 为什么不能像 DPO 一样只读静态 chosen/rejected 数据?
3. policy gradient 里的 `advantage` 决定什么更新方向?
4. `old_resp_logp` 为什么必须在 rollout 时保存?
5. `ratio = exp(new_logp - old_logp)` 大于 1 代表什么?
6. PPO clip 对 `A_t > 0` 和 `A_t < 0` 分别限制了什么?
7. `advantages` 和 `returns` 分别给哪个 loss 使用?
8. MiniMind 的 reference KL penalty 和 DPO 的 reference logratio 有什么区别?
9. 第 `j` 个 completion token 为什么对应 `prompt_len - 1 + j` 的 logits 位置?
10. actor 更新后为什么要调用 `rollout_engine.update_policy(actor_model)`?
## 10. 下一课
第 24 课进入 [GRPO 与 CISPO](24_grpo_cispo.md)。
下一课要解决:
```text
GRPO 和 PPO 的核心差异是什么;
为什么 GRPO 可以减少对 critic/value model 的依赖;
MiniMind 的 train_grpo.py 如何组织 multiple generations;
group-relative reward/advantage 如何进入 loss;
CISPO 和普通 GRPO 的 loss_type 在源码里如何分叉。
```