一、问题背景:为什么不用全量微调?

老板丢给我一个任务:把公司内部的合同条款问答做成一个私有化模型。数据量不大,2万条左右,但要求模型能理解法律文本的严谨表述。我第一反应是直接全量微调Llama-3-8B,但很快被现实打脸:

  • 显存爆炸:8B模型fp16权重就要16GB,加上梯度、优化器状态,单卡4090(24GB)根本不够。
  • 训练时间:全量微调2000步,单卡4090预计需要18小时,时间成本过高。
  • 灾难性遗忘:全量微调后模型通用能力下降明显,回答日常问题变得呆板。

所以我把目光转向LoRA(Low-Rank Adaptation)和它的进阶版QLoRA。LoRA通过在权重矩阵旁路添加低秩分解矩阵,训练时只更新这部分参数,参数量骤降到0.1%以下。QLoRA更进一步,用4-bit量化基座模型,显存占用直接砍半。

环境版本说明
- GPU:单张NVIDIA RTX 4090(24GB)
- CUDA:12.1
- Python:3.10
- PyTorch:2.1.2
- transformers:4.38.2
- peft:0.8.2
- bitsandbytes:0.41.3
- trl:0.7.4
- datasets:2.14.5

二、数据准备:法律问答对清洗与格式化

数据源是公司内部的合同问答库,格式混乱。我需要把原始数据清洗成instruction/input/output结构。这里有个关键点:法律文本里经常出现“甲方”“乙方”等指代,如果直接切分会导致上下文丢失,所以我用正则做了指代还原。

import re
import json
from datasets import Dataset

def clean_legal_text(text):
    # 还原甲方乙方指代
    text = re.sub(r'甲方', '合同方A', text)
    text = re.sub(r'乙方', '合同方B', text)
    # 去除多余空白
    text = re.sub(r'\s+', ' ', text).strip()
    return text

def format_llama3_prompt(instruction, input_text=None, output_text=None):
    # Llama-3的对话模板
    if input_text:
        user_content = f"指令:{instruction}\n上下文:{input_text}"
    else:
        user_content = f"指令:{instruction}"
    if output_text:
        return f"user\n{user_content}assistant\n{output_text}"
    return f"user\n{user_content}assistant\n"

# 读取原始数据
with open('raw_qa.json', 'r') as f:
    raw_data = json.load(f)

formatted_data = []
for item in raw_data:
    question = clean_legal_text(item['question'])
    answer = clean_legal_text(item['answer'])
    context = clean_legal_text(item.get('context', ''))
    prompt = format_llama3_prompt(question, context, answer)
    formatted_data.append({'text': prompt})

# 过滤过短或过长的样本
formatted_data = [d for d in formatted_data if 50 < len(d['text']) < 2048]
print(f"清洗后样本数: {len(formatted_data)}")

清洗后从2.3万条降到1.8万条,主要是过滤了重复和过短条目。注意:这里没有做严格的中文分词,因为Llama-3的tokenizer对中文支持一般,长度超过2048的直接截断。

三、训练配置:QLoRA参数与bitsandbytes量化

训练配置上我踩了三个坑:
1. 4-bit量化类型nf4fp4在推理时更稳,我实测BLEU高0.03。
2. LoRA的r值:试过8/16/32,r=16在收敛速度和效果上平衡最好,继续增大到32反而过拟合。
3. 学习率:用2e-4跑崩了,loss直接NaN,降到2e-5才稳定。

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, 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_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True
)

# 加载基座模型
model_id = "meta-llama/Meta-Llama-3-8B"
model = AutoModelForCausalLM.from_pretrained(
    model_id,
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True
)
tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token

# 冻结基座参数,准备k-bit训练
model = prepare_model_for_kbit_training(model)

# LoRA配置
lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
    lora_dropout=0.1,
    bias="none",
    task_type="CAUSAL_LM"
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 输出:trainable params: 8,388,608 || all params: 8,127,834,112 || trainable%: 0.1032

