一、为什么要微调,以及为什么选LoRA

先说场景。我手头有个垂域的问答任务,模型需要按固定格式输出结构化结果,同时要理解一批领域术语。直接拿Qwen2.5-7B-Instruct跑,格式经常飘,术语也理解不到位,准确率只有43%左右。试过prompt engineering,把few-shot示例塞到8个,准确率能到58%,但推理成本上去了,而且长尾case还是不行。

全量微调7B模型?单卡4090想都别想,光是优化器状态和梯度就要吃掉70GB以上。所以LoRA是自然选择——只训练低秩旁路矩阵,参数量从7B降到几千万级别,显存和存储都友好。QLoRA更进一步,把基座模型量化成4bit,冻结权重显存直接砍到1/4,代价是训练速度略慢、精度有极小损失,但对24GB卡来说是刚需。

我最终用的是QLoRA(NF4量化)+ LoRA适配器组合。

二、环境与版本

环境这块我踩过版本坑,先把确定能跑通的组合列出来:

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.21.0
trl 0.9.6
GPU: RTX 4090 24GB

重点提醒:bitsandbytes 和 torch 的 CUDA 版本必须匹配,我一开始装了cu118的torch配0.43.3的bnb,加载4bit模型直接报 CUDA error: no kernel image is available。换成cu121就好了。另外peft 0.12.0对Qwen2的支持是OK的,再老的版本会有 target_modules 匹配不到的问题。

三、方案设计

整体思路:

  1. 数据格式统一成Alpaca风格的instruction/input/output,用chat template包一层。
  2. 基座加载时用 BitsAndBytesConfig 做NF4双重量化。
  3. LoRA挂在q_proj、k_proj、v_proj、o_proj、gate_proj、up_proj、down_proj上——注意,只挂attention的q/v是很多教程的做法,但对垂域任务,MLP层也参与知识存储,实测挂全了效果更好。
  4. 训练用trl的SFTTrainer,配合cosine学习率、warmup 3%。

关键超参:

lora_r = 16
lora_alpha = 32
lora_dropout = 0.05
learning_rate = 2e-4
num_train_epochs = 3
per_device_train_batch_size = 2
gradient_accumulation_steps = 8   # 等效batch size 16
max_seq_length = 1024
optim = paged_adamw_8bit
lr_scheduler = cosine
warmup_ratio = 0.03
bf16 = True

等效batch size 16对1.2万条数据来说,一个epoch约750步,3个epoch约2250步,4090上大概2小时40分跑完。

四、核心实现

4.1 数据准备

数据是自建的1.2万条问答对,JSONL格式,每条长这样:

{"instruction": "根据以下病历摘要提取诊断和用药", "input": "患者男,58岁,主诉...", "output": "诊断:2型糖尿病\n用药:二甲双胍 0.5g bid"}]

用chat template转换,这里要注意Qwen2.5用的是自己的template,别用默认的:

from datasets import load_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
tokenizer.padding_side = "right"   # 训练必须right padding

def format_sample(sample):
    messages = [
        {"role": "system", "content": "你是一个专业的医疗信息抽取助手,严格按格式输出。"},
        {"role": "user", "content": sample["instruction"] + "\n" + sample["input"]},
        {"role": "assistant", "content": sample["output"]},
    ]
    text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
    return {"text": text}

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

padding_side 一定要设成right,left padding训练时会算错loss,这个坑我后面细说。

4.2 加载4bit基座 + 配置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_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,
    attn_implementation="flash_attention_2",
)

model = prepare_model_for_kbit_training(model)
model.config.use_cache = False   # 训练时必须关,否则和gradient checkpointing冲突

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,986,688 || trainable%: 0.5273

可训练参数约4037万,占比0.53%,显存和存储都极其友好——最终adapter文件才160MB左右。

4.3 训练

from transformers import TrainingArguments
from trl import SFTTrainer

training_args = TrainingArguments(
    output_dir="./qwen2.5-7b-lora-med",
    per_device_train_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,
    save_strategy="epoch",
    bf16=True,
    optim="paged_adamw_8bit",
    gradient_checkpointing=True,
    gradient_checkpointing_kwargs={"use_reentrant": False},
    report_to="tensorboard",
    max_grad_norm=0.3,
)

trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=dataset["train"],
    eval_dataset=dataset["test"],
    dataset_text_field="text",
    max_seq_length=1024,
    packing=False,
)

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

跑起来后nvidia-smi显示峰值显存18.7GB,还有余量,说明batch size还能再往上提一点。

五、踩坑与优化

坑1:loss从第一步就是0或者nan。 原因是padding_side设成了left,且没有正确处理label的padding。改成right padding后正常。

坑2:target_modules只挂q_proj/v_proj,效果明显差。 我做了对比实验,只挂q/v时准确率到78%,挂全attention+MLP到86%。垂域知识确实存在MLP里。

坑3:学习率2e-4一开始震荡。 前50步loss在1.5~2.1之间跳,把max_grad_norm从1.0降到0.3,warmup提到3%后平稳了。LoRA的学习率可以比全量微调大(因为只更新低秩矩阵),但也不是越大越好。

坑4:save的时候adapter和基座混在一起。trainer.save_model() 存的是adapter,推理时要先load基座再load adapter,别指望直接from_pretrained那个目录能跑。

六、效果数据

6.1 loss曲线

训练loss从初始的1.82稳步下降,第1个epoch结束到0.94,第2个epoch到0.72,第3个epoch收敛到0.61。验证loss最低点出现在第2.7个epoch附近(0.68),第3个epoch略有回升到0.70,说明稍微过拟合了一点点,但幅度很小,可以接受。整体曲线没有明显震荡,cosine scheduler的尾部平滑下降特征很明显。

6.2 推理效果对比

测试集500条,逐条人工核对。同一个输入,基线模型和微调后模型对比:

输入: 患者女,45岁,反复咳嗽3月,夜间加重,既往过敏性鼻炎史。胸片未见异常。

基线Qwen2.5-7B-Instruct输出:

根据描述,患者可能患有咳嗽变异性哮喘,建议进一步检查,如支气管激发试验。用药方面可考虑吸入性糖皮质激素。

微调后输出:

诊断:咳嗽变异性哮喘(待排)
鉴别:过敏性鼻炎相关咳嗽
建议检查:支气管激发试验、FeNO
用药:布地奈德福莫特罗吸入剂 1吸 bid

格式完全对齐训练数据,还补了鉴别和具体用药。这就是微调的价值——不是让模型更聪明,而是让它更"听话"、更符合业务格式。

准确率统计:

指标 基线 few-shot(8) LoRA微调
格式合规率 61% 89% 99.2%
任务准确率 43% 58% 86%
平均推理延迟 1.9s 3.4s 1.9s

延迟没变,因为adapter可以merge进基座,推理时零额外开销。

七、总结

LoRA/QLoRA在7B模型上做垂域微调,单卡24GB完全够用,2~3小时能跑完一轮完整实验。几个核心经验:数据格式比超参更重要,target_modules别偷懒只挂q/v,padding_side和学习率是最容易翻车的地方。如果你也在做类似的垂域适配,建议先用500条数据跑一个epoch验证pipeline,通了再上全量数据,能省不少时间。