Chapter 09

实战:ColossalChat 的 SFT → RM → PPO 流水线

📌 commit 4f9953be335e 把 InstructGPT 三段式 RLHF 完整跑一遍

ColossalChat 是 ColossalAI 团队 2023 年开源、首批中文友好的 InstructGPT 复刻项目。 整套 RLHF 三段式 pipeline (SFT → RM → PPO)的代码都集中在 applications/ColossalChat/, 本章一段一段拆给你看。

9.1项目结构总览

applications/ColossalChat/
├── examples/
│   ├── training_scripts/
│   │   ├── train_sft.py
│   │   ├── train_rm.py
│   │   ├── train_ppo.py             ★ PPO 主脚本
│   │   ├── train_dpo.py
│   │   ├── train_orpo.py
│   │   ├── train_kto.py
│   │   └── train_grpo.py
│   ├── data_preparation_scripts/
│   │   ├── prepare_sft_dataset.py
│   │   ├── prepare_preference_dataset.py
│   │   └── prepare_prompt_dataset.py
│   └── inference/
├── coati/
│   ├── trainer/
│   │   ├── sft.py
│   │   ├── rm.py
│   │   ├── ppo.py                    ★ PPOTrainer
│   │   ├── dpo.py
│   │   └── kto.py
│   ├── models/
│   │   ├── reward_model.py
│   │   ├── critic.py
│   │   └── lora.py
│   ├── experience_maker/             ★ rollout 引擎
│   │   └── naive.py
│   ├── experience_buffer/
│   ├── dataset/
│   └── utils/
└── tests/

三段式数据流:

flowchart LR Base[预训练 base] -->|train_sft.py| SFT[SFT 模型] Pref[偏好数据] -->|train_rm.py| RM[Reward Model] SFT --> PPO RM --> PPO Prompt[prompt-only 数据] --> PPO PPO[train_ppo.py] --> Final[RLHF 模型]

9.2段 1:SFT

SFT 跟第 8 章基本一样,重点是用 chat template。看 train_sft.py

colossalai run --nproc_per_node 8 train_sft.py \
    --pretrain meta-llama/Meta-Llama-3-8B \
    --tokenizer_dir meta-llama/Meta-Llama-3-8B \
    --dataset ./sft_data/ \
    --plugin zero2 \
    --batch_size 4 --lr 5e-6 \
    --max_len 2048 \
    --save_path ./out_sft \
    --conversation_template_config ./conf/conversation_template/llama3.json

核心代码(简化):

from coati.trainer import SFTTrainer

trainer = SFTTrainer(
    model=model,
    booster=booster,
    optim=optimizer,
    lr_scheduler=scheduler,
    max_epochs=1,
    accumulation_steps=1,
    use_flash_attn=True,
)
trainer.fit(train_dataloader, eval_dataloader)

9.3段 2:Reward Model

RM 把语言模型的 lm_head 换成一个标量 head:

class RewardModel(nn.Module):
    def __init__(self, base_model):
        super().__init__()
        self.base = base_model              # transformer body
        d = base_model.config.hidden_size
        self.value_head = nn.Linear(d, 1)   # 输出单一标量

    def forward(self, input_ids, attention_mask):
        h = self.base(input_ids, attention_mask).last_hidden_state
        # 取最后一个非 pad token 的 hidden
        last_idx = attention_mask.sum(dim=1) - 1
        last_hidden = h[torch.arange(h.size(0)), last_idx]
        return self.value_head(last_hidden).squeeze(-1)

训练 loss 是 Bradley-Terry pairwise

$$\mathcal{L}_{\text{RM}} = -\mathbb{E}_{(x,y_w,y_l) \sim D}\Big[\log \sigma\big(R(x,y_w) - R(x,y_l)\big)\Big]$$

其中 $y_w$ 是被偏好的回答、$y_l$ 是被拒绝的回答。

colossalai run --nproc_per_node 8 train_rm.py \
    --pretrain ./out_sft \
    --dataset ./preference_data/ \
    --plugin zero2 \
    --batch_size 4 --lr 5e-6 \
    --max_len 2048 \
    --save_path ./out_rm

9.4段 3:PPO 主流程

PPO 同时维护四个模型:

模型角色是否更新初始权重
Actor 当前策略,生成 response ✅ 更新 SFT
Critic 价值函数 V(s),预测累积 reward✅ 更新 RM
Reward Model给 (prompt, response) 打分 ❌ 冻结 RM
Reference SFT base,KL 散度参考 ❌ 冻结 SFT

