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

先说结论:很多业务场景下,直接调用通用大模型API并不能解决问题。我最近接到的需求是让模型稳定输出某种特定格式的结构化内容,同时要贴合垂直领域的表达习惯。用prompt工程试了两周,效果不稳定,长尾case错误率一直在15%以上。

全量微调7B模型?一张24GB的4090根本放不下。即使用A100 80GB,全量微调也需要保存优化器状态、梯度、参数副本,显存开销大约是模型参数的4倍以上,7B模型FP16下轻松超过80GB。而且全量微调还有个隐患:灾难性遗忘,通用能力容易掉。

于是转向PEFT(Parameter-Efficient Fine-Tuning),具体就是LoRA和QLoRA。LoRA只训练低秩分解矩阵,参数量能降到原来的1%以下;QLoRA在此基础上把基座模型量化到4bit,进一步压缩显存。这套方案让我在一张消费级显卡上完成了7B模型的微调。

二、环境与版本

环境版本这块我踩过坑,先列清楚,避免大家重复试错:

  • 操作系统:Ubuntu 22.04
  • GPU:NVIDIA RTX 4090 24GB
  • CUDA:12.1
  • Python:3.10.13
  • PyTorch:2.2.1+cu121
  • transformers:4.43.3
  • peft:0.12.0
  • bitsandbytes:0.43.1
  • trl:0.9.6
  • accelerate:0.33.0
  • datasets:2.20.0

这里重点提醒:bitsandbytes和CUDA版本强绑定,0.43.x版本对CUDA 12.1支持较好,如果装错版本,QLoRA量化时会直接报CUDA error: no kernel image is available。另外peft 0.12.0开始对target_modules的自动推断更智能,但显式指定更稳妥。

安装命令:

pip install torch==2.2.1 --index-url https://download.pytorch.org/whl/cu121
pip install transformers==4.43.3 peft==0.12.0 bitsandbytes==0.43.1 \
    trl==0.9.6 accelerate==0.33.0 datasets==2.20.0

三、方案设计

整体思路分三步:数据准备、LoRA/QLoRA训练、推理对比。

模型选的是Qwen2.5-7B-Instruct,原因是中文能力强、社区支持好、tokenizer对中文友好。基座模型用4bit NF4量化加载,计算时用bfloat16,这就是QLoRA的经典配置。LoRA挂在attention的q_proj、k_proj、v_proj、o_proj以及MLP的gate_proj、up_proj、down_proj上,覆盖范围比只挂q、v更广,效果更稳。

关键超参:
- LoRA rank:16
- LoRA alpha:32
- LoRA dropout:0.05
- 学习率:2e-4
- 学习率调度:cosine
- warmup ratio:0.03
- batch size:per device 2,梯度累积8,等效batch 16
- epoch:3
- max_seq_length:1024
- 优化器:paged_adamw_8bit

这套配置显存峰值约9.6GB,训练3个epoch大概4.5小时。

四、核心实现

4.1 数据准备

数据格式采用Alpaca风格的instruction/input/output三字段,最终转成chat模板。我准备了约1.2万条样本,其中10%作为验证集。数据清洗主要做了三件事:去重、过滤超长样本(超过1024 token的截断)、过滤空output。

import json
from datasets import Dataset

def load_data(path):
    with open(path, 'r', encoding='utf-8') as f:
        raw = json.load(f)
    data = []
    for item in raw:
        instruction = item.get('instruction', '').strip()
        inp = item.get('input', '').strip()
        output = item.get('output', '').strip()
        if not instruction or not output:
            continue
        if inp:
            user_content = f"{instruction}\n{inp}"
        else:
            user_content = instruction
        # 过滤超长样本
        if len(user_content) + len(output) > 1500:
            continue
        messages = [
            {"role": "user", "content": user_content},
            {"role": "assistant", "content": output}
        ]
        data.append({"messages": messages})
    # 简单去重
    seen = set()
    dedup = []
    for d in data:
        key = d["messages"][0]["content"]
        if key not in seen:
            seen.add(key)
            dedup.append(d)
    return Dataset.from_list(dedup)

dataset = load_data("train.json")
split = dataset.train_test_split(test_size=0.1, seed=42)
train_ds, eval_ds = split["train"], split["test"]
print(f"train: {len(train_ds)}, eval: {len(eval_ds)}")

4.2 模型加载与LoRA配置

QLoRA加载需要BitsAndBytesConfig,注意bnb_4bit_compute_dtype设为bfloat16,bnb_4bit_quant_type用nf4,bnb_4bit_use_double_quant开启双重量化,能再省一点显存。

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)

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,656,000,000 || trainable%: 0.527

可以看到可训练参数只有约4037万,占全量的0.527%,这就是LoRA的威力。

4.3 训练配置

用trl的SFTTrainer最省事,它内置了chat模板处理和loss计算。

from transformers import TrainingArguments
from trl import SFTTrainer, DataCollatorForCompletionOnlyLM

def format_chat(example):
    text = tokenizer.apply_chat_template(
        example["messages"], tokenize=False, add_generation_prompt=False
    )
    return {"text": text}

train_ds = train_ds.map(format_chat)
eval_ds = eval_ds.map(format_chat)

training_args = TrainingArguments(
    output_dir="./qwen2.5-7b-lora",
    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=100,
    save_strategy="steps",
    save_steps=200,
    save_total_limit=3,
    bf16=True,
    optim="paged_adamw_8bit",
    gradient_checkpointing=True,
    gradient_checkpointing_kwargs={"use_reentrant": False},
    report_to="none",
    max_grad_norm=0.3,
)

trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=train_ds,
    eval_dataset=eval_ds,
    tokenizer=tokenizer,
    max_seq_length=1024,
    dataset_text_field="text",
    packing=False,
)

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

五、踩坑与优化

坑1:gradient_checkpointing和LoRA的兼容问题。 早期peft版本开启gradient_checkpointing后,LoRA梯度不回传,loss几乎不下降。解决办法是prepare_model_for_kbit_training会自动调用enable_input_require_grads,但如果你是自己手动加载,需要显式加model.enable_input_require_grads()。另外use_reentrant=False在新版PyTorch下更稳。

坑2:tokenizer的pad_token。 Qwen2.5默认没有pad_token,直接训练会报错。设置tokenizer.pad_token = tokenizer.eos_token即可,但要注意这会轻微影响loss计算,因为pad位置也会算loss。更严谨的做法是用DataCollatorForCompletionOnlyLM只对assistant部分算loss,我试过后发现收敛更干净,但配置稍微复杂。

坑3:学习率太大导致发散。 一开始用5e-4,前50步loss直接飙到3.0以上,明显发散。降到2e-4后稳定下降。QLoRA因为基座量化,对学习率更敏感,建议1e-4到2e-4之间。

坑4:eval loss回升。 第2个epoch后期eval loss开始轻微回升,典型过拟合。我把epoch从5降到3,并加了0.05的LoRA dropout,缓解明显。

优化点: 开启packing能把多个短样本拼成一条长序列,训练速度提升约30%,但要注意packing会让attention mask跨样本,可能影响效果。我最终没开packing,因为数据集里长样本占比不低,packing收益有限。

六、效果数据

Loss曲线: 训练loss从初始的1.82稳定下降到0.61,eval loss从1.75降到0.78,第3个epoch末eval loss基本走平。整体收敛平滑,没有明显震荡。

显存与速度:
- QLoRA 4bit:显存峰值9.6GB,3 epoch约4.5小时
- LoRA FP16(未量化):显存峰值18.2GB,3 epoch约3.1小时
- 全量微调(参考值):显存>80GB,单卡4090无法运行

推理效果对比: 我用50条人工标注的测试集评估,指标是格式正确率和内容准确率。

方案 格式正确率 内容准确率
原始Qwen2.5-7B-Instruct 72% 68%
Few-shot Prompt 84% 74%
LoRA微调后 96% 89%

格式正确率提升最明显,从72%到96%。内容准确率也从68%提到89%。推理时把LoRA权重合并回基座,用vLLM部署,吞吐比未合并版本高约1.8倍。

推理代码示例:

from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch

base = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-7B-Instruct",
    torch_dtype=torch.bfloat16,
    device_map="auto",
)
model = PeftModel.from_pretrained(base, "./qwen2.5-7b-lora/final")
model = model.merge_and_unload()  # 合并LoRA权重

tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")
messages = [{"role": "user", "content": "你的测试指令"}]
text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = tokenizer(text, return_tensors="pt").to(model.device)
with torch.no_grad():
    out = model.generate(**inputs, max_new_tokens=512, temperature=0.1, do_sample=False)
print(tokenizer.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True))

七、总结

LoRA和QLoRA让7B模型的微调门槛降到了单张24GB显卡。这次实践的核心结论:rank=16、alpha=32、lr=2e-4、3个epoch是7B模型中文指令微调的一个稳健起点;QLoRA比LoRA省一半显存,代价是训练慢约45%;格式类任务用LoRA微调收益最大,能到96%的格式正确率。

后续我打算试试DoRA和LoRA+,据说在同等rank下效果更好。另外如果数据量再大一些,考虑用rsLoRA缩放,避免高rank下的梯度问题。有在踩同样坑的朋友欢迎交流。