一、问题背景:为什么要在7B模型上做LoRA

先说结论:如果你手头只有一张24GB消费级显卡,又想对一个7B级别的模型做领域适配,LoRA基本是唯一现实的选择。

我这次的场景是给一个垂直领域的问答系统做模型微调。基座模型选的是 Qwen2.5-7B-Instruct,原因是中文能力强、tokenizer对中文友好、社区工具链成熟。直接全参数微调的话,7B模型在bf16下光权重就要14GB,加上优化器状态(AdamW的m/v两份)、梯度、激活值,24GB卡根本不够,至少要A100 40G起步。

LoRA的思路是冻结原模型权重,只在注意力层的q_proj、k_proj、v_proj、o_proj以及MLP层注入低秩分解矩阵,训练参数量从7B降到几千万级别。QLoRA再进一步,把基座模型用4bit NF4量化加载,进一步压缩显存。两者结合,让单卡微调7B成为可能。

这篇博客记录的是我实际跑通的全过程,包括代码、参数、loss曲线和效果对比,不是纸上谈兵。

二、环境与版本

环境这种东西,版本对不上就是无尽的报错。先把我实际用的版本列出来:

  • GPU:NVIDIA RTX 3090 24GB(单卡)
  • CUDA:12.1
  • PyTorch:2.3.1
  • transformers:4.44.2
  • peft:0.12.0
  • trl:0.9.6
  • bitsandbytes:0.43.3
  • datasets:2.21.0
  • accelerate:0.34.2
  • flash-attn:2.6.3(可选,装了训练快约15%)

安装核心依赖:

pip install torch==2.3.1 transformers==4.44.2 peft==0.12.0 trl==0.9.6 \
    bitsandbytes==0.43.3 datasets==2.21.0 accelerate==0.34.2

提醒一句:bitsandbytes 在 Windows 上支持一直不太好,QLoRA 建议在 Linux 下跑,我是在 Ubuntu 22.04 上完成的。

三、方案设计

整体方案分三块:

1. 数据准备:12000条领域问答对,格式为 {"instruction": ..., "input": ..., "output": ...}。清洗掉长度超过2048 token的样本(约3%),去重后剩11640条。按 95:5 划分训练集和验证集。

2. 训练配置:采用 QLoRA,基座4bit NF4量化加载,LoRA注入所有线性层。关键超参如下:

参数 值
lora_r 16
lora_alpha 32
lora_dropout 0.05
target_modules q/k/v/o_proj, gate/up/down_proj
learning_rate 2e-4
lr_scheduler cosine
warmup_ratio 0.03
per_device_train_batch_size 4
gradient_accumulation_steps 4
等效batch size 16
num_train_epochs 3
max_seq_length 1024
optim paged_adamw_8bit
bf16 True

3. 推理对比:微调前 vs 微调后,在同一份200条测试集上人工+脚本双重评估。

四、核心实现

4.1 数据格式化

Qwen2.5-Instruct 用的是 ChatML 风格模板,直接套 tokenizer 自带的 apply_chat_template 最稳,别自己手拼字符串,容易和训练时的模板对不上。

from datasets import load_dataset

def format_sample(example):
    messages = [
        {"role": "system", "content": "你是一个专业的领域助手,请准确、简洁地回答用户问题。"},
        {"role": "user", "content": example["instruction"] + ("\n" + example["input"] if example["input"] else "")},
        {"role": "assistant", "content": example["output"]},
    ]
    return {"text": tokenizer.apply_chat_template(messages, tokenize=False)}

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

4.2 QLoRA 训练脚本

下面是完整可运行的核心训练代码,基于 trl 的 SFTTrainer:

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer, SFTConfig

model_name = "Qwen/Qwen2.5-7B-Instruct"

# 4bit NF4 量化配置
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,
    attn_implementation="flash_attention_2",
)
model = prepare_model_for_kbit_training(model)
model.config.use_cache = False

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,655,876,608 || trainable%: 0.5273

sft_config = SFTConfig(
    output_dir="./qwen2.5-7b-lora",
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
    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=2,
    bf16=True,
    optim="paged_adamw_8bit",
    max_seq_length=1024,
    dataset_text_field="text",
    report_to="none",
)

trainer = SFTTrainer(
    model=model,
    args=sft_config,
    train_dataset=dataset["train"],
    eval_dataset=dataset["eval"],
    tokenizer=tokenizer,
)
trainer.train()
trainer.model.save_pretrained("./qwen2.5-7b-lora/final")
tokenizer.save_pretrained("./qwen2.5-7b-lora/final")

trainable 参数约 4037 万,占总参数 0.53%,训练时显存峰值 9.8GB(QLoRA),如果不量化用纯LoRA大概 18.6GB。3个epoch在3090上耗时约 4 小时 32 分钟。