这里有个细节:target_modules必须和模型架构匹配。Llama-3的attention层是q_proj/k_proj/v_proj/o_proj,如果写错会报错。另外bias="none"默认即可,法律任务不需要额外偏置。

四、核心实现:训练循环与loss曲线

我用trl库的SFTTrainer,省去手动写train_step的痛苦。关键参数:per_device_train_batch_size=2gradient_accumulation_steps=8,等效batch_size=16。序列长度设512,因为法律问答的上下文不会太长。

from trl import SFTTrainer, SFTConfig

training_args = SFTConfig(
    output_dir="./lora-llama3-legal",
    per_device_train_batch_size=2,
    gradient_accumulation_steps=8,
    learning_rate=2e-5,
    num_train_epochs=3,
    logging_steps=20,
    save_steps=500,
    eval_steps=500,
    evaluation_strategy="steps",
    warmup_ratio=0.05,
    lr_scheduler_type="cosine",
    bf16=True,
    max_seq_length=512,
    packing=False,  # 不打包,保持样本独立
    report_to="tensorboard",
)

trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=Dataset.from_list(formatted_data[:16000]),
    eval_dataset=Dataset.from_list(formatted_data[16000:]),
    tokenizer=tokenizer,
)
trainer.train()

Loss曲线分析
- 前100步loss从1.8快速降到0.9,说明模型在快速适配法律词汇。
- 200-500步loss平稳下降到0.6,但eval loss在400步后开始波动,轻微过拟合迹象。
- 我把epoch从3降到2,final eval loss稳定在0.58,比3轮低0.04。

训练耗时:2000步,单卡4090约2小时40分钟。显存峰值11.2GB,全程无OOM。

五、踩坑与优化:三个关键问题

坑1:梯度检查点必须开
我一开始没开gradient_checkpointing,显存直接飙到18GB。加上后降到11GB,损失了一点训练速度,但完全值得。注意要同时设置model.config.use_cache=False

坑2:中文tokenizer的padding策略
Llama-3的tokenizer不支持pad_token,默认用eos_token填充。这会导致padding部分参与attention计算,影响训练效果。我的解法:训练时设置tokenizer.padding_side="right",并在数据预处理时手动加eos_token。

坑3:评估指标别用accuracy
法律问答是生成任务,我用bleurouge-l评估。但BLEU对中文不友好,我最后发现更实用的指标是“关键词命中率”:手动标注20个测试问题,检查回答中是否包含关键法律术语(如“违约责任”“不可抗力”)。命中率从微调前的62%提升到89%。

六、效果对比:微调前后推理差异

我用同一个问题测试:

问题:“如果合同中没有约定违约金,守约方还能主张赔偿吗?”

模型版本 回答
基座Llama-3-8B “这是一个复杂的法律问题,建议咨询专业律师……”(回答泛泛,无具体法律依据)
LoRA微调后 “根据《民法典》第五百八十五条,当事人可以约定违约金。若未约定,守约方可主张实际损失赔偿,但需提供证据证明具体损失数额……”

微调后回答更具体,引用了法条,且逻辑更清晰。在20个测试题上,平均回答长度从120字降到78字,信息密度更高。

七、总结与建议

整体来看,LoRA/QLoRA方案在单卡4090上微调8B模型是可行的,关键收益有:

  1. 显存友好:QLoRA量化+LoRA低秩,11GB显存就能跑8B模型微调。
  2. 训练效率:2小时40分钟完成2000步训练,比全量微调快9倍。
  3. 效果可接受:领域内回答质量明显提升,且保留了模型通用能力。

后续优化方向
- 尝试rsLoRA缩放因子,可能提升长序列效果。
- 用Unsloth加速训练,据说能再快30%。
- 如果数据量超过5万条,考虑混合LoRA+全量微调策略。

最后提醒:LoRA的r值、lr、target_modules都是超参数,不同任务差异很大。建议先用小样本跑通流程,再逐步调参。别像我一样一上来就全量跑,白烧了两天电费。