一、为什么要微调,而不是直接上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_norm 和 use_reentrant 这两个参数,网上很多教程都不提,但实际跑的时候不设就是会出问题。建议直接抄 QLoRA 原论文的配置。
第四,别迷信 packing。吞吐是上去了,但如果你对输出格式敏感,packing 带来的边界污染可能得不偿失。先跑一版不开 packing 的 baseline,再决定要不要开。
最后,LoRA 的 rank 不是越大越好。我试过 r=64,可训练参数涨到 80M,训练时间翻倍,eval loss 只降了 0.01,性价比极低。r=16 对 7B 模型来说是个很舒服的甜点。
代码和数据格式我都放在上面了,换成本地的数据就能直接跑。有问题评论区聊。