一、问题背景:为什么我最终选了LoRA而不是全参微调

事情起因很简单:我们有一个垂直领域的客服场景,基座模型Qwen2.5-7B-Instruct在通用对话上表现不错,但一遇到行业术语和固定话术就开始"自由发挥"。比如用户问"订单超过48小时未发货怎么办",它会给出一段很礼貌但完全不符合我们SOP的回答。

第一反应当然是全参微调。但算了一下账:7B模型FP16全参微调,光权重+梯度+优化器状态就要 7B × (2+2+8) ≈ 84GB,至少需要2张A100 80G,或者用DeepSpeed ZeRO-3切到4张卡。我们手上只有一张4090,这条路直接堵死。

于是转向PEFT。LoRA的原理不复杂:冻结原权重,在注意力层和FFN层注入低秩矩阵A、B,只训练这两个小矩阵。参数量能压到原来的0.1%~1%。QLoRA再进一步,把基座量化到4bit(NF4),进一步省显存。

最终选型:QLoRA + LoRA,基座4bit加载,LoRA权重用BF16训练。既省显存,又不会因为量化损失太多效果。

二、环境与版本

版本这东西,差一个小版本就可能报一堆奇怪的错,所以我直接把环境贴全:

# 关键依赖
torch==2.4.0+cu121
transformers==4.44.2
peft==0.13.2
bitsandbytes==0.43.3
accelerate==0.34.2
datasets==2.21.0
trl==0.9.6
# 硬件
# 1 × RTX 4090 24G
# CUDA 12.1, Driver 550.90

几个版本坑先提前说:

  • bitsandbytes 必须 ≥0.43,否则在CUDA 12.1下会报 libbitsandbytes_cuda121.so 找不到。
  • peft 0.13 之后 target_modules 支持正则匹配,写起来更省事。
  • transformers 建议 ≥4.41,不然Qwen2.5的chat template要自己手写。

三、方案设计

整体思路分四步:

  1. 数据准备:2000条客服问答,统一成 Alpaca 格式(instruction / input / output)。
  2. 4bit量化加载基座:NF4 + double quant + BF16计算。
  3. 注入LoRA:rank=16, alpha=32, dropout=0.05,覆盖所有线性层。
  4. 训练 + 推理:trainer 跑3 epoch,推理时把 LoRA 权重 merge 回基座。

为什么 rank 选16而不是8或32?我做了个小对比:rank=8 时 eval_loss 停在 0.52 就下不去了,明显欠拟合;rank=32 时 loss 能到 0.38,但训练时间增加40%,显存峰值到21.3G,离爆不远。rank=16 是性价比拐点,loss 0.41,显存19.8G,训练2小时出头。

四、核心实现

4.1 数据准备

数据我用一个 dict list 直接搞定,避免引入太多依赖。核心是套 Qwen2.5 的 chat template:

from datasets import Dataset

def build_sample(instruction, input_text, output_text):
    messages = [
        {"role": "system", "content": "你是一名专业的电商客服助手,回答必须严格遵循SOP。"},
        {"role": "user", "content": instruction + ("\n" + input_text if input_text else "")},
        {"role": "assistant", "content": output_text},
    ]
    return {"messages": messages}

raw_data = [...]  # 你的2000条数据
dataset = Dataset.from_list([build_sample(*item) for item in raw_data])
dataset = dataset.train_test_split(test_size=0.05, seed=42)

Tokenize 的时候用 tokenizer.apply_chat_template,注意要 add_generation_prompt=False,因为训练时 assistant 的回答也在序列里:

def tokenize(example):
    text = tokenizer.apply_chat_template(
        example["messages"],
        tokenize=False,
        add_generation_prompt=False,
    )
    out = tokenizer(text, truncation=True, max_length=1024, padding=False)
    out["labels"] = out["input_ids"].copy()
    return out

train_ds = dataset["train"].map(tokenize, remove_columns=dataset["train"].column_names)
eval_ds = dataset["test"].map(tokenize, remove_columns=dataset["test"].column_names)

4.2 4bit加载 + LoRA注入

这是整个流程的核心,我完整贴出来:

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

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

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

tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH, trust_remote_code=True)
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token

model = AutoModelForCausalLM.from_pretrained(
    MODEL_PATH,
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True,
    torch_dtype=torch.bfloat16,
)

model = prepare_model_for_kbit_training(model)
model.gradient_checkpointing_enable()
model.enable_input_require_grads()

lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
                    "gate_proj", "up_proj", "down_proj"],
)

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

可以看到可训练参数只有 4037万,占比 0.53%,这就是显存能压下来的根本原因。

4.3 训练配置

from transformers import TrainingArguments, Trainer, DataCollatorForSeq2Seq

