从零开始的LLM 4.微调与RL (代码实战篇)
前言
从零开始学习ai文章系列已完成《动手学深度学习》和《磨菇书》两本书的学习,新开的LLM系列课本来自《Happy-LLM》和《Hello-Agents》,但是内容和排版等个人重新进行整理,因此不会按照原来课本中的章节来写。如果哪里有错误的欢迎指正。或者不清晰的可以直接查看原文部分。
本章内容参考自 第十一章 Agentic-RL。不过原作者在讲解时使用的是一套自行封装的框架,对初学者而言抽象程度较高、不易跟随细节,因此我重新整理了一份更加"过程化"的代码实现与配套教程——尽量把每一步的输入输出、中间过程都摊开来讲,方便大家照着跑通、照着改。
在前面的章节中,我们已经系统介绍了大模型的基础知识、预训练方法和微调技术。这里先做一个小小的说明:预训练和微调在代码实现上其实高度相似,核心差异仅在于——预训练用的数据量更大、不需要套用对话模板(chat template)格式;而微调则需要将数据整理成"指令-回复"的对话形式。正因为如此,本章不再重复预训练的内容,而是聚焦于强化学习相关的两个核心方法:监督微调(Supervised Fine-Tuning,SFT) 和 群组相对策略优化(Group Relative Policy Optimization,GRPO)。
文章所有代码都能在:https://github.com/zusda/LLM-learning-from-zero/tree/main 里面找到
1. 整体规划
在正式动手之前,先梳理一下整体的实验路线,让大家对"我们要做什么、为什么这么做"有一个清晰的预期。
任务目标:以 Qwen3-0.6B 为基座模型,通过 SFT + GRPO 两阶段训练,使其具备解决 GSM8K 数学推理数据集 中小学应用题的能力,并能按照我们指定的格式输出推理过程与最终答案。
整体流程分为四大块:
数据准备
- 下载 GSM8K 原始数据集
- 分别转换成 SFT 训练格式(指令-回复对)和 RL 训练格式(仅问题,答案用于奖励计算)
模型准备 下载 Qwen3-0.6B 基座模型,封装好加载、推理、生成等常用接口函数,作为后续 SFT 和 GRPO 训练的统一基础设施。
监督微调(SFT)
- 编写训练所需的数据加载、Trainer、损失计算等模块
- 启动训练,并通过对比训练前后模型在样例问题上的表现,直观感受 SFT 带来的变化
GRPO 强化学习 思路与 SFT 阶段类似:先搭建好奖励函数、采样逻辑、策略更新等核心组件,再启动训练,最后对比 SFT 后模型与 GRPO 后模型的表现差异,看看强化学习阶段到底带来了哪些提升。
2. 数据准备
2.1 GSM8K 数学推理数据集
数学推理是评估 LLM 推理能力的理想任务。首先,数学问题有明确的标准答案,可以自动评估,无需人工标注或复杂的奖励模型;其次,解决数学问题需要拆解问题、逐步推导,这正是多步推理的典型场景;最后,在数学任务上学到的推理能力具有较强的泛化性,能够迁移到其他领域。相比之下,开放式问答任务(如"如何学习编程?")的答案质量难以客观评估,往往需要大量人工标注。
GSM8K(Grade School Math 8K)是一个高质量的小学数学应用题数据集。如下图 所示,数据集包含 7,473 个训练样本和 1,319 个测试样本,难度对应小学数学水平(2-8 年级),题型为应用题,每道题需要 2-8 步推理才能得出最终答案。

2.2 下载数据
我们可以通过一行代码,将数据集下载到本地:
1 | from datasets import load_dataset |
这里通过 cache_dir
参数将数据集下载到指定文件夹下(若不指定,默认下载路径为
C:\Users\用户名\.cache\huggingface\datasets)。
1 | \# 查看数据集结构 |