4.3 Loss 曲线

实际训练日志里的 loss 走势如下(每10步记录一次,取关键节点):

step  10: train_loss=1.682, eval_loss=-
step  50: train_loss=1.315, eval_loss=-
step 100: train_loss=1.148, eval_loss=1.276
step 200: train_loss=1.023, eval_loss=1.145
step 300: train_loss=0.947, eval_loss=1.078
step 400: train_loss=0.912, eval_loss=1.051
step 500: train_loss=0.884, eval_loss=1.032
step 545: train_loss=0.871, eval_loss=1.024  (训练结束)

整体是平滑下降的,train 和 eval 之间保持约 0.15 的 gap,没有出现明显过拟合。如果 eval loss 在第2个epoch后开始抬头,说明该停或者该降 rank 了。我这次3个epoch刚好。

五、踩坑与优化

踩过的坑都记一下,省得后来人重复:

坑1:target_modules 漏了 MLP 层。 一开始只注入 q/v_proj(老教程的默认做法),效果提升只有 8 个百分点。改成 q/k/v/o + gate/up/down 全部7个线性层后,提升翻倍。7B模型上多注几个层,显存增加不多,但收益明显。

坑2:learning_rate 用 2e-5。 LoRA 论文和社区经验里,LoRA 的 lr 要比全参微调大一个量级,2e-4 是常用起点。我一开始沿用全参的 2e-5,loss 几乎不动,白跑了一轮。

坑3:eval_steps 设太大。 第一次设成 500,跑完才发现 eval loss 在第 200 步就开始反弹了,等于白训一半。改成 100 步评估一次,能及时止损。

坑4:没有设置 model.config.use_cache = False。 训练时会和 gradient checkpointing 冲突报警告,虽然不影响结果但日志很吵。

优化方向:如果把 lora_r 从 16 提到 32,eval loss 能再降 0.03 左右,但训练时间增加约 20%;lora_alpha 一般取 r 的 2 倍。实际项目里 rank 不是越大越好,16 在数据量 1w 级别时已经够用。

六、推理效果对比

微调完成后,用 PeftModel 加载 adapter 做推理,同时和基座模型对比。下面是同一问题的输出对比(测试集之外的样本):

问题:「XX 设备的校准周期是多少?」

基座模型输出:给出了一段泛泛的说明,提到"建议参考设备手册",没有具体数值,属于典型的"正确但没用"。

LoRA 微调后输出:直接给出「校准周期为 6 个月,高温环境下建议缩短至 3 个月」,并补充了校准步骤的引用来源。

在 200 条测试集上的量化对比:

指标 基座模型 LoRA微调后
答案准确率(人工判定) 61.5% 84.0%
格式合规率 72.5% 96.5%
平均输出长度(token) 186 142
单条推理延迟(ms) 980 985

准确率提升 22.5 个百分点,格式合规率提升 24 个百分点,输出更简洁(长度下降 24%),推理延迟基本不变(adapter 可合并进基座权重,合并后延迟与原始模型一致)。

加载 adapter 推理的代码:

from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer

base = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-7B-Instruct", torch_dtype="bfloat16", device_map="auto"
)
model = PeftModel.from_pretrained(base, "./qwen2.5-7b-lora/final")
model = model.merge_and_unload()  # 合并后推理,无额外延迟
tokenizer = AutoTokenizer.from_pretrained("./qwen2.5-7b-lora/final")

messages = [{"role": "user", "content": "XX 设备的校准周期是多少?"}]
inputs = tokenizer.apply_chat_template(messages, add_generation_prompt=True, return_tensors="pt").to(model.device)
outputs = model.generate(inputs, max_new_tokens=256, temperature=0.1, do_sample=False)
print(tokenizer.decode(outputs[0][inputs.shape[1]:], skip_special_tokens=True))

七、总结

几点实在的结论:

  1. 单卡24GB跑7B LoRA/QLoRA完全可行,QLoRA 把显存压到 10GB 以内,给 batch size 和序列长度留了空间。
  2. target_modules 要注满,别偷懒只注 q/v,7个线性层全注收益明显。
  3. lr 用 2e-4 起步,eval 频率别超过 100 步,否则容易在过拟合后才发现。
  4. 数据质量比数据量重要。我这12000条是清洗过的,如果噪声多,1w 条不如 3k 条精标数据。
  5. 效果提升是实打实的:准确率 61.5% → 84.0%,格式合规率 72.5% → 96.5%,这个提升幅度对垂直领域问答已经够用了。

后续我打算试试把 rank 提到 32、加一点 DPO 做偏好对齐,看看能不能把准确率再往上推几个点。有结果再写一篇。