一、问题背景:为什么需要微调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上了,有需要的可以自取。