一、为什么要微调,而不是直接上RAG

先说结论:RAG解决"知识不在模型里"的问题,微调解决"模型不会按你要的方式说话"的问题。这两个不是替代关系。

我这次的需求是做一个医疗问诊助手。直接拿Qwen2.5-7B-Instruct跑,问题是它回答太"安全"了——张口就是"建议您及时就医""请咨询专业医生",而且格式松散,有时候一段话能给你绕三圈。用RAG把知识库塞进去,知识是有了,但语气和结构还是不对。

我需要的是:模型能稳定输出"症状分析→可能原因→建议措施→就医提示"这种四段式结构,并且语气像一个耐心的全科医生,而不是一个免责声明生成器。这种"行为对齐"就是微调的活儿。

但7B模型全参数微调,fp16权重14GB,加上优化器状态、梯度、激活值,轻松突破60GB。我手上只有一张4090 24GB。所以LoRA和QLoRA是唯一现实的选择。

二、环境与版本

版本这东西必须写清楚,不然复现的时候能气死人。我踩过的坑后面会讲。

  • 硬件:单卡 RTX 4090 24GB,CPU 是 Ryzen 9 7950X,内存 64GB
  • CUDA:12.1
  • Python:3.10.13
  • PyTorch:2.3.1+cu121
  • transformers:4.44.2
  • peft:0.12.0
  • bitsandbytes:0.43.3
  • trl:0.9.6
  • accelerate:0.33.0
  • datasets:2.20.0

这里要强调:bitsandbytes 0.43.x 和 transformers 4.44 是能配上的,我一开始用 bitsandbytes 0.41 + transformers 4.42,加载4-bit模型时直接报 mat1 and mat2 must have the same dtype,折腾了一下午。版本对齐比什么都重要。

三、方案设计

3.1 LoRA vs QLoRA 怎么选

LoRA 的思路是在原始权重旁边挂两个低秩矩阵 A 和 B,训练时只更新这两个小矩阵。7B模型如果对 q_proj、k_proj、v_proj、o_proj、gate_proj、up_proj、down_proj 全部加 LoRA,rank=8 的情况下可训练参数大约 20M,占比 0.28%。

QLoRA 是在 LoRA 基础上,把基座模型量化成 4-bit NF4 存储,前向传播时反量化计算。这样基座权重从 14GB 压到约 4GB,显存占用大幅下降,代价是训练速度慢一点。

我的实测:

方案 基座显存 训练峰值显存 速度
LoRA (fp16) ~15GB 21.3GB 3.1 it/s
QLoRA (4-bit NF4) ~4.5GB 9.6GB 2.3 it/s

最后我选了 QLoRA,因为 21.3GB 太贴边了,batch size 稍微一动就 OOM,而且没法开 gradient checkpointing 之外的其他优化。9.6GB 留足了余量,我甚至能把 batch size 从 1 提到 4,配合梯度累积,实际吞吐反而更高。

3.2 超参数

  • LoRA rank: 16
  • LoRA alpha: 32(alpha/rank=2,经验上比较稳)
  • LoRA dropout: 0.05
  • target_modules: q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj
  • 学习率: 2e-4,cosine schedule,warmup 100 步
  • batch size: 4,梯度累积 4,等效 batch 16
  • max_seq_length: 1024(数据里 95% 的样本在 800 token 以内)
  • epoch: 3
  • 优化器: paged_adamw_8bit

四、核心实现

4.1 数据准备

数据格式我用的是 ShareGPT 风格的多轮对话,然后转成 Qwen 的 chat template。原始数据是 12000 条,我按 9:1 切了训练和验证。

import json
from datasets import Dataset

def build_dataset(path):
    records = []
    with open(path, "r", encoding="utf-8") as f:
        for line in f:
            item = json.loads(line)
            # item: {"conversations": [{"from":"human","value":...},{"from":"gpt","value":...}]}
            messages = []
            for turn in item["conversations"]:
                role = "user" if turn["from"] == "human" else "assistant"
                messages.append({"role": role, "content": turn["value"]})
            records.append({"messages": messages})
    return Dataset.from_list(records)

train_ds = build_dataset("data/train.jsonl")
eval_ds = build_dataset("data/val.jsonl")
print(train_ds[0])

这里有个细节:Qwen2.5 的 chat template 会自动处理 system prompt 和特殊 token,所以我不需要手动拼 `。但如果你用的是别的模型,务必确认 tokenizer 的apply_chat_template` 行为。

4.2 训练脚本

