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

最近接手一个法律咨询场景的对话项目。直接调用Qwen2.5-7B-Instruct的API,在通用问题上表现不错,但一碰到“劳动合同违约金上限”“竞业限制补偿标准”这类具体问题时,回答要么过于笼统,要么直接编造法条。实测200条法律问答,准确率只有61.2%。

全量微调7B模型?单卡A100 80G都够呛,更别说我手头只有一张RTX 4090 24G。于是转向LoRA——冻结原模型权重,只训练低秩适配矩阵。但普通LoRA在7B模型上仍需约14GB显存用于优化器状态,加上激活值容易OOM。最终方案是QLoRA:先把基座模型4-bit量化,再挂LoRA适配器,显存直接降到可接受范围。

这篇文章就是整个过程的记录,包括我踩过的坑和最终的效果数据。

二、环境与版本

先列一下我的环境,版本不一致很容易出各种诡异错误:

  • 操作系统:Ubuntu 22.04
  • GPU:RTX 4090 24GB(单卡)
  • CUDA:12.1
  • 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

安装命令:

pip install torch==2.3.1 --index-url https://download.pytorch.org/whl/cu121
pip install transformers==4.44.2 peft==0.12.0 bitsandbytes==0.43.3 trl==0.9.6 accelerate==0.33.0 datasets==2.20.0

注意bitsandbytes在Windows上支持很差,建议Linux环境。另外peft 0.12.0和transformers 4.44.2搭配稳定,我试过peft 0.11.1会出现target_modules识别异常。

三、方案设计

核心思路:4-bit量化基座 + LoRA适配器 + 梯度检查点。

具体配置:

  • 量化:NF4(4-bit NormalFloat),双重量化开启,计算类型bfloat16
  • LoRA:rank=64,alpha=128,dropout=0.05
  • 目标模块:q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj(全注意力+FFN)
  • 训练:3 epochs,batch_size=2,gradient_accumulation=8,等效batch=16
  • 优化器:paged_adamw_8bit
  • 学习率:2e-4,cosine调度,warmup_ratio=0.03
  • 最大序列长度:1024

为什么rank选64?我试过rank=8和rank=32,在验证集上loss下降不够,rank=64时效果明显提升,rank=128反而过拟合。alpha=2*rank是经验值。

四、核心实现

4.1 数据准备

数据格式为JSONL,每行一个样本:

{"instruction": "劳动合同违约金上限是多少?", "output": "根据《劳动合同法》第二十二条和二十三条,只有两种情形可以约定违约金:一是违反服务期约定,二是违反竞业限制约定。除此之外,用人单位不得与劳动者约定由劳动者承担违约金。服务期违约金不得超过用人单位提供的培训费用,且不得超过服务期尚未履行部分所应分摊的培训费用。"}

我用了1.2万条法律问答,按8:1:1划分训练/验证/测试。数据清洗时去掉了长度超过1024 token的样本(约300条),以及output为空或纯模板回复的样本。

数据处理代码:

from datasets import load_dataset
from transformers import AutoTokenizer

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

def format_example(example):
    messages = [
        {"role": "system", "content": "你是一名专业法律顾问,请根据中国现行法律法规回答问题。"},
        {"role": "user", "content": example["instruction"]},
        {"role": "assistant", "content": example["output"]}
    ]
    text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
    return {"text": text}

dataset = load_dataset("json", data_files={"train": "train.jsonl", "valid": "valid.jsonl"})
dataset = dataset.map(format_example, remove_columns=dataset["train"].column_names)

def tokenize_fn(examples):
    tokens = tokenizer(examples["text"], truncation=True, max_length=1024, padding=False)
    tokens["labels"] = tokens["input_ids"].copy()
    return tokens

tokenized = dataset.map(tokenize_fn, batched=True, remove_columns=["text"])

4.2 模型加载与LoRA配置

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

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(
    model_name,
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True
)
model = prepare_model_for_kbit_training(model)
model.gradient_checkpointing_enable()

lora_config = LoraConfig(
    r=64,
    lora_alpha=128,
    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
from trl import SFTTrainer

training_args = TrainingArguments(
    output_dir="./qwen2.5-7b-lora-legal",
    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_strategy="steps",
    save_steps=100,
    save_total_limit=3,
    bf16=True,
    optim="paged_adamw_8bit",
    gradient_checkpointing=True,
    report_to="none",
    max_grad_norm=0.3,
    group_by_length=True
)

trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=tokenized["train"],
    eval_dataset=tokenized["valid"],
    tokenizer=tokenizer,
    dataset_text_field="text",
    max_seq_length=1024,
    packing=False
)

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

五、踩坑与优化

坑1:Loss从0.8开始不下降。 原因是prepare_model_for_kbit_training默认把LayerNorm转为fp32,但我的数据里system prompt太长,模型在学模板而不是内容。后来把system prompt缩短到20字以内,loss才正常下降到0.3左右。

坑2:eval_loss比train_loss低。 这通常不正常。检查发现是验证集和训练集分布不同——验证集里短问题多,而训练集长问题多。重新分层采样后解决。

坑3:显存峰值18.3GB,但偶尔OOM。 原因是group_by_length=True在数据加载时会有波动。把per_device_train_batch_size从4降到2,gradient_accumulation从4升到8,等效batch不变但峰值降到17.1GB。

优化1: 开启gradient_checkpointing后训练速度下降约25%,但显存节省约30%。权衡后保留。

优化2: 使用paged_adamw_8bit替代adamw_torch,优化器状态显存从约5.6GB降到约1.4GB。

优化3: 学习率从1e-4调到2e-4,收敛更快。但再高到5e-4会震荡。

六、效果数据

训练3个epoch,总步数约2250步,耗时4小时37分。Loss曲线:训练loss从初始0.82降到0.21,验证loss从0.79降到0.24,没有明显过拟合。

推理对比(测试集200条法律问答,人工评分):

指标 基座模型 LoRA微调后
准确率 61.2% 84.7%
法条引用正确率 43.5% 79.3%
回答完整性 72.1% 88.5%
通用对话能力(MMLU子集) 68.4% 67.9%

通用能力仅下降0.5%,说明LoRA对基座知识破坏很小。

推理代码:

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

base_model = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-7B-Instruct",
    torch_dtype=torch.bfloat16,
    device_map="auto"
)
model = PeftModel.from_pretrained(base_model, "./qwen2.5-7b-lora-legal/final")
model = model.merge_and_unload()  # 合并LoRA权重,推理更快
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")

def chat(question):
    messages = [
        {"role": "system", "content": "你是一名专业法律顾问。"},
        {"role": "user", "content": question}
    ]
    text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
    inputs = tokenizer(text, return_tensors="pt").to(model.device)
    outputs = model.generate(**inputs, max_new_tokens=256, temperature=0.3, do_sample=True)
    return tokenizer.decode(outputs[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)

print(chat("竞业限制补偿金标准是多少?"))

实际输出对比:

基座模型:“竞业限制补偿金一般由双方约定,没有法定标准,但不得低于当地最低工资标准。”

微调后:“根据《最高人民法院关于审理劳动争议案件适用法律问题的解释(一)》第三十六条,竞业限制补偿金未约定时,按劳动者离职前十二个月平均工资的30%按月支付,且不得低于劳动合同履行地最低工资标准。若约定了但低于上述标准,劳动者可要求补足。”

七、总结

LoRA/QLoRA在7B模型上的微调,单卡24G完全可行。关键点:4-bit量化省显存、rank=64够用、学习率2e-4、梯度检查点必开。最终准确率提升23.5个百分点,通用能力几乎无损。如果你也在做垂直领域微调,建议先用500条数据跑通流程,再逐步加数据调参。完整代码和配置文件我放在GitHub上了,有需要的可以自取。