一、问题背景:为什么我要在24GB卡上折腾7B模型
事情起因很具体:团队要做一个垂直领域的客服助手,基座模型选的是Qwen2.5-7B-Instruct。直接上全参数微调?单卡24GB(RTX 4090)根本扛不住,7B模型fp16权重就要14GB,加上优化器状态和梯度,轻松突破40GB。多卡当然可以,但预算和排期都不允许。
于是转向PEFT路线。LoRA是我最先考虑的方案:只训练低秩旁路矩阵,可训练参数通常不到1%,显存压力小很多。但实测发现,即便冻结主干,fp16加载7B模型加上激活值和KV Cache,24GB卡在batch size稍大时依然会OOM。这就引出了QLoRA——把基座模型量化到4bit NF4,冻结的权重只占约4GB,剩下的显存留给LoRA参数和激活值。
最终我两条路都跑了一遍,本文把数据准备、训练配置、loss曲线、推理效果对比完整记录下来,给同样在单卡上做微调的同学一个参考。
二、环境与版本
环境这块我踩过版本不匹配的坑,先把确定能跑的版本列出来:
- GPU:RTX 4090 24GB,驱动 550.54
- CUDA:12.1
- Python:3.10.13
- PyTorch:2.3.1+cu121
- transformers:4.43.3
- peft:0.12.0
- bitsandbytes:0.43.3(QLoRA必需)
- trl:0.9.6
- accelerate:0.33.0
特别提醒:bitsandbytes 0.43.x 对 CUDA 12.1 支持比较稳定,之前用0.41版本在4bit加载时偶发 CUDA error: an illegal memory access,升级后消失。另外 peft 0.12 的 LoraConfig 接口和0.9之前有细微差别,网上老教程里的 fan_in_fan_out 默认值别照抄。
三、方案设计
整体思路分三层:
- 数据层:8000条客服对话,统一成
{"instruction": ..., "input": ..., "output": ...}格式,用chat template拼成单轮prompt,max_length截断到1024。 - 模型层:LoRA方案加载fp16基座;QLoRA方案用
BitsAndBytesConfig做4bit NF4量化,bnb_4bit_compute_dtype=torch.bfloat16,双重量化开启。 - 训练层:LoRA挂在
q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj全部线性层上,r=16,alpha=32,dropout=0.05。学习率2e-4,cosine调度,warmup比例0.03,batch size 4,梯度累积4,等效batch 16,训练3个epoch。
为什么r选16而不是8或32?我做了个小对比:r=8时loss能降但收敛偏慢,r=32在8000条数据上开始出现轻微过拟合(验证loss第3个epoch回升),r=16是比较平衡的点。alpha按惯例取2r。
四、核心实现
4.1 数据准备与tokenize
import json
from datasets import Dataset
from transformers import AutoTokenizer
MODEL_PATH = "Qwen/Qwen2.5-7B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token
def build_prompt(sample):
messages = [
{"role": "system", "content": "你是一个专业的客服助手,回答要准确、简洁。"},
{"role": "user", "content": sample["instruction"] + "\n" + sample.get("input", "")},
{"role": "assistant", "content": sample["output"]},
]
text = tokenizer.apply_chat_template(messages, tokenize=False)
return {"text": text}
raw = [json.loads(l) for l in open("data/customer_service.jsonl", encoding="utf-8")]
ds = Dataset.from_list(raw).map(build_prompt, remove_columns=list(raw[0].keys()))
def tokenize_fn(ex):
out = tokenizer(ex["text"], truncation=True, max_length=1024, padding=False)
out["labels"] = out["input_ids"].copy()
return out
ds = ds.map(tokenize_fn, remove_columns=["text"])
ds = ds.train_test_split(test_size=0.05, seed=42)
print(ds)
# DatasetDict({train: 7600, test: 400})
这里有个细节:labels 直接复制 input_ids,意味着system和user部分也参与loss计算。对客服场景来说,我更希望模型只学assistant的回答,所以进阶做法是把prompt部分的label置为-100。第一版我先用全序列loss跑通,后面在优化小节里说改进效果。
4.2 LoRA与QLoRA训练配置
import torch
from transformers import AutoModelForCausalLM, BitsAndBytesConfig, TrainingArguments
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer
USE_QLORA = True
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
model = AutoModelForCausalLM.from_pretrained(
MODEL_PATH,
quantization_config=bnb_config if USE_QLORA else None,
torch_dtype=torch.bfloat16 if not USE_QLORA else None,
device_map="auto",
trust_remote_code=True,
)
if USE_QLORA:
model = prepare_model_for_kbit_training(model)
model.gradient_checkpointing_enable()
lora_config = LoraConfig(
r=16,
lora_alpha=32,
lora_dropout=0.05,
target_modules=["q_proj","k_proj","v_proj","o_proj",
"gate_proj","up_proj","down_proj"],
bias="none",
task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# trainable params: 40,370,176 || all params: 7,655,986,688 || trainable%: 0.5273
args = TrainingArguments(
output_dir="output/qwen2.5-7b-lora",
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
num_train_epochs=3,
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=10,
save_strategy="epoch",
eval_strategy="epoch",
bf16=True,
gradient_checkpointing=True,
optim="paged_adamw_8bit",
report_to="none",
)
trainer = SFTTrainer(
model=model,
args=args,
train_dataset=ds["train"],
eval_dataset=ds["test"],
tokenizer=tokenizer,
dataset_text_field="text",
max_seq_length=1024,
packing=False,
)
trainer.train()
trainer.model.save_pretrained("output/qwen2.5-7b-lora/final")
注意 optim="paged_adamw_8bit",这是QLoRA场景下的关键,8bit分页优化器能进一步压显存,实测比默认adamw省约1.5GB。
五、踩坑与优化
坑1:OOM发生在eval阶段而不是train阶段。 训练时batch=4没问题,但eval默认batch和train一致,加上 model.generate 之外的forward峰值,24GB卡在LoRA fp16方案下直接炸。解决办法是 per_device_eval_batch_size=2,并且eval时 torch.cuda.empty_cache()。
坑2:packing开启后loss异常。 SFTTrainer的 packing=True 会把多条样本拼进一个1024窗口,短样本场景下训练效率高,但我的数据长度分布方差大(200到1000 token),packing后attention mask处理不当导致loss虚低。关掉packing后loss曲线才正常。
坑3:label mask的收益。 前面说的把prompt部分label置-100,改完后验证loss从0.68降到0.61,推理时模型更少复述用户问题。实现上就是在tokenize后按assistant起始位置切片置-100,代码略长这里不展开。
优化:gradient_checkpointing + use_reentrant=False。 PyTorch 2.3下默认reentrant版本和peft的hook有冲突,显式设 gradient_checkpointing_kwargs={"use_reentrant": False} 后训练稳定,速度还快了约8%。
六、效果数据
Loss曲线:LoRA方案train loss从1.82(step 10)降到0.42(epoch 3末),验证loss最低0.63;QLoRA方案train loss从1.85降到0.44,验证loss最低0.66。两者差距很小,说明4bit量化对这个小规模微调任务影响有限。QLoRA验证loss在第3个epoch末有轻微抬头(0.66→0.68),提示可以早停。
显存与耗时:LoRA fp16峰值显存21.3GB,3 epoch耗时2小时12分;QLoRA峰值显存9.8GB,耗时2小时38分。QLoRA慢约20%,主要是4bit反量化开销,但换来的是batch size可以开到8,实际吞吐差距缩小。
推理效果对比:在200条人工标注的测试集上,按“回答是否命中关键信息点”打分:
| 方案 | 准确率 | 平均输出长度 | 复述问题比例 |
|---|---|---|---|
| 基座模型 | 54% | 186 token | 31% |
| LoRA fp16 | 87% | 142 token | 9% |
| QLoRA 4bit | 86% | 145 token | 10% |
一个具体case:用户问“订单超过48小时未发货怎么处理”,基座模型泛泛而谈“请联系客服”,微调后能准确输出“先查物流异常原因,若仓库缺货则触发补发流程并给用户5元补偿券”,和训练数据里的标准话术高度一致。
七、总结
这轮实践的核心结论有三条:第一,24GB单卡上7B模型微调,QLoRA是现实选择,显存从21GB降到9.8GB,代价是20%左右的训练时间;第二,r=16、alpha=32、全线性层挂载,在8000条数据规模上是稳妥配置,再大容易过拟合;第三,label mask和关闭packing这两个细节对最终效果的影响,比调学习率更明显。
如果后续要上更大数据量或更强效果,我会考虑:QLoRA + r=32配合早停、数据侧做长度分桶减少padding浪费、以及用vLLM加载合并后的LoRA权重做推理压测。这套配置和脚本我已经整理成仓库,换数据集基本能直接复用。