1 | \# 查看训练集第一条 |
1 | { |
可以看到,原始数据中每条样本包含 question(问题)和
answer(答案,内含完整的解题步骤,以 ####
标记最终数值结果)两个字段。
2.3 转换格式
拿到原始数据后,并不能直接喂给模型训练——SFT 和 RL 这两种训练方式所需要的数据"形态"并不一样,因此需要针对性地做格式转换,如下图所示。

原始格式直接来自数据集,包含问题(question)和答案(answer,含完整解题步骤),更适合人类阅读,但还不能直接用于训练。
2.3.1 SFT格式
SFT 格式用于监督微调,核心思路是:将问题转换为对话格式的 prompt,将完整解答作为 completion。例如:
1 | { |

这里的关键在于使用模型自带的对话模板(如 Qwen 的
<|im_start|> / <|im_end|>
标记):prompt 部分包含用户提出的问题,completion
部分则包含完整的解题过程与最终答案。这样模型在训练中既能学会"按指定格式输出",也能学会"如何分步推理"。
2.3.2 RL格式
RL 格式用于强化学习,与 SFT 格式不同的是:它只提供问题和正确答案,不提供解题过程。例如:
1 | { |
关键点在于:prompt 部分与 SFT 格式保持一致,但
ground_truth
只保留最终答案(用于后续计算奖励),不再提供中间推理步骤。这种设计是刻意为之——它迫使模型必须自己生成完整的推理过程,而不是简单地"背答案"。
下面的代码虽然返回了 4 个字段,但实际训练中我们只会用到其中两个:prompt 和 ground_truth,另外两个字段(question、full_answer)主要用于调试和结果展示。
1 | def format_for_rl(self, example: Dict[str, Any]) -> Dict[str, Any]: |
2.4 保存与加载本地数据
保存与加载本地数据
将原始数据转换成对应格式后,为了节省时间、避免每次训练前都重新走一遍"下载 + 转换"的流程,我们会把转换好的数据直接缓存到本地磁盘。具体逻辑是:训练开始前先检测本地是否已存在缓存文件,存在则直接加载,不存在则进行转换并保存,供下次复用。
下面以 SFT 对应的数据集类为例,说明这套缓存机制的具体实现。
1 | class GSM8K_SFT_Dataset: |
整个类的核心逻辑可以概括为一句话:load_or_create()
负责判断"是加载还是新建",create_sft_data() 负责"下载 +
转换",save_to_jsonl() / load_from_jsonl()
负责"读写本地缓存文件"——三者各司其职,组合起来就实现了"首次运行自动生成缓存,之后直接复用"的效果。
使用方法如下:
1 | train_dataset = GSM8K_SFT_Dataset(split="train", max_samples=1000,sft_cache_path='sft_train.jsonl') |
几个参数说明一下:
- split:我们下载的原始数据中包含 train 和 test 两部分,需要通过该参数指定读取哪一部分;
- max_samples:指定读取的数据条数;若填写为
None或不传该参数,则默认读取全部数据; - sft_cache_path:转换后数据的本地保存路径,若不指定则会根据
split和max_samples自动生成一个文件名(如gsm8k_sft_train_1000.jsonl)。
数据最终结构如下:

3. 模型准备
首先需要将模型下载到本地。这里以 Qwen3-0.6B
为例,我将模型保存在:.\LLM_models\Qwen_Qwen3-0.6B。
对应的模型 ID 为:
1 | self.model_id="Qwen/Qwen3-0.6B", |
3.1 下载模型
当程序首次运行时,本地还没有模型文件,因此需要从 Hugging Face 下载。
1 | def download_model(self): |
这里最核心的代码是:AutoModelForCausalLM.from_pretrained(self.model_id)
。
会根据提供的模型 ID 自动完成以下工作:
- 从 Hugging Face 下载模型配置文件(config.json)
- 下载模型权重文件(model.safetensors)
- 构建对应的模型结构
- 将权重加载到模型中
随后调用:
1 | model.save_pretrained(self.local_dir) |
将模型和 tokenizer 保存到本地目录,避免每次运行时重复下载。
3.2 本地模型目录
模型保存路径由下面的代码生成:
1 | self.local_dir = Path(local_root) / model_id.replace("/", "_") |
例如:
1 | model_id = "Qwen/Qwen3-0.6B" |
会转换成:LLM_models/Qwen_Qwen3-0.6B/
3.3 加载本地模型
后续程序再次启动时,会先检查模型目录是否存在。
如果目录已经存在,说明模型之前已经下载过,此时无需再访问网络,而是直接从本地加载:
1 | def load_model(self): |
这里与下载阶段最大的区别在于:
1 | # 下载时 |
from_pretrained() 既可以接收 Hugging Face 模型
ID,也可以接收本地目录路径。
当传入模型 ID 时,会从 Hugging Face 下载模型;当传入本地路径时,则直接读取本地保存的模型文件。因此模型下载一次后,后续启动都可以直接从本地加载,无需再次联网。
当然,在下载、保存、加载model的时候,tokenizer也是一并一起处理的。
3.4 使用
使用方式非常简单,只需要指定模型 ID 和本地模型存储目录即可:
1 | if __name__ == "__main__": |
4. stf训练
4.1 确定传入数据
首先确定训练数据格式。 SFT(Supervised Fine-Tuning)的本质是让模型学习:
给定 Prompt,预测 Completion。
因此每条训练样本至少需要能够拆分出:
- Prompt(模型输入)
- Completion(模型目标输出)
下面以 prompt/completion 格式作为示例。
1 | train_data = [ |
4.2 构建 SFTDataset
接下来需要将原始训练数据封装为 Dataset 对象,即重写
Dataset 类。这样才能配合 DataLoader
实现批量加载(Batch)、数据打乱(Shuffle)、多进程读取等功能,并在训练过程中按批次将数据传递给模型。
首先定义数据集类:
1 | class SFTDataset(Dataset): |
在初始化函数中传入原始数据、Tokenizer 以及相关配置:
1 | def __init__( self, data, tokenizer, max_length=512, use_chat_template=True ): |
构建 Prompt ID 与 Full ID
为了后续正确计算 SFT Loss,需要额外实现一个函数,将单条原始数据转换为:
prompt_ids:仅包含用户输入(Prompt)的 Token ID;full_ids:包含 Prompt 与 Completion 的完整 Token ID。
在生成 prompt_ids 时,需要设置:
1 | add_generation_prompt=True |
这样会自动在对话末尾补充 Assistant 的起始标记:
1 | <|im_start|>assistant |
从而告诉模型:
接下来应该开始生成 Assistant 回复。
而构造完整的 full_ids
时,则需要显式将答案加入对话模板:
1 | messages = [ |
随后再次调用:
1 | full_ids = tokenizer.apply_chat_template( messages, tokenize=True ) |
此时 completion 会被嵌入到 Assistant
对应的位置,从而得到完整训练样本。

之所以要同时构造 prompt_ids 和
full_ids,是因为在 SFT 训练中:
- Prompt 部分属于输入条件;
- Completion 部分才是模型需要学习预测的内容。
因此后续构造 labels 时,需要将 Prompt
部分屏蔽掉,只让模型在 Completion 部分计算 Loss。
重写 getitem
下面实现 Dataset 的核心函数:
1 | def __getitem__(self, idx): |
分成这两部分,主要是我们下面提供input id的时候,我们同时也需要提供labels,告诉后续训练哪些位置才是需要计算loss的,因为prompt部分是不需要计算loss的。我们需要根据这两部分来获取labels。
下面就是我们重写Dataset类的__getitem__函数
其中最重要的是:
1 | labels = [-100] * len(prompt_ids) + full_ids[len(prompt_ids):] |
在 Hugging Face 的因果语言模型训练中,label = -100
表示当前位置不参与 Loss 计算。
经过 __getitem__() 处理后,原始数据:
1 | { |
会被转换为:
1 | { |
其中:
input_ids为完整的prompt + completion经过 Tokenizer 编码后的结果;labels用于标记哪些 Token 需要参与 Loss 计算。
后续通过 DataLoader 读取数据:
1 | for batch_idx, batch in enumerate(self.dataloader): |
每次获得的 batch 都是由多个样本经过
__getitem__() 处理后再拼接得到的。
注意:很多初学者容易误认为
labels是一个由0/1组成的 Mask。实际上并不是。在 Hugging Face 的 CausalLM 训练中,
labels保存的是真实目标 Token ID,而不是 Mask。其中:
-100表示忽略该位置,不参与 Loss 计算;- 其它位置保存真实 Token ID,用于监督模型学习下一个 Token 的预测。
因此 SFT 中的
labels通常类似:
1 [-100, -100, -100, 151645, 872, 3094, ...]而不是:
1 [0, 0, 0, 1, 1, 1, ...]
如果是预训练(Pretraining)场景,由于训练目标是学习语言本身,因此所有 Token 都需要参与预测。
通常直接:
1 labels = input_ids.copy()即所有位置都会参与 Loss 计算,不需要对任何部分进行遮蔽。
而 SFT(Supervised Fine-Tuning)则不同。
SFT 数据通常由 Prompt 和 Completion 两部分组成,其中:
- Prompt 用于提供上下文;
- Completion 才是模型需要学习生成的内容。
因此需要将 Prompt 部分设置为
-100。这也是预训练与 SFT 最本质的区别之一:
4.3 Batch 数据预处理
在上一节中,我们通过 SFTDataset
完成了单条样本的数据预处理,每次调用 __getitem__()
都会得到:
1 | { |
但模型训练并不是一次处理一条数据,而是一次处理一个 Batch。因此,在
DataLoader 将多条样本组合成 Batch
时,还需要进行额外处理。
最主要的问题是:
不同样本经过 Tokenizer 编码后长度通常不同,而 Tensor 要求同一维度长度一致,因此需要对 Batch 内的数据进行 Padding(补齐)。
Padding 和 Batch 整理逻辑通常定义在 DataLoader 的
collate_fn 参数中。当 DataLoader 从
Dataset 中取出多个样本准备组成一个 Batch
时,会自动调用我们传入的 collate_fn()
函数,对这些样本进行补齐、拼接等预处理操作,最终生成模型可直接训练的
Batch 数据。
1 | dataset = SFTDataset(train_data, tokenizer, max_length=max_length) |
处理函数如下,代码注释很清楚因此不过多解释。
1 | def collate_fn(batch, pad_token_id): |
需要注意区分 attention_mask 和 labels
的作用。
attention_mask 作用于 Attention
计算阶段,用于告诉模型哪些位置是真实 Token、哪些位置是
Padding。框架会根据它构造 Attention Mask,从而在构建 Attention
计算图时忽略 Padding 节点。
而 labels 作用于 Loss
计算阶段,用于告诉模型哪些位置需要参与损失计算。对于 SFT 来说,Prompt
部分和 Padding 部分通常会被设置为 -100,从而不参与 Loss
计算。
因此:
attention_mask决定哪些 Token 可以参与信息交互;labels决定哪些 Token 参与 Loss 计算。
二者虽然经常同时出现,但作用于模型计算流程中的不同阶段。
不过对于 Qwen、Llama、GPT 等 Decoder-Only 模型,还需要额外说明一点:模型内部的 Causal Attention 本身就自带下三角 Mask,保证每个 Token 只能看到自己及其左侧的 Token,而无法看到未来 Token。
因此在最常见的 右侧 Padding(Right Padding)+ Decoder 模型 的训练场景下,即使将所有位置的
attention_mask都设置为 1,右侧 Padding Token 通常也无法被前面的真实 Token 看到。在这种情况下,attention_mask对训练结果的影响往往很小。真正决定 Prompt 和 Padding 是否参与训练的,实际上是:
1 labels = -100因为 Loss 计算阶段会直接忽略这些位置。
当然,在实际工程中仍然建议正确构造
attention_mask,以保证与 Hugging Face、Flash Attention、推理框架以及各种特殊场景保持兼容。
4.4 开始训练
成数据预处理后,就可以正式开始训练了。
整个训练流程如下:
1 | 读取Batch |
首先遍历数据集,并将当前 Batch 中的数据移动到训练设备(GPU 或 CPU)上。
首先遍历数据集,并将当前 Batch 中的数据移动到训练设备(GPU 或 CPU)上。
这里使用的 tqdm
仅用于显示训练进度,对训练逻辑本身没有影响。
1 | for epoch in range(self.num_epochs): |
可以看到,最终送入模型的数据仅包含:
1 | input_ids |
这三个字段。
随后执行前向传播,获得模型输出并计算 Loss:
1 | for batch_idx, batch in progress_bar: |
由于这里使用了梯度累积(Gradient Accumulation),因此并不会每个 Batch 都更新一次参数,而是累积足够次数后再统一更新。
当达到指定的梯度累积步数后,会执行梯度裁剪、参数更新、学习率更新以及梯度清空等操作。与此同时,还会记录当前 Loss,并按照设定的间隔保存模型检查点(Checkpoint)。
1 | for batch_idx, batch in progress_bar: |
至此,一个完整的 SFT 训练流程就完成了。
4.5 SFT 训练的 Main 函数
前面已经完成了:
- 数据集构建;
- Tokenizer 加载;
- 模型加载;
- Dataset 与 DataLoader 构建;
- SFT Trainer 实现。
因此最终的训练入口函数实际上非常简单。
首先准备训练所需的三个核心对象:
tokenizermodeltrain_data
1 | def stf_train_main(): |
随后为模型添加 LoRA。
由于笔者使用的是笔记本显卡,无法承担全量参数微调(Full Fine-Tuning)的显存开销,因此这里采用 PEFT(Parameter Efficient Fine-Tuning)中的 LoRA 方案,仅训练少量新增参数。
完成 LoRA 包装后,将模型、Tokenizer 和训练数据传入前面实现的
SimpleSFTTrainer,最后调用:
1 | trainer.train() |
即可开始训练。
1 | def stf_train_main(): |
在个人笔记本环境下,每个 Epoch 训练时间大约为 30~40 分钟。
为了突出核心原理,文中省略了部分工程化细节,例如混合精度训练、学习率调度器配置、Checkpoint 恢复训练、日志记录等内容。相关实现均已保留在源码及代码注释中,如果需要自行复现,建议结合源码一起阅读。
4.6 训练结果
训练完成后,先观察 Loss 的变化情况。
下图是根据训练日志中的 Loss 数据绘制的曲线图。其中浅色曲线表示每个 Step 的原始 Loss,红色曲线表示经过平滑处理后的平均 Loss。

可以看到,随着训练进行,Loss 快速下降并逐渐趋于稳定。后期平均 Loss 基本维持在 0.4~0.6 之间,说明模型已经较好地拟合了当前训练数据。
当然,仅观察 Loss 并不能完全说明模型是否真正学到了目标能力。对于大语言模型来说,更直观的方法还是实际测试模型输出结果。
因此下面编写一个简单的工具,用于:
- 加载指定 Checkpoint;
- 恢复 LoRA Adapter;
- 封装一个简单的 Chat 接口;
- 对比训练前后的模型输出。
代码如下:
1 | import importlib.util |
随后从测试集中挑选一道题目,同时分别使用:
- SFT 训练后的模型;
- 原始基础模型;
进行推理测试。
1 | if __name__ == '__main__': |
测试结果如下:

可以看到,经过 SFT 训练后,模型已经明显学习到了训练数据中的输出模式。
在原始模型中,对于这类 GSM8K 数学推理问题,回答往往不够稳定,或者无法按照数据集要求的格式进行推理。而经过 SFT 后,模型开始主动输出符合训练样本风格的 Step-by-Step 推理过程,并能够按照预期格式组织答案。
当然,这里仅展示了一条测试样例,并不能严格代表模型整体性能。如果需要进行严谨评估,还应该在完整测试集上统计准确率等指标。不过从定性结果来看,本次 SFT 训练已经成功将训练数据中的行为模式迁移到了模型输出中。
5. GRPO训练
5.1 GRPO训练概述
在进入代码实现之前,先从整体上理解 GRPO(Group Relative Policy Optimization)的核心思想。
可以将 GRPO 理解为一种基于相对比较的强化学习训练方法:
- 模型针对同一个问题生成多个回答(Group)。
- 使用奖励函数(Reward Function)对每个回答进行评分。
- 在同组回答内部计算相对优劣,即优势(Advantage)。
- 提高高优势回答的生成概率,降低低优势回答的生成概率。
- 经过大量训练后,模型会逐渐倾向于生成能够获得更高奖励的回答。
从优化目标来看,GRPO 本质上并不是在向模型灌输新的知识,而是在调整模型的行为策略(Policy)。
换句话说:
SFT 更偏向于学习知识和模仿示例,而 RL 更偏向于学习“什么样的回答更值得生成”。
因此,强化学习阶段的主要作用通常是:
- 提高回答正确率;
- 提高推理质量;
- 约束输出格式;
- 减少幻觉;
- 强化安全策略;
- 对齐人类偏好(Human Preference)。
而不是让模型凭空学会训练数据中从未接触过的新知识。
5.1.1 GRPO 中最关键的部分是奖励来源。
通常有两种方式:
方式1:人工设计奖励函数(Rule-based Reward)
例如:
1 | reward = ( |
奖励函数根据各种规则进行打分:
- 答案是否正确;
- 格式是否符合要求;
- 是否包含指定标签;
- 推理过程是否完整;
- 长度是否合理;
这类奖励函数实现简单、可解释性强,也是多数小规模实验最常见的做法。
方式2:奖励模型(Reward Model)
另一种做法是训练专门的奖励模型(Reward Model)。
训练流程通常为:
1 | 人工偏好数据 |
奖励模型学习人类对回答质量的偏好:
1 | 回答A > 回答B |
训练完成后,Reward Model 会输出奖励分数:
1 | reward = reward_model(response) |
随后 GRPO 再利用这些奖励计算优势(Advantage),更新策略模型。
因此无论奖励来自:
- 手工规则;
- 奖励模型;
- AI 评审模型(LLM-as-a-Judge);
最终都会统一转换为一个奖励值,然后进入 Advantage 计算流程。
5.1.2 GRPO训练面临的问题
虽然 GRPO 能够有效优化模型行为,但它并不是万能的。
问题1:探索空间不足
假设某个问题生成了 8 个回答:
1 | 回答1:错误 |
如果这一组回答全部错误,那么即使计算出相对优势,也只是在:
一堆错误答案中寻找相对较好的错误答案。
此时优化方向仍然可能是错误的。
因此强化学习通常要求:
- 基础模型本身具备一定能力;
- 或者先经过 SFT 训练;
- 或者增加采样数量与探索能力;
否则 RL 很容易陷入局部最优。
问题2:奖励函数决定优化方向
奖励函数本质上只是一个打分器。
它不会告诉模型:
1 | 为什么错 |
它只会给出:
1 | 90分 |
模型只能通过不断试错来寻找获得高奖励的行为。
因此:
奖励函数的质量,往往直接决定了强化学习训练效果的上限。
如果奖励函数设计存在缺陷,模型甚至可能学会“投机取巧(Reward Hacking)”,通过钻奖励规则的漏洞来获得高分,而不是真正完成任务。
5.2 数据准备
在本项目中,GRPO 阶段使用的数据格式如下:
1 | [ |
与 SFT 不同,GRPO 训练并不需要数据集提供 completion。在
SFT 中,模型学习的是数据集中已经存在的标准答案;而在 GRPO
中,回答(Completion)需要由当前模型实时生成,然后根据奖励函数进行评分。
因此从训练角度来看,真正参与 GRPO 训练的核心字段实际上只有:
prompt:于生成回答ground_truth:用于计算正确性奖励(Correctness Reward)
与前面的 SFT 数据集不同,GRPO 阶段不需要提前将数据转换成 Token ID。
原因在于后续训练过程中,同一个 Prompt 通常需要生成多个不同回答(Multiple Completions),因此 Tokenize 会统一放到生成阶段进行处理。
Dataset 只负责保留训练所需字段:
1 | class RLPromptDataset(Dataset): |
此时 Dataset 中的数据形式如下:
1 | [ |
可以看到,此时保存的仍然是原始字符串,而不是已经编码完成的 Token ID。
接下来定义 collate_fn。
由于后续生成阶段需要对同一个 Prompt 采样多个回答,因此这里同样不进行 Tokenize,而是保持字符串格式。
1 | def rl_collate_fn(batch): |
然后构建 DataLoader:
1 | self.dataloader = DataLoader( |
假设一个 Batch 中包含三条样本:
1 | [ |
经过 collate_fn 后会被整理为:
1 | { |
5.3 回答生成
完成数据准备后,GRPO 训练的第一步并不是计算 Loss,而是先让模型生成回答。
这是因为 GRPO 属于在线(Online)强化学习方法,训练所需要的 Completion 并不来自数据集,而是由当前模型实时生成。
对于同一个 Prompt,我们通常会生成多个回答:
1 | Question: |
随后对这组回答分别计算 Reward:
1 | Completion 1 → Reward = 1.0 |
再利用组内奖励计算相对优势(Advantage):
1 | Advantage = Reward - Group Mean Reward |
因此,生成多个回答是整个 GRPO 训练流程的起点。
下面是回答生成的核心代码:
1 | def generate_completions(self, prompts): |
为什么使用左侧 Padding
这里有一个容易被忽略但非常重要的细节:
1 tokenizer.padding_side = "left"假设两个 Prompt:
1
2
3
4
5 Prompt A:
hello world
Prompt B:
hello world today how are you经过左侧 Padding 后:
1
2 [PAD PAD hello world]
[hello world today how are you]对应的 Tensor:
1
2
3
4 [
[PAD, PAD, t1, t2],
[t1, t2, t3, t4]
]生成后会变成:
1
2
3
4 [
[PAD, PAD, t1, t2, n1, n2, n3],
[t1, t2, t3, t4, n1, n2, n3]
]此时所有样本的 Prompt 长度已经被 Padding 到统一长度:
1 prompt_len = inputs["input_ids"].shape[1]因此可以直接:
1 completion_ids = generated[:, prompt_len:]一次性截取所有样本的新生成部分。
如果使用右侧 Padding:
1
2 [t1 t2 PAD PAD]
[t1 t2 t3 t4]那么生成结果会混入 Padding 区域,处理起来会复杂得多。
因此在生成任务中通常都会采用左侧 Padding。
经过上述步骤,我们最终获得了针对每条 Query 所生成的多个回答,以及后续 GRPO 训练所需的全部信息:
| 变量 | 作用 |
|---|---|
input_ids |
Padding 后的 Prompt Token |
attention_mask |
Prompt 对应的 Attention Mask |
completion_ids |
模型生成的 Completion Token |
completions_text |
解码后的文本回答 |
5.4 Reward与Advantage计算
生成完多个回答后,接下来需要使用奖励函数(Reward Function)对每个回答进行评分。
例如对于同一个问题:
1 | Question: |
奖励函数可能给出:
1 | Completion 1 → 2.0 |
但 GRPO 并不会直接使用这些 Reward 进行训练。
因为 Reward 本身只能反映回答的绝对质量,而强化学习真正需要的是:
当前回答相对于同组其他回答来说,到底是更好还是更差。
因此在获得 Reward 后,还需要进一步计算 Advantage(优势)。 \[ 优势 = \frac{ 当前奖励 - 平均奖励 }{ 标准差 + 10^{-4} } \] 对应代码如下:
1 | def compute_advantages(self, completions_text, ground_truths): |
一开始我也说过了,奖励的好坏是我们训练的关键,由于我这仅仅是做个学习,因此采用原课程中相对简陋的自定义奖励函数:
正如前面提到的,奖励函数的质量往往决定了强化学习训练效果的上限。由于这里的目标主要是学习 GRPO 的整体流程,因此采用课程中的一个相对简单的规则奖励函数(Rule-based Reward)作为示例实现。
奖励函数代码:3. reward_2.py
5.5 计算log概率
前面我们已经获得:
1 | prompt_ids |
其中:
1 | prompt_ids |
但此时还无法直接计算 GRPO Loss。
因为强化学习训练并不仅仅需要知道模型生成了什么内容,还需要知道:
模型当时到底有多大概率生成这些 Token。
也就是我们常说的:
1 | log_prob |
为什么要重新 Forward
很多刚接触 RLHF 的读者都会有一个疑问:
1 | 既然已经调用 generate() 生成出了回答, |
原因在于:
1 | generated = model.generate(...) |
得到的主要是:
1 | completion_ids |
即最终采样出的 Token。
而后续 GRPO Loss 需要的是:
1 | 每个生成Token对应的log_prob |
因此必须重新将:
1 | prompt_ids |
拼接回完整序列,再执行一次 Forward。
拼接完整序列
首先构造:
1 | full_ids = torch.cat( |
例如:
1 | Prompt: |
拼接后:
1 | full_ids: |
然后送入模型:
1 | outputs = self.model( |
得到:
1 | outputs.logits |
形状为:
1 | (batch_size, seq_len, vocab_size) |
其中:
1 | vocab_size |
表示词表大小。
假设词表大小为 150000,那么每个位置都会产生一个维度为 150000 的向量:
1 | [ |
表示:
模型认为下一个 Token 是词表中每个 Token 的可能性大小。
Logits 与 Token 的对应关系
这里最容易理解错,因此单独举例。
假设:
1 | full_ids: [4, 5, 7] |
经过模型计算:
1 | 位置0 → logits_0 |
注意:
1 | logits_0 |
并不是预测当前位置的 Token。
而是在预测:
1 | 位置1应该是什么Token |
即:
1 | 4 |
同理:
1 | 5 |
因此真实对应关系为:
1 | Token: |
或者写成:
1 | 4 ──► L1 ──► 5 |
其中:
1 | L1[5] |
表示:
在位置1的真实答案是 Token 5 时,模型给出的 Logit。
而:
1 | L2[7] |
表示:
在位置2的真实答案是 Token 7 时,模型给出的 Logit。
为什么要错位
由于语言模型始终在预测:
1 | 下一个Token |
因此:
1 | logits = outputs.logits[:, :-1, :] |
例如:
1 | Token: |
对应关系为:
1 | t1 → L2 → t2 |
可以发现:
1 | 第一个Token没有对应Logit |
因此:
1 | logits[:, :-1] |
都需要截断。
获取真实 Token 对应的 Log Probability
接下来:
1 | log_probs = F.log_softmax( |
将:
1 | logits |
转换为:
1 | log_prob |
这里的每个位置都保存了:
1 | 词表中所有Token的log概率 |
例如:
1 | [ |
但我们只关心:
实际生成出来的那个 Token。
因此:
1 | token_log_probs = ( |
作用就是:
1 | 从整个词表中,只取真实Token对应的那个log_prob |
例如:
1 | 真实Token = 7 |
那么:
1 | log_probs[..., 7] |
就会被提取出来。
最终得到:
1 | token_log_probs |
形状:
1 | (batch_size, seq_len - 1) |
其中每一个元素都表示:
模型生成该 Token 时对应的 Log Probability。
为什么只保留 Completion
前面计算出的:
1 | token_log_probs |
同时包含:
1 | Prompt部分 |
但 GRPO 只关心模型生成的内容。
Prompt 是训练数据提供的:
1 | 用户输入 |
不是模型生成的。
因此:
1 | completion_log_probs = |
只保留:
1 | Completion对应的log_prob |
例如:
1 | Prompt: |
最终保留:
1 | t4对应的log_prob |
而t1,t2,t3对应的部分全部丢弃。
这里顺便解释一下:
为什么强化学习一定要计算 log_prob?
因为后续 GRPO 的优化目标本质上是在调整:
1 | 模型生成当前回答的概率 |
如果某个回答:
1 | Advantage > 0 |
说明它比组内平均水平更好。
那么训练会提高:
1 | 该回答对应Token的log_prob |
反之:
1 | Advantage < 0 |
则会降低对应 Token 的生成概率。
因此 Log Probability 实际上就是:
强化学习阶段连接「奖励信号」与「模型参数更新」的桥梁。
经过上述步骤,我们最终获得:
1 | completion_log_probs |
其中:
completion_log_probs:Completion 部分每个 Token 对应的 Log Probability;completion_mask_for_loss:对应位置的有效 Mask。
这些数据将在下一节用于计算最终的 GRPO Loss。
以下就是我们的实现代码:
1 | # ---------- 第三步:重新算log prob(generate不保留梯度,必须重新forward) ---------- |
5.6 计算 Loss 与参数更新
这部分没什么好说的。对"优势"有什么概念的疑惑,可以在我博客里面搜索“advantage”,找到强化学习 4.策略梯度算法 (应该最开始是这里讲解了,我也有点忘了,也可能是另一篇)
1 | # policy gradient loss: -advantage * log_prob,只在completion有效token上算,按token数做平均 |
然后就是累计指定次数,进行更新
1 | if (batch_idx + 1) % self.gradient_accumulation_steps == 0: |
5.7 GRPO main函数训练
和之前SFT一样,准备好我们需要的model 、tokenizer、train data 3部分,然后model上面搭上LORA。
1 | from peft import LoraConfig, get_peft_model, TaskType |
5.8 测试与效果
这一部分实际上并没有完成完整测试,主要原因是训练耗时过长,个人用于实验的小笔记本难以支撑长时间的 GRPO 训练。
在前面的实现中,我们设置:
1 | max_new_tokens = 612 |
这个值看起来比较大,但实际上是必要的。
因为当前训练数据要求模型输出完整的推理过程以及最终答案。如果生成长度限制过小,模型往往还处于推理阶段就已经到达长度上限,被强制截断。
此时模型无法输出最终答案:
1 | Final Answer: ... |
奖励函数自然也无法正确判断回答是否正确。
这样就会出现前面提到的问题:
同一组回答全部都是无效回答,Reward 全部接近于 0,模型无法获得有效的优化方向。
因此在实验中只能将 max_new_tokens
设置得相对较大,以保证大部分样本能够完整生成。
但这也带来了另一个问题:
GRPO 的主要时间开销几乎全部集中在回答生成阶段(Generate)。
训练过程中统计得到的耗时如下:

可以看到,相比于 Reward 计算、Loss 计算以及反向传播等步骤,回答生成阶段的耗时远高于其他部分。
其中:
1 | Generate ≈ 76s |
单次生成就需要一分钟以上。
而整个实验仅使用约 20 条样本进行训练,总耗时仍达到:
1 | 约 36 分钟 |
这也反映出了 RLHF / GRPO 与传统 SFT 的一个明显区别:
SFT 的主要开销来自反向传播,而 GRPO 的主要开销往往来自回答生成。
因为对于每个 Prompt,模型不仅需要生成回答,而且还需要生成多个回答(Group),随后才能计算 Reward 和 Advantage。
目前工业界解决这一问题最常见的方法,是将训练模型与生成模型分离。
例如:
1 | 训练模型 |
其中较常见的方案就是使用 vLLM 负责生成,而训练框架只负责参数更新。
相比 HuggingFace 原生的 generate(),vLLM
在大批量生成场景下通常能够获得明显更高的吞吐量,因此也是目前许多
RLHF 框架的默认选择。
不过在本系列文章编写时,我的实验环境主要为 Windows,本篇内容更关注 GRPO 流程本身,因此暂未进一步集成 vLLM。
由于训练数据规模较小、训练轮数有限,加上缺少系统性的 Benchmark 评测,因此本文并不给出模型效果方面的结论。
本章的重点也并不在于训练出一个高性能模型,而是:
从零实现并跑通一次完整的 GRPO 训练流程,理解 Reward、Advantage、Log Probability 以及策略梯度在 LLM 强化学习中的作用。
至此,一个最小可运行版本的 GRPO 训练框架便已经实现完成。