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

先说结论:通用大模型在垂直领域真的不够用。

我手头有个医疗问答场景,需要模型能准确回答“二甲双胍的禁忌症”“儿童布洛芬剂量”这类问题。直接用Qwen2.5-7B-Instruct跑,结果让人头大——它会给出看似合理但细节错误的答案,比如把成人剂量直接套给儿童,或者漏掉关键禁忌症。测试了200条专业问题,准确率只有54%,还不如查指南。

全量微调7B模型?一张24GB的4090根本扛不住,光是优化器状态就要吃掉几十GB。所以LoRA成了唯一现实的选择。它的思路很简单:冻结原模型权重,只在注意力层注入低秩矩阵,训练参数量降到原来的0.1%左右。再配合QLoRA的4-bit量化,连基座模型都能压到4GB以内。

这篇文章就记录我从零到跑通的全过程,包括数据怎么洗、参数怎么设、loss长什么样、最后效果到底行不行。

二、环境与版本:别小看版本兼容性

我用的环境如下,建议直接抄:

  • 操作系统:Ubuntu 22.04
  • GPU:RTX 4090 24GB
  • CUDA:12.1
  • PyTorch:2.3.1
  • transformers:4.44.2
  • peft:0.12.0
  • bitsandbytes:0.43.3
  • trl:0.9.6
  • accelerate:0.33.0

这里有个坑:bitsandbytes 0.43.x 和 PyTorch 2.3.1 搭配才能正常做4-bit量化,用0.42会报 CUDA error: an illegal memory access。另外peft 0.12.0 对Qwen2架构支持比较稳,再老的版本加载LoRA时会丢key。

安装命令:

pip install torch==2.3.1 transformers==4.44.2 peft==0.12.0 bitsandbytes==0.43.3 trl==0.9.6 accelerate==0.33.0 datasets==2.20.0

三、方案设计:QLoRA + 指令微调

整体方案分三层:

  1. 基座模型:Qwen2.5-7B-Instruct,用bitsandbytes做NF4量化,计算类型用bfloat16。
  2. LoRA配置:目标模块选 q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj,rank=16,alpha=32,dropout=0.05。注意alpha一般设为rank的2倍,这是经验值。
  3. 训练策略:指令微调,数据格式统一成 [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}],用trl的SFTTrainer,3个epoch,学习率2e-4,cosine调度,warmup比例0.03。

为什么选这些参数?我试过r=8和r=32:r=8时loss降到0.9就下不去了,欠拟合;r=32时显存多占1.5GB,但效果只比r=16好一点点,性价比不高。所以r=16是甜点。

四、核心实现:数据准备与训练代码

4.1 数据准备

我的原始数据是8k条医疗问答,存在JSONL里。清洗步骤:去掉长度超过1024 token的样本(避免截断影响),过滤掉答案里包含“建议咨询医生”这类无信息量的回复,最后剩7,842条。

格式化成Qwen的chat template:

from datasets import load_dataset
import json

def format_data(example):
    messages = [
        {"role": "user", "content": example["question"]},
        {"role": "assistant", "content": example["answer"]}
    ]
    text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
    return {"text": text}

dataset = load_dataset("json", data_files="medical_qa.jsonl", split="train")
dataset = dataset.map(format_data, remove_columns=dataset.column_names)
dataset = dataset.train_test_split(test_size=0.05, seed=42)

4.2 训练代码

完整可运行脚本如下,关键参数我都标了注释:

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
from datasets import load_dataset

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

# 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,
)

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,
)
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",
                    "gate_proj", "up_proj", "down_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 输出: trainable params: 20,185,088 || all params: 7,635,000,000 || trainable%: 0.264

# 数据加载与格式化
dataset = load_dataset("json", data_files="medical_qa.jsonl", split="train")
def format_data(example):
    messages = [
        {"role": "user", "content": example["question"]},
        {"role": "assistant", "content": example["answer"]}
    ]
    return {"text": tokenizer.apply_chat_template(messages, tokenize=False)}
dataset = dataset.map(format_data, remove_columns=dataset.column_names)
split = dataset.train_test_split(test_size=0.05, seed=42)

# 训练配置
training_args = SFTConfig(
    output_dir="./qwen2.5-7b-lora-medical",
    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,
    save_strategy="epoch",
    bf16=True,
    optim="paged_adamw_8bit",
    gradient_checkpointing=True,
    max_seq_length=1024,
    dataset_text_field="text",
    report_to="none",
)

trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=split["train"],
    eval_dataset=split["test"],
    tokenizer=tokenizer,
)

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

显存占用:训练时峰值约18.3GB,比全量微调省了60%以上。训练时间:3个epoch共2小时37分钟,平均每步0.82秒。

五、踩坑与优化:我遇到的三个问题

坑1:loss不下降,一直卡在1.8左右。 排查发现是学习率设成了2e-5,太小了。LoRA因为只训练低秩矩阵,学习率通常要比全量微调大10倍左右,改成2e-4后loss立刻开始降。

坑2:eval loss波动大。 原因是验证集只有392条,batch size又小。后来把per_device_eval_batch_size调到8,并且每500步评估一次,曲线就平滑了。

坑3:推理时输出重复。 微调后的模型有时会重复一句话。解决办法是在生成时加repetition_penalty=1.1,并且把temperature从0.7降到0.3。另外训练数据里如果有重复模式,也会导致这个问题,所以数据去重很重要。

优化点:开启gradient_checkpointing后显存从22GB降到18GB,代价是训练速度慢15%左右,但能跑起来比什么都强。

六、效果数据:loss曲线与推理对比

6.1 Loss曲线

训练loss从第1步的1.82稳步下降,到第3个epoch结束时为0.67。验证loss从1.75降到0.72,没有明显过拟合。具体节点:

  • Epoch 1结束:train loss 1.12,eval loss 1.08
  • Epoch 2结束:train loss 0.81,eval loss 0.83
  • Epoch 3结束:train loss 0.67,eval loss 0.72

曲线形状是标准的平滑下降,没有剧烈震荡,说明学习率和batch size搭配合理。

6.2 推理效果对比

我用同样的200条测试问题,分别跑基座模型和LoRA微调后的模型,人工评估准确率:

模型 准确率 显存占用 推理速度
Qwen2.5-7B-Instruct 54% 14.2GB 42 token/s
+ LoRA (r=16) 89% 15.1GB 40 token/s

显存只多了0.9GB,速度几乎没损失。举两个具体例子:

问题:“二甲双胍的绝对禁忌症有哪些?”
- 基座回答:提到了肾功能不全,但漏了“严重感染”和“代谢性酸中毒”。
- 微调回答:完整列出eGFR<30、急性代谢性酸中毒、严重感染、缺氧性疾病等,和指南一致。

问题:“3岁儿童布洛芬混悬液用量是多少?”
- 基座回答:给出“每次5-10mg/kg”,但没说明每6-8小时一次,也没提24小时不超过4次。
- 微调回答:准确给出“每次5-10mg/kg,每6-8小时一次,24小时内不超过4次,最大单次剂量400mg”。

6.3 合并与部署

训练完后,LoRA权重可以合并回基座模型,方便用vLLM部署:

from peft import PeftModel
from transformers import AutoModelForCausalLM

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-medical/final")
model = model.merge_and_unload()
model.save_pretrained("./qwen2.5-7b-medical-merged")

合并后模型大小约15GB(bf16),用vLLM启动后吞吐量能到1200 token/s(batch=8)。

七、总结

LoRA/QLoRA让单卡微调7B模型变得非常现实。我的经验是:数据质量比数量重要,8k条清洗过的数据远好过5万条脏数据;rank=16、alpha=32、lr=2e-4这组参数在7B模型上比较通用;4-bit量化几乎不损失效果,但省下一大半显存。

当然也有局限:LoRA对知识注入的效果有限,如果想让模型学会全新的事实性知识,可能还是需要全量微调或者RAG配合。但对于格式对齐、领域术语、回答风格这类任务,LoRA已经足够好了。

下一步我打算试试DoRA(Weight-Decomposed LoRA),据说在低rank下比LoRA更稳。如果大家有兴趣,我可以再写一篇对比。