一、问题背景:为什么我们要微调7B模型

先说结论:通用大模型在垂直领域上的表现,往往比想象中差。

我们做的是一个电商售后客服场景。直接用Qwen2.5-7B-Instruct跑测试集,200条真实用户问题里,能给出符合业务口径回答的只有123条,准确率61.5%。问题集中在三类:

  1. 不知道我们特定的退换货政策(比如"7天无理由但拆封的电子产品除外");
  2. 回复风格太"AI",用户问"我东西坏了怎么办",它给你列一二三四五条通用建议,而我们要的是"亲,方便提供下订单号吗?我帮您查下保修状态";
  3. 会编造不存在的售后流程。

全量微调7B模型?一张24G的4090想都别想。FP16权重就要14GB,加上优化器状态、梯度、激活值,起步60GB+。所以我们走LoRA路线,后来为了进一步压显存又试了QLoRA。

这篇博客就把整个过程摊开讲,包括数据怎么搞、参数怎么设、loss怎么读、效果到底提升多少。

二、环境与版本

环境这东西必须先说清楚,不然代码跑不起来别怪我。

  • GPU:NVIDIA RTX 4090 24GB(单卡)
  • CUDA:12.1
  • 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
  • 基座模型:Qwen2.5-7B-Instruct

这里有个坑先提前说:bitsandbytes在Windows上装起来极其痛苦,我用的是WSL2 + Ubuntu 22.04。如果你也是Windows,强烈建议直接WSL,别折腾原生Windows。

三、方案设计:LoRA还是QLoRA

先说两者的区别,一句话:LoRA是在FP16的基座模型上挂低秩矩阵,QLoRA是把基座模型先4-bit量化再挂LoRA。

显存账大概是这样(7B模型,batch size=1,seq len=1024):

方案 基座权重 优化器+梯度 激活值 总计
全量微调 14GB 56GB 4GB ~74GB
LoRA (FP16) 14GB 0.5GB 4GB ~19GB
QLoRA (NF4) 3.5GB 0.5GB 4GB ~8-11GB

LoRA其实4090也能跑,但一旦你把seq len拉到2048或者batch size提到4,就容易OOM。QLoRA给的余量更大,而且实测效果掉得不多(后面有数据)。

最终我两个都跑了,主推QLoRA。

LoRA配置核心参数:
- 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(全挂上,效果比只挂q,v好)

QLoRA额外加:
- load_in_4bit=True
- bnb_4bit_quant_type="nf4"
- bnb_4bit_compute_dtype=torch.bfloat16
- bnb_4bit_use_double_quant=True

四、核心实现

4.1 数据准备

数据格式走标准的Alpaca风格,instruction/input/output三段式。我们自己标了1.2万条,切分是 11000训练 / 1000验证。

import json
from datasets import Dataset

def build_dataset(path):
    records = []
    with open(path, 'r', encoding='utf-8') as f:
        for line in f:
            item = json.loads(line)
            # 走Qwen的chat template
            messages = [
                {"role": "system", "content": "你是一名专业的电商售后客服。"},
                {"role": "user", "content": item["instruction"] + 
                 (("\n" + item["input"]) if item.get("input") else "")},
                {"role": "assistant", "content": item["output"]}
            ]
            records.append({"messages": messages})
    return Dataset.from_list(records)

train_ds = build_dataset("data/train.jsonl")
eval_ds = build_dataset("data/val.jsonl")
print(f"train: {len(train_ds)}, eval: {len(eval_ds)}")
# train: 11000, eval: 1000

数据这块踩过一个坑:一开始我instruction里带了大量重复的"请回答以下问题:"前缀,结果模型学得很快但也学废了——推理时你不加这个前缀它就不理你。后来统一清洗掉了。数据质量比数量重要,1.2万条干净数据 > 5万条脏数据。

4.2 训练脚本

完整训练代码,可以直接抄:

import torch
from transformers import (
    AutoModelForCausalLM, AutoTokenizer,
    BitsAndBytesConfig, TrainingArguments
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer

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

# 1. 4-bit量化配置(QLoRA核心)
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_PATH, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"

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.config.use_cache = False  # 训练时必须关,否则和gradient checkpointing冲突

# 2. LoRA配置
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"],
)

# 3. 训练参数
training_args = TrainingArguments(
    output_dir="./output/qwen2.5-7b-lora",
    per_device_train_batch_size=2,
    gradient_accumulation_steps=8,   # 等效batch = 16
    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",
    max_grad_norm=0.3,
    report_to="none",
)

trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=train_ds,
    eval_dataset=eval_ds,
    peft_config=lora_config,
    tokenizer=tokenizer,
    max_seq_length=1024,
    packing=False,
)

trainer.train()
trainer.model.save_pretrained("./output/qwen2.5-7b-lora/final")
tokenizer.save_pretrained("./output/qwen2.5-7b-lora/final")

跑起来后显存占用稳定在 10.8GB 左右,训练速度约 2.3 it/s,3个epoch(11000条 × 3 / 16 ≈ 2062步)大约 15 分钟一轮,全程45分钟。

五、踩坑与优化

说几个真实踩过的坑,都是血泪:

坑1:loss一开始不降反升。
原因是learning rate给到了5e-4,QLoRA在4-bit基座上学习率太大会震荡。降到2e-4后曲线立刻正常。经验值:LoRA/QLoRA的lr建议1e-4 ~ 2e-4。

坑2:use_cache=False忘了关。
不关会报错,因为gradient checkpointing和KV cache冲突。这个报错信息还挺隐晦,第一次遇到会懵。

坑3:max_grad_norm=0.3比默认1.0好。
QLoRA本身梯度就有点噪,梯度裁剪严一点收敛更稳。这是我对比了0.3和1.0之后的结果,0.3的eval loss低约0.04。

坑4:target_modules别只挂q,v。
一开始我按老教程只挂q_proj, v_proj,结果模型学不会业务话术。挂上MLP层(gate/up/down)之后,回复风格的迁移明显更好。代价是参数量从约4M涨到约20M,但相比7B基座还是毛毛雨。

坑5:padding_side要设成right。
Qwen训练时用right padding,推理时如果用left padding,长序列场景下可能出现位置编码错乱。虽然现在HF基本处理好了,但还是设一下保险。

六、效果数据

训练loss曲线(3 epoch):

  • Epoch 1:train loss 1.42 → 0.78,eval loss 0.81
  • Epoch 2:train loss 0.78 → 0.51,eval loss 0.53
  • Epoch 3:train loss 0.51 → 0.42,eval loss 0.49

Epoch 3的eval loss只比epoch 2降了0.04,基本收敛了。再往后train loss还在降但eval开始平,有轻微过拟合迹象,所以3个epoch踩住了。

推理效果对比(200条测试集,人工评估):

指标 基座模型 LoRA微调 QLoRA微调
业务口径准确率 61.5% 88.0% 87.3%
风格符合度 42.0% 91.5% 90.5%
幻觉率 18.5% 4.0% 4.5%
平均响应长度 187字 96字 98字

QLoRA相比LoRA掉了不到1个点,但显存省了将近一半,非常划算。

几个具体的case对比:

Case 1:
用户:我买的手机用了3天屏幕有亮点,能换吗?
- 基座:根据消费者权益保护法,您可以在7天内……(一通法律条文,没回答问题)
- 微调后:亲,3天内出现亮点属于质量问题,可以免费换新的哦~麻烦提供下订单号和屏幕亮点的照片,我这边帮您走换货流程。

Case 2:
用户:退货运费谁出?
- 基座:一般来说,退货运费的承担方需要根据具体情况判断……(含糊)
- 微调后:如果是质量问题,运费我们承担;如果是7天无理由,运费需要您先垫付,收到货后我们会退给您。

差别非常直观。

合并LoRA权重到基座模型:

from peft import PeftModel
from transformers import AutoModelForCausalLM

base = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-7B-Instruct",
    torch_dtype=torch.bfloat16,
    device_map="auto"
)
model = PeftModel.from_pretrained(base, "./output/qwen2.5-7b-lora/final")
merged = model.merge_and_unload()
merged.save_pretrained("./output/qwen2.5-7b-merged")

合并后权重约15GB,可以脱离peft库独立部署,推理速度也比动态加载LoRA快15%左右。

七、总结

几个takeaway:

  1. 7B模型微调,QLoRA是当前单卡最优解。24G显存随便跑,甚至16G都够。效果相比全精度LoRA掉不到1个点。
  2. 数据质量 > 数据数量。1.2万条清洗过的数据,比5万条脏数据效果好得多。
  3. lr、grad_norm、target_modules是三个最关键的调参点。这几个调好,其他用默认值基本没问题。
  4. 一定要留验证集看eval loss。只看train loss容易过拟合还不自知。
  5. 微调不是银弹。如果你的任务只是改变输出格式,prompt engineering可能就够了,别上来就微调。但如果涉及领域知识注入和风格迁移,微调确实是目前最有效的手段。

下一步我打算试试DPO,把模型的对齐再往上推一档,有结果再写一篇。

代码已经整理到仓库里,需要的同学可以自取。有问题评论区聊。