一、为什么选择LoRA而不是全量微调

先说结论:不是不想全量微调,是钱包不允许

我这次的任务是让一个7B的基座模型学会我们业务领域的结构化输出——具体来说是把用户口语化的需求转成固定JSON格式。全量微调7B模型,FP16精度下光权重就要14GB,加上优化器状态(AdamW的momentum+variancce)、梯度、激活值,保守估计需要80GB以上显存。A100 80G租一天的钱够我吃一个月外卖了。

LoRA的思路很朴素:冻结原始权重,在旁边插两个低秩矩阵A和B,只训练这两个小矩阵。参数量能降到原来的0.1%~1%。QLoRA更进一步,把基座模型用4-bit NF4量化加载,进一步压显存。

我的硬件是单卡RTX 4090 24GB,目标模型Qwen2.5-7B-Instruct。最终方案:QLoRA(4-bit量化)+ LoRA(r=16),峰值显存14.2GB,训练3个epoch约4.5小时。

二、环境与版本

环境版本这块我踩过坑,先列清楚:

- Python: 3.10.13
- PyTorch: 2.3.1 + cu121
- transformers: 4.44.2
- peft: 0.12.0
- bitsandbytes: 0.43.3
- trl: 0.9.6
- accelerate: 0.33.0
- datasets: 2.20.0
- GPU: RTX 4090 24GB
- CUDA: 12.1

这里有个坑:bitsandbytes 0.43.x 对 CUDA 12.1 兼容性最好,我一开始装了0.44.0,加载4-bit模型时报 CUDA error: no kernel image is available,降级后解决。

三、数据准备

数据格式我用的是Alpaca风格的三段式:instruction / input / output。原始数据来自业务日志,清洗后大概8000条。举一条样例:

{
  "instruction": "将下面的用户需求转成标准JSON格式",
  "input": "帮我订一张明天下午从北京到上海的高铁票,二等座",
  "output": "{\"action\":\"book_train\",\"from\":\"北京\",\"to\":\"上海\",\"date\":\"明天下午\",\"seat\":\"二等座\"}"
}

数据处理的几个关键点:

  1. 去重:用SimHash做近似去重,删掉了约600条高度相似的样本。
  2. 长度过滤:token长度超过1024的直接截断,实测99%的样本在512以内。
  3. 划分:train:val:test = 8:1:1,即6400/800/800。最终训练时我用了全部8000条(含验证集)做3 epoch,单独留了100条做最终测试。

数据加载用的是datasets库,配合trl的SFTTrainer。这里贴一段核心的prompt template:

def format_prompt(sample):
    return f"""system
你是一个专业的结构化数据转换助手,请严格按照要求输出JSON。
user
{sample['instruction']}
{sample['input']}
assistant
{sample['output']}"""

Qwen2.5用的是ChatML格式,这两个特殊token必须和官方一致,否则loss会异常高。

四、核心实现:QLoRA配置与训练

4.1 加载4-bit量化模型

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training

model_name = "Qwen/Qwen2.5-7B-Instruct"

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
)

tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token

model = AutoModelForCausalLM.from_pretrained(
    model_name,
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True,
)
model = prepare_model_for_kbit_training(model)

bnb_4bit_use_double_quant=True 这个参数我强烈建议开,能再省0.5GB左右显存,几乎不掉点。

4.2 LoRA配置

lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
                    "gate_proj", "up_proj", "down_proj"],
    lora_dropout=0.05,
    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

target_modules 一定要覆盖全部7个线性层,只调q/v的话效果会差一截。r=16是我试过16/32/64之后性价比最高的,r=32只提升0.8个点但显存多花1.8GB。

4.3 训练配置

from transformers import TrainingArguments
from trl import SFTTrainer

training_args = TrainingArguments(
    output_dir="./qwen2.5-7b-lora-json",
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,   # 等效batch=16
    num_train_epochs=3,
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    logging_steps=10,
    save_strategy="epoch",
    bf16=True,
    optim="paged_adamw_8bit",
    gradient_checkpointing=True,
    max_grad_norm=0.3,
    report_to="none",
)

trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
    tokenizer=tokenizer,
    formatting_func=format_prompt,
    max_seq_length=1024,
    packing=False,
)

trainer.train()

几个参数的解释:

  • optim="paged_adamw_8bit":优化器状态用8-bit分页,省显存神器
  • gradient_checkpointing=True:用时间换显存,训练慢约20%,但省30%显存
  • learning_rate=2e-4:LoRA的经典学习率,比全量微调的2e-5高一个数量级
  • max_grad_norm=0.3:QLoRA论文推荐值,防止梯度爆炸

五、踩坑与优化

坑1:OOM在step 47出现。一开始batch_size=8,跑到第47步爆显存。改batch=4+grad_accum=4后稳定。

坑2:loss不下降。第一次训练loss从1.87掉到1.65就卡住了,排查发现是formatting_func没生效,SFTTrainer用了默认的text字段。加packing=False并显式传formatting_func后正常。

坑3:推理时输出乱码。加载LoRA权重做推理时,忘记合并tokenizer的special tokens,导致`被拆成多个token。用tokenizer.add_special_tokens`或直接复用训练时的tokenizer即可。

坑4:prepare_model_for_kbit_training必须调用。不调用的话embedding层和layernorm会用fp16计算,容易NaN。

优化方向:试了Unsloth,训练速度能提升约1.8倍,显存再降2GB左右,但和trl 0.9.6有版本冲突,最后没用。如果对速度有要求,建议直接上Unsloth。

六、效果数据

6.1 Loss曲线

Epoch 1: 1.87 → 0.94  (avg 1.12)
Epoch 2: 0.94 → 0.63  (avg 0.71)
Epoch 3: 0.63 → 0.52  (avg 0.56)

第3个epoch已经基本收敛,再训下去验证loss开始抬头,有轻微过拟合。

6.2 推理效果对比

测试集100条,评价标准是JSON字段完全匹配:

模型 字段完全匹配 JSON可解析 平均生成时间
Qwen2.5-7B-Instruct 原始 61% 78% 1.2s
+ LoRA (r=16, 3ep) 89% 97% 1.3s

举个具体case:

输入:"下周三上午十点提醒我开会,会议室A"

原始模型输出:

好的,我会在下周三上午十点提醒您开会,会议室A。

(是自然语言,不是JSON)

微调后输出:

{"action":"set_reminder","time":"下周三上午十点","content":"开会","location":"会议室A"}

字段完全正确。

6.3 显存与耗时

  • 训练峰值显存:14.2GB
  • 3 epoch耗时:4小时32分钟(约2.2 steps/s)
  • LoRA权重文件大小:约155MB(fp16)
  • 合并后模型大小:约15.2GB

七、总结

LoRA/QLoRA让单卡24GB微调7B模型成为现实,这套方案我跑下来有几个结论:

  1. r=16 + 全7层target_modules 是性价比拐点,再往上收益递减。
  2. QLoRA的4-bit量化几乎不掉点,但显存省了一半,强烈推荐。
  3. 数据质量比数据量重要,我清洗前后8000条 vs 12000条脏数据,前者效果好20个点。
  4. 推理时直接merge LoRA权重,比用PEFT动态加载快约15%,且部署简单。

下一步我准备试试DPO做偏好对齐,把JSON格式的严格性再拉高一点。有踩过DPO坑的朋友欢迎评论区交流。