当前位置: 首页 > article >正文

实战LLM强化学习——使用GRPO(DeepSeek R1出圈算法)

——关于使用Unsloth库、LoRa微调及GRPO Trainer自定义奖励函数实现“只输出10个英语单词”的探索


为什么要进行“只输出10个英文单词”的极端尝试?

在大模型的训练或微调当中,大多数场景我们都希望它能“自由发挥”,给出越丰富越好的答案。但,为了更好的理解强化学习在LLM训练过程中发挥的意义,也为了学习GPRO这个强化学习算法,笔者出此题目,方便大家学习理解。

GRPO(Group Relative Policy Optimization) 是当下挺流行也非常值得关注的一种RL范式。它本质上类似于PPO(Proximal Policy Optimization),但与PPO相比,它*不需要显式地维护一个庞大的价值网络*(Critic),而是通过在一个批次中为同一个提示(Prompt)生成多条不同输出(称为一组),然后用相对的方式来对各输出的优劣进行打分。这样就能节省大量的计算资源、内存占用,并且在对话/生成大模型的实际场景中,经常会用到“一题多答”来对比的方法,所以GRPO相当契合。

所以我们这篇博客文章的主线目标是:利用LoRa做参数高效微调,用Unsloth来轻松加载大模型+LoRa适配器,然后用GRPO做强化学习,把奖励函数改造成‘回答如果正好是10个英语单词就给最高分,否则就给低分’。在这种训练若成功之后,你就能看到一个非常“听话”的模型:只要你问它任何问题,它都会【强行】只给你写10个英文单词,既不多也不少。非常有意思且能加深你对RLHF、GRPO、LoRa、Unsloth等技术要点的理解。


Unsloth库简述

或许你会问:为什么要用Unsloth库而不是直接用Hugging Face Transformers官方库或者别的?当然没有谁说必须要用哪个库才能做LoRa微调,但Unsloth的特色就是:

  1. 支持4-bit/8-bit量化:能让我们在显存并不宽裕的情况下加载一些本来很大的模型,比如7B、13B甚至70B规模的变体,尽量节省GPU内存又保持较好的推理性能。
  2. 提供便捷的LoRa接口:你只要调用get_peft_model(...)就可以快速给现成的Transformers模型挂上LoRa适配器,而无需自己手写复杂的配置,减少了很多繁琐细节。
  3. 有一些额外的优化:比如原生支持2倍速推理(FastLanguageModel.for_inference(model)),还有保存为GGUF、合并到16bit等一系列功能,让你更容易把LoRa训练好的模型导出到各种推理后端(llama.cpp、webui等)。

接下来我们会用到一段官方Demo代码,在此基础上进行拓展。你如果想在自己的电脑上先安装Unsloth,按照以下命令即可(后面也会重复出现):

!pip install unsloth

在Google Colab里直接执行上述命令就OK,如果你用的是本地conda环境,也可以类似地pip install unsloth或者pipx install unsloth等方式安装。


LoRa微调入门:为什么要LoRa?

LoRa(Low-Rank Adaptation)是一种参数高效微调方法:在大模型内部有很多权重矩阵,我们并不想全部在微调中参与更新,否则要占用非常多的显存和计算量。LoRa的思路是:给某些指定的权重添加一个低秩分解的增量矩阵,我们只更新这些低秩增量矩阵,原先的权重保持冻结。这样就能用很少的额外参数,让模型在特定下游任务上完成微调。

当我们需要做对话式微调时,往往只想让模型“学”到某些额外的行为,并不希望重置或大规模动摇其原本学到的语言能力。而LoRa正好能实现这种“在预训练基础上做小范围但有效的调整”,并且训练时只需要更新非常少的参数,就能在极其有限的GPU显存条件下完成训练。对于动辄几十亿上百亿的参数量,这点就尤其关键。

本篇我们要做的就是:在Qwen2.5-Coder-7B-Instruct这样一个基础模型上,利用LoRa来让它只输出10个单词。因为仅仅SFT无法实现如此极端的目标,我们还需要借助强化学习方法GRPO,以及一个能够自定义奖励逻辑的训练器。写到这里,你或许已经感受到:LoRa作为底层的微调方式,GRPO作为顶层的策略优化算法,再加上一个“奖励=10词得高分,否则低分”的reward函数,最终让模型学到我们这种奇葩需求。


GRPO简述:相对于PPO的优势

大家在RLHF(Reinforcement Learning from Human Feedback)情景下,最常听到的可能是PPO(Proximal Policy Optimization)。然而PPO需要一个价值网络Critic去估计动作价值或优势函数,这在大模型场景中非常昂贵,因为它意味着再多出一个跟Actor体量差不多的模型参数来训练。

