大模型微调实战:QLoRA 从零训练你的专属 AI 模型(附完整代码)
大模型微调实战:QLoRA 从零训练你的专属 AI 模型(附完整代码)
🔥 AI 全栈开发实战:从大模型部署到 Agent 工程化
引言
2024-2025年,大模型的能力已经让人惊叹,但通用模型 ≠ 你的专属模型。
你是否遇到过这些场景:
❌ 想让 LLM 用你们公司的专业术语回答问题,但它总是"说人话"
❌ 想让模型按固定格式输出(如 JSON),但它经常"自由发挥"
❌ 想让模型学习某个领域的知识(医疗、法律、金融),但它总是"一本正经地胡说八道"
这时候,微调(Fine-tuning) 就是你的答案。
而 QLoRA 是目前性价比最高的微调方案——一张 24GB 的消费级显卡,就能微调 7B 参数的大模型。
本文将从原理到代码,手把手带你完成一次完整的 QLoRA 微调实战。
一、为什么需要微调?
1.1 通用模型 vs 领域模型
先来看一个直观对比:
表格
维度 通用大模型 微调后的领域模型
知识范围 广泛但泛化 精准聚焦特定领域
输出格式 不可控 严格遵循指定格式
专业术语 理解不准确 精准使用领域术语
响应风格 通用对话风格 符合业务场景的风格
推理成本 依赖 Prompt Engineering 减少 Prompt 长度,降低 Token 消耗
幻觉问题 容易胡说八道 大幅减少幻觉
Prompt Engineering 能解决的问题,不要微调。 微调适合解决那些"怎么 Prompt 都不行"的问题。
1.2 三种微调方式对比
plaintext
1
2
3
4
5
6
7
8
9
10
11
12
13
┌─────────────────────────────────────────────────────────┐
│ 大模型微调方式全景图 │
├─────────────┬──────────────┬─────────────┬──────────────┤
│ 对比维度 │ 全参数微调 │ LoRA │ QLoRA │
├─────────────┼──────────────┼─────────────┼──────────────┤
│ 可训练参数 │ 100% │ ~1% │ ~1% │
│ 7B显存需求 │ ~120GB │ ~28GB │ ~18GB │
│ 训练速度 │ 慢 │ 快 │ 较快 │
│ 效果 │ 最优 │ 接近全参数 │ 接近LoRA │
│ 硬件门槛 │ A100/H100 │ RTX 4090 │ RTX 3090 │
│ 适用场景 │ 资源充足 │ 主流方案 │ 资源受限 │
└─────────────┴──────────────┴─────────────┴──────────────┘
QLoRA = 4-bit 量化 + LoRA,在几乎不损失效果的前提下,将显存需求压缩到消费级显卡可承受的范围。这就是我们选择 QLoRA 的原因。
二、QLoRA 核心原理
QLoRA 来自 2023 年 Tim Dettmers 等人的论文 “QLoRA: Efficient Finetuning of Quantized Language Models”。它的核心创新是四个关键技术的叠加:
2.1 四大核心技术
plaintext
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
┌─────────────────────────────────────────────────────────────┐
│ QLoRA 技术架构 │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ 4-bit NF4 │ │ LoRA 低秩 │ │ 双重量化 │ │
│ │ 量化 │ + │ 适配器 │ + │ (Double Quant)│ │
│ │ │ │ │ │ │ │
│ │ 权重压缩到 │ │ 冻结原始权重 │ │ 量化常量的 │ │
│ │ 4-bit精度 │ │ 训练低秩矩阵 │ │ 再次量化 │ │
│ └──────────────┘ └──────────────┘ └────────────────┘ │
│ │
│ + ┌──────────────────┐ │
│ │ 分页优化器 │ │
│ │ (Paged Optimizer)│ │
│ │ │ │
│ │ GPU显存不足时 │ │
│ │ 自动卸载到CPU │ │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
技术一:4-bit NormalFloat (NF4) 量化
将模型权重从 FP16(16位)压缩到 4-bit,但不是简单的截断,而是使用 NF4 数据类型——一种基于正态分布假设的信息论最优量化格式。
plaintext
1
2
3
4
5
6
7
8
9
显存节省计算(以 Qwen2.5-7B 为例):
FP16: 7B × 2 bytes = 14 GB
4-bit: 7B × 0.5 bytes = 3.5 GB(节省 75%)
加上梯度和优化器状态:
全参数 FP16: 14 × (1 + 1 + 2) = 56 GB → 实际约 120 GB(含激活值)
QLoRA: 3.5 + LoRA参数 + 梯度 ≈ 18 GB(节省 ~85%)
技术二:LoRA 低秩适配
不修改原始权重 W,而是在旁边添加低秩分解矩阵 A 和 B:
plaintext
1
2
3
4
5
6
7
原始输出: h = Wx (W: d×d, 不可训练)
LoRA输出: h = Wx + BAx (B: d×r, A: r×d, r << d)
当 r = 16, d = 4096 时:
原始参数: 4096 × 4096 = 16,777,216
LoRA参数: 4096 × 16 + 16 × 4096 = 131,072(仅 0.78%)
技术三:双重量化(Double Quantization)
量化本身也需要存储量化常数(如缩放因子),双重量化就是对这些量化常数再做一次量化,进一步节省约 0.37 bit/参数的显存。
技术四:分页优化器(Paged Optimizer)
利用 NVIDIA 统一内存特性,当 GPU 显存不足时,自动将优化器状态分页卸载到 CPU 内存,需要时再换回,避免 OOM。
2.2 显存对比
表格
模型规模 全参数微调 LoRA (FP16) QLoRA (4-bit) 节省比例
7B ~120 GB ~28 GB ~18 GB 85%
13B ~240 GB ~52 GB ~24 GB 90%
70B ~1.2 TB ~280 GB ~48 GB 96%
三、环境搭建
3.1 硬件要求
表格
模型规模 显存需求(QLoRA) 推荐显卡 训练时间(1000条)
7B ~18 GB RTX 4090 2-3 小时
13B ~24 GB 2×RTX 3090 4-5 小时
70B ~48 GB 4×RTX 4090 8-12 小时
3.2 软件环境安装
bash
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
创建虚拟环境
conda create -n qlora python=3.10 -y
conda activate qlora
安装 PyTorch(CUDA 12.1)
pip install torch2.3.1 torchvision0.18.1 --index-url https://download.pytorch.org/whl/cu121
安装核心依赖
pip install transformers4.44.0
pip install peft0.12.0
pip install bitsandbytes0.43.3
pip install trl0.9.6
pip install datasets2.20.0
pip install accelerate0.33.0
pip install sentencepiece protobuf
可选: WandB 训练监控
pip install wandb
3.3 验证环境
python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import torch
import transformers
import peft
import bitsandbytes as bnb
import trl
print(f"PyTorch: {torch.version}“)
print(f"CUDA available: {torch.cuda.is_available()}”)
print(f"CUDA version: {torch.version.cuda}“)
print(f"GPU: {torch.cuda.get_device_name(0)}”)
print(f"Transformers: {transformers.version}“)
print(f"PEFT: {peft.version}”)
print(f"BitsAndBytes: {bnb.version}“)
print(f"TRL: {trl.version}”)
预期输出:
plaintext
1
2
3
4
5
6
7
8
9
PyTorch: 2.3.1
CUDA available: True
CUDA version: 12.1
GPU: NVIDIA GeForce RTX 4090
Transformers: 4.44.0
PEFT: 0.12.0
BitsAndBytes: 0.43.3
TRL: 0.9.6
四、数据准备
4.1 数据格式
我们采用 Alpaca 格式,这是最常用的指令微调数据格式:
json
1
2
3
4
5
6
{
“instruction”: “解释什么是量子计算”,
“input”: “”,
“output”: “量子计算是一种利用量子力学原理进行信息处理的计算方式。与经典计算机使用比特(0或1)不同,量子计算机使用量子比特(qubit),它可以同时处于0和1的叠加态…”
}
4.2 用大模型自动生成训练数据
手动标注数据太贵?用大模型批量生成是最实用的技巧:
python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
“”"
data_generator.py - 用大模型批量生成训练数据
“”"
import json
import random
from openai import OpenAI
可替换为任意兼容 OpenAI API 的服务
client = OpenAI(
api_key=“your-api-key”,
base_url=“https://api.openai.com/v1”
)
定义你需要的领域种子指令
SEED_INSTRUCTIONS = [
“解释{concept}的基本原理”,
“{concept}和{concept2}有什么区别?”,
“在实际项目中如何使用{concept}?”,
“{concept}的常见误区有哪些?”,
“请给出{concept}的代码示例”,
]
CONCEPTS = [
“Transformer架构”, “注意力机制”, “位置编码”, “多头注意力”,
“LayerNorm”, “残差连接”, “交叉熵损失”, “梯度下降”,
“学习率调度”, “权重衰减”, “Dropout正则化”, “批归一化”,
]
def generate_qa_pair(instruction: str) -> dict:
“”“调用大模型生成一个 QA 对”“”
response = client.chat.completions.create(
model=“gpt-4o-mini”, # 用便宜的小模型生成数据
messages=[
{“role”: “system”, “content”: “你是一个专业的技术文档写手。”
“请用简洁、准确的语言回答技术问题。回答控制在200字以内。”},
{“role”: “user”, “content”: instruction}
],
temperature=0.7,
)
return {
“instruction”: instruction,
“input”: “”,
“output”: response.choices[0].message.content
}
def generate_dataset(num_samples: int = 500) -> list:
“”“批量生成训练数据集”“”
dataset = []
for i in range(num_samples):
# 随机选择种子模板和概念
template = random.choice(SEED_INSTRUCTIONS)
concept = random.choice(CONCEPTS)
concept2 = random.choice([c for c in CONCEPTS if c != concept])
instruction = template.format(concept=concept, concept2=concept2)
try:
qa = generate_qa_pair(instruction)
dataset.append(qa)
print(f"[{i+1}/{num_samples}] 生成完成: {instruction[:40]}...")
except Exception as e:
print(f"[{i+1}/{num_samples}] 生成失败: {e}")
continue
return dataset
if name == “main”:
# 生成 500 条训练数据
dataset = generate_dataset(num_samples=500)
# 保存为 JSON 文件
with open("train_data.json", "w", encoding="utf-8") as f:
json.dump(dataset, f, ensure_ascii=False, indent=2)
print(f"\n生成完成!共 {len(dataset)} 条数据,已保存到 train_data.json")
4.3 数据清洗与格式化
python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
“”"
data_preprocessing.py - 数据清洗与格式化
“”"
import json
from datasets import Dataset, DatasetDict
def load_and_clean_data(json_path: str) -> DatasetDict:
“”“加载并清洗数据,返回 DatasetDict”“”
with open(json_path, "r", encoding="utf-8") as f:
raw_data = json.load(f)
# ---- 数据清洗规则 ----
cleaned = []
for item in raw_data:
# 规则1: 过滤空指令
if not item.get("instruction", "").strip():
continue
# 规则2: 过滤过短的回答(低于20字)
if len(item.get("output", "").strip()) < 20:
continue
# 规则3: 过滤过长的回答(超过2000字,避免训练不稳定)
if len(item.get("output", "").strip()) > 2000:
continue
# 规则4: 去重(基于 instruction)
cleaned.append(item)
# 去重
seen = set()
deduped = []
for item in cleaned:
key = item["instruction"].strip()
if key not in seen:
seen.add(key)
deduped.append(item)
print(f"清洗前: {len(raw_data)} 条 → 清洗后: {len(deduped)} 条")
# ---- 构造训练文本 ----
# 将 instruction + input + output 拼接为完整文本
def format_example(example):
if example["input"]:
text = (
f"### 指令:\n{example['instruction']}\n\n"
f"### 输入:\n{example['input']}\n\n"
f"### 回答:\n{example['output']}"
)
else:
text = (
f"### 指令:\n{example['instruction']}\n\n"
f"### 回答:\n{example['output']}"
)
return {"text": text}
dataset = Dataset.from_list(deduped)
dataset = dataset.map(format_example)
# ---- 划分训练集/验证集(9:1)----
dataset = dataset.train_test_split(test_size=0.1, seed=42)
return DatasetDict({
"train": dataset["train"],
"test": dataset["test"]
})
if name == “main”:
dataset = load_and_clean_data(“train_data.json”)
print(f"训练集: {len(dataset[‘train’])} 条")
print(f"验证集: {len(dataset[‘test’])} 条")
print(f"示例:\n{dataset[‘train’][0][‘text’][:200]}…")
# 保存到本地供训练使用
dataset.save_to_disk("./dataset_processed")
print("数据集已保存到 ./dataset_processed")
五、完整训练代码
这是本文的核心部分——完整的 QLoRA 微调代码,以 Qwen2.5-7B-Instruct 为例。
5.1 训练脚本
python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
“”"
qlora_train.py - QLoRA 微调完整训练脚本
模型: Qwen2.5-7B-Instruct
硬件: 单卡 RTX 4090 (24GB)
“”"
import os
import torch
from datasets import load_from_disk
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
BitsAndBytesConfig,
TrainingArguments,
HfArgumentParser,
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer, SFTConfig
===================== 配置区 =====================
模型路径(本地路径或 HuggingFace 模型名)
MODEL_NAME = “Qwen/Qwen2.5-7B-Instruct”
数据集路径
DATA_PATH = “./dataset_processed”
输出目录
OUTPUT_DIR = “./output/qwen2.5-7b-qlora”
LoRA 超参数
LORA_R = 16 # LoRA 秩,越大效果越好但显存越多
LORA_ALPHA = 32 # LoRA alpha 缩放系数,一般设为 2×r
LORA_DROPOUT = 0.05 # LoRA dropout,防止过拟合
训练超参数
NUM_TRAIN_EPOCHS = 3
PER_DEVICE_TRAIN_BATCH_SIZE = 2
GRADIENT_ACCUMULATION_STEPS = 4 # 等效 batch_size = 2 × 4 = 8
LEARNING_RATE = 2e-4
MAX_SEQ_LENGTH = 1024
WARMUP_RATIO = 0.05
WEIGHT_DECAY = 0.01
LOGGING_STEPS = 10
SAVE_STRATEGY = “steps”
SAVE_STEPS = 100
EVAL_STRATEGY = “steps”
EVAL_STEPS = 100
===================== 1. 4-bit 量化配置 =====================
bnb_config = BitsAndBytesConfig(
load_in_4bit=True, # 开启 4-bit 量化
bnb_4bit_quant_type=“nf4”, # 使用 NF4 量化类型
bnb_4bit_compute_dtype=torch.bfloat16, # 计算时用 bf16(比 fp16 更稳定)
bnb_4bit_use_double_quant=True, # 开启双重量化,进一步节省显存
)
print(“✅ 4-bit 量化配置完成”)
===================== 2. 加载模型和 Tokenizer =====================
加载 Tokenizer
tokenizer = AutoTokenizer.from_pretrained(
MODEL_NAME,
trust_remote_code=True,
padding_side=“right”, # QLoRA 推荐右填充
)
tokenizer.pad_token = tokenizer.eos_token
加载量化后的模型
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
quantization_config=bnb_config,
device_map=“auto”, # 自动分配到可用 GPU
trust_remote_code=True,
torch_dtype=torch.bfloat16,
attn_implementation=“sdpa”, # 使用 SDPA 加速注意力计算
)
为 k-bit 训练做准备(冻结量化层、转换 layernorm 为 fp32 等)
model = prepare_model_for_kbit_training(model, use_gradient_checkpointing=True)
print(f"✅ 模型加载完成: {MODEL_NAME}“)
print(f” 模型参数量: {sum(p.numel() for p in model.parameters()) / 1e9:.2f}B")
print(f" GPU 显存占用: {torch.cuda.memory_allocated() / 1024**3:.2f} GB")
===================== 3. LoRA 配置 =====================
找到所有线性层作为 target_modules(QLoRA 推荐做法)
def find_all_linear_names(model):
“”“自动找到模型中所有的线性层名称”“”
cls = torch.nn.Linear
lora_module_names = set()
for name, module in model.named_modules():
if isinstance(module, cls):
# 取最后一层名称
names = name.split(“.”)
lora_module_names.add(names[-1])
# 移除 lm_head(通常不训练)
if "lm_head" in lora_module_names:
lora_module_names.remove("lm_head")
return list(lora_module_names)
target_modules = find_all_linear_names(model)
print(f" LoRA target modules: {target_modules}")
lora_config = LoraConfig(
r=LORA_R,
lora_alpha=LORA_ALPHA,
lora_dropout=LORA_DROPOUT,
target_modules=target_modules,
bias=“none”,
task_type=“CAUSAL_LM”,
)
应用 LoRA 配置
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
输出: trainable params: 20,971,520 || all params: 7,635,801,600 || trainable%: 0.2747
===================== 4. 加载数据集 =====================
dataset = load_from_disk(DATA_PATH)
print(f"✅ 数据集加载完成: 训练集 {len(dataset[‘train’])} 条, "
f"验证集 {len(dataset[‘test’])} 条")
===================== 5. 训练参数配置 =====================
training_args = SFTConfig(
output_dir=OUTPUT_DIR,
num_train_epochs=NUM_TRAIN_EPOCHS,
per_device_train_batch_size=PER_DEVICE_TRAIN_BATCH_SIZE,
per_device_eval_batch_size=PER_DEVICE_TRAIN_BATCH_SIZE,
gradient_accumulation_steps=GRADIENT_ACCUMULATION_STEPS,
gradient_checkpointing=True, # 用时间换显存
gradient_checkpointing_kwargs={“use_reentrant”: False},
optim=“paged_adamw_8bit”, # 分页优化器,防止 OOM
learning_rate=LEARNING_RATE,
warmup_ratio=WARMUP_RATIO,
weight_decay=WEIGHT_DECAY,
lr_scheduler_type=“cosine”, # 余弦学习率调度
logging_steps=LOGGING_STEPS,
save_strategy=SAVE_STRATEGY,
save_steps=SAVE_STEPS,
eval_strategy=EVAL_STRATEGY,
eval_steps=EVAL_STEPS,
save_total_limit=3, # 最多保存 3 个 checkpoint
bf16=True, # 使用 bf16 混合精度
max_seq_length=MAX_SEQ_LENGTH,
report_to=“tensorboard”, # 或 “wandb”
dataloader_pin_memory=True,
remove_unused_columns=False,
)
===================== 6. 初始化 Trainer =====================
trainer = SFTTrainer(
model=model,
args=training_args,
train_dataset=dataset[“train”],
eval_dataset=dataset[“test”],
tokenizer=tokenizer,
dataset_text_field=“text”, # 数据集中文本字段的名称
packing=False, # 是否使用样本打包(短样本场景建议 True)
)
===================== 7. 开始训练 =====================
print(“\n” + “=”*60)
print(“🚀 开始 QLoRA 训练…”)
print(“=”*60)
train_result = trainer.train()
打印训练结果
metrics = train_result.metrics
print(f"\n✅ 训练完成!“)
print(f” 训练 Loss: {metrics.get(‘train_loss’, ‘N/A’):.4f}“)
print(f” 训练时间: {metrics.get(‘train_runtime’, ‘N/A’):.1f} 秒")
保存最终模型
trainer.save_model(OUTPUT_DIR)
tokenizer.save_pretrained(OUTPUT_DIR)
print(f"✅ 模型已保存到: {OUTPUT_DIR}")
===================== 8. 评估 =====================
print(“\n📊 开始评估…”)
eval_metrics = trainer.evaluate()
print(f" 评估 Loss: {eval_metrics.get(‘eval_loss’, ‘N/A’):.4f}")
5.2 启动训练
bash
1
2
3
4
5
6
7
8
9
单卡训练
python qlora_train.py
如果想用 WandB 监控
WANDB_PROJECT=qwen2.5-qlora python qlora_train.py
查看训练日志(TensorBoard)
tensorboard --logdir ./output/qwen2.5-7b-qlora
六、推理测试
6.1 加载 LoRA 权重直接推理
python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
“”"
inference.py - 使用 LoRA 微调后的模型进行推理
“”"
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import PeftModel
基础模型和 LoRA 权重路径
BASE_MODEL = “Qwen/Qwen2.5-7B-Instruct”
LORA_PATH = “./output/qwen2.5-7b-qlora”
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,
)
加载基础模型
base_model = AutoModelForCausalLM.from_pretrained(
BASE_MODEL,
quantization_config=bnb_config,
device_map=“auto”,
trust_remote_code=True,
)
加载 LoRA 权重
model = PeftModel.from_pretrained(base_model, LORA_PATH)
model.eval()
加载 Tokenizer
tokenizer = AutoTokenizer.from_pretrained(LORA_PATH, trust_remote_code=True)
def chat(instruction: str, max_new_tokens: int = 512) -> str:
“”“使用微调后的模型进行对话”“”
messages = [
{“role”: “system”, “content”: “你是一个专业的技术助手。”},
{“role”: “user”, “content”: instruction},
]
text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
inputs = tokenizer(text, return_tensors="pt").to("cuda")
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
temperature=0.7,
top_p=0.9,
do_sample=True,
pad_token_id=tokenizer.eos_token_id,
)
# 只取新生成的 token
response_tokens = outputs[0][inputs["input_ids"].shape[1]:]
response = tokenizer.decode(response_tokens, skip_special_tokens=True)
return response
---- 测试 ----
if name == “main”:
test_questions = [
“解释一下 Transformer 中注意力机制的工作原理”,
“Dropout 正则化是怎么防止过拟合的?”,
“请给出一个使用 PyTorch 实现多头注意力的代码示例”,
]
for q in test_questions:
print(f"\n{'='*60}")
print(f"❓ 问题: {q}")
print(f"{'='*60}")
answer = chat(q)
print(f"💬 回答: {answer}")
print()
6.2 合并模型并导出
训练完成后,你可能需要将 LoRA 权重合并回基础模型,方便部署:
python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
“”"
merge_and_export.py - 合并 LoRA 权重并导出完整模型
“”"
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
BASE_MODEL = “Qwen/Qwen2.5-7B-Instruct”
LORA_PATH = “./output/qwen2.5-7b-qlora”
MERGED_OUTPUT = “./output/qwen2.5-7b-merged”
print(“📦 加载基础模型(FP16)…”)
base_model = AutoModelForCausalLM.from_pretrained(
BASE_MODEL,
torch_dtype=torch.float16,
device_map=“cpu”, # 合并时在 CPU 上进行
trust_remote_code=True,
)
print(“📦 加载 LoRA 权重…”)
model = PeftModel.from_pretrained(base_model, LORA_PATH)
print(“🔀 合并 LoRA 权重到基础模型…”)
merged_model = model.merge_and_unload()
print(f"💾 导出合并后的模型到: {MERGED_OUTPUT}")
merged_model.save_pretrained(MERGED_OUTPUT, safe_serialization=True)
同时保存 Tokenizer
tokenizer = AutoTokenizer.from_pretrained(LORA_PATH, trust_remote_code=True)
tokenizer.save_pretrained(MERGED_OUTPUT)
print(“✅ 模型合并导出完成!”)
print(f" 合并后模型大小: {sum(p.numel() for p in merged_model.parameters()) / 1e9:.2f}B 参数")
⚠️ 注意:合并后的模型是 FP16 格式,约 14GB。如果显存不够,可以用 device_map=“cpu” 在内存中完成合并。
七、训练监控与调优
7.1 Loss 曲线分析
训练过程中,Loss 的变化是最直接的指标:
plaintext
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
典型的训练 Loss 曲线(理想情况):
Loss
│
│████
│ ████
│ ████
│ ███
│ ███
│ ██
│ ██
│ ████ ← 后期可能微升(正常)
│ ██
└──────────────────────────── Steps
0 100 200 300 400
关注要点:
✅ Loss 平稳下降 → 训练正常
⚠️ Loss 突然飙升 → 学习率过大,考虑降低 LR
⚠️ Loss 几乎不变 → 学习率过小,或数据有问题
⚠️ 训练 Loss 下降但验证 Loss 上升 → 过拟合,减少 Epoch 或增加数据
7.2 显存监控
bash
1
2
3
4
5
6
7
8
9
10
11
实时监控 GPU 显存
watch -n 1 nvidia-smi
训练脚本中添加显存统计
python -c "
import torch
print(f’Allocated: {torch.cuda.memory_allocated()/10243:.2f} GB’)
print(f’Reserved: {torch.cuda.memory_reserved()/10243:.2f} GB’)
print(f’Max Alloc: {torch.cuda.max_memory_allocated()/1024**3:.2f} GB’)
"
7.3 超参数调优指南
表格
问题现象 可能原因 调优建议
OOM(显存溢出) 序列过长或 batch 过大 减小 max_seq_length、减小 batch_size、增大 gradient_accumulation_steps
Loss 不下降 学习率太小 提高 learning_rate(尝试 1e-4 ~ 5e-4)
Loss 震荡剧烈 学习率太大 降低 learning_rate、增加 warmup_ratio
过拟合 数据太少或训练太久 减少 num_train_epochs、增加 lora_dropout
效果不理想 LoRA 秩不够 增大 r(尝试 32 或 64)
生成重复文本 解码参数不当 调整 temperature、top_p,增加 repetition_penalty
八、生产部署
微调完成后的模型如何上线?以下是两种主流部署方案。
8.1 使用 vLLM 部署(推荐)
先合并模型,再用 vLLM 高性能推理引擎部署:
bash
1
2
3
4
5
6
7
8
9
10
11
12
安装 vLLM
pip install vllm
启动 OpenAI 兼容的 API 服务
python -m vllm.entrypoints.openai.api_server
–model ./output/qwen2.5-7b-merged
–host 0.0.0.0
–port 8000
–max-model-len 4096
–gpu-memory-utilization 0.9
–quantization awq # 可选:AWQ 量化进一步加速
调用方式与 OpenAI API 完全兼容:
python
1
2
3
4
5
6
7
8
9
10
11
from openai import OpenAI
client = OpenAI(base_url=“http://localhost:8000/v1”, api_key=“not-needed”)
response = client.chat.completions.create(
model=“qwen2.5-7b-merged”,
messages=[{“role”: “user”, “content”: “解释注意力机制”}],
temperature=0.7,
)
print(response.choices[0].message.content)
8.2 使用 Ollama 部署(本地轻量)
bash
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
先使用合并后的模型创建 GGUF 格式
安装 llama.cpp 并转换
git clone https://github.com/ggerganov/llama.cpp
cd llama.cpp
python convert_hf_to_gguf.py …/output/qwen2.5-7b-merged --outtype q4_k_m
创建 Modelfile
cat > Modelfile << ‘EOF’
FROM ./qwen2.5-7b-merged-q4_k_m.gguf
PARAMETER temperature 0.7
PARAMETER top_p 0.9
SYSTEM “你是一个专业的技术助手。”
EOF
导入到 Ollama
ollama create my-qlora-model -f Modelfile
运行
ollama run my-qlora-model
九、避坑指南
经过大量实战总结的常见问题和解决方案:
坑1:CUDA Out of Memory
plaintext
1
2
3
4
5
6
7
8
9
10
❌ torch.cuda.OutOfMemoryError: CUDA out of memory
✅ 解决方案(按优先级):
- 减小 max_seq_length(1024 → 512)
- 减小 per_device_train_batch_size(2 → 1)
- 增大 gradient_accumulation_steps 保持等效 batch_size
- 确认 gradient_checkpointing=True
- 确认 optim=“paged_adamw_8bit”
- 训练前清理显存:torch.cuda.empty_cache()
坑2:Tokenizer 的 pad_token 问题
python
1
2
3
4
5
6
7
❌ 错误做法:直接用 tokenizer.pad_token 但模型没有设置
很多模型(如 LLaMA)没有默认的 pad_token
✅ 正确做法:
tokenizer.pad_token = tokenizer.eos_token
model.config.pad_token_id = tokenizer.eos_token_id
坑3:BitsAndBytes 安装失败
bash
1
2
3
4
5
6
7
8
9
10
11
12
❌ pip install bitsandbytes 报错:找不到 CUDA 库
✅ 解决方案:
1. 确认 CUDA 已正确安装
nvcc --version
2. 安装指定 CUDA 版本的 bitsandbytes
pip install bitsandbytes==0.43.3
3. 如果是 Windows,使用非官方编译版:
pip install bitsandbytes-windows
4. 验证安装:
python -c “import bitsandbytes as bnb; print(bnb.version)”
坑4:训练 Loss 为 NaN
plaintext
1
2
3
4
5
6
7
8
❌ Loss 直接变成 NaN,训练崩溃
✅ 常见原因及解决方案:
- 学习率过大 → 降低到 1e-4 或 5e-5
- 数据中有异常值 → 检查数据清洗是否到位
- 混合精度问题 → 从 bf16 切换到 fp16(或反过来)
- 梯度爆炸 → 添加 max_grad_norm=0.3 到训练参数
坑5:生成重复或无意义文本
python
1
2
3
4
5
6
7
8
9
10
11
✅ 推理时添加 repetition_penalty
outputs = model.generate(
**inputs,
max_new_tokens=512,
temperature=0.7,
top_p=0.9,
repetition_penalty=1.15, # 关键:惩罚重复
no_repeat_ngram_size=4, # 禁止 4-gram 重复
do_sample=True,
)
坑6:多卡训练的 device_map 问题
python
1
2
3
4
5
6
7
8
9
10
11
12
13
❌ 多卡时 device_map=“auto” 可能导致显存不均匀
✅ 显式指定每张卡的分配:
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
quantization_config=bnb_config,
device_map={“”: 0}, # 单卡指定 GPU 0
trust_remote_code=True,
)
多卡 QLoRA 建议使用 accelerate 的 deepspeed 方案
accelerate launch --config_file deepspeed_config.yaml qlora_train.py
十、总结与资源
本文回顾
我们通过 QLoRA 技术,用一张 RTX 4090 完成了 Qwen2.5-7B 的微调:
plaintext
1
2
3
4
5
6
7
完整流程回顾:
数据准备 ──→ 量化配置 ──→ LoRA适配 ──→ 模型训练 ──→ 推理测试 ──→ 部署上线
│ │ │ │ │ │
Alpaca格式 NF4 4-bit r=16α=32 SFTTrainer PeftModel vLLM
LLM生成 双重量化 分页优化器 bf16混合 合并导出 Ollama
进阶方向
DPO/RLHF:在 SFT 基础上做偏好对齐,让模型更符合人类偏好
多卡分布式训练:使用 DeepSpeed ZeRO-3 训练更大模型
长文本微调:结合 RoPE 扩展支持超长上下文
多模态微调:对 Qwen2-VL、LLaVA 等视觉语言模型进行微调
参考资源
📄 QLoRA 论文
📦 HuggingFace PEFT 文档
📦 TRL 文档(SFTTrainer)
📦 BitsAndBytes 文档
🚀 vLLM 官方文档
📝 关于本专栏
这是「AI 全栈开发实战」专栏的第5篇。本系列从大模型部署、微调、RAG、Agent 到工程化,手把手带你构建完整的 AI 应用开发能力。
如果本文对你有帮助,欢迎 点赞 👍 + 收藏 ⭐ + 关注 🔔 三连支持!
有问题欢迎在评论区交流,我会逐一回复 🙌
更多推荐

所有评论(0)