一、问题背景:为什么我要在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 默认值别照抄。

三、方案设计

整体思路分三层:

  1. 数据层:8000条客服对话,统一成 {"instruction": ..., "input": ..., "output": ...} 格式,用chat template拼成单轮prompt,max_length截断到1024。
  2. 模型层:LoRA方案加载fp16基座;QLoRA方案用 BitsAndBytesConfig 做4bit NF4量化,bnb_4bit_compute_dtype=torch.bfloat16,双重量化开启。
  3. 训练层: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权重做推理压测。这套配置和脚本我已经整理成仓库,换数据集基本能直接复用。