GRPO(Group Relative Policy Optimization) 提出了一种新的方法:不用显式价值网络,而是对同一个prompt采样多个输出(比如一次采样8个或者16个),对它们分别打分,然后进行分组内的相对比较,把分数归一化后当作优势,来指导策略网络进行梯度更新。这样就不再依赖价值网络估计中间时刻的价值,而是把奖励分直接看作整段输出的好坏指标即可。这种相对奖励的思想,一举减少了对Critic的需求,也简化了PPO中价值网络部分的训练流程及内存负担。

在我们“只输出10个英文单词”的例子里,奖励函数其实就是根据回答是否满足正好10词来给分。只要在分组内,这些回答都各自得到一个分数(如果回答了15词就会被判为较差,如果回答是10词就被判为较好),然后分组做标准化,就能区分“谁更好”。随着迭代进行,模型就会更倾向于产生10词输出。


环境与库准备

如果你在Colab上:

  1. 在Colab中新建Notebook。
  2. 选择运行时类型:GPU(Tesla T4或者更高规格都行)。
  3. !pip install unsloth安装库。

如果你在本地:

  1. 建议先创建一个干净的conda环境,比如conda create -n unsloth_env python=3.9并激活。
  2. 安装PyTorch(CUDA版本要与你的GPU驱动对应),可以pip install torch==2.0.1+cu118 --index-url ...等命令来安装。
  3. pip install unsloth即可。

官方的Unsloth说明也写在Github页面,按需查看更详细内容。


加载Qwen模型并挂载LoRa

接下来是最常规的一步:我们先把Qwen2.5-Coder-7B-Instruct加载进来,配置成4bit量化,显存更省,然后挂载LoRa适配器。以下代码是常见的Unsloth用法示例:

from unsloth import FastLanguageModel
import torch

# 准备一些配置
max_seq_length = 2048  # 最大序列长度
dtype = None           # 让Unsloth自动检测
load_in_4bit = True    # 4bit量化,节省显存

# 我们列出了一些可选模型清单,以便你了解
fourbit_models = [
    "unsloth/Meta-Llama-3.1-8B-bnb-4bit",
    "unsloth/Meta-Llama-3.1-70B-bnb-4bit",
    "unsloth/Mistral-Small-Instruct-2409",
    "unsloth/mistral-7b-instruct-v0.3-bnb-4bit",
    "unsloth/Phi-3.5-mini-instruct",
    "unsloth/Phi-3-medium-4k-instruct",
    "unsloth/gemma-2-27b-bnb-4bit",
    "unsloth/Llama-3.2-1B-bnb-4bit",
    "unsloth/Llama-3.2-1B-Instruct-bnb-4bit",
    "unsloth/Llama-3.2-3B-Instruct-bnb-4bit",
]

# 这里是Qwen系列的模型
qwen_models = [
    "unsloth/Qwen2.5-Coder-32B-Instruct",
    "unsloth/Qwen2.5-Coder-7B",
    "unsloth/Qwen2.5-14B-Instruct",
    "unsloth/Qwen2.5-7B",
    "unsloth/Qwen2.5-72B-Instruct",
]

# 让我们选择Qwen2.5-Coder-7B-Instruct
model, tokenizer = FastLanguageModel.from_pretrained(
    model_name = "unsloth/Qwen2.5-Coder-7B-Instruct",
    max_seq_length = max_seq_length,
    dtype = dtype,
    load_in_4bit = load_in_4bit,
)

# 然后挂载LoRa
model = FastLanguageModel.get_peft_model(
    model,
    r = 16,  # LoRa秩(秩越大,微调容量越大,同时资源消耗也更多)
    target_modules = ["q_proj", "k_proj", "v_proj", "o_proj",
                      "gate_proj", "up_proj", "down_proj",],
    lora_alpha = 16,
    lora_dropout = 0,
    bias = "none",
    use_gradient_checkpointing = "unsloth",
    random_state = 3407,
    use_rslora = False,
    loftq_config = None,
)

对于LoRa的一些参数说明:

  • r:低秩分解的秩,数值越大,越有能力去拟合复杂的变化,但也会带来更多要训练的参数。
  • lora_alpha:相当于LoRa增量在合并时的一个缩放因子。
  • target_modules:指定在哪些投影层插入LoRa权重,比如QKVO投影,以及一些前馈层等。不同模型的命名略有区别,这里对Qwen这套模块如此选择即可。
  • use_gradient_checkpointing:为了省显存,Unsloth在某些情况下支持梯度检查点技术(反向时重算前向),但要注意速度会变慢。你可根据需求开启或关闭。