显存压力是 SFT 的 4 倍,所以 PPO 通常需要 TP / Gemini / LoRA。

PPO objective

$$\mathcal{L}_\text{PPO} = -\mathbb{E}_t\Big[\min\!\big(r_t \hat A_t,\,\text{clip}(r_t,\,1\!-\!\epsilon,\,1\!+\!\epsilon)\hat A_t\big)\Big] + \beta\,\text{KL}\big(\pi_\theta\|\pi_\text{ref}\big)$$

$r_t = \pi_\theta(a_t|s_t)/\pi_{\theta_\text{old}}(a_t|s_t)$ 是策略比、$\hat A_t$ 是 GAE advantage、$\beta$ 是 KL 惩罚强度。

每个 step 干什么

flowchart TB S1["1. 从 prompt 池采一批 prompt"] S2["2. Actor.generate() 出 response (rollout)"] S3["3. RM 给 reward"] S4["4. Reference 算 KL"] S5["5. Critic 算 value"] S6["6. GAE 计算 advantage"] S7["7. 对 Actor + Critic 跑若干 epoch PPO update"] S8["8. 旧策略 := 新策略"] S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7 --> S8

启动 PPO

colossalai run --nproc_per_node 8 train_ppo.py \
    --pretrain ./out_sft \
    --rm_pretrain ./out_rm \
    --prompt_dataset ./prompt_data/ \
    --pretrain_dataset ./sft_data/ \    # 防遗忘的 PT-Mixin
    --plugin hybrid_parallel \
    --tp 2 --zero_stage 1 \
    --max_length 2048 \
    --num_episodes 1 \
    --num_collect_steps 4 \
    --num_update_steps 4 \
    --train_batch_size 4 \
    --experience_batch_size 8 \
    --lr 1e-6 \
    --kl_coef 0.1 \
    --save_path ./out_ppo \
    --use_flash_attn

9.5experience_maker:rollout 引擎

coati/experience_maker/naive.py

class NaiveExperienceMaker:
    def __init__(self, actor, critic, reward_model, ref_model, tokenizer, kl_coef, ...):
        self.actor = actor
        self.critic = critic
        self.reward_model = reward_model
        self.ref_model = ref_model

    @torch.no_grad()
    def make_experience(self, prompts):
        # 1) Actor 生成
        sequences = self.actor.generate(prompts, max_length=...)
        # 2) 计算 log-probs(actor 和 ref)
        action_log_probs = compute_log_probs(self.actor, sequences)
        ref_log_probs    = compute_log_probs(self.ref_model, sequences)
        # 3) Reward
        rewards = self.reward_model(sequences)
        # 4) KL 惩罚(per-token)
        kl = action_log_probs - ref_log_probs
        rewards_with_kl = rewards - self.kl_coef * kl.sum(-1)
        # 5) Value
        values = self.critic(sequences)
        # 6) GAE advantage
        advantages = compute_gae(rewards_with_kl, values, gamma, lam)
        return Experience(sequences, action_log_probs, advantages, ...)

9.6显存 / 时间预算

LLaMA-3-8B PPO、8×A100-80GB、TP=2、ZeRO-1、seqlen=2048:

阶段显存峰值/卡耗时(每 episode)
rollout (Actor generate) ~50 GB ~3 min
reward / ref / critic forward~62 GB ~1 min
PPO update ~70 GB ~4 min
合计/episode ~8 min
10k episodes 总训 ~55 h

显存不够时常用招:

9.7DPO / GRPO 的位置

ColossalChat 同时支持 DPO(去 RM 直接训)和 GRPO(去 critic):

算法需要 RM需要 criticrollout
PPO
DPO
KTO
ORPO
GRPO ✅(或 rule reward)

显存紧时优先用 DPO(一份模型 + reference 就够),效果接近 PPO 但工程复杂度低一个数量级。

9.8常见踩坑

现象处理
reward 突然飙到很高但回答质量崩 典型 reward hacking:减小 lr、加大 kl_coef、用 reward whitening
KL 一直涨 kl_coef 过小,回归 0.1–0.2
Critic loss 不下降 检查 critic 初始化(应从 RM 复制)、把 critic_lr 提到 actor 的 3–5×
训练后期吞吐变慢 rollout 时模型变更 KV cache miss 多;定期清 cache
OOM in rollout experience_batch_size 而不是 train_batch_size

9.9这章你需要带走的