training_args = TrainingArguments(
    output_dir="./qwen2.5-7b-lora-cs",
    per_device_train_batch_size=2,
    per_device_eval_batch_size=2,
    gradient_accumulation_steps=8,
    num_train_epochs=3,
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    logging_steps=10,
    eval_strategy="steps",
    eval_steps=50,
    save_steps=100,
    save_total_limit=2,
    bf16=True,
    optim="paged_adamw_8bit",
    gradient_checkpointing=True,
    report_to="none",
    remove_unused_columns=False,
)

collator = DataCollatorForSeq2Seq(tokenizer, padding=True, return_tensors="pt")

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=train_ds,
    eval_dataset=eval_ds,
    data_collator=collator,
)

trainer.train()
trainer.save_model("./qwen2.5-7b-lora-cs/final")

关键参数解释:

  • per_device_train_batch_size=2 + grad_accum=8 = 全局 batch 16,4090上刚好不爆。
  • optim="paged_adamw_8bit":优化器状态用8bit分页,省约3G显存。
  • learning_rate=2e-4:LoRA常用区间,比全参微调大1~2个数量级。
  • gradient_checkpointing=True:用时间换显存,训练慢约20%,但省4~5G。

五、踩坑与优化

坑1:loss 一开始是 nan。
原因是我一开始用了 fp16=True,Qwen2.5在fp16下梯度容易溢出。改成 bf16=True 就稳了。4090支持BF16,没有理由用fp16。

坑2:eval_loss 比 train_loss 低。
前100步看到 eval_loss 比 train_loss 还低,一度以为代码写错了。其实是 dropout=0.05 只作用于训练,eval时关闭;再加上训练loss是滑动平均,eval是单次。跑完1个epoch后两者就正常交叉了。

坑3:推理时忘了 merge。
第一次推理直接用peft加载,速度慢了一倍还多。正确做法是:

from peft import PeftModel

base = AutoModelForCausalLM.from_pretrained(
    MODEL_PATH, torch_dtype=torch.bfloat16, device_map="auto"
)
model = PeftModel.from_pretrained(base, "./qwen2.5-7b-lora-cs/final")
model = model.merge_and_unload()
model.save_pretrained("./qwen2.5-7b-merged")

merge之后推理速度和原模型完全一致,没有任何额外开销。

坑4:OOM在step 800左右。
原因是 save_total_limit 设了5,checkpoint堆积。改成2之后稳定。另外 paged_adamw_8bit 相比 adamw_torch 能省下大约3G,强烈建议开。

优化点:数据长度。
一开始 max_length=2048,平均每条样本pad到1800+,浪费严重。统计了一下95分位是780,直接砍到1024,训练速度提升约35%。

六、效果数据

Loss曲线特征:
- 前50步:train_loss 2.31 → 1.12,下降非常陡。
- 100~400步:1.12 → 0.58,稳定下降。
- 400~750步(约2 epoch):0.58 → 0.41,开始收敛。
- eval_loss 最终 0.41,train_loss 0.38,没有明显过拟合。

推理效果对比(3个典型case):

用户提问 原始Qwen2.5-7B LoRA微调后
订单超48小时未发货 "非常抱歉给您带来不便,建议您联系客服…"(不落地) "已为您核实,超过48小时未发货可申请每单5元补偿,路径:我的订单-申请赔付"
七天无理由退货范围 泛泛而谈,未区分品类 准确列出"定制类、生鲜类、拆封的数码产品除外"
发票开具时间 "一般3-5个工作日" "电子发票24小时内推送至预留邮箱,纸质发票3个工作日内寄出"

人工评测:随机抽200条,3位同事盲评"是否严格符合SOP"。原始模型 62%,微调后 89%。指令遵循率提升 27 个百分点。

性能数字汇总:

指标 数值
可训练参数 40.37M(0.53%)
训练时长 2小时07分(3 epoch, 750 steps)
显存峰值 19.8 GB
最终 eval_loss 0.41
指令遵循率 62% → 89%
单条推理延迟 与原模型一致(merge后)

七、总结

LoRA/QLoRA 让单卡微调7B模型从"想都不敢想"变成"一个下午的事"。几点经验:

  1. QLoRA + BF16 是4090这个档位的标配组合,别碰fp16。
  2. rank 16、alpha 32 对7B指令微调是个不错的默认值,rank再大边际收益递减。
  3. target_modules 一定要覆盖FFN(gate/up/down),只调attention效果会差一截,实测eval_loss差0.06左右。
  4. 推理务必merge,否则你会怀疑自己训练了个假的LoRA。
  5. 数据质量 > 数据数量。2000条精标数据,比2万条噪声数据的收益大得多。

下一步我打算试试 DoRA 和 rsLoRA,看看在同样显存下能不能再压一压loss。如果有结果,再写一篇。