目录
- 一、SFT 之后,模型还缺什么
- 二、LoRA:不改原书,贴一张便利贴
- 三、蒸馏:抄答案,还是抄思路
- 四、DPO:给你两份答案让你选
- 五、PPO 与 GRPO:请裁判,还是同题多答
- 六、CISPO:两行代码的差别
- 七、把四个算法收成一张表
- 八、Agentic RL:从答题到用工具做完一件事
- 九、代价:「对齐全税」
- 十、你自己怎么上手
- 十一、自检清单
- 附:本文的核查方式
- 附:延伸阅读
一、SFT 之后,模型还缺什么
上一篇讲到 SFT 的机制:把”每个词都要预测”收窄成”只有 assistant 该说的话要预测”。
但它有个更深的限制,藏在上一句里没展开——
SFT 的 labels 是数据里”实际上被写下的那一句话”。
{"role": "assistant", "content": "你好!"}
一个提问配一句回复。模型学到的是”这里该说这个”。可很多事情并不只有一种好答案:
| 提问 | SFT 的处境 |
|---|---|
| “1+1 等于几” | 没问题,答案唯一 |
| “推荐几个杭州美食” | 有正确答案吗?说三个真的就够了,但数据里只写了三个 |
| “帮我写个快排” | 能跑就行,可数据里那版用了递归,尾递归版本是不是更差? |
SFT 从来没有告诉模型”哪种答法更好”,它只告诉模型”数据里是这么答的”。
要让它学会”比较”,就得给它一个能表达偏好的信号——这就是后训练(post-training)要解决的整件事。
后训练这条线上有四类工作,各解决一个不同的问题:
| 方法 | 解决的问题 | 一句话 |
|---|---|---|
| LoRA | 全参微调太贵 | 只训一小块 |
| 蒸馏 | 想学的东西老师已经会了 | 抄老师的 |
| DPO | 有现成的好坏答案对比 | 从对比里学 |
| PPO / GRPO / CISPO | 连”好答案”都没有,只有个打分标准 | 自己试,按分数调整 |
(还有一个贯穿始终的问题:“哪些 token 该算 loss”——上一篇 §8.5 说过,这个约定会一路跟到 Agentic RL。)
二、LoRA:不改原书,贴一张便利贴
2.1 全参微调贵在哪
上一篇算过 64M 的 MiniMind。看着小,但训练它要的不是 64M:
| 占用项 | 相对权重的倍数 |
|---|---|
| 权重本身 | 1× |
| 梯度 | 1× |
| AdamW 的两个动量 | 2× |
| 中间激活值 | 视 batch 和序列长度而定 |
换句话说,训一遍 64M 的模型,显存里的常驻开销是权重的好几倍。 换成一个 7B 的模型,这个”好几倍”就变成了几十 GB。
更麻烦的是产出:全参微调改的是每一个参数,所以每换一个任务,就要存一整个新模型。
2.2 LoRA 的做法:原权重冻住,旁边挂一条小路
model/model_lora.py:6-18,整个 LoRA 的核心类,逐字照抄:
class LoRA(nn.Module):
def __init__(self, in_features, out_features, rank):
super().__init__()
self.rank = rank # LoRA的秩(rank),控制低秩矩阵的大小
self.A = nn.Linear(in_features, rank, bias=False) # 低秩矩阵A
self.B = nn.Linear(rank, out_features, bias=False) # 低秩矩阵B
# 矩阵A高斯初始化
self.A.weight.data.normal_(mean=0.0, std=0.02)
# 矩阵B全0初始化
self.B.weight.data.zero_()
def forward(self, x):
return self.B(self.A(x))
挂载方式(model_lora.py:21-32,逐字照抄):
def apply_lora(model, rank=16):
for name, module in model.named_modules():
if isinstance(module, nn.Linear) and module.in_features == module.out_features:
lora = LoRA(module.in_features, module.out_features, rank=rank).to(model.device)
setattr(module, "lora", lora)
original_forward = module.forward
# 显式绑定
def forward_with_lora(x, layer1=original_forward, layer2=lora):
return layer1(x) + layer2(x)
module.forward = forward_with_lora
全部的秘密就是最后那句 return layer1(x) + layer2(x)。
原来的线性层算 y = Wx,现在算:
y = Wx + BAx
W是原来的大矩阵(768×768),冻结,不训练A是 768×16,B是 16×768,只有这两个训练rank=16是中间那层的宽度
打个比方:原书不动,在旁边贴一张便利贴。 你要查的内容 = 原书 + 便利贴。
2.3 那两个小矩阵为什么能顶用:说说「秩」
代码注释里那个 秩(rank),是 LoRA 能成立的根据。
想象一张 768 × 768 的表格,一共 58 万个数。如果这张表内部其实是有规律的(比如每一行都是某几行的组合),那就没必要老老实实存 58 万个数——存那”几行”加上组合方式就够了。
秩,就是描述这张表最少需要几个”基础行”。 秩越小,这张表包含的独立信息越少,就越能被压缩表示。
LoRA 赌的就是:微调需要做出的那个改动,是一个”低秩”的改动——它不需要 58 万个自由度,十几个就够。
算一下这笔账:A 加 B 一共 768×16 + 16×768 = 24,576 个参数,而它替换的那张表是 768×768 = 589,824 个。约 4%。
2.4 两个设计细节,都不是随便定的
① B 初始化为全零。
B.weight.data.zero_() 意味着训练刚开始时 BAx = 0——加上 LoRA 之后,模型输出和原模型一模一样。
这不是洁癖。微调是在一个已经不错的基础上做的,如果挂上去的瞬间模型就变差了,那第一步训的就是在修复自己造成的破坏。全零初始化让增量从”什么都不改”开始,只往好的方向发展。
(A 用高斯初始化则是为了让 A 的每一维一开始就不一样,否则所有维度会对称地一起动,等于白搭。)
② 只挂在方阵上(in_features == out_features)。
apply_lora 的条件是”输入输出维度相同”。这样加法的形状才对得上。前馈层那些 768→2432 的矩阵不满足,就被跳过了。
2.5 冻结逻辑:优化器只看得到 LoRA
trainer/train_lora.py 里(逐字照抄):
# 冻结非LoRA参数,收集LoRA参数
lora_params = []
for name, param in model.named_parameters():
if 'lora' in name:
param.requires_grad = True
lora_params.append(param)
else:
param.requires_grad = False
然后优化器只拿到 lora_params。不是”学习率设得很小”,是梯度压根不算、优化器压根不管。 这才是省显存和省算力的来源。
脚本会把这几个数打出来(trainer/train_lora.py:124-128,逐字照抄):
total_params = sum(p.numel() for p in model.parameters())
lora_params_count = sum(p.numel() for name, p in model.named_parameters() if 'lora' in name)
Logger(f"LLM 总参数量: {total_params / 1e6:.3f} M")
Logger(f"LoRA 参数量: {lora_params_count / 1e6:.3f} M")
Logger(f"LoRA 参数占比: {lora_params_count / total_params * 100:.2f}%")
(实际数值是运行时才算出来的,日志格式如上。)
2.6 用完可以撕下来贴回原书
model_lora.py:64:
state_dict[f'{name}.weight'] += (module.lora.B.weight.data @ module.lora.A.weight.data).cpu().half()
W ← W + BA。便利贴的内容被合并进原书,之后就不需要便利贴了。 合并后的产物是一个普通的模型文件,推理时不需要任何 LoRA 相关的代码。
2.7 LoRA 适合什么
项目 README 给了两个例子,正好代表两类需求:
垂域数据——基础模型医学知识不足:
{"conversations": [{"role": "user", "content": "请问颈椎病的人枕头多高才最好?"}, {"role": "assistant", "content": "颈椎病患者选择枕头的高度应该根据..."}]}
自我认知数据——让模型知道”我叫什么”:
{"conversations": [{"role": "user", "content": "你叫什么名字?"}, {"role": "assistant", "content": "您好,我名叫 MiniMind,是由 Jingyao Gong 开发的人工智能助手。"}]}
训练完用 python eval_llm.py --weight full_sft --lora_weight lora_medical 组合使用。基础模型的通用能力保留不动,额外的领域能力单独叠一层——这就是”便利贴”这个比喻最实用的地方。
train_lora.py 的默认值:--from_weight full_sft(基于 SFT 之后的模型)、--learning_rate 1e-4。注意这个学习率比 SFT 的 1e-5 还大 10 倍——因为只在训一个小分支,步子可以放开些。
三、蒸馏:抄答案,还是抄思路
3.1 黑盒蒸馏:老师说什么就学什么
README 里的定义很直接:
黑盒蒸馏:更常见,也更贴近当前主线的实际做法。严格来说,它本质上仍然是面向教师输出结果的监督微调,也就是基于硬标签继续训练。
换句话说:让强模型(比如 Qwen3)生成一堆高质量回答,然后拿这些回答当 SFT 数据训小模型。 上一篇讲的 SFT 流程完全不用改——数据集换掉就行。
README 说得很明确:MiniMind 当前主线的 full_sft 数据里,已经混入了相当一部分这样的数据(用 qwen3-4b 合成的约 10 万条 tool call 数据等)。
所以”蒸馏”这个词听起来玄,黑盒版它其实等于:找个好老师,把它的答案抄成训练集。
3.2 白盒蒸馏:不只抄答案,还抄”犹豫”
白盒多做了一件事:老师输出不只是一句话,还有一个完整的概率分布。
比如某个位置,老师给”是”90%、给”为”6%、给”的”2%。黑盒蒸馏只带走”答案是’是‘“这一条信息,那 6% 和 2% 就丢了——而”老师觉得’为’也有可能”这个信息本身是有价值的,它告诉学生哪些选项是”错得没那么离谱”的。
trainer/train_distillation.py:25-36,逐字照抄:
def distillation_loss(student_logits, teacher_logits, temperature=1.0, reduction='batchmean'):
with torch.no_grad():
teacher_probs = F.softmax(teacher_logits / temperature, dim=-1).detach()
student_log_probs = F.log_softmax(student_logits / temperature, dim=-1)
kl = F.kl_div(
student_log_probs,
teacher_probs,
reduction=reduction
)
return (temperature ** 2) * kl
三件事:
| 行 | 在干嘛 |
|---|---|
with torch.no_grad() + .detach() | 老师只读,不参与训练,不产生梯度 |
softmax(... / temperature) | 温度:把分布”抹平”一点,让小概率项也看得见 |
return (temperature ** 2) * kl | 乘 T² 是为了让梯度大小不随温度变化 |
温度这一项值得多说一句。如果不加热,分布是非常尖锐的(“是”99.9%,其余全是 0.0001%)。学生能学到的就只有”答案是’是’“,跟黑盒没区别。加热之后,次要选项的相对大小才显现出来——这才是”抄思路”比”抄答案”多出来的那部分信息。
最后,白盒不是取代黑盒,而是混着用(train_distillation.py:91):
loss = (alpha * ce_loss + (1 - alpha) * distill_loss) / args.accumulation_steps
一半学”数据里的标准答案”(CE),一半学”老师的偏好分布”(KL)。默认 alpha=0.5、temperature=1.5,README 推荐温度范围 1.0–2.0。
这一节和本号《模型正在被「故意变笨」》是同一个话题的两面:那篇讲的是”用强模型蒸馏小模型”这个趋势的宏观后果,这里讲的是它在代码上怎么实现。
四、DPO:给你两份答案让你选
4.1 数据形态:一对好坏的答案
DPO 的训练数据长这样(README 给出的 dpo.jsonl 格式):
{
"chosen": [{"content": "Q", "role": "user"}, {"content": "good answer", "role": "assistant"}],
"rejected":[{"content": "Q", "role": "user"}, {"content": "bad answer", "role": "assistant"}]
}
同一个问题,两个回答,一个是好的一个是差的。 数据本身不解释”为什么这个好”,它只提供对比。
4.2 四行就是全部公式
trainer/train_dpo.py:34-50,整个损失函数(逐字照抄):
def dpo_loss(ref_log_probs, policy_log_probs, mask, beta):
# ref_log_probs 和 policy_log_probs 都是 shape: (batch_size, seq_len)
ref_log_probs = (ref_log_probs * mask).sum(dim=1)
policy_log_probs = (policy_log_probs * mask).sum(dim=1)
# 将 chosen 和 rejected 数据分开
batch_size = ref_log_probs.shape[0]
chosen_ref_log_probs = ref_log_probs[:batch_size // 2]
reject_ref_log_probs = ref_log_probs[batch_size // 2:]
chosen_policy_log_probs = policy_log_probs[:batch_size // 2]
reject_policy_log_probs = policy_log_probs[batch_size // 2:]
pi_logratios = chosen_policy_log_probs - reject_policy_log_probs
ref_logratios = chosen_ref_log_probs - reject_ref_log_probs
logits = pi_logratios - ref_logratios
loss = -F.logsigmoid(beta * logits)
return loss.mean()
其中最关键的四个赋值是:
| 行 | 含义 |
|---|---|
pi_logratios = chosen_policy - reject_policy | 现在:相比坏答案,好答案的概率高多少 |
ref_logratios = chosen_ref - reject_ref | 原来:同上 |
logits = pi_logratios - ref_logratios | 差值 = 我们”额外”拉开了多少 |
loss = -F.logsigmoid(beta * logits) | 拉开得越多,loss 越小 |
(前面那几行 mask 乘法,就是上一篇 §8.5 说的”只对 assistant 段算”——mask 是 0/1,乘上去等于把 system 和 user 的位置清零。)
用大白话讲:模型要学的是”相比原来,把好答案和坏答案的差距又拉开了多少”。
4.3 为什么需要一个”参照模型”
DPO 里同时跑两个模型(trainer/train_dpo.py:71-79,逐字照抄):
with autocast_ctx:
with torch.no_grad():
ref_outputs = ref_model(x)
ref_logits = ref_outputs.logits
ref_log_probs = logits_to_log_probs(ref_logits, y)
outputs = model(x)
logits = outputs.logits
policy_log_probs = logits_to_log_probs(logits, y)
ref_model 是训练开始前那个模型的一份冻结拷贝。为什么要它?
因为只看”好答案概率高不高”是不够的——那会把模型推向”什么都答得更自信”,连坏答案的概率一起涨。减掉 ref 那一项之后,衡量的是相对变化:好答案涨得比坏答案多,才算真的学会了偏好。
(beta 控制这个差距被放大的力度。这个参数在源码里有两个默认值:train_dpo.py:53 的函数签名写 beta=0.1,而命令行参数 --beta 的默认值是 0.15(train_dpo.py:143)——实际跑起来以后者为准,也就是 0.15。)
4.4 DPO 的特点
(先解释两个会反复出现的词:on-policy = 用”当前这个模型”现场生成的数据来训练;off-policy = 用已经生成好的、固定不变的现成数据来训练。差别在于:前者必须边训边生成,后者可以反复压榨同一批数据。)
README 说得最清楚:
DPO 训练范式:off-policy,使用静态偏好数据集,可反复多轮 epoch;Ref 模型固定(预先缓存输出)。 DPO 的局限在于不做在线探索……对”能不能做对题”的智力能力提升有限。
(epoch 上一篇文章解释过:把整个数据集完整过一遍叫一个 epoch。)
翻译过来:数据是现成的、不会变的,所以便宜、稳定、可以反复压榨同一批数据。但正因为它从不自己去试,它也只能学到”数据里已经体现出的偏好”,学不到数据之外的东西。
五、PPO 与 GRPO:请裁判,还是同题多答
到这一步,问题变了:如果连”好坏答案对”都没有,只有一个打分标准呢?
比如”这道数学题算对了吗”——没有现成的 chosen/rejected,但可以做对做错。这时候就需要模型自己生成、自己被打分、再按分数调整。这就是 RL。
README 给了一个极简的统一视角,所有 PO(Policy Optimization)算法都在优化同一件事:
先别被符号吓到——它说的就是”这件事要优化的目标是什么”。逐字翻译一遍:
| 符号 | 读作 | 意思 |
|---|---|---|
| J | 要最大化的那个总量。整个 RL 训练就是在想办法把它做大 | |
| 期望 | “平均下来”。样本是随机抽的,所以看的是平均表现,不是某一次 | |
| 概率比 | 更新后的模型”说这句话”的概率 ÷ 更新前的概率。大于 1 说明新模型更偏爱这个回答 | |
| 优势 | 这一步比预期好多少。正数 = 好于平均,负数 = 差于平均 | |
| 策略目标 | 把上面两个结合起来,决定”该往哪边改、改多少” | |
| 见下 | 偏离参考模型的程度 | |
| 正则项 | 一个惩罚函数,把 KL 转成”扣多少分” | |
| beta | 惩罚的力度——罚得有多狠 | |
| 下标 | — | 第 t 步(这里就是第 t 个 token) |
拆开就三个组件:
| 组件 | 回答什么问题 | 类比 |
|---|---|---|
| 策略项 | 根据”这次比上次好多少”怎么更新 | 怎么改 |
| 优势项 | 怎么判断”这次到底好不好” | 谁来评 |
| 正则项 KL | 别改太猛,改猛了会变傻 | 别跑偏 |
先说清那个反复出现的 KL:
KL 散度衡量的是”两个概率分布差多远”。在这里,比的是当前模型和训练开始前那份冻结的参考模型。
它的作用就一句话:别跑偏。 没有它,模型会为了刷高分把自己拧成一台只会答某类问题的机器——分数上去了,别的能力全丢了。偏离越多罚得越狠,而
β决定罚多重。
PPO、GRPO、CISPO 的区别,几乎全在”优势项怎么来”和”策略项怎么裁”。 下面逐一看代码。
5.1 PPO:请一个裁判实时打分
PPO 的做法是额外训一个 Critic(评论家) 网络,它的唯一工作是:看当前局面,估计最终能拿多少分。
这个 Critic 在 MiniMind 里长这样(train_ppo.py:36-48,逐字照抄):
class CriticModel(MiniMindForCausalLM):
def __init__(self, params):
super().__init__(params)
# lm_head 不参与 forward,仅靠 tie_word_embeddings 与 embed_tokens 共享权重,解绑后 DDP 会报未使用参数
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])
# 使用value_head获取价值估计
values = self.value_head(hidden_states).squeeze(-1)
return values
注意 class CriticModel(MiniMindForCausalLM)——它不是个新网络,就是同一个模型加了一个输出单个数的头。
这一步值得停一下:“裁判”和”选手”用的是同一套对语言的理解,只是最后接了个不同的头。 所以 PPO 的显存开销不是”多一个模型”那么夸张,但也绝不小——README 的说法是”约为单网络方法的 1.5–2 倍”。
有了 Critic,“优势”具体怎么算就有了着落。MiniMind 用的算法缩写叫 GAE(Generalized Advantage Estimation,泛化优势估计)——名字不用记,看代码更直接(train_ppo.py:140-147,逐字照抄):
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) # [B, R]
returns = advantages + old_resp_values # [B, R]
从后往前扫,每一步用”实际拿到的奖励 + 对未来的估计”减去”原先的估计”——也就是”比预期好多少”。
然后是裁剪过的策略损失(train_ppo.py:206-209,逐字照抄):
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 是”新策略和旧策略对同一个回复的概率之比”。裁剪(clip_epsilon=0.2)的意思是:一次更新最多让概率比偏离 0.8~1.2 这个区间。
为什么要裁?因为 on-policy 的数据是用”更新前的策略”采样出来的。改得太猛,这些数据就作废了——你拿着旧地图走太远,方向就不可信了。
默认参数:clip_epsilon=0.2、gamma=1.0、lam=0.95。
5.2 GRPO:同一道题答 6 遍,自己跟自己比
PPO 的问题是 Critic 本身也要训,而 Critic 训得好不好,直接决定优势估得准不准。两个网络互相依赖,收敛就慢。
GRPO 的想法很直接:那我不要裁判了。同一道题让模型答 N 遍,谁比这 N 遍的平均分高谁就好。
trainer/train_grpo.py:121-124,逐字照抄:
grouped_rewards = rewards.view(-1, args.num_generations) # [B, num_gen]
mean_r = grouped_rewards.mean(dim=1).repeat_interleave(args.num_generations) # [B*num_gen]
std_r = grouped_rewards.std(dim=1, unbiased=False).repeat_interleave(args.num_generations) # [B*num_gen]
advantages = (rewards - mean_r) / (std_r + 1e-4) # [B*num_gen]
三行,A = (R − μ) / σ。 就这么简单——优势 = 你比同组平均好多少个标准差。
num_generations 默认是 6(train_grpo.py:219)。所以同一个问题,模型要生成 6 个回答,一起打分,组内互比。
这正是”去掉 Critic”的代价与收益:
| PPO | GRPO | |
|---|---|---|
| 谁是基准 | Critic 估出来的价值 | 同组 N 个回答的平均分 |
| 需要几个网络 | actor + critic | actor + ref |
| 每个 prompt 生成几个 | 1 个 | 6 个 |
| 成本花在哪 | 多一个网络 | 多算 5 遍生成 |
用”多生成几次”换掉”多训一个网络”。 算力便宜、显存紧张的时候,这笔账很划算。
README 也点出了 GRPO 的软肋:退化组——如果某个问题上 6 个回答得分几乎一样,标准差接近 0,这一组的学习信号就没了。在 MiniMind 这种小模型上尤其明显。
5.3 两个算法共用的 KL 惩罚
两边的”别跑偏”用的是同一个东西(train_grpo.py:132-133):
kl_div = ref_per_token_logps - per_token_logps
per_token_kl = torch.exp(kl_div) - kl_div - 1 # [B*num_gen, R]
exp(k) − k − 1 这个形式(通常叫 k3 估计量)有个好性质:它恒大于等于零,且在 k = 0 时取到 0。 意思是”只有偏离参考模型才受罚”。
它保证模型不会为了刷分,把自己变成一台只会回答某类问题的机器。
六、CISPO:两行代码的差别
GRPO 的损失(train_grpo.py:139-142,逐字照抄):
clipped_ratio = torch.clamp(ratio, 1 - args.epsilon, 1 + args.epsilon)
per_token_loss1 = ratio * advantages.unsqueeze(1)
per_token_loss2 = clipped_ratio * advantages.unsqueeze(1)
per_token_loss = -(torch.min(per_token_loss1, per_token_loss2) - args.beta * per_token_kl)
CISPO 的损失(train_grpo.py:135-137,逐字照抄):
if args.loss_type == "cispo":
clamped_ratio = torch.clamp(ratio, max=args.epsilon_high).detach()
per_token_loss = -(clamped_ratio * advantages.unsqueeze(1) * per_token_logps - args.beta * per_token_kl)
差别只有一个 .detach()。
- GRPO:
torch.min(ratio*A, clipped_ratio*A)——当ratio超出区间,clipped_ratio是个常数,这一项的梯度直接变成 0,梯度路径被截断了 - CISPO:
clamped_ratio.detach() * A * log_probs——ratio 被裁剪后只当权重用(.detach()让它不带梯度),真正参与求导的是log_probs,梯度路径始终通着
用一句话概括:
GRPO 是”这部分超出范围了,不管了”;CISPO 是”这部分虽然不额外加权,但还是要按原方向调”。
train_grpo.py 里 --loss_type 的默认值就是 cispo(第 221 行)——两个分支共用一个脚本、共用一套采样和奖励逻辑,只是换一行损失。这也是 README 说”CISPO 可以直接视作 GRPO 的 loss 变体来实现”的确切含义。
七、把四个算法收成一张表
README 用那个”三个组件”的视角把它们收在一起,这张表值得留着:
| 算法 | 策略项 | 优势项 | 正则项 | 训练模型数 |
|---|---|---|---|---|
| DPO | 无显式优势项 | 隐含在 中 | 1(前向参与 2) | |
| PPO | ,由 Critic 估计(GAE) | 2 | ||
| GRPO | 同上 | 1 | ||
| CISPO | 1 |
这张表全是符号,所以先给一张对照(回头查的时候用得上):
| 符号 | 什么意思 |
|---|---|
| / | 概率比:新模型说这句话的概率 ÷ 旧模型的。大于 1 表示变得更偏爱它 |
| / | DPO 里 chosen(好答案)与 rejected(差答案)各自的概率比 |
| 优势:这一步比预期好多少 | |
| 也是优势,但第 i 题第 j 个回答——由”同一组回答的平均分”算出来 | |
| 把 卡在 到 之间,超出就削到边界 | |
| / | 裁剪的边界宽度(PPO 默认 0.2) |
| / | 一组奖励的平均值 / 标准差 |
| 偏离参考模型的程度(前面解释过) | |
| KL 惩罚的力度 | |
| 模型本身。 是它的全部参数,""是”策略”的惯用写法 | |
| 奖励分数 | |
| / | 一共几步 / 第几步 |
看这张表不需要会推导——只要看出四行在「优势项」和「策略项」两列上的差别就够了,那正是它们唯一真正的分歧点。
这张表的读法:四个算法不是四套东西,是同一个目标函数下、对三个组件的不同选择。
- 优势项:DPO 用现成的偏好对,PPO 请裁判估,GRPO/CISPO 用组内平均
- 策略项:PPO/GRPO 裁掉超范围的更新,CISPO 保留梯度路径
- 正则项:全都有一项 KL 把你拴在参考模型附近
README 的总结很到位:“这些 RL 算法不是割裂独立的,而是在统一优化视角下,对同一目标函数进行不同设计权衡后形成的自然变体。”
八、Agentic RL:从答题到用工具做完一件事
8.1 问题变复杂了
前面所有方法都在优化一段回答。Agentic RL 优化的是一条轨迹:
翻译一下:(希腊字母 tau)读作”一条轨迹”,它是一个动作和观察交替出现的序列:
| 符号 | 意思 |
|---|---|
| 一整条轨迹——从开始到结束的完整过程 | |
| 第 t 步的动作:模型决定做什么(比如”调用查天气的工具”) | |
| 第 t 步的观察:做完之后环境回了什么(比如”北京 22 度”) | |
| 一共走了多少步 |
注意它和前面所有公式的区别:前面优化的都是一段回答里的 token,这里优化的是一整条多步过程。
每一步:模型决定调不调工具()、工具返回什么()、再决定下一步。直到任务做完或者放弃,才结算一次总分。
这带来两个根本变化:
| 普通 RL | Agentic RL | |
|---|---|---|
| 一个样本的形态 | 一问一答 | 多轮工具调用 |
| 奖励什么时候来 | 生成完立刻打分 | 整条轨迹跑完才结算 |
| 奖励来自哪 | 主要是奖励模型 | 主要是环境反馈(工具真实返回、最终答案对不对) |
8.2 奖励是人手写出来的
整个奖励函数是 trainer/train_agent.py:188-239。这里按分支摘出四段(都是连续片段,逐字照抄)。
先看”这一轮没调工具”的那个分支(train_agent.py:201-208):
reward -= 0.5 * sum(abs(turn.count('<tool_call>') - turn.count('</tool_call>')) for turn in turn_answers) # 标签扣分
# -------- 无工具调用:格式+reward奖励 --------
if not tool_calls:
reward += 0.5 if 5 <= len(response.strip()) <= 800 else -0.5 # 长度分
if '</think>' in response:
think, answer = response.split('</think>', 1)
reward += 1.0 if 20 <= len(think.strip()) <= 300 else -0.5 # 思考长度分
reward += 0.25 if response.count('</think>') == 1 else -0.25 # 思考闭合分
奖励模型那一项也在同一个分支里(train_agent.py:210-216,逐字照抄):
if reward_model is not None:
prompt = prompts[sample_idx]
pattern = r"<\|im_start\|>(system|user|assistant)\s+(.*?)<\|im_end\|>"
matches = re.findall(pattern, prompt, re.DOTALL)
messages = [{"role": role, "content": content.strip()} for role, content in matches]
score = reward_model.get_score(messages, answer)
reward += score # RM分
以及两处收尾(train_agent.py:217-218 与 233-238,逐字照抄):
reward -= rep_penalty(answer)
rewards[idx] = max(min(reward, 3.0), -3.0) # 总分Clip
final_text = "" if unfinished else (answer.split('</tool_call>')[-1] if '</tool_call>' in answer else answer)
verified = validate_gt_in_text(final_text, gt) if gt else set()
if gt: reward += 2.5 * len(verified) / len(gt) # GT分
if unfinished: reward -= 0.5 # 未完成扣分
reward -= rep_penalty(final_text if final_text else answer)
rewards[idx] = max(min(reward, 3.0), -3.0) # 总分Clip
对应 README 写的:
逐个念一遍就是:回答对不对 + 工具用得对不对 + 格式合不合规 + 奖励模型给的分 - 任务没做完的扣分。整条轨迹的最终分数,是这几项加起来。
看这些 if——每一项奖励都是人手写出来的规则。 这带来两个直接后果:
- 好处:完全自动、可大规模扩展,不需要人工标注(这就是 RLAIF 相对 RLHF 的核心优势)
- 风险:既然分数是人定的,模型就可能找到”刷分但不干活”的路子——这叫 reward hacking。README 里也提到了模型学到”迎合当前奖励定义”的问题
8.3 小模型上最要命的:奖励稀疏
README 把这件事说得非常直白:
对于 MiniMind 这种 0.1B 参数量、能力较弱的模型,在通用任务(如 R1 风格的数学数据集)上会遇到严重的奖励稀疏问题: 模型生成的候选回答几乎全部错误,导致所有奖励分数 如同让小学生做高考数学题,无论尝试多少次都得零分,无法通过分数差异学习改进策略。
这就是为什么 MiniMind 的 RLAIF 选择奖励模型而不是二元的规则奖励:连续分数至少能区分”更差”和”没那么差”,优势函数才不至于全是 0。
8.4 “训推分离”
(先说 rollout 这个词:字面是”展开、跑一轮”,在这里指让当前模型把整个过程真跑一遍、把走过的每一步记录下来——RL 必须先跑出这些数据,才有东西可学。)
train_agent.py 支持把一个叫 rollout_engine 的参数设成 sglang,把”生成轨迹”这一步放到另一个推理引擎上跑(train_agent.py:401 一带的参数区)。
README 的解释很清楚:
所谓”训推分离”,就是把参数更新和轨迹展开拆开:训练侧负责优化 policy,rollout 侧负责高吞吐采样……因此训练脚本并不需要关心底层到底是本地
generate还是远端inference引擎。
训练侧关心”怎么改”,rollout 侧关心”怎么快”,中间同步权重。 README 也诚实地标注了现状:当前实现仍是同步模式(采样完一批再更新),还不是完全异步的 rollout buffer 训练。
九、代价:「对齐全税」
9.1 一组对比数字(来自 README)
README 给了一组 full_sft 与 agent 权重在同一批 20 道数学 ToolUse 题上的对比:
| 权重 | 通过数 | 通过率 |
|---|---|---|
full_sft | 12 / 20 | 60% |
agent(经过 Agentic RL) | 17 / 20 | 85% |
25 个百分点的提升,而且是同一批题。 后训练在这条能力线上的效果是实打实的。
9.2 但代价是明确的
README 紧跟着写了另一面(这里几乎是原文):
agent更适合这类轻 Agent / ToolUse 场景,并不意味着它在通用问答上也同步变强。实际体验里,这类权重在事实性问题上的稳定性通常会下降一些,知识幻觉也会更明显,更容易出现”工具任务做得更好,但开放问答反而更敢编”的现象。……后训练往往能把某一条能力线拉得很高,但几乎都会伴随比较明显的”对齐全税”。也就是说,模型在特定奖励目标上变强的同时,通常会牺牲掉一部分通用性、事实性或者自然分布下的稳定性。
这个问题并不新,也很难彻底避免,本质上就是优化目标变窄之后,模型会越来越擅长”迎合当前奖励定义”,却不一定等价于整体能力无损提升。
这段话把整条链路串起来了,也回答了第一篇埋下的那个问题:
- 预训练的目标是”预测下一个词”——宽泛、无偏见,但不知道什么”有用”
- 后训练的目标是”在这个奖励下拿高分”——精准、有效,但目标一窄,别的地方就可能塌
你没法只要后者不要前者,也没法两者都要满。 这正是”对齐”这件事到今天仍然没有标准答案的原因。
本号《模型正在被「故意变笨」》讲的是同一个现象的另一面:当”变强”的考核标准变窄时,模型会朝着标准演化,而标准之外的能力就悄悄退化。两篇可以对着看。
十、你自己怎么上手
前面三篇讲的都是”它是怎么回事”。如果你想自己跑一遍,这里是一条最短路径——全部来自项目 README 的「快速开始」,我把它整理成了顺序,并标出了 README 没明说但会绊人的地方。
10.1 先说硬件门槛
按 README 自述,作者的环境是:
| 项 | 值 |
|---|---|
| CPU | Intel i9-10980XE |
| 内存 | 128 GB |
| GPU | NVIDIA RTX 3090 (24GB) × 8 |
| 系统 | Ubuntu 20.04 / CUDA 12.2 / Python 3.10.16 |
但所有”2 小时 / 3 块钱”的说法都是按单张 3090 算的(README 的成本表明确标注”单卡 3090”)。八卡是作者自己的机器,不是最低要求。
⚠️ README 没明说但会绊人的地方:requirements.txt 里的 torch 那一行是被注释掉的:
# torch==2.6.0
也就是说装依赖不会替你装 PyTorch,得自己按显卡和 CUDA 版本去 pytorch.org 选对应命令装。装完先用这两行确认:
import torch
print(torch.cuda.is_available())
README 自己对此的提醒是:“若 cuda 不可用,也仍可根据自身设备选择 CPU 或 MPS 运行,但训练速度与兼容性会有非常大的差异。”
10.2 两条路径,先想清楚要哪条
| 路径 A:只跑推理 | 路径 B:从零训一遍 | |
|---|---|---|
| 需要 GPU | 不需要(CPU 也能跑) | 需要 |
| 需要多久 | 下载几分钟 | 约 2.31 小时(单卡 3090) |
| 花多少钱 | 0 | 约 3 元(按 README 的租卡估算) |
| 能学到什么 | 模型文件是什么、怎么用 | 整条训练链路 |
建议先走 A 再走 B。 先确认”模型能跑起来”这件事是通的,再去烧钱训练——不然训练失败了你会分不清是环境问题还是训练问题。
10.3 路径 A:只跑推理
# 1. 克隆仓库、装依赖
git clone --depth 1 https://github.com/jingyaogong/minimind
cd minimind && pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple
# 2. 单独装 PyTorch(见上面的警告,requirements.txt 里没有)
# 3. 下载训好的模型权重(国内建议走 ModelScope)
modelscope download --model gongjy/minimind-3 --local_dir ./minimind-3
# 4. 跑起来
python eval_llm.py --load_from ./minimind-3
第 4 步会进一个交互式对话。这时候你看到的输出,就是第一篇里说的”那张巨大的数字表”在起作用。
10.4 路径 B:从零训一遍(README 称作 MiniMind Zero)
第 1 步:下数据。 从 ModelScope 或 HuggingFace 下载,放进 ./dataset/ 目录。
README 说得很明确——要走最快路径,只需要两个文件:
| 文件 | 体积 | 用途 |
|---|---|---|
pretrain_t2t_mini.jsonl | 1.2 GB | 预训练 |
sft_t2t_mini.jsonl | 1.6 GB | 指令微调 |
(主线版的 pretrain_t2t.jsonl 是 10GB、sft_t2t.jsonl 是 14GB,效果更好但慢得多。先用 mini 跑通,再考虑主线。)
第 2 步:预训练。
cd trainer && python train_pretrain.py
产出 out/pretrain_768.pth(768 是 hidden_size,见第一篇的推算)。
第 3 步:SFT。
cd trainer && python train_full_sft.py
产出 out/full_sft_768.pth。它默认的 --from_weight 就是 pretrain,会自动去读上一步的产物——这就是第二篇 §9.2 讲的那条”文件接文件”的链路。
第 4 步:测试。
python eval_llm.py --weight full_sft
到这一步,你就是”从零训出了一个能对话的语言模型”。 大概花了 2 个多小时和 3 块钱。
10.5 再往后可以做什么
跑通主干之后,README 里的其余脚本就都是”换个数据集、换个 --from_weight”的事了:
cd trainer && python train_lora.py # LoRA:换成垂域数据
cd trainer && python train_dpo.py # DPO:需要 dpo.jsonl
cd trainer && python train_grpo.py # GRPO / CISPO:需要 rlaif.jsonl + 奖励模型
cd trainer && python train_agent.py # Agentic RL:需要 agent_rl.jsonl
注意 GRPO 那条:它默认要加载一个奖励模型(InternLM2-1.8B-Reward,README 说需要放在 minimind 项目的同级目录下)。这是第一个超出”单卡跑个小模型”范围的东西——奖励模型比 MiniMind 本身大得多。
三个脚本都支持断点续训(加 --from_resume 1),长时间训练中断了不用从头来。
十一、自检清单
读完这一篇,整条链路应该接上了:
- 为什么 SFT 之后还要后训练?SFT 的 labels 缺了什么信息?
- LoRA 为什么要把
B初始化成全零?不这么做会怎样? - 为什么 LoRA 只挂在
in_features == out_features的层上? - LoRA 的”省”省在哪——是学习率小,还是别的东西?
- 黑盒蒸馏和白盒蒸馏,多出来的那部分信息具体是什么?温度起什么作用?
- DPO 为什么要减掉
ref那一项?不减会怎样? - PPO 的 Critic 为什么可以是”同一个模型加一个头”?
- GRPO 用”多生成几次”换掉了什么?代价是什么?
- CISPO 和 GRPO 的差别落在代码上是哪一处的哪几个字符?
- Agentic RL 的奖励为什么大多是规则而不是奖励模型?这样做有什么风险?
- “对齐全税”是 bug 还是必然?它和预训练的目标函数有什么关系?
- 从一个空目录到”一个能对话的模型”,最短要几步?哪一步 README 没提醒但容易踩?
附:本文的核查方式
代码引用:本文所有代码块均取自 jingyaogong/minimind(2026-10-16 拉取的 master),标注了文件名与行号。
代码排版约定(与前两篇相同):标了「逐字照抄」的块,缩进与空行与源码完全一致,可以直接拿去比对。凡做过改动(截断、拼接、去缩进),都在块下方就地说明。源码里有几处代码风格上不统一的地方(比如 from_weight!= 'none' 缺空格),保持原样。
数字来源
| 数字 | 来源 |
|---|---|
full_sft 12/20(60%)→ agent 17/20(85%) | 项目 README「测试 2:轻 Agent 任务对比」 |
| 2.31 小时 / 约 3 元 / 单卡 3090 | 项目 README「训练开销」表 |
| 数据集体积 1.2GB / 1.6GB / 10GB / 14GB | 项目 README「MiniMind 训练数据集」 |
各算法默认超参(clip_epsilon=0.2、gamma=1.0、lam=0.95、beta=0.1、num_generations=6、temperature=1.5、alpha=0.5) | 直接读源码里的 add_argument 默认值 |
--loss_type 默认 cispo | train_grpo.py:221 |
LoRA rank=16 | model_lora.py:21 的函数默认值 |
特别声明
本文没有任何一次训练或推理是我运行的。 所有关于”输出效果""通过率""耗时成本”的内容,全部引自项目 README 的作者自述,我没有独立复现。§9.1 那组 20 题对比、§六与§七的样例对话,都请当作”项目作者报告的结果”来读,而不是”本文验证过的结论”。
§十的路径是基于 README 的「快速开始」章节整理和排序的,不是我走通的记录。其中”requirements.txt 里 torch 被注释掉”这一条,是直接查看该文件得出的。
关于 PPO:MiniMind 的 PPO 实现使用的奖励来源是奖励模型(calculate_rewards),每个 prompt 只生成 1 个样本(num_generations=1)。这与教科书里”RLHF 用人类偏好训练奖励模型”的经典叙述不完全相同,请注意区分。
附:延伸阅读
- 参照项目:jingyaogong/minimind —— 本文的直接来源。
model/model_lora.py、trainer/train_dpo.py、train_grpo.py、train_ppo.py、train_agent.py、train_distillation.py都值得自己翻一遍 - 本系列:
- 本号相关:
- 《模型正在被「故意变笨」》 —— 对齐全税与蒸馏的另一面
- 《AI 应用开发面试笔记》 —— 如果你要的是能背下来的版本(Q6.3 LoRA、Q6.4 RLHF/PPO/DPO)
- 《Agent 实战(三):写工具与权限系统》 —— 从应用侧看工具调用与安全边界
- 相关论文(README 列出):PPO、GRPO (DeepSeekMath)、CISPO