1. 问题背景:为什么不用全参微调

我手头有个医疗客服场景,需要模型识别用户意图(挂号、退费、问诊等8类)并抽取关键槽位(科室、时间、症状)。直接用Qwen2.5-7B-Instruct的zero-shot效果:意图准确率78.3%,但槽位F1只有0.71——最典型的问题是模型把“明天下午三点”识别成“明天”和“下午”两个独立实体。

当时考虑两个方案:
- 全参微调:7B模型用AdamW+BF16,单卡4090(24G)根本跑不动,ZeRO-3+CPU offload勉强能跑但batch size只能设1,训练速度惨不忍睹。
- LoRA:冻结原模型,只训练低秩矩阵。理论显存占用是原模型的1/10左右,而且效果在指令遵循类任务上接近全参微调。

我选了LoRA,后来为了在双卡(两张3090 24G)上并行训练又试了QLoRA。下面直接说实操。

2. 环境与版本

Python 3.10.12
torch 2.1.2+cu118
transformers 4.38.2
peft 0.9.0
datasets 2.17.0
accelerate 0.27.0
bitsandbytes 0.43.1

注意:peft 0.9.0以上才支持target_modules传字符串列表,老版本传["q_proj","v_proj"]会报错。显卡驱动要≥525.60.13,否则bitsandbytes的4bit量化会崩。

3. 方案设计:LoRA还是QLoRA?

我的训练数据是3万条人工标注的客服对话(每条包含用户query、意图标签、槽位标签)。格式化后长这样:

用户:我想挂明天下午王医生的号
意图:挂号预约
槽位:{"科室": "心血管内科", "时间": "明天下午", "医生": "王医生"}

LoRA配置
- r=16alpha=32dropout=0.05
- 只注入q_projv_proj(经验证,加k_projo_proj提升不到1%但显存多占2G)
- 学习率2e-4,余弦退火,warmup 200步

QLoRA配置
- 4bit NF4量化,bnb_4bit_use_double_quant=True
- bnb_4bit_quant_type="nf4"compute_dtype=torch.bfloat16
- 其他跟LoRA一致

训练时用pack_of_2(gradient_checkpointing=True)来省显存。

4. 核心实现:数据准备和训练代码

4.1 数据预处理

from datasets import Dataset
from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct", trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token

def format_example(example):
    # 按Qwen2.5的chat模板格式化
    messages = [
        {"role": "user", "content": example["query"]},
        {"role": "assistant", "content": f"意图:{example['intent']}\n槽位:{example['slots']}"}
    ]
    text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
    return {"text": text}

dataset = Dataset.from_json("train.jsonl").map(format_example)

def tokenize_function(examples):
    model_inputs = tokenizer(examples["text"], truncation=True, max_length=512, padding="max_length")
    # 关键:labels和input_ids相同,但padding部分要设为-100
    labels = []
    for i, input_ids in enumerate(model_inputs["input_ids"]):
        # 找到第一个eos_token的位置,后面的padding都算-100
        eos_pos = input_ids.index(tokenizer.eos_token_id) if tokenizer.eos_token_id in input_ids else len(input_ids)
        labels.append([-100 if j >= eos_pos else input_id for j, input_id in enumerate(input_ids)])
    model_inputs["labels"] = labels
    return model_inputs

tokenized_dataset = dataset.map(tokenize_function, batched=True, remove_columns=dataset.column_names)
tokenized_dataset.save_to_disk("train_tokenized")

踩坑提示:Qwen2.5的tokenizer默认add_generation_prompt=False,如果你忘了设,模型会在训练时多学一个``token,导致loss永远降不下去。

4.2 LoRA训练代码

from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from transformers import TrainingArguments, Trainer, AutoModelForCausalLM
import torch

# 加载模型(LoRA用BF16,QLoRA用4bit)
model = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-7B-Instruct",
    torch_dtype=torch.bfloat16,
    device_map="auto",
    use_cache=False  # 必须关,否则gradient checkpointing冲突
)

# QLoRA额外加这两行
# from transformers import BitsAndBytesConfig
# bnb_config = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_use_double_quant=True, bnb_4bit_compute_dtype=torch.bfloat16)
# model = AutoModelForCausalLM.from_pretrained(..., quantization_config=bnb_config)
# model = prepare_model_for_kbit_training(model)

lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "v_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM"
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()  # 应该输出7B参数里只训练了800万

training_args = TrainingArguments(
    output_dir="./qwen7b-lora-checkpoints",
    per_device_train_batch_size=8,
    gradient_accumulation_steps=16,  # 等效batch_size=8*16=128
    num_train_epochs=3,
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_steps=200,
    logging_steps=50,
    save_steps=500,
    evaluation_strategy="steps",
    eval_steps=500,
    fp16=False,  # 用bf16,A100上fp16会溢出
    bf16=True,
    gradient_checkpointing=True,
    save_total_limit=2,
    load_best_model_at_end=True,
)

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=tokenized_dataset,
    eval_dataset=eval_tokenized,
)
trainer.train()

5. 踩坑与优化记录

坑1:loss震荡不收敛
- 现象:前200步loss在1.8左右横跳,不下降
- 原因:学习率设太高(5e-4),且warmup太短
- 解决:降到2e-4,warmup加到200步,loss曲线立刻变得平滑

坑2:QLoRA显存爆了
- 现象:bitsandbytes报CUDA OOM,即使batch_size=1
- 原因:bnb_4bit_compute_dtype设成了fp16,在3090上跑会异常
- 解决:改成torch.bfloat16,显存占用从17G降到14.5G

坑3:推理结果出现乱码
- 现象:微调后模型输出`标记 - 原因:训练时apply_chat_template没带add_generation_prompt=True,模型没学会正确的回复格式 - 解决:推理脚本里固定加上add_generation_prompt=True`,并在解码时跳过特殊token

6. 效果数据对比

6.1 Loss曲线

训练3000步,loss从1.82降到0.87(eval loss 0.91)。前500步下降快,后面平台期明显,说明数据量3万对7B模型来说偏少——如果加到8万,loss应该能到0.7以下。

6.2 推理效果对比

测试集500条,温度0.2,max_new_tokens=128,beam search=4:

指标 原始Qwen2.5-7B LoRA微调 QLoRA微调
意图准确率 78.3% 91.2% 90.6%
槽位F1 0.71 0.88 0.87
平均响应延迟(ms) 420 435 492
训练显存占用(G) - 23.8 14.5
训练时间(h) - 96 102

关键结论:
- LoRA和QLoRA在效果上几乎无差别(差0.6%意图准确率),但QLoRA能省9G显存,代价是训练时间多了6小时(4bit反量化开销)
- 槽位F1提升最明显的是“时间”槽位,从0.62涨到0.85——模型学会了“明天下午”是一个整体
- 延迟增加3.5%是因为LoRA的秩矩阵计算,可接受

7. 总结

LoRA/QLoRA在7B模型上完全够用,除非你的任务需要极端领域知识(比如医学诊断),否则没必要全参微调。

几个经验值:
- 单卡24G用LoRA,双卡或单卡16G用QLoRA(4bit)
- r=16是性价比最高的点,r=32效果提升不到1%但显存翻倍
- 数据质量比数据量重要:我挑了2000条错误标注,重新清洗后准确率直接+3%
- 推理时记得merge_and_unload()合并LoRA权重,能省一半显存

最后说一句,别迷信QLoRA的“无损压缩”,在数学推理任务上它比LoRA差5%左右(我测过GSM8K)。但如果你只做指令遵循类任务,QLoRA就是白嫖的显存优化器。