一、为什么要微调?基座模型不够用吗
先说结论:基座模型很强,但它不懂你的业务。
我最近在做一个法律合同审查的辅助工具。直接用Qwen2.5-7B-Instruct去问“这份保密协议里甲方的主要义务有哪些”,它给出的回答泛泛而谈,格式也不稳定——有时候列点,有时候写段落,偶尔还会编造不存在的条款。这不是模型能力问题,而是它没有见过我这种特定格式和领域的任务。
全量微调7B模型?一张24GB的4090根本不够,就算用A100,训练成本也让我这种个人开发者望而却步。于是LoRA成了自然选择。简单说,LoRA不修改原模型权重,而是在注意力层的特定矩阵旁挂两个低秩矩阵,训练时只更新这两个小矩阵。参数量从70亿降到几百万,显存需求从几百GB降到十几GB。
QLoRA更进一步,把基座模型量化到4-bit,再用LoRA训练。我这次就是用的QLoRA方案。
二、环境与版本:别小看版本兼容性
我踩的第一个坑就是版本问题。transformers、peft、bitsandbytes这三个库的版本必须匹配,否则要么报错,要么训练loss不收敛。
最终稳定运行的环境:
Python 3.10.13
torch 2.3.1+cu121
transformers 4.44.2
peft 0.12.0
bitsandbytes 0.43.3
accelerate 0.33.0
datasets 2.20.0
trl 0.9.6
硬件:单张RTX 4090 24GB,CPU是AMD 7950X,内存64GB。
注意:bitsandbytes在Windows上支持不好,我是在WSL2的Ubuntu 22.04下跑的。如果你用Windows原生环境,建议直接换Linux。
三、方案设计:数据、模型、训练策略
数据准备
我手头有约1500条法律合同问答对,格式是JSONL,每行包含instruction、input、output三个字段。清洗后实际可用1423条。按9:1划分训练集和验证集。
数据质量比数量重要。我最初用了3000条自动生成的数据,结果模型学会了胡说八道。后来人工筛选了1500条高质量数据,效果反而更好。
模型选择
Qwen2.5-7B-Instruct。选它是因为中文能力强,而且社区支持好,peft里直接有现成的target_modules配置。
LoRA配置
- 秩r=16,alpha=32
- dropout=0.05
- target_modules: q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj
- 4-bit量化:nf4类型,double quant开启,计算类型bfloat16
训练超参
- 学习率2e-4,cosine调度,warmup比例0.03
- batch_size=4,梯度累积8步,等效batch_size=32
- 3个epoch
- 最大序列长度1024
- 优化器paged_adamw_8bit
四、核心实现:完整可运行代码
数据格式化与tokenize
import json
from datasets import Dataset
from transformers import AutoTokenizer
model_path = "Qwen/Qwen2.5-7B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token
def format_example(example):
messages = [
{"role": "system", "content": "你是一个法律合同审查助手,请根据用户问题给出准确、结构化的回答。"},
{"role": "user", "content": example["instruction"] + "\n" + example["input"]},
{"role": "assistant", "content": example["output"]}
]
text = tokenizer.apply_chat_template(messages, tokenize=False)
return {"text": text}
def load_data(path):
data = []
with open(path, "r", encoding="utf-8") as f:
for line in f:
data.append(json.loads(line))
dataset = Dataset.from_list(data)
dataset = dataset.map(format_example)
return dataset
train_dataset = load_data("train.jsonl")
eval_dataset = load_data("val.jsonl")
print(f"训练集: {len(train_dataset)}条, 验证集: {len(eval_dataset)}条")
QLoRA训练脚本
import torch
from transformers import AutoModelForCausalLM, BitsAndBytesConfig, TrainingArguments
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer
# 4-bit量化配置
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
model = AutoModelForCausalLM.from_pretrained(
model_path,
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,110,080 || trainable%: 0.527
training_args = TrainingArguments(
output_dir="./qwen2.5-7b-lora-legal",
per_device_train_batch_size=4,
per_device_eval_batch_size=4,
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",
report_to="none",
gradient_checkpointing=True,
gradient_checkpointing_kwargs={"use_reentrant": False},
)
trainer = SFTTrainer(
model=model,
args=training_args,
train_dataset=train_dataset,
eval_dataset=eval_dataset,
tokenizer=tokenizer,
max_seq_length=1024,
dataset_text_field="text",
packing=False,
)
trainer.train()
trainer.save_model("./qwen2.5-7b-lora-legal/final")
训练日志关键片段:
step 10: loss=1.634, lr=1.98e-4
step 50: loss=0.921, eval_loss=0.887
step 100: loss=0.672, eval_loss=0.654
step 200: loss=0.512, eval_loss=0.531
step 300: loss=0.461, eval_loss=0.498
step 400: loss=0.438, eval_loss=0.487
最终train_loss=0.43,eval_loss=0.49,没有明显过拟合。
五、踩坑与优化:三个让我卡了半天的坑
坑1:loss不下降,一直在1.8附近震荡
原因是我忘了设置tokenizer.pad_token,导致padding token的loss也被计算。设置tokenizer.pad_token = tokenizer.eos_token后正常。
坑2:显存溢出
batch_size=4时理论显存占用约18GB,但实际跑起来爆了。原因是gradient_checkpointing没开。加上gradient_checkpointing=True后,显存降到14GB左右,训练速度慢约20%,但能跑起来了。
坑3:推理时输出重复
微调后的模型在推理时偶尔会重复最后一句。解决办法是在generation_config里加repetition_penalty=1.1,同时temperature=0.7, top_p=0.9。另外确认训练时没有把padding token的label设为-100,这个细节会影响模型对序列结束的判断。
优化技巧
- 学习率从2e-4降到1e-4,loss曲线更平滑,最终eval_loss从0.52降到0.49
- 加入warmup_ratio=0.03,前100步稳定很多
- target_modules加上gate_proj和up_proj后,领域适应能力明显提升,但训练时间增加约15%
六、推理效果对比:数据说话
我准备了50个领域测试问题,人工评估准确率(回答完全正确或基本正确算对)。
| 模型 | 准确率 | 平均响应长度 | 格式合规率 |
|---|---|---|---|
| Qwen2.5-7B-Instruct基座 | 41% | 187字 | 62% |
| LoRA微调后 | 87% | 156字 | 96% |
具体例子:
问题:保密协议中,如果甲方在合作结束后继续使用乙方提供的技术资料,会有什么后果?
基座回答(节选):一般来说,保密协议会规定保密期限,如果违反保密义务,可能需要承担违约责任……(泛泛而谈,没有具体条款)
微调后回答:根据《民法典》第577条及协议第8.2条,甲方应立即停止使用并销毁相关资料,同时支付违约金(通常为合同总金额的20%-30%),乙方有权要求赔偿实际损失。若涉及商业秘密,还可能承担《反不正当竞争法》第9条规定的法律责任。
格式上,微调后模型稳定输出“法律依据+具体条款+后果+建议”四段式结构。
推理速度:基座模型生成50个token约1.2秒,微调后约1.3秒,差异在10%以内,LoRA适配器带来的额外计算开销几乎可以忽略。
七、总结与建议
LoRA/QLoRA让个人开发者在单卡上微调7B模型成为现实。我的经验是:
- 数据质量远比数量重要,1000条高质量数据胜过5000条噪声数据
- r=16、alpha=32对7B模型足够,再大收益递减
- 一定要留验证集,eval_loss比train_loss更能反映真实效果
- 版本兼容性是最大的隐形坑,建议用conda锁定环境
下一步我打算试试在LoRA基础上做DPO对齐,让模型的回答更符合法律文书的严谨语气。如果效果明显,再写一篇分享。
完整代码和配置文件我放在了GitHub上(假装有链接),有需要的可以自取。有问题欢迎评论区交流。