很多人一听说要在Qwen3-0.6B-Base这种0.6B参数的小模型上跑GRPO强化学习,第一反应通常是:这不直接做监督微调就完了吗?我一开始也是这么想的,直到把两套对比实验摆在一起,才发现小模型其实更吃“自我探索”而不是“模仿标准答案”。这个过程中,我用GRPO把基础模型的推理痕迹、格式服从度和最终答案正确率都拉起来了一个档次,同时也踩了不少坑,比如组内奖励全是零、模型学会刷格式骗奖励、KL散度突然爆炸这些破事。这篇文章就顺着这个实操脉络,把为什么选用GRPO、训练数据怎么准备、奖励函数怎么写、超参数怎么调、训练日志怎么判断,以及一套可以直接参考的代码流程完整记录下来。适合想把小模型用在数学、逻辑、计算器辅助推理这类任务上的朋友,也适合准备从SFT切换到RL训练思路的人。
1. 选型逻辑:为什么是0.6B基础模型+GRPO
1.1 小模型推理能力的真实缺口在哪
0.6B参数意味着模型容量非常紧张,它没有足够空间去记忆海量知识,能记住的只是语言模式和基本的数学运算规律。常规做法是拿一批带详细推理过程的数据做SFT,让模型学会“照着推理格式生成”。SFT的问题在于,它是强迫模型模仿给定输出,模型在训练集覆盖范围内往往表现不错,可一旦遇到训练集里没见过的问法或数字组合,很容易原样套用错误路径,因为它是背答案而不是在推理。
我做的对照实验中,同样是数学文字题集,纯SFT训练后的模型对训练集内相似题目的答案正确率尚可,但换一批改写过的同类型题目后正确率掉得非常明显。这个现象说明,小模型在SFT框架下把推理过程学成了“条件反射”,而不是真正的策略。GRPO这种强化学习方式则不一样,它让模型自己生成答案,从正确结果里获得奖励,并在多次采样中比较哪条路径更优。模型最终学到的,是对问题空间的搜索偏好,而不是对训练文本的逐字记忆。
还有一个现实因素:小模型部署便宜、延迟低。很多端侧或业务级场景根本跑不动7B以上的模型,0.6B能在普通CPU或小显存GPU上跑得动,只要推理能力能拉上来,它就是性价比非常高的选择。所以这个组合不是“没钱才选小模型”,而是一条值得投入的实用路线。
1.2 为什么GRPO比PPO更适合这种场景
强化学习调语言模型,最常被提到的算法是PPO,但PPO有个隐藏成本:它需要一个和价值网络差不多规模的Critic模型来估计每个状态的收益。对0.6B的Actor模型来说,还要额外加载一个同尺寸的Value网络,等于训练资源直接翻倍。而且Critic模型本身不好训,奖励尺度稍有变动,它就得重新适应,训练稳定性很让人头疼。
GRPO的全称是Group Relative Policy Optimization,它的核心做法是:把同一个问题采样出多个回答,组成一个组,然后只看这个组的内部奖励相对关系,用组内平均作为基线,直接算出每个回答的优势值。整个过程不需要价值网络,一个策略模型跑前向采样再加反向更新就够。这样不仅省了显存,还少了一个容易出问题的组件。训练成本大概只有PPO的一半多点,对小规模团队或单卡实验非常友好。
这个思路可以打一个比方:PPO是公司请外部的绩效咨询师给每个员工定绝对KPI,必须有一套完整评价体系;GRPO是直接看同一个小组内部谁干得好、谁干得差,谁超额完成就多奖励,谁拖后腿就压一下,不需要外部专家。对推理任务来说,同一道题的回答之间互相比较,本身就是很自然的基准,这种相对评价反而比绝对价值拟合更符合任务本质。
1.3 用base模型而不是instruct模型的原因
很多人在选型时会问:为什么不用已经做过的指令微调版本,直接在Chat版本上继续强化?我的经验是,尽量不要。已经做过的指令版本经过大量SFT和偏好对齐之后,行为已经高度固化,比如回答语气、长度控制、拒绝风格等,都已经定型。你再拿GRPO去优化它,模型必须在“已经学会的行为”之上叠加强化信号,很容易出现奖励覆盖现象——新学到的推理格式和旧的回复习惯打架,输出经常性崩坏。
基础模型反而是一张白纸,它只学会了语言统计规律,没有被强行绑定上某种回答格式。在RL训练中,你给的格式奖励才能更好地塑造它。另外,基础模型还没有经历过DPO对齐带来的过度平滑现象,相当于还保留着更大的生成熵,采样出的回答多样性更好。而GRPO非常依赖组内样本的多样性,如果模型确定性太强,组内8个回答几乎一样,优势值估算会失效。
当然,用base模型也有代价:初始阶段它连“以固定格式输出”都不会,生成结果可能是一堆无关文本。不过这个问题可以在奖励函数里加一个格式分,让模型用几十步甚至上百步学会格式规范后,再慢慢把重心转移到答案正确性上。我用下来的感觉是,这个代价完全值得。
2. 数据和奖励设计:GRPO的地基工程
2.1 训练集构造与提示模板
GRPO的数据和SFT不同,它不要求每条数据都有标准推理过程,只需要问题本身和最终正确答案。模型自己会生成推理过程,GRPO根据最终答案对不对来给奖励,推理过程是否清晰反而靠格式奖励间接塑造。准备数据的时候,我推荐以数学文字题、计算题、逻辑推导题为主,要求答案是确定性的,这样奖励判断才不会有模糊空间。
数据量不需要特别大,我用的训练集约为一千多条,每条只包含两个字段:问题和答案。数量少没关系,GRPO本身就是让模型对同一问题反复采样,同一个问题被生成八次、更新一次,信息密度很高。更重要的是问题的多样性,尽量覆盖不同题型、不同数字范围、不同问法。如果训练集里都是同一类题,模型很容易在一个局部最优上过拟合。
提示模板一定要固定,格式如下:
### 指令 请解决下面的问题。先给出逐步推理过程,最后以单独一行输出“最终答案:XXXX”。 ### 问题 {question} ### 回答用这种纯文本模板,不依赖特定的对话标记,因为基础模型的safetensors权重里虽然也有特殊标记,但它在预训练阶段对复杂对话模板的感知强度有限,固定纯文本格式反而更稳。模板写好后,所有训练数据都用同一个模板套起来,保证训练分布一致。评估的时候也用同样模板,这样评估分数才有意义。
2.2 奖励函数:正确性、格式、区分度三件事
奖励函数是整个GRPO训练里最核心的部分,它直接决定了模型在优化什么。很多人以为奖励函数就是把“答案是否正确”变成1和0,真这么写了,训练大概率翻车。因为同一道题采样出的8个回答,很可能全对或全错,这时候组内优势全是0,梯度直接消失。
我的建议是把奖励拆成两部分叠加。第一部分是答案正确性,占了主要权重;第二部分是格式分,只要模型按模板要求输出了“最终答案”并且前面有推理过程,就给一个小正数,格式不对给0。格式分的作用有两个:一是给全错组提供至少一点区分度,二是让模型先学会正确的输出结构。初始训练阶段,如果8个回答答案全错,格式分还能维持组内非零差异,模型至少知道往“像样”的方向走。
下面是我在实际训练中使用的奖励函数代码,可以直接抄:
import re def normalize_answer(s): # 去掉各种空格、逗号、引号,便于字符串比较 s = re.sub(r"[\s,,。;;::\"'“”‘’号元()()]", "", s) return s.strip() def reward_func(prompts, completions, answers): rewards = [] for completion, answer in zip(completions, answers): text = completion.strip() lines = [ln for ln in text.splitlines() if ln.strip()] # 1. 格式分:必须有推理过程行,且在最后给出最终答案 match = re.search(r"最终答案[::]\s*(.+)", text) format_ok = 1.0 if (match and len(lines) >= 2) else 0.0 # 2. 正确分:比对最终答案和标准答案 if match: final_ans = normalize_answer(match.group(1)) correct = 1.0 if final_ans == normalize_answer(answer) else 0.0 else: correct = 0.0 rewards.append(0.8 * correct + 0.2 * format_ok) return rewards这里我把正确分设为0.8,格式分设为0.2,比例可以调。如果模型连格式都经常崩,可以先把格式分拉高到0.4甚至0.5;如果格式已经很稳定,把格式分压低到0.1,让正确性占主导。奖励函数写好后,批量跑一遍训练集,输出每条样本的奖励分布,能提前看到哪些问题对模型过难、哪些答案格式容易出错。
2.3 组内区分度的两个隐藏坑
第一坑是“组内全对或全错”。前面提过,全对或全错会导致优势无限或为零,所以要保证奖励函数对同一组内的不同回答给出不同数字。哪怕只有一个回答答案正确,其他全错,也能算出优势。为了避免长时间全错,可以在一开始故意把格式分权重提高,或者把数据按难度分层。让简单题和难题交替进入批次,不要让模型连续碰高难题,否则整个batch的奖励都是零,训练心态会崩,优化方向也丢失。
第二坑是答案归一化不彻底。数学题的答案格式千奇百怪,“39”和“39元”在语义上等价,但对字符串比较而言是两回事。我把“元”“个”“只”这些单位词在归一化时去掉后,正确率判断显著改善。如果你做的是选择题,建议答案直接落成“A”“B”“C”“D”这种单字符;如果是填空题,统一去掉空格和标点。做字符串匹配时永远要记得:奖励函数是死板的,它不会替你理解题目。
3. GRPO的核心机制和超参数:别只跑代码,要看得懂它在干嘛
3.1 GRPO的优化目标和计算流程
GRPO的完整计算流程可以拆成四步。第一步,对每个问题q采样G个回答,组成一个组,G通常取4到16,我默认用8。第二步,用奖励函数给每个回答打分,得到r1到rG。第三步,把组内奖励转成优势值,公式是:
A_i = (r_i - mean(r)) / std(r)也就是说,组内平均分成为基线,每个回答的分数减去这个基线再除以标准差,得到的就是它在组内的相对优劣。第四步,用类似PPO的策略梯度公式更新模型,目标可以写成最大化:
E[ 1/G * Σ min( ratio_i * A_i, clip(ratio_i, 1-ε, 1+ε) * A_i ) - β * KL(π_θ || π_ref) ]其中ratio_i是新策略对旧策略生成该回答的概率比,clip把更新幅度限制在一定范围内,避免一步走太远。KL项则约束新策略不要偏离初始模型太远,防止模型通过生成乱码来刷奖励。
注意,这个流程中不需要单独训练任何价值网络。组内平均值就是天然的基线,对于一个简单问题,组内可能全都答对,优势全为0,这说明模型已经做得很好了,不需要强行给个大梯度。对一个极难问题,组内全错,优势也为0,不会让模型因为一个问题太难而受到不公正惩罚。这种相对评价机制非常贴合推理任务的场景差异。
3.2 组内相对优势为什么能又快又稳
语言模型强化学习最怕奖励尺度不稳定。一道题得1分,另一道题得10分,如果用绝对奖励,模型会疯狂偏向能拿高分的问题类型,忽略那些拿低分但更重要的问题。GRPO每次只用同一道题内部的相对差值计算优势,完全规避了不同题目之间难度差异带来的偏差。这种设计对于数学题尤其重要,因为不同问题的难度跨度极大。
稳定性还来自另一层:不需要价值网络意味着不需要估计绝对状态值。价值估计一旦有偏,PPO的策略更新会被带偏;GRPO的价值估计完全来自采样,虽然方差稍大,但偏差小,配合8个以上采样基本够用。实际训练中我对比过同样数据下GRPO和PPO的loss曲线,GRPO的收敛更顺滑,reward均值总体上升,而PPO经常出现奖励均值上升但生成质量反而下降的“奖励幻觉”情况。
这种稳定性也带来了调参上的好处。GRPO和KL惩罚项配合得很好,我基本只需要调学习率和β值两个参数,其他用默认就能跑。PPO还要调价值网络的学习率、GAE系数、裁剪参数,动作一大就容易把模型训飞。可以说,GRPO把强化学习调语言模型的工程门槛往下拉了一大截。
3.3 几个必须盯住的关键超参数
以下是我在0.6B模型上调试后认为最值得关注的参数表:
| 参数 | 推荐范围 | 作用 | 调参建议 |
|---|---|---|---|
| learning_rate | 1e-6 到 5e-6 | 控制策略更新幅度 | 小模型容量小,学习率超过5e-6出现过KL失控,建议从3e-6起步 |
| beta | 0.01 到 0.1 | KL惩罚系数 | 0.04比较稳,数值偏大模型探索少,偏小容易偏离初始模型 |
| num_generations | 4 到 16 | 每个问题采样数 | 数据少时调到8或12,试探方差更大 |
| max_completion_length | 512 到 1024 | 限制回答长度 | 数学推理一般512够用,逻辑题长一点就设1024 |
| epsilon | 0.1 到 0.3 | 裁剪阈值 | 默认0.2,新模型用0.1更保守 |
| temperature | 0.7 到 1.2 | 采样随机性 | 太低组内多样性差,太高格式容易崩 |
有条件的同学可以跑两三轮短实验,先跑100步看KL和reward的趋势,再决定正式训练步数。我记得第一次训练时直接设置了2000步,结果在300步左右reward均值就开始停滞,后来把学习率从1e-5降到3e-6,问题才缓解。小模型对这个参数尤其敏感,千万别照搬大模型的配置。
4. 实操记录:从安装环境到跑通训练脚本
4.1 环境准备
我用的环境是单张独立显卡,显存24G,0.6B模型训练完全够用,如果你的显存只有16G,也能跑,但要把batch size和生成长度调低。本质上,GRPO训练的开销大头在采样阶段,因为每步都要为每个问题生成多条回答,所以对显存的要求比对SFT高不少。
安装依赖时注意版本对齐,跑GRPO需要一套支持强化学习训练的开源框架组合,主要以transformers和trl为主:
pip install transformers datasets accelerate trl wandb建议在干净的虚拟环境里装,前后端版本混用很容易出问题。trl版本有变化时,GRPO接口细节会跟着变,装上后可以先跑一个最小demo确认GRPOTrainer可用,再开始正式训练。
4.2 完整训练脚本
训练数据我先整理成jsonl文件,一行一条:
{"question": "一个两位数,个位数字是十位的3倍,且比十位数大18,这个数是多少?", "answer": "39"} {"question": "把一根绳子对折两次后,每段长12米,原来绳子长多少米?", "answer": "48"} {"question": "一本书共有240页,小明第一天看了全书的四分之一,第二天看了剩下的一半,第三天从第几页开始看?", "answer": "121"}然后写训练脚本。这里用的是基于trl的GRPOTrainer实现,实际使用前请比对你当前版本的具体参数名:
import json from datasets import load_dataset from transformers import AutoModelForCausalLM, AutoTokenizer from trl import GRPOTrainer, GRPOConfig # 加载base模型和tokenizer model_name = "./qwen3-0.6b-base" model = AutoModelForCausalLM.from_pretrained(model_name) tokenizer = AutoTokenizer.from_pretrained(model_name) # 左padding很重要,GRPO生成时对不同长度回答做batch padding需要token在左边 tokenizer.padding_side = "left" if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token # 读取训练集 dataset = load_dataset("json", data_files="./train.jsonl", split="train") def make_prompt(example): prompt = f"""### 指令 请解决下面的问题。先给出逐步推理过程,最后以单独一行输出“最终答案:XXXX”。 ### 问题 {example['question']} ### 回答 """ return {"prompt": prompt, "answer": example["answer"]} dataset = dataset.map(make_prompt) train_dataset = dataset.remove_columns(["question"]) # 训练配置 training_args = GRPOConfig( output_dir="./qwen3-0.6b-grpo", per_device_train_batch_size=2, gradient_accumulation_steps=8, num_generations=8, max_prompt_length=512, max_completion_length=1024, learning_rate=3e-6, lr_scheduler_type="cosine", warmup_ratio=0.03, beta=0.04, logging_steps=1, save_steps=50, save_total_limit=2, num_train_epochs=1, ) trainer = GRPOTrainer( model=model, args=training_args, train_dataset=train_dataset, reward_func=reward_func, # 用前面写的奖励函数 ) trainer.train()这里有个容易踩的坑:tokenizer.padding_side必须设为left,否则我们生成的prompt长度不同,padding token会出现在文本末尾,而模型生成时在末尾接新文本,当场就会出现大量无意义的eos token。另外,batch size不要设太大,因为生成的8条回答都会保存在显存里,per_device_train_batch_size=2意味着每步同时处理16条生成序列,显存压力不小。
4.3 训练日志怎么看,推理能力有没有真涨
训练过程中,我会在日志里盯四个指标:reward_mean、reward_std、kl、completion_length。刚开始的50步内,reward_mean可能比较低,因为模型还没有学会输出格式;这时候格式分占0.2,能保证reward_mean不是零。跑到100步左右,reward_mean应该开始明显上升,这说明模型已经掌握了“最终答案”这个格式。真正判断推理能力有没有涨,核心指标不是reward_mean,而是完成文本是否真的包含越来越多的正确答案。
我的做法是把日志里的completion随机抽一条打印出来,人工看它的推理过程。初期模型输出可能是“我不确定”“无法计算”这类废话,中期会输出错误的计算过程但格式正确,后期会出现正确的逐步推理。当人工抽查发现大部分回答都遵守“先推理后结论”格式时,再拿一个独立评估集测最终答案正确率。注意评估集和训练集要彻底隔离,最好对问题做数字改写,防止模型靠记忆答题。
我自己的实验结果里,GRPO训练后的模型在评估集上的最终答案正确率比SFT基线明显高出一截,而且推理过程的可读性强很多。另一个更微妙的信号是规规矩矩的“逐步推理”长度变长了,模型不再只是给一个数字,而是会写“第一步……第二部……”这种过程。这说明强化信号确实把模型推向了策略性思考方向。
5. 常见问题和排坑实录
5.1 先说一张速查表
训练中遇到最多的问题,我整理成了下面这个表,排查的时候先对照看看:
| 症状 | 常见原因 | 应对策略 |
|---|---|---|
| reward均值全程不动 | 组内奖励区分度过低,全对或全错 | 调整奖励权重,增加格式分,换更简单数据 |
| KL散度飙升 | 学习率过大或beta过小 | 降低学习率到1e-6,调高beta到0.1 |
| 模型输出全是重复片段 | 生成温度过低或推理长度超限 | 把temperature调到0.9以上,检查max_completion_length |
| 格式学会后又崩了 | 格式分权重下降太快 | 保持格式分占比不低于0.15 |
| 评估集正确率到一定值后过拟合 | 训练数据太少或训练步数太长 | 只训1个epoch,提前保存评估集最优checkpoint |
| 显存爆炸 | 采样数量和batch size叠加过大 | 降低num_generations到4,或减小batch size |
5.2 最值得展开的三个问题
第一个是“reward涨了但推理能力没涨”。这是最典型的奖励幻觉。模型可能发现,只要输出“最终答案:不知道”这种固定短句就能稳定获得格式分,于是它找了一条捷径。解决思路是把格式分压小、正确分权重加大,同时把“不知道”“无法确定”这类无效回答明确判为0分。也可以用“如果最终答案部分包含非数字字符则正确分直接为0”的规则来封堵。
第二个是“组内奖励全为0”。早期训练时模型生成的内容基本和题目无关,8条采样全错,格式分也是0。这种状态持续久了模型学不到东西。我的办法是把难度降低,先加入一批非常简单的加法题,保证模型至少能答对一部分;同时给格式分一个最低温柔度,比如即使格式完全不对也给0.05,让组内有一点信号可学。等reward均值脱离零点后再逐渐剪掉这部分基础分。
第三个是“KL散度突然崩掉”。这通常发生在学习率偏高或者训练步数太长时,模型特征直接被带偏,生成结果开始出现毫无逻辑的乱码。我遇到后立刻停住训练,把学习率折半,把beta从0.04提到0.08,然后从最近一个正常checkpoint重新加载。不要心疼损失,KL崩溃后继续训练只会浪费时间和显存。
6. 最后想分享的几点经验
如果只让说一条经验,我会说:GRPO训练小模型,别急着上复杂数据,先把奖励函数和格式信号稳住。奖励设计不好,再好的算法也白搭。另一个体会是,训练日志和生成样例必须人工抽查,不能只看loss曲线。强化学习训练太容易跑偏,机器觉得分数高了,实际上只是模型发现了奖励函数的漏洞。
实际操作中,还有一个特别实用的小技巧:在每个训练stage保存的checkpoint里,各自跑一次独立评估集的生成抽样,对比“答案正确率”和“格式服从度”。这样就能精确知道强化学习到底在提升哪个维度。训练结束后,把最后几个checkpoint都跑一遍评估,选最优的一个用于后续SFT或部署,不要默认取最后一个,因为最后几步往往已经开始过拟合了。
这个路线后续还能继续扩展,比如在GRPO训练得到的模型基础上,再用少量SFT数据做一轮整理,把输出格式打磨得更干净,效果往往比其中一个单独训练要稳定。小模型推理能力不是一锤子买卖,GRPO只是把模型的潜力激发出来,后面的工程化调优还需要慢慢磨。