大模型训练笔记(三):LoRA、蒸馏与强化学习,以及你自己怎么上手

目录


一、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=E[Φ(rt,At)⏟策略目标−h(KLt)⏟正则项]\mathcal{J} = \mathbb{E}\left[\underbrace{\Phi(r_t, A_t)}_{\text{策略目标}} - \underbrace{h(\text{KL}_t)}_{\text{正则项}}\right]

先别被符号吓到——它说的就是”这件事要优化的目标是什么”。逐字翻译一遍:

符号读作意思
J\mathcal{J}J要最大化的那个总量。整个 RL 训练就是在想办法把它做大
E[⋅]\mathbb{E}[\cdot]期望“平均下来”。样本是随机抽的,所以看的是平均表现,不是某一次
rtr_t概率比更新后的模型”说这句话”的概率 ÷ 更新前的概率。大于 1 说明新模型更偏爱这个回答
AtA_t优势这一步比预期好多少。正数 = 好于平均,负数 = 差于平均
Φ(rt,At)\Phi(r_t, A_t)策略目标把上面两个结合起来,决定”该往哪边改、改多少”
KLt\text{KL}_t见下偏离参考模型的程度
h(⋅)h(\cdot)正则项一个惩罚函数,把 KL 转成”扣多少分”
β\betabeta惩罚的力度——罚得有多狠
下标 tt—第 t 步(这里就是第 t 个 token)

拆开就三个组件:

组件回答什么问题类比
策略项根据”这次比上次好多少”怎么更新怎么改
优势项 AtA_t怎么判断”这次到底好不好”谁来评
正则项 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”的代价与收益:

PPOGRPO
谁是基准Critic 估出来的价值同组 N 个回答的平均分
需要几个网络actor + criticactor + 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 用那个”三个组件”的视角把它们收在一起,这张表值得留着:

算法策略项优势项正则项训练模型数
DPOlog⁡rw−log⁡rl\log r_w - \log r_l无显式优势项隐含在 β\beta 中1(前向参与 2)
PPOmin⁡(rtAt, clip(rt,1±ε)At)\min(r_t A_t,\ \mathrm{clip}(r_t, 1\pm\varepsilon) A_t)AtA_t,由 Critic 估计(GAE)β⋅E[KL]\beta \cdot \mathbb{E}[\mathrm{KL}]2
GRPO同上Ai,j=Ri,j−μiσi+ϵA_{i,j}=\dfrac{R_{i,j}-\mu_i}{\sigma_i+\epsilon}β⋅KLt\beta \cdot \mathrm{KL}_t1
CISPOclip(r,0,εhigh)⋅At⋅log⁡πθ\mathrm{clip}(r, 0, \varepsilon_{\mathrm{high}}) \cdot A_t \cdot \log \pi_\thetaR−μσ\dfrac{R-\mu}{\sigma}β⋅KLt\beta \cdot \mathrm{KL}_t1

这张表全是符号,所以先给一张对照(回头查的时候用得上):

符号什么意思
rr / rtr_t概率比:新模型说这句话的概率 ÷ 旧模型的。大于 1 表示变得更偏爱它
rwr_w / rlr_lDPO 里 chosen(好答案)与 rejected(差答案)各自的概率比
AtA_t优势:这一步比预期好多少
Ai,jA_{i,j}也是优势,但第 i 题第 j 个回答——由”同一组回答的平均分”算出来
clip(x, a, b)\mathrm{clip}(x,\ a,\ b)把 xx 卡在 aa 到 bb 之间,超出就削到边界
ε\varepsilon / εhigh\varepsilon_{\mathrm{high}}裁剪的边界宽度(PPO 默认 0.2)
μ\mu / σ\sigma一组奖励的平均值 / 标准差
KL\mathrm{KL}偏离参考模型的程度(前面解释过)
β\betaKL 惩罚的力度
πθ\pi_\theta模型本身。θ\theta 是它的全部参数,"π\pi"是”策略”的惯用写法
RR奖励分数
TT / tt一共几步 / 第几步

看这张表不需要会推导——只要看出四行在「优势项」和「策略项」两列上的差别就够了,那正是它们唯一真正的分歧点。

这张表的读法:四个算法不是四套东西,是同一个目标函数下、对三个组件的不同选择。

  • 优势项:DPO 用现成的偏好对,PPO 请裁判估,GRPO/CISPO 用组内平均
  • 策略项:PPO/GRPO 裁掉超范围的更新,CISPO 保留梯度路径
  • 正则项:全都有一项 KL 把你拴在参考模型附近

README 的总结很到位:“这些 RL 算法不是割裂独立的,而是在统一优化视角下,对同一目标函数进行不同设计权衡后形成的自然变体。”


八、Agentic RL:从答题到用工具做完一件事

8.1 问题变复杂了

前面所有方法都在优化一段回答。Agentic RL 优化的是一条轨迹:

τ=(a1,o1,a2,o2,…,aT)\tau = (a_1, o_1, a_2, o_2, \dots, a_T)

翻译一下:τ\tau(希腊字母 tau)读作”一条轨迹”,它是一个动作和观察交替出现的序列:

符号意思
τ\tau一整条轨迹——从开始到结束的完整过程
ata_t第 t 步的动作:模型决定做什么(比如”调用查天气的工具”)
oto_t第 t 步的观察:做完之后环境回了什么(比如”北京 22 度”)
TT一共走了多少步

注意它和前面所有公式的区别:前面优化的都是一段回答里的 token,这里优化的是一整条多步过程。

每一步:模型决定调不调工具(ata_t)、工具返回什么(oto_t)、再决定下一步。直到任务做完或者放弃,才结算一次总分。

这带来两个根本变化:

普通 RLAgentic 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 写的:

R(τ)=Ranswer+Rtool+Rformat+Rrm−RunfinishedR(\tau) = R_{\text{answer}} + R_{\text{tool}} + R_{\text{format}} + R_{\text{rm}} - R_{\text{unfinished}}

逐个念一遍就是:回答对不对 + 工具用得对不对 + 格式合不合规 + 奖励模型给的分 - 任务没做完的扣分。整条轨迹的最终分数,是这几项加起来。

看这些 if——每一项奖励都是人手写出来的规则。 这带来两个直接后果:

  • 好处:完全自动、可大规模扩展,不需要人工标注(这就是 RLAIF 相对 RLHF 的核心优势)
  • 风险:既然分数是人定的,模型就可能找到”刷分但不干活”的路子——这叫 reward hacking。README 里也提到了模型学到”迎合当前奖励定义”的问题

8.3 小模型上最要命的:奖励稀疏

README 把这件事说得非常直白:

对于 MiniMind 这种 0.1B 参数量、能力较弱的模型,在通用任务(如 R1 风格的数学数据集)上会遇到严重的奖励稀疏问题: 模型生成的候选回答几乎全部错误,导致所有奖励分数 r(x,y)≈0r(x,y) \approx 0 如同让小学生做高考数学题,无论尝试多少次都得零分,无法通过分数差异学习改进策略。

这就是为什么 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_sft12 / 2060%
agent(经过 Agentic RL)17 / 2085%

25 个百分点的提升,而且是同一批题。 后训练在这条能力线上的效果是实打实的。

9.2 但代价是明确的

README 紧跟着写了另一面(这里几乎是原文):

agent 更适合这类轻 Agent / ToolUse 场景,并不意味着它在通用问答上也同步变强。实际体验里,这类权重在事实性问题上的稳定性通常会下降一些,知识幻觉也会更明显,更容易出现”工具任务做得更好,但开放问答反而更敢编”的现象。

……后训练往往能把某一条能力线拉得很高,但几乎都会伴随比较明显的”对齐全税”。也就是说,模型在特定奖励目标上变强的同时,通常会牺牲掉一部分通用性、事实性或者自然分布下的稳定性。

这个问题并不新,也很难彻底避免,本质上就是优化目标变窄之后,模型会越来越擅长”迎合当前奖励定义”,却不一定等价于整体能力无损提升。

这段话把整条链路串起来了,也回答了第一篇埋下的那个问题:

  • 预训练的目标是”预测下一个词”——宽泛、无偏见,但不知道什么”有用”
  • 后训练的目标是”在这个奖励下拿高分”——精准、有效,但目标一窄,别的地方就可能塌

你没法只要后者不要前者,也没法两者都要满。 这正是”对齐”这件事到今天仍然没有标准答案的原因。

本号《模型正在被「故意变笨」》讲的是同一个现象的另一面:当”变强”的考核标准变窄时,模型会朝着标准演化,而标准之外的能力就悄悄退化。两篇可以对着看。


十、你自己怎么上手

前面三篇讲的都是”它是怎么回事”。如果你想自己跑一遍,这里是一条最短路径——全部来自项目 README 的「快速开始」,我把它整理成了顺序,并标出了 README 没明说但会绊人的地方。

10.1 先说硬件门槛

按 README 自述,作者的环境是:

项值
CPUIntel i9-10980XE
内存128 GB
GPUNVIDIA 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.jsonl1.2 GB预训练
sft_t2t_mini.jsonl1.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 默认 cispotrain_grpo.py:221
LoRA rank=16model_lora.py:21 的函数默认值

特别声明

本文没有任何一次训练或推理是我运行的。 所有关于”输出效果""通过率""耗时成本”的内容,全部引自项目 README 的作者自述,我没有独立复现。§9.1 那组 20 题对比、§六与§七的样例对话,都请当作”项目作者报告的结果”来读,而不是”本文验证过的结论”。

§十的路径是基于 README 的「快速开始」章节整理和排序的,不是我走通的记录。其中”requirements.txt 里 torch 被注释掉”这一条,是直接查看该文件得出的。

关于 PPO:MiniMind 的 PPO 实现使用的奖励来源是奖励模型(calculate_rewards),每个 prompt 只生成 1 个样本(num_generations=1)。这与教科书里”RLHF 用人类偏好训练奖励模型”的经典叙述不完全相同,请注意区分。


附:延伸阅读