import torch
from transformers import (
    AutoModelForCausalLM, AutoTokenizer,
    BitsAndBytesConfig, TrainingArguments
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer

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

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_id, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token

model = AutoModelForCausalLM.from_pretrained(
    model_id,
    quantization_config=bnb_config,
    device_map="auto",
    torch_dtype=torch.bfloat16,
    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"],
)

args = TrainingArguments(
    output_dir="outputs/qwen2.5-7b-medical-lora",
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
    num_train_epochs=3,
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_steps=100,
    logging_steps=10,
    save_strategy="epoch",
    eval_strategy="epoch",
    bf16=True,
    gradient_checkpointing=True,
    gradient_checkpointing_kwargs={"use_reentrant": False},
    optim="paged_adamw_8bit",
    report_to="none",
    max_grad_norm=0.3,
)

trainer = SFTTrainer(
    model=model,
    args=args,
    train_dataset=train_ds,
    eval_dataset=eval_ds,
    peft_config=lora_config,
    tokenizer=tokenizer,
    max_seq_length=1024,
    packing=False,
)

trainer.train()
trainer.model.save_pretrained("outputs/lora_adapter")

跑起来之后,显存稳定在 9.6GB,GPU 利用率 92% 左右。3 个 epoch 大概 2 小时 15 分钟。

五、踩坑与优化

坑一:use_reentrant=False 不加会报错。 开了 gradient checkpointing 之后,如果还用默认的 reentrant 模式,和 PEFT 的梯度流会冲突,报 element 0 of tensors does not require grad。加上这个参数就好了。

坑二:max_grad_norm=0.3 不是随便写的。 我一开始用默认的 1.0,loss 在前 200 步剧烈震荡,从 0.9 蹦到 1.4 又掉回来。查了一下,QLoRA 原文里就建议用 0.3,因为 4-bit 量化的梯度噪声比较大,裁剪阈值要小一点。改完之后曲线平滑多了。

坑三:packing 的取舍。 SFTTrainer 有个 packing=True 能把多条短样本拼到一条 1024 里,吞吐能提升 30% 以上。但我的数据里 assistant 回复的结构很重要,packing 之后不同样本之间会串,虽然 attention mask 处理正确,但实测效果反而略降。最后关了。

坑四:验证集 loss 的计算。 默认 SFTTrainer 的 eval loss 是包含 prompt 部分的。如果只关心回答质量,应该只对 assistant token 计算 loss。我在 DataCollatorForCompletionOnlyLM 上折腾了一会儿,最后发现 Qwen 的 template 里 assistant 部分以 assistant\n 开头,直接用它作为 response_template 就行。

六、效果数据

6.1 loss 曲线

训练集 loss 从第 1 步的 0.87 一路降到第 3 个 epoch 结束的 0.41。验证集 loss 从 0.82 降到 0.45,中间没有明显的反弹,说明没有过拟合。

具体几个节点:

step train_loss eval_loss
10 0.87 -
500 0.58 -
1125 (epoch1) 0.49 0.53
2250 (epoch2) 0.43 0.47
3375 (epoch3) 0.41 0.45

QLoRA 的收敛曲线比 LoRA 稍微抖一点,但最终值只差 0.04,完全可以接受。

6.2 推理效果对比

我用同一批 200 条测试问题,对比了基座模型和微调后的模型。评测方式是让 3 个标注同学盲评,看回答是否"结构完整、语气合适、无明显错误"。

模型 采纳率 平均输出长度 四段式结构完整率
Qwen2.5-7B-Instruct 基线 52% 138 token 31%
+ LoRA 83% 246 token 94%
+ QLoRA 81% 241 token 92%

一个具体例子。问:"最近总是半夜胃疼,吃点东西能缓解,是什么问题?"

基线回答:"胃疼可能由多种原因引起,建议您注意饮食规律,如果症状持续请及时就医。"

微调后回答:"症状分析:夜间空腹性疼痛、进食后缓解,是典型的十二指肠溃疡表现。可能原因:胃酸分泌过多、幽门螺杆菌感染、长期服用非甾体药物。建议措施:避免辛辣和咖啡,规律三餐,可短期使用抑酸药。就医提示:建议尽快做胃镜和幽门螺杆菌检测,若出现黑便或呕血请立即就诊。"

差距非常直观。

6.3 合并与部署

推理的时候可以把 LoRA adapter 合并回基座,省掉 peft 的加载开销:

from peft import PeftModel
from transformers import AutoModelForCausalLM

base = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-7B-Instruct",
    torch_dtype=torch.bfloat16,
    device_map="auto",
)
model = PeftModel.from_pretrained(base, "outputs/lora_adapter")
merged = model.merge_and_unload()
merged.save_pretrained("outputs/qwen2.5-7b-medical-merged")

合并后 fp16 权重约 15GB,用 vLLM 部署,单卡 4090 上并发 8 路时首 token 延迟约 180ms,吞吐约 1400 token/s。这个性能做内部工具完全够用。

七、总结

几点真实感受:

第一,QLoRA 在 24GB 卡上微调 7B 模型是当前性价比最高的方案。显存从 21GB 降到 9.6GB,速度只慢 25%,效果差距在评测里几乎看不出来。如果你的卡更小,比如 16GB,QLoRA 是唯一选择。

第二,数据质量比超参数重要十倍。我第一版数据只有 3000 条,loss 降到 0.5 就下不去了,回答结构也不稳定。补到 12000 条、把重复和低质样本清掉之后,同样的超参数,eval loss 直接降到 0.45。

第三,max_grad_normuse_reentrant 这两个参数,网上很多教程都不提,但实际跑的时候不设就是会出问题。建议直接抄 QLoRA 原论文的配置。

第四,别迷信 packing。吞吐是上去了,但如果你对输出格式敏感,packing 带来的边界污染可能得不偿失。先跑一版不开 packing 的 baseline,再决定要不要开。

最后,LoRA 的 rank 不是越大越好。我试过 r=64,可训练参数涨到 80M,训练时间翻倍,eval loss 只降了 0.01,性价比极低。r=16 对 7B 模型来说是个很舒服的甜点。

代码和数据格式我都放在上面了,换成本地的数据就能直接跑。有问题评论区聊。