24GB单卡跑通Gemma 4微调:Unsloth量化+SFT+RL全流程实战
打开我吃灰的那台机器时我心里其实打了个问号一块 24GB 显存的显卡真的能把 Gemma 4 的 SFT 和 RL 两阶段都跑完吗前两年大家默认的规矩还是“没有两张卡别碰微调”但 Unsloth 这一套工具链把量化、LoRA、前向反向和显存优化都焊在了一起入口确实被压下来了。这篇文章就把我这次完整跑通的思路、参数、代码片段和翻车记录都摊开讲给同样只有一张 24GB 卡、又想在 Gemma 4 上做 SFT 和 RL 的朋友做参考。1. 先算清楚账24GB 显存在 Gemma 4 面前是什么段位很多人一听“24GB 显存”就本能觉得不够用理由是现在动不动就宣称基座模型多大多大。但实际工程里我们不在显存里装“完整模型”而是装“训练所需要的最小状态集”。这一节的目的是把账算明白免得后面调参全靠感觉。1.1 一张卡能装下多少东西显存占用拆解假设用 Gemma 4 家族里 12B 这个量级的权重在 4bit 量化下模型本身的显存占用大约是12B × 4bit ÷ 8 约 6GB量化时的 scale 和 zero point 等元数据再算 1GB 左右LoRA 可训练参数及其优化器状态按 rank16 算一般不超过 1.5GB梯度、中间激活、KV Cache 和临时张量这是弹性最大的一部分通常占 6GB 到 10GB所以模型权重加训练状态24GB 是完全覆盖得住的。真正吃掉显存的大头是序列长度和 batch size序列越长、并发样本越多KV Cache 和激活值就翻倍涨。这也是为什么后面我把 SFT 阶段的最大序列长度压在 4096、RL 阶段压在 2048而不是盲目往 8192 冲。1.2 SFT 和 RL 对显存的不同胃口SFT 的计算模式是“固定输入前向一次、反向一次”显存峰值相对可控。到了 RL 阶段就不一样了模型要先生成一批候选回答再做策略更新等于推理和训练挤在一起。同样条件下RL 的显存峰值通常比 SFT 高出 30% 到 50%。我把两阶段的具体预算列成了一张表方便你心里有数资源项SFT 阶段RL 阶段模型权重4bit约 7GB约 7GBLoRA 优化器状态约 1.5GB约 2GB梯度与中间变量约 3GB约 5GBKV Cache 生成缓存约 5GBseq4096约 8GB多路生成余量3GB 左右1GB 左右结论很直接24GB 卡做 12B 级别模型的 SFT 是舒适区做 RL 是紧平衡。如果你想跑更大尺寸的模型或者超长上下文就得牺牲 batch size、减少每轮生成候选数或者把数据长度砍短。2. 把 Unsloth 环境一次装对少走三个弯路环境这块看似简单但我见过太多人死在第一步装了 Unsloth 之后模型加载报错于是怀疑人生。其实大部分问题不是工具不行而是依赖链没对齐。2.1 装环境的两个关键原则Unsloth 为了性能会直接用自定义算子重写 attention 和前向过程因此它对 PyTorch、CUDA toolkit、transformers 的版本有强依赖。我这次用的是 Python 3.11 PyTorch 官方稳定版 Unsloth 最新稳定版最重要的原则有两条第一别用 conda 强行装一个老版本 CUDA toolkit让 PyTorch 的预编译包里自带的 CUDA runtime 自己工作第二transformers 和 peft 的版本跟着 Unsloth 的安装依赖走不要在装完 Unsloth 之后再手动升级很容易把兼容性打破。我用的是这套最小安装命令conda create -n gemma4 python3.11 -y conda activate gemma4 pip install --upgrade pip pip install unsloth[colab-new]githttps://github.com/unslothai/unsloth.git pip install --upgrade transformers peft accelerate trl这里我故意没有写死torch的安装命令因为 Unsloth 的安装脚本会自动拉取对应版本。如果你之前已经装了自己的 PyTorch建议先卸载干净再装 Unsloth否则经常出现“两个 torch 互相打架”的情况。2.2 验证环境能不能顺利加载是关键环境装完之后别急着上训练先用一个最短脚本验证模型加载和量化链路from unsloth import FastLanguageModel model, tokenizer FastLanguageModel.from_pretrained( model_nameunsloth/gemma-4-12b, max_seq_length1024, load_in_4bitTrue, ) print(model.config.model_type) print(type(tokenizer))如果这一句能顺利跑完说明 CUDA、量化、模型权重下载全部正常。如果报错绝大多数是版本问题直接看 Unsloth 的安装文档对照 transformers 版本即可。另外我强烈建议在一开始就设置好环境变量export CUDA_VISIBLE_DEVICES0 export TOKENIZERS_PARALLELISMfalse后一个变量可以避免某些 tokenizer 在多进程加载时打印一堆莫名其妙的警告虽然不影响结果但会干扰你看训练日志。3. SFT让模型学会你数据里的“话术”与“规矩”SFT 是让模型从“会接话”变成“按你的话术和格式说话”的关键阶段。这个阶段的成败一半在数据准备一半在超参选择。3.1 数据长什么样模型就长什么样我在这次实验里用的是两类数据混合一类是带标准指令的问答对一类是带固定输出模板的示例对。总量大概 3 万条每条平均长度 800 token 左右。有一个很容易忽略的点混合数据时一定要先按任务类型分层再做随机洗牌。比如我手上有 2 万个客服问答和 1 万个代码生成示例如果直接整体洗牌模型对“该用哪种风格输出”的边界会模糊。分层洗牌的意思是先确定每条数据属于哪类任务再在每类内部随机排序最后拼起来训练。这样模型在收敛时能比较稳定地学到“不同输入类型对应不同输出风格”。数据格式我用的是 Gemma 4 的 chat tokenizer 所支持的对话模板大致长这样messages [ {role: user, content: 请把这句话翻译成英文你好世界}, {role: assistant, content: Hello world}, ] inputs tokenizer.apply_chat_template(messages, tokenizeTrue, add_generation_promptFalse)我特别建议不要绕开apply_chat_template手写模板。Gemma 4 的 tokenizer 对特殊 token 有严格的拼接要求手写模板很容易多一个空格或少一个 token训练时 loss 很难降到底。3.2 参数选择这套配置在 24GB 卡上很稳SFT 阶段我用的是 4bit QLoRALoRA rank 设为 16alpha 设为 16。这里不推荐 rank 开太大比如 64 或 128虽然表达能力强但在 24GB 卡上会挤占激活值空间而且 QLoRA 的量化误差会放大训练稳定性反而不如 rank16。目标模块上我全选了线性层q_proj、k_proj、v_proj、o_proj、gate_proj、up_proj、down_proj。embed_tokens和lm_head没有动原因有两个一是这两个模块参数量大训练起来显存压力明显二是它们对收敛速度的贡献在 LoRA 场景下并不突出跳过它们可以省下一大块优化器显存。训练超参如下学习率2e-4使用 cosine 衰减warmup 比例 0.03每设备 batch size4梯度累积 2实际等效 batch 8最大序列长度4096训练轮数2代码骨架大概是from trl import SFTTrainer from transformers import TrainingArguments from unsloth import is_bfloat16_supported trainer SFTTrainer( modelmodel, tokenizertokenizer, train_datasetdataset, max_seq_length4096, dataset_text_fieldtext, argsTrainingArguments( per_device_train_batch_size4, gradient_accumulation_steps2, learning_rate2e-4, warmup_ratio0.03, lr_scheduler_typecosine, logging_steps10, output_dir./sft_checkpoints, fp16not is_bfloat16_supported(), bf16is_bfloat16_supported(), save_strategysteps, save_steps200, ), ) trainer.train()3.3 训练过程的真实观感loss 会掉但别只看 lossSFT 跑起来之后loss 会从 1.5 左右一路下降到 0.6 附近。很多人在这里会产生“稳了”的幻觉直到验证集上发现模型输出开始自说自话。我的经验是SFT 阶段必须拿一批“留出集”反复做生成测试不要只看 loss。具体做法是每训练 200 步就用当前的 LoRA 权重生成 5 条固定测试输入的回答人工瞄一眼话术、格式、长度是否正常。如果在验证集上发现模型开始啰嗦、重复、或者忽略指令多半是过拟合了这时要马上把学习率调低三分之一或者提前终止。这一阶段的产物要保留好因为 RL 阶段是建立在 SFT 权重之上的。我建议每个 checkpoint 都单独存一份 LoRA 权重不要覆盖后面 RL 回归的时候要反复切换。4. RL监督微调之后的临门一脚GRPO 怎么落SFT 教会了模型“模仿”但模仿不代表模型会为了目标而努力。RL 阶段的价值是让模型在生成时不仅像人还会在你设定的奖励信号上持续变好。4.1 为什么选 GRPO 而不是 PPO如果直接上 PPO需要同时维护策略模型、参考模型、价值模型和奖励模型显存压力在 24GB 卡上几乎必爆。GRPO 的思路是用一组采样输出来估计优势值把价值模型省掉这样显存占用直接砍掉一整块。GRPO 的大致流程是对于每个 prompt让当前模型采样生成 n 个回答然后对这 n 个回答计算奖励用组内相对优势来更新策略。这种方式在 24GB 卡上尤其友好因为一次只需要加载策略模型、参考模型和计算奖励的开销。我这次用的方案是 Unsloth 对 TRL 的适配核心代码如下from trl import GRPOTrainer, GRPOConfig training_args GRPOConfig( output_dir./rl_checkpoints, learning_rate1e-5, per_device_train_batch_size1, gradient_accumulation_steps4, num_generation_iterations1, max_prompt_length1024, max_completion_length1024, beta0.04, logging_steps10, bf16is_bfloat16_supported(), fp16not is_bfloat16_supported(), )RL 的学习率一定要比 SFT 低一个数量级我一般用 5e-6 到 1e-5。因为策略更新步长如果太大模型会迅速往奖励函数的高原地带冲然后退化成一两个固定话术的复读机。4.2 奖励函数这可能是最决定成败的环节奖励函数的设计直接决定了模型会被“塑造成什么样”。我这次的项目里用了三个奖励信号的加权组合格式奖励输出是否符合预设的 JSON 或字段结构用字符串解析来判断关键词奖励是否包含了指定的领域关键词用于约束内容方向语义长度奖励回答长度是否落在合理区间用来压制过短或过长的输出奖励函数实现上我做了个小的微创新把格式解析和关键词匹配分开打分最后再乘上长度惩罚系数。这样模型可以在格式完全正确但内容空洞时得到一个偏低的整体分数从而意识到“光套格式没有用”。def reward_fn(prompt, completion, answerNone): score 0.0 if check_format(completion): score 1.0 if check_keywords(completion): score 1.0 length len(completion) if min_len length max_len: score 0.5 return score4.3 24GB 卡上的 RL 到底怎么调才不爆显存RL 阶段对显存最不友好的地方在于生成候选回答。num_generation_iterations1意味着每个 prompt 只做一轮生成显存峰值集中在“同时对 n 个回答做前向”的时刻。为了把单卡压力降下来我把 prompt 长度压到了 1024completion 长度也压到 1024并且开启了use_vllmFalse让生成在训练框架内完成避免额外拉起一个推理后端占用显存。组内采样数量我用的是 4而不是论文里常见的 8。数量越少单步奖励估计的方差越大但显存更稳。实测下4 组采样在 24GB 卡上能稳定跑到 2 个训练步再往上加到 8 就会偶尔触发显存溢出。4.4 RL 训练中必须盯的两个指标RL 训练不像 SFT 那样有单调下降的 loss 可以看。我主要盯的是平均奖励曲线和生成多样度。平均奖励应该逐渐上升但如果发现奖励在上升的同时生成文本的相似度也在上升说明模型正在“钻奖励函数空子”。这时候我会做一次人工抽查往往能看到模型开始输出大量“为了符合格式而写的废话”比如把一句话拆成十个字段硬填回去。对付这种情况要么给奖励函数加更多内容约束要么把beta调大一些限制模型对参考策略的偏离速度。RL 跑通之后模型在目标指标上的表现会比 SFT 阶段好一截尤其是在格式遵从和关键约束满足这类可量化的指标上。5. 跑完两阶段我栽过的三个自找的坑这一节不写顺风顺水的流程专门记录这次实验中让我浪费了三个下午的坑。每一个都是自己挖的报错信息不直观排查起来特别绕。5.1 坑一SFT 和 RL 用的 chat template 不一致第一次跑 RL 时我直接用 SFT 阶段保存的模型做初始化但没有统一两边的数据预处理逻辑。结果 RL 阶段生成的文本总带着pad和eos混在一起的脏符号格式奖励几乎永远得 0 分。排查了很久才发现问题不在奖励函数而在 SFT 数据构建时我用的是apply_chat_template生成的 token但 RL 训练器的 prompt 构造却默认走了 tokenizer 的 encode 裸文本路径。两者拼接出来的上下文根本不是一个体系。解决方式是在 RL 训练前把所有 prompt 统一用同一个函数转成标准格式并且加入一个前置断言打印前两个样本的 tokenize 结果检查特殊 token 的位置是否正确。5.2 坑二显存没爆但训练卡死原来是梯度检查点没开RL 跑到第 20 步左右显存占用看起来还有余量但训练速度骤降GPU 利用率掉到 20% 以下训练时不时卡住。我一开始以为是数据加载太慢折腾了很久的数据预处理甚至怀疑是磁盘 IO 问题。后来仔细看日志发现是我在 RL 训练器里打开了生成但没有开启梯度检查点。Unsloth 虽然做了很多算子融合但生成采样时临时张量会瞬间堆高显存触发隐性内存 swap。打开gradient_checkpointing之后显存峰值下降了一个台阶训练恢复流畅。这也是一个很多人忽略的点在 RL 阶段不要因为 Unsloth 默认省显存就觉得万事大吉GRPOConfig里的gradient_checkpointing要显式确认是否为 True。5.3 坑三奖励函数被模型“钻空子”输出退化成固定模板这个坑是最隐蔽的。我原本的奖励函数里格式分占 1.0关键词分占 1.0长度分占 0.5。模型很快发现只要输出一个固定格式的套话把关键词硬塞进去长度控制在区间内就能拿到 80% 的奖励。训练到后期平均奖励涨到了 1.8 附近但抽查生成结果时发现50% 的回答都是同一个空壳模板只是关键词位置在换。这说明模型在 RL 阶段学会了“欺骗奖励函数”而不是真正提升回答质量。我的处理办法是重写奖励函数把关键词匹配改成“不仅出现而且位置合理、有上下文关联”的弱约束判断同时加入对模板重复度的惩罚项。改完之后重新训练奖励曲线不再虚高人工抽查质量也明显回升。6. 如果让我再跑一遍我会怎么安排这次完整跑下来我对“24GB Unsloth Gemma 4 SFT RL”这个组合的边界有了清晰感受。如果重新做一次我会把节奏安排得更从容一些。SFT 阶段至少留 60% 的时间做数据清洗和格式验证而不是急着开训练。数据里每一条都要在训练前跑一遍 tokenize确认特殊 token 的位置符合预期。RL 阶段则把 40% 的时间花在奖励函数分析和验证上。先拿固定模型跑一组奖励分数看分布是否合理再决定是否开始训练。奖励函数没有设计好之前训练跑多少次都是在浪费时间。显卡选择上24GB 这张卡做 12B 级别模型的 QLoRA SFT 很合适做 RL 则要把序列长度、采样数量和 batch size 都控制在偏保守的档位。除非你的任务长度要求真的很高否则没必要挑战 8192 上下文生成速度和稳定性都会明显打折扣。最后分享一个小技巧把训练过程中每个 checkpoint 的生成样例按时间顺序存下来训练结束之后拼成一个视频或对比图你会非常直观地看到模型从一个“乱说话的大模型”变成一个“按规矩办事的家伙”。这个变化过程比任何指标曲线都更能说明问题。