9.8 KiB
Lesson 23 笔记:PPO 中 Response 采样与梯度问题
目录
核心问题:为什么 y 不能直接求导
response y 是模型采样出来的离散 token 序列,不能像普通 CE loss 那样直接对 token 选择过程求导。
普通 CE Loss 的梯度怎么传
# 普通 SFT
logits = model(input_ids) # 模型输出每个 token 的 logits
loss = F.cross_entropy(logits, labels) # 直接拿 ground truth labels 算 loss
loss.backward() # 梯度直接流回 logits → embedding
关键: labels 是固定的 ground truth,不是从模型里采样出来的。
| 步骤 | 说明 |
|---|---|
| 1 | logits = model(input_ids) |
| 2 | loss = cross_entropy(logits, y_fixed) |
| 3 | backward → 梯度流回 logits |
PPO 的问题:y 是"采样"出来的
# PPO 里
y_w = torch.multinomial(probs, num_samples) # 从模型分布中采样
| 问题 | 说明 |
|---|---|
| 采样是随机的 | torch.multinomial 每次结果可能不同 |
| 采样不可导 | autograd 无法跟踪随机采样过程 |
梯度流断在这里:
logits → softmax → sample → y (随机!)
↑
这个随机采样过程没有梯度
DPO 的解决方案
DPO 绕过了这个问题,不采样,而是直接用 model 输出的 log_probs:
# DPO 直接用模型输出的概率分布
policy_log_probs = model(x_chosen).log_probs # 可导
ref_log_probs = ref_model(x_chosen).log_probs # 可导
# 计算 ratio 时,不需要对采样求导
log_ratio = policy_log_probs - ref_log_probs
PPO 的解决方案:Reinforce 估计
PPO 需要对采样过程求导,用的是 Reinforce 梯度估计:
∇θ E_{y~πθ}[r(y)] = E_{y~πθ}[r(y) * ∇θ log πθ(y)]
| 步骤 | 说明 |
|---|---|
| 1 | 从 π_θ(y |
| 2 | 计算 reward r(y) |
| 3 | 用 ∇ log πθ(y) 把 reward 的信号传回去 |
关键技巧: 用 log probability 的梯度来"引导"随机采样的方向。
PPO + KL 约束公式:k = r - log r - 1
公式拆解
k = π_ref(a|s) / π_θ(a|s) - log(π_ref(a|s) / π_θ(a|s)) - 1
设 r = π_ref / π_θ,则:
k(r) = r - log r - 1
核心性质
| 性质 | 说明 |
|---|---|
| 最小值在 r = 1 | k(1) = 1 - 0 - 1 = 0,policy = reference 时无惩罚 |
| 永远 ≥ 0 | 经典不等式 r - log r - 1 ≥ 0 |
| 偏离越大,惩罚越大 | r >> 1 或 r << 1 时,k 增大 |
为什么这样设计
限制 policy 不要离 reference 太远,但又不能太死
| 公式项 | 含义 |
|---|---|
r = π_ref / π_θ |
policy 相对于 reference 的概率比值 |
r - log r - 1 |
KL divergence 的单 token surrogate |
作用:
- 保证非负
- 在 r=1 处最小
- 对大偏离进行更强惩罚
- 实现稳定的策略更新约束
Thinking 机制:open_thinking 的作用
RLAIFDataset 中的 thinking 控制
def create_chat_prompt(self, 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
)
| 模式 | 效果 |
|---|---|
open_thinking=True |
模型学会"先想再答",输出包含 <think> 推理过程 |
open_thinking=False |
模型学会"直接回答",不输出推理过程 |
chat template 中的实现
{%- if open_thinking is defined and open_thinking is true %}
{{- '<think>\n' }} {# 只打开,不关闭,让模型自己生成 </think> #}
{%- else %}
{{- '<think>\n\n</think>\n\n' }} {# 完整空 thinking 块 #}
{%- endif %}
| open_thinking | 模板输出 |
|---|---|
| True | `< |
| False | `< |
之前阶段有没有训练过 thinking
| 阶段 | 有 thinking 训练数据吗 |
|---|---|
| Pretrain | 没有 |
| SFT | 有字段但实际数据没有(reasoning_content 是空的) |
| RLAIFDataset | 有 — 通过 open_thinking 参数控制 |
SFT 中也有 thinking 相关机制
SFTDataset 的数据 features 定义了 reasoning_content 字段,chat template 对 assistant 消息会做:
<|im_start|>assistant
<think>
{reasoning_content}
</think>
{content}<|im_end|>
另外 post_processing_chat() 会随机移除空 thinking 块:
if '<think>\n\n</think>\n\n' in prompt_content and random.random() > empty_think_ratio:
prompt_content = prompt_content.replace('<think>\n\n</think>\n\n', '')
总结:thinking 训练流程
| 阶段 | 作用 |
|---|---|
| SFT | 让模型见过 <think>...</think> 格式,也见过空 thinking 或被移除的情况 |
| RLAIF/PPO | 用 thinking_ratio 随机决定 rollout 时"打开 thinking"还是"空 thinking 直答" |
| 推理 | 用户用 open_thinking 开关控制模型偏向"显式思考"还是"直接回答" |
PPO 中四个模型的角色
| 模型 | 来源 | 训练方式 |
|---|---|---|
| actor_model | 从 full_sft 来 | PPO 训练它 |
| ref_model | 从 full_sft 来 | 冻结,当参考模型 |
| critic_model | 从 full_sft backbone 来,加 value_head | PPO 训练它 |
| reward_model | 外部奖励模型 | 只负责给 response 打分 |
SGLang 详解
一句话定性
HTTP API 的作用:把"模型生成能力"变成一个"可调用的远程服务接口"。本质不是为了"网络",而是为了:解耦 + 调度 + 并发 + 工程化部署。
model.generate vs SGLang
| 方式 | model.generate | SGLang |
|---|---|---|
| 形式 | 函数调用 | HTTP / RPC |
| 执行 | 当前进程 | 独立推理服务 |
| batch | 固定 batch | Continuous batching |
| cache | 本地函数里 | server 统一管理 |
SGLang 额外做了什么
没有发生的事:
- ❌ 改变模型结构
- ❌ 改变 forward 计算
- ❌ 替换 model.generate 的算法本质
发生的事:
- 把"generate 循环"从 Python 进程搬到一个高性能推理系统里执行
Continuous Batching(关键)
| 方式 | 行为 |
|---|---|
| HF generate | 等所有请求到齐,固定 batch 一起生成 |
| SGLang | 动态拼 batch,完成就退出,新请求随时加入 |
t=0: prompt A, B
t=1: prompt C 加进来
t=2: prompt D 加进来
→ GPU 利用率更高
HTTP API 的核心作用
| 作用 | 说明 |
|---|---|
| 解耦 | 训练和推理完全分开,可独立升级/扩展 |
| 服务化 | 变成标准接口,不关心内部怎么生成 |
| 并发调度入口 | 统一入口供 scheduler 做 continuous batching |
| 分布式扩展 | 多训练 worker 共用一个推理服务 |
RLHF 场景为什么特别需要
| 需求 | 为什么需要 HTTP API |
|---|---|
| rollout 量极大 | 一个 step = 100~1000 prompts,必须高吞吐入口 |
| 训练/推理资源分离 | Trainer 用一批 GPU,Rollout 用另一批,必须跨进程通信 |
| 动态调度 | prompt 随时进来,generation 长度不一致,必须统一调度 |
RLHF pipeline 中的位置
Prompt dataset
↓
HTTP API(SGLang)
↓
Generated trajectories
↓
Reward model
↓
PPO / GRPO update
一句话总结(考试级)
SGLang 并没有替代 model.generate,而是把"生成过程(forward + sampling + KV cache loop)"从本地函数调用,升级为可并发调度的推理服务;模型参数 θ 完全不变,只是执行环境和调度方式变了。
approx_kl 详解
公式
approx_kl = (
0.5 * (log_ratio ** 2) * resp_policy_mask[inds]
).sum() / resp_policy_mask[inds].sum().clamp(min=1)
展开:
approx_kl = Σ_{b,t} m_{b,t} * 0.5 * (log π_θ(a_{b,t}|s_{b,t}) - log π_old(a_{b,t}|s_{b,t}))²
/ Σ_{b,t} m_{b,t}
其中 m_{b,t} = resp_policy_mask[b,t]
resp_policy_mask 的作用
| 排除的内容 | 说明 |
|---|---|
| prompt tokens | 只统计 response |
| padding | 只统计有效 token |
| EOS 之后 | 只统计有效 response 区域 |
为什么叫 approx_kl
PPO 需要监控:当前 actor π_θ 离 rollout 时的 old actor π_old 有多远。
KL 本来是衡量两个分布距离的量。当新旧策略很接近时,可以用二阶近似:
KL(π_old || π_θ) ≈ 0.5 * (log π_θ - log π_old)²
所以源码用 0.5 * (log_ratio ** 2) 来近似新旧策略的 KL。
直观判断
| log_ratio | 含义 | approx_kl |
|---|---|---|
| 接近 0 | new actor 和 old actor 很接近 | 小 |
| 绝对值变大 | new actor 和 old actor 差得更远 | 大 |
一句话总结
| 概念 | 说明 |
|---|---|
| 普通 CE | y 是 ground truth 常数 → 梯度直接流回 |
| DPO | y 是分布比较,不需要采样 → 直接用 log_probs |
| PPO | y 必须采样 → 用 Reinforce 估计器把 reward 信号传回去 |
| KL 约束 | k = r - log r - 1,保证 policy 不偏离 reference 太远 |
| approx_kl | 0.5 * (log_ratio)²,新旧策略 KL 散度的近似 |
| Thinking | 通过 open_thinking 控制模型是否显式输出推理过程 |
| SGLang | 生成服务化,支持并发调度和连续 batching |