到此为止,我们已经把Qwen + LoRa准备好了。下一步就是“如何喂数据进行微调”了。


数据如何准备:我们最终想干嘛?

在文章前半部分,我想先举一个常规的“SFT”风格数据集例子,让你知道如何把一个对话式数据集给Qwen做多轮对话微调。虽然我们最终要做“只输出10单词”的极端奖励,但是开篇先让你熟悉下传统流程,这样有了最基本的SFT之后,再引入GRPO就会更清晰。

先讲“如何给Qwen做常规对话式微调”

Qwen采用的对话格式用<|im_start|>system ... <|im_end|><|im_start|>user ... <|im_end|>这样的标记来分块区分角色。所以我们需要把现有的数据,转换成类似的多轮结构,然后进行tokenize并放进模型训练即可。Unsloth提供了一个 chat_templates.get_chat_template() 的小帮手来帮我们处理。

示例:加载并转换ShareGPT风格数据

from datasets import load_dataset
from unsloth.chat_templates import get_chat_template, standardize_sharegpt

dataset = load_dataset("mlabonne/FineTome-100k", split="train")
dataset = standardize_sharegpt(dataset)  # 把原本'from'/'value'结构转换成'role'/'content'

# 让tokenizer带上Qwen-2.5这种模板
tokenizer = get_chat_template(
    tokenizer,
    chat_template="qwen-2.5",
)

def formatting_prompts_func(examples):
    """格式化提示,把conversations列表转换成text列表,符合Qwen-2.5的分隔符。"""
    convos = examples["conversations"]
    texts = [
        tokenizer.apply_chat_template(convo, tokenize=False, add_generation_prompt=False)
        for convo in convos
    ]
    return {"text": texts}

dataset = dataset.map(formatting_prompts_func, batched=True,)

这样做完后,dataset里就多了一个"text"字段,它会是一大串<|im_start|>system\n...\n<|im_end|>的形式,对应多轮对话。然后我们在做训练的时候,就会对这串text进行进一步的tokenize处理并传给模型。


基础SFT训练过程

在引入GRPO之前,Unsloth也提供了一些整合好的方法,比如 SFTTrainer 来做普通的SFT(Supervised Fine-Tuning)。一段示例如下:

from trl import SFTTrainer
from transformers import TrainingArguments, DataCollatorForSeq2Seq
from unsloth import is_bfloat16_supported

trainer = SFTTrainer(
    model=model,
    tokenizer=tokenizer,
    train_dataset=dataset,
    dataset_text_field="text",
    max_seq_length=max_seq_length,
    data_collator=DataCollatorForSeq2Seq(tokenizer=tokenizer),
    dataset_num_proc=4,
    packing=False,
    args=TrainingArguments(
        per_device_train_batch_size=1,
        gradient_accumulation_steps=4,
        warmup_steps=5,
        max_steps=100,
        learning_rate=2e-4,
        fp16=not is_bfloat16_supported(),
        bf16=is_bfloat16_supported(),
        logging_steps=1,
        optim="paged_adamw_8bit",
        weight_decay=0.01,
        lr_scheduler_type="linear",
        seed=3407,
        output_dir="outputs",
        report_to="none",
    ),
)

在这里,你会发现我们没有再写太多样板代码——因为SFTTrainer已经替我们封装了大部分逻辑,比如如何在一个对话样本的文本上执行forward/backward,以及如何组织batch。注意:per_device_train_batch_size=1有点小,但是因为演示,我们只想省显存,实际可以改大点。max_steps=100也是为了演示缩短时间,真正训练时可能需要几千上万步。

只训练回答部分

有时候我们并不想让模型对“system”和“user”这部分也计算loss,只想对“assistant”回答计算loss(常见于SFT,对于用户输入不希望模型在训练时拟合)。Unsloth也贴心地提供了train_on_responses_only 函数,帮你把输入标签替换为 -100,从而不影响梯度。示例:

from unsloth.chat_templates import train_on_responses_only

trainer = train_on_responses_only(
    trainer,
    instruction_part="<|im_start|>user\n",
    response_part="<|im_start|>assistant\n",
)

然后调用 trainer.train() 就能启动训练了。训练完后,你可以 model.save_pretrained("lora_model") 来保存LoRa适配器。如果你想把LoRa合并到16-bit、4-bit或者GGUF格式,也可以使用 model.save_pretrained_merged(...)。具体后面会看到示例。


初步推理测试

在获得微调过的模型后,我们就可以对它进行推理,看它如何回答用户问题。示例如下:

FastLanguageModel.for_inference(model)  # 启用推理优化

messages = [
    {"role": "user", "content": "Please explain how to add two fractions, with an example."}
]
inputs = tokenizer.apply_chat_template(
    messages,
    tokenize=True,
    add_generation_prompt=True,
    return_tensors="pt",
).to("cuda")

outputs = model.generate(
    input_ids=inputs["input_ids"],
    max_new_tokens=64,
    temperature=1.5,
    use_cache=True
)

print(tokenizer.batch_decode(outputs))

这段代码会拿你的SFT后模型来回答一个问题,看看它的质量如何。也可以换成文本流输出TextStreamer的方式,看它边生成边打印。


正题:使用GRPO并自定义奖励函数,让模型输出仅限10个英语单词

到这里,咱们前面介绍的是一个普通SFT流程。现在是本篇文章的核心要素:我们希望生成的回答只包含10个英语单词,多一个或少一个都不给高分,只有恰好10个才是最高奖励。既然我们要执行RL训练,就需要一个强化学习式的Trainer来反复“采样生成-计算奖励-更新策略”。而且我们不想再去维护一个庞大的价值网络Critic,所以GRPO就是完美之选。

GRPOTrainer简介

trl库(HuggingFace的TRL项目)里提供了GRPOTrainer,其用法和PPOTrainer有些相似。我们只要把“模型”和“自定义奖励函数”都传进去,再给定一些超参数(batch大小、学习率等)即可。

GRPO的关键点
  • 在一个batch里,对同一个prompt会生成多条输出(比如一次生成8条),称之为group
  • 对这些输出都打分,然后做相对比较,得到一批相对奖励(Group Relative Reward)。
  • 用这种相对奖励去更新策略,把好回答的logprob乘以高优势,把坏回答的logprob乘以负优势。

自定义奖励函数:专门为“正好10个英文单词”打高分

核心来了:我们要告诉GRPO:“如果回答的单词数==10,就给奖励+1,否则就给-1。” 或者你想更精细一点,可以说“越离10越远,得分越低”,形成一个类似正态分布的奖励曲线。举个例子:回答9个或者11个都可以给-0.5分,回答2个或20个就给-0.9分这样。任何能区分好坏的打分公式都行。

让我们先写一个最简单的例子:

import re

def reward_func_simple(prompts, completions, **kwargs):
    """
    我们这里忽略prompts,用completions来判断输出。
    如果回答里正好是10个英文单词,则奖励=1,否则=-1。
    """
    rewards = []
    for comp in completions:
        # comp通常是一个字符串(对标准格式时)或一个列表(对对话格式时)
        # 如果是对话格式,可能需要取comp[0]["content"]之类
        # 这里假设它是标准单字符串
        text = comp
        # 通过正则或split来简单统计英文单词数量
        words = re.findall(r"[a-zA-Z]+", text)
        # words的长度就是输出的英文单词数
        if len(words) == 10:
            rewards.append(1.0)
        else:
            rewards.append(-1.0)
    return rewards

这样,每次GRPO都会对采样到的一堆回答做评估,如果回答恰好10词,则在后续优化中得到更高的梯度上升。但这个过于粗糙,我们可能希望更平滑一点,比如差1词就给-0.2分,差2词就给-0.4分等,差越多分越低,刚好10词最高分。可以写成:

def reward_func_gaussian(prompts, completions, **kwargs):
    """
    用一个简单的正态分布曲线,以10为中心,方差=2或3之类,计算一个值。
    """
    import math
    
    rewards = []
    for comp in completions:
        text = comp
        words = re.findall(r"[a-zA-Z]+", text)
        n = len(words)
        # 定义以10为均值,std=2的正态分布pdf值
        # pdf = (1 / (sqrt(2pi)*sigma)) * exp(- (x - mu)^2 / (2 sigma^2))
        mu = 10
        sigma = 2
        pdf = (1.0 / (math.sqrt(2*math.pi)*sigma)) * math.exp(-((n - mu)**2)/(2*(sigma**2)))
        # 不过pdf的最大值大概是1/(2.5066*sigma),我们可以做个放大或缩小
        # 或者你可以直接pdf当reward
        reward = pdf  # 这样离10越近越高
        # 为了让数值更显著,可以再乘以一个系数
        reward *= 10  
        
        rewards.append(float(reward))
    return rewards

这样就能在距离10单词越近的范围内收获更高分数,超过一定范围后分数很低。当然这些都是示例,真实项目中你可以任意定义你的reward逻辑,只要它能返回一个列表的浮点数与每条生成匹配即可。

把奖励函数丢进GRPOTrainer

有了上述reward_func_gaussianreward_func_simple,接下来就是配置GRPOTrainer啦。下面是一个伪代码风格的例子(仅供演示,或许你还要补充tokenizer啥的):

from trl import GRPOTrainer, GRPOConfig
from peft import LoraConfig

training_args = GRPOConfig(
    # 这里面包括很多RL训练的参数
    output_dir="Qwen-10words-GRPO",
    learning_rate=1e-5,
    logging_steps=10,
    gradient_accumulation_steps=8,
    max_completion_length=64,  # 生成时最多补全多少token
    # 其他可调参数,比如 batch_size, kl_coeff, etc...
)

# 假设我们有一个包含"prompt"列的数据集, 这里简单用SFT的做法
# 但实际上你可以自己构造prompt
my_dataset = ...

trainer = GRPOTrainer(
    model=model,  # 我们LoRa后的Qwen
    reward_funcs=reward_func_gaussian,  # 也可以传列表
    args=training_args,
    train_dataset=my_dataset,  # 要有"prompt"列
    peft_config=None,  # 如果你还没给模型挂LoRa,可以在这儿再给
)

然后执行 trainer.train()即可开始RL训练。在训练过程中,GRPO会在每个batch中,对同一个prompt采样多次输出,然后用reward_func_gaussian对这些输出评分,再执行策略梯度更新。训练完成后,模型就会越来越倾向于产生“离10个单词更近”的回答。


详解:如何准备“prompt列”来做10词限制

上面代码里提到 train_dataset=my_dataset,那my_dataset需要至少包含一列叫“prompt”,告诉Trainer要在这个prompt上做多次采样回答。具体你可以自己准备一个纯文本文件,把每行当成一个提示;或者你可以把上面SFT的数据dataset直接再加工一下,比如:

def make_prompt(example):
    # 强行给它加一句“Please respond in exactly 10 English words: ”
    text = example["text"]  # 这是原先system+user+assistant
    # 你也可以只取system+user部分,忽略assistant(因为我们现在用RL)
    prompt = text
    return {"prompt": prompt}

dataset_for_rl = dataset.map(make_prompt)

然后在GRPOTrainer里写 train_dataset=dataset_for_rl 即可。当然,这只是一个思路,怎么做prompt都灵活。你要确保reward函数里拿到的completions是模型回复的文本,而“prompt”只是输入。


训练时长与显存消耗

LoRa + 4bit量化的组合非常省显存,7B模型在一个单卡T4上也能搞定。具体batch_size等配置需要根据你显存大小调参。如果你想让训练效果好一些,就要跑更多步、或加大数据规模。不过要注意:因为GRPO每个batch要对同一个prompt生成多条回答(group size),所以其实inference部分的耗时也不小。如果group size=8,那就要生成8条回答再打分,所以训练速度会慢于普通SFT。


训练完后做推理:看看它如何乖乖只输出10词

当训练完成,你可以像之前SFT那样用model.save_pretrained("lora_10words")来保存LoRa适配器,然后用 model.generate(...)测试看看实际回答。理论上,如果训练收敛到位,那么不管你问啥问题,它都会努力回答正好10个英文单词。如果你写了正态分布reward,那么它会产生接近10词的输出,但有时可能偶尔出12、9词之类,也要看训练效果好坏。

以下是一段演示性代码:(注意,这只是一个推理演示,不一定保证真能完美输出10词,因为需要看你的训练是否足够收敛)

# 假设已经训练并保存了LoRa
from unsloth import FastLanguageModel

model_10words, tokenizer_10words = FastLanguageModel.from_pretrained(
    "lora_10words",
    max_seq_length=2048,
    dtype=None,
    load_in_4bit=True,
)
FastLanguageModel.for_inference(model_10words)

prompt = "Explain the concept of artificial intelligence in a single short paragraph."
inputs = tokenizer_10words.apply_chat_template(
    [{"role":"user","content":prompt}],
    tokenize=True,
    add_generation_prompt=True,
    return_tensors="pt"
).to("cuda")

outputs = model_10words.generate(
    input_ids=inputs["input_ids"],
    max_new_tokens=50,
    temperature=1.2,
    do_sample=True
)

print(tokenizer_10words.decode(outputs[0]))

这时,你就能观察输出里英文单词数是否为10。有时,它可能会在句末加一些标点符号或数字,你可以再改进你的reward函数,让它只数单词并对标点不额外处罚,或者把标点当单词处理,这都取决于你实际所需的约束。


关于保存模型到GGUF、合并到16bit

Unsloth的一大好处就是提供了大量“保存、合并、导出”的接口,以便你把LoRa好的模型交给不同推理后端使用。比如若你想把LoRa权重合并到16-bit权重上,可以这样:

model.save_pretrained_merged(
    save_directory="model_16bit_merged",
    tokenizer=tokenizer_10words,
    save_method="merged_16bit",
)

如果你想把它量化成4bit再合并,就save_method="merged_4bit"。如果你想保存成GGUF以便在llama.cpp或OpenWebUI、Ollama等使用:

model.save_pretrained_gguf(
    save_directory="model_gguf",
    tokenizer=tokenizer_10words,
    quantization_method="q4_k_m",  
)

选项包括q8_0, q5_k_m, f16等若干量化/浮点方式。之后就能在llama.cpp命令行或其它GUI环境中载入model-*.gguf文件直接推理了。


进阶:如果我们要一次性给多个奖励函数

有时,你不止想限制单词数,还想对回答包含敏感词时扣分,或者回答是否有毒性语言时扣分等。那就在reward_funcs传一个列表即可,每个函数返回一个分数,最后GRPO会把它们加和得到总reward。比如:

def reward_func_safety(prompts, completions, **kwargs):
    # 简单关键词过滤
    block_words = ["violent_word1", "racist_word2", ...]
    # 如果输出中包含这些词,就-2,否则0
    rewards = []
    for c in completions:
        text = c.lower()
        if any(bw in text for bw in block_words):
            rewards.append(-2.0)
        else:
            rewards.append(0.0)
    return rewards

trainer = GRPOTrainer(
    model=model,
    reward_funcs=[reward_func_gaussian, reward_func_safety],
    args=training_args,
    train_dataset=dataset_for_rl,
)

这样就实现了“双重奖励”:不但要求10词,还不能含某些违禁词。你可以拓展为更多维度的奖励函数。


实操建议

  • 如果你只是想做一个极端小测试(只几十条prompt),可以把max_stepsbatch_size都调得小一点,看看模型能否学到这种简单pattern。
  • 真实要得到更稳健的“回答10词”习惯,最好多做一些prompt模板,比如包含各种多样请求,并且都需要回答10词,减少模型只对特定上下文有效的风险。
  • 训练过程中,多观察日志:log里会打印平均奖励、kl散度等信息,若奖励在逐渐提升,说明模型在学。如果奖励一直在负分,说明采样出的回答都不符合10词这个要求,需要加强引导(或增大惩罚力度、或加大训练步数)。

常见疑问与错误

  1. 万一模型学不会或老是超10词?
    • 需要确认你的reward对偏离10词时是否惩罚够大,以及确保训练步数、学习率足够。
    • 可以在prompt中也给出更多“指令提示”,让模型有更大概率满足长度限制以得到正反馈。
    • 也要注意在tokenizer层面可能有WordPiece/BPE之类的分词方式,如果只用空格切分英语单词,记得对分词和奖励逻辑保持一致预期(否则有时数字、标点会带来偏差)。
  2. GRPO速度为什么不快?
    • 因为每个prompt要采样多次输出再打分,跟PPO相比它省了Critic网络的开销,但却多了重复采样的计算。取舍要看具体batch大小和group size设多少。
    • 如果可以用更大的batch或分布式训练,可以更好利用GPU加速。
  3. LoRa秩要选多大?
    • 对于这种“回答长度控制”需求,本质上对模型的语言能力改动不算特别大,通常r=8或r=16就够了。若你想要极强的表达/理解能力(还是多话题同时微调),则可以提高秩。
  4. 为什么有时模型回答的标点符号不是英文单词,却也被计入?
    • 这是你在reward_func里处理正则的问题,如果仅以 [a-zA-Z]+ 匹配,那标点符号会被忽略,也可能和实际使用中出现差异。你要确保自己的统计规则与实际需求相吻合。
  • ModuleNotFoundError: No module named ‘unsloth’: 没装或者环境冲突了,重新pip install unsloth再试。
  • CUDA out of memory: 说明batch_size或group size或LoRa秩等设置超出T4能支撑的范围,降低一下或者重启再来。
  • KeyError: ‘prompt’: 说明你的dataset缺少prompt列或列名弄错了。
  • RuntimeError: Expected all tensors to be on the same device: 典型的是你没把输入张量放到cuda或者模型在cpu,需要.to("cuda")
  • 奖励函数维度对不上: 记得reward_funcs返回的list长度要跟这次batch里生成的补全条数一致。

数学原理补充:GRPO中的优势函数从何而来

在PPO里,我们需要一个价值网络去预测 V φ ( s ) V_\varphi(s) Vφ(s),然后优势 A ( s , a ) = r + γ V φ ( s ′ ) − V φ ( s ) A(s,a) = r + \gamma V_\varphi(s') - V_\varphi(s) A(s,a)=r+γVφ(s)Vφ(s)在GRPO里,我们直接对同一个Prompt多次采样,得到一组输出 o i {o_i} oi,并对每个输出给出一个标量奖励 r i r_i ri,然后把他们做归一化
r i ^ = r i − mean ( r ) std ( r ) \hat{r_i} = \frac{r_i - \text{mean}(\mathbf{r})}{\text{std}(\mathbf{r})} ri^=std(r)rimean(r)
并把 r i ^ \hat{r_i} ri^ 当作所有token的优势值(等同分配)。这就跳过了对价值网络的依赖。然后再像PPO一样,用剪切的比率 r r a t i o r_{\mathrm{ratio}} rratio = π θ ( o i , t ∣ q , o i , < t ) π θ o l d ( o i , t ∣ q , o i , < t ) \frac{\pi_\theta(o_{i,t} \mid q, o_{i,<t})}{\pi_{\theta_\mathrm{old}}(o_{i,t} \mid q, o_{i,<t})} πθold(oi,tq,oi,<t)πθ(oi,tq,oi,<t) 来做更新,并可加入KL惩罚保持稳定。

若你还想进一步学习GRPO在数理推理场景的大显身手,可以了解“DeepSeekMath”等项目,它们在数学领域用这个方法取得了不错成果。也能看看TRLX、trl等官方文档对GRPO的更多介绍。


代码汇总:从LoRa加载到GRPO训练,再到推理

接下来,我会把核心代码段做一个近乎“自包含”的汇总,让你直接能复制到Colab,跑完就得到一个只输出10个英文单词的模型。当然你得先有个自己的数据集,哪怕是简单的几百行prompt都行,因为这里仅仅演示流程,并不保证数据足够多时的效果。示例如下(概念上):

!pip install unsloth

import torch
from unsloth import FastLanguageModel
from trl import GRPOTrainer, GRPOConfig
from peft import LoraConfig
import re

# 1. 加载模型
model_name = "unsloth/Qwen2.5-Coder-7B-Instruct"
model, tokenizer = FastLanguageModel.from_pretrained(
    model_name=model_name,
    max_seq_length=2048,
    dtype=None,
    load_in_4bit=True,
)

# 2. 配置LoRa
model = FastLanguageModel.get_peft_model(
    model,
    r=16,
    target_modules=["q_proj","k_proj","v_proj","o_proj",
                    "gate_proj","up_proj","down_proj"],
    lora_alpha=16,
    lora_dropout=0,
    bias="none",
    use_gradient_checkpointing="unsloth",
)

# 3. 定义奖励函数
def reward_func_10words(prompts, completions, **kwargs):
    rewards = []
    for text in completions:
        # 抽取英文单词
        words = re.findall(r"[a-zA-Z]+", text)
        # 计算差距
        diff = abs(len(words) - 10)
        # 差距越小奖励越高,这里用个简单函数
        if diff == 0:
            # 正好10词
            reward = 2.0  # 可以稍微大点
        else:
            # 距离越远,奖励越低
            reward = 1.0 - (0.1 * diff)
            # 不让它太低,也别太高
            reward = max(reward, -1.0)
        rewards.append(float(reward))
    return rewards

# 4. 准备数据
# 你需要一个dataset,包含"prompt"列
from datasets import Dataset
my_prompts = [
    {"prompt":"Hello AI, who are you? Respond in exactly ten words."},
    {"prompt":"What is your name? Use precisely ten English words."},
    # ... 自己多加一些 ...
]
train_dataset = Dataset.from_list(my_prompts)

# 5. 配置GRPO
training_args = GRPOConfig(
    output_dir="qwen_10words_grpo",
    learning_rate=1e-5,
    logging_steps=1,
    gradient_accumulation_steps=4,
    max_completion_length=32,
    per_device_train_batch_size=1,
    max_steps=50,  # 演示
)

trainer = GRPOTrainer(
    model=model,
    reward_funcs=reward_func_10words,
    args=training_args,
    train_dataset=train_dataset,
    peft_config=None,  # 我们已经手动 get_peft_model 了
)

# 6. 开始训练
trainer.train()

# 7. 保存LoRa
model.save_pretrained("lora_10words")
tokenizer.save_pretrained("lora_10words")

# 8. 测试推理
FastLanguageModel.for_inference(model)
test_inp = "Tell me something about the universe in exactly 10 words!"
inputs = tokenizer.apply_chat_template(
    [{"role":"user","content":test_inp}],
    tokenize=True,
    add_generation_prompt=True,
    return_tensors="pt"
).to("cuda")

outputs = model.generate(
    input_ids=inputs["input_ids"],
    max_new_tokens=40,
    temperature=1.2,
    do_sample=True
)

print(tokenizer.decode(outputs[0]))

在实际跑完后,如果看到模型回答有10个英文单词,且非常干脆,不多不少,就说明这个奖励函数约束效果达到一定程度了。如果还不理想,多加训练数据、多跑一些step、多大一些group size就行。


进一步思考与后续挑战

当然了,现实世界里我们可能不会只让模型输出10单词这么无聊的需求,但这个例子很好地说明了一个道理:当我们不容易收集到满足我们特殊规则的大量数据做监督微调时,就能利用自定义奖励和RL来硬性逼迫模型执行。而且LoRa让我们几乎不改原模型的大多数权重,保证了其语言能力和知识库本身保留,又增加了一点小的可学习低秩模块,以“学习如何只输出10词”这样的古怪技巧。日后你有类似思路,比如“让模型回答都带有某种诗意韵脚”,或者“回答时不可以出现任何与主题无关的话”之类,都可以依葫芦画瓢,通过写一个相应的reward_func来实现。

  1. 稳定性与多样性:当我们迫使模型只输出10个词时,可能牺牲了表达能力与上下文完整性。真实业务中,你可能更想用相对更平滑的惩罚,允许在某个区间内输出,或仅在极端时做负分。这部分可以进一步研究一个更柔性化的reward函数。
  2. 大规模数据与多轮对话:如果你的目标不止是输出长度,还想在多轮对话中保持礼貌、安全、有用等多个维度,这就需要多重奖励函数协同,并且要在大量多轮对话数据上训练,才能获得一个兼顾实用性的好模型。
  3. 资源消耗:GRPO虽然不需要价值网络,但每次要生成多条回答做分组对比,因此在训练速度方面也要做技巧优化,比如分布式训练、缓存等。
  4. 对输入的依赖:如果用户输入里就塞了很多指令,让模型输出50个词,那和我们“只输出10个词”会冲突。由于我们训练时给了模型更高的动机去听从RL奖励,所以它极可能还是死活控制在10词内,这也可以视为RL对于指令微调的一种“override”。
  5. 数学推理类场景:如果要对解题过程也做过程监督并强化“中间步骤正确性”,就要更复杂的奖励机制,比如每一步token都计算奖励。那就会回到一个类似“PPO+DPO+GRPO”混合的思路。此时LoRa依旧好用,而是否能纯用GRPO不带价值网络要看具体情况。

http://www.kler.cn/a/525910.html

相关文章:

  • 【C语言】在Windows上为可执行文件.exe添加自定义图标
  • DRF开发避坑指南01
  • 网站如何正式上线(运维详解)
  • SQL UCASE() 函数详解
  • 【机器学习】自定义数据集 使用pytorch框架实现逻辑回归并保存模型,然后保存模型后再加载模型进行预测
  • Python GUI 开发 | Qt Designer — 工具介绍
  • 论文阅读(八):结构方程模型用于研究数量遗传学中的因果表型网络
  • 拦截器快速入门及详解
  • 词表设计:特殊Token区域与共享去区域的深入探讨
  • 讯飞智作 AI 配音技术浅析(一)
  • CF 766A.Mahmoud and Longest Uncommon Subsequence(Java实现)
  • 宇宙大爆炸是什么意思
  • leetcode——合并K个有序链表(java)
  • (2024 MSSP) Self-paced-decentralized-federated-transfer-framewor
  • 深度学习笔记——正则化
  • Vue.js组件开发-实现全屏平滑移动、自适应图片全屏滑动切换
  • Blazor-@bind
  • Qt之数据库的使用一
  • 报错:MC1000未知的生成错误Invalid number of sections declared in PE header
  • react中如何实现组件通信
  • AI编程风潮下的生产力革命:从 Copilot 到 Trae
  • Java-多态(详解)
  • 记录使用EasyWeChat做微信小程序登陆和其他操作
  • OpenAI 宕机 | 如何让 k8s 集群更稳定
  • 基础位运算
  • AI时代来临:掌握信息收集,才能不被淘汰!!!