模型微调 Fine-tuning
当你需要一个专门用于生成电商产品描述的模型时,有三种主要选择。
第一种是从头训练(Pre-training from scratch)。这需要收集海量文本数据(可能是万亿级 token),租用数十块 A100 GPU 训练几个月,花费数百万美元。这是 OpenAI、Google 这些巨头做的事,对绝大多数人来说不现实。
-
第二种是 Prompt 工程。给通用大模型一个精心设计的提示词,比如"你是一个专业的电商文案写手,请根据以下产品信息生成吸引人的描述..."。这种方式零成本、即时可用,但问题是:每次都要写长长的提示词、效果不稳定、容易被无关输入干扰、token 消耗大。
-
第三种就是微调(Fine-tuning)。拿一个已经训练好的通用模型,用你的专有数据继续训练一小会儿,让它"学会"你的特定任务。这样得到的模型,既能保留通用能力,又能在你的任务上表现出色,而且成本可控。
三种方式的对比:
| 方式 | 成本 | 效果 | 适用场景 |
|---|---|---|---|
| 从头训练 | 极高(百万美元级) | 完全定制 | 创建新的基础模型(仅巨头) |
| Prompt 工程 | 几乎为零 | 不稳定,依赖提示词 | 简单任务、快速验证 |
| 微调 | 中等(几百到几千美元) | 稳定且专业 | 特定领域任务、产品化 |
用一个简单的比喻:
- 从头训练就像自己从零开始培养一个医生,需要十几年。
- Prompt 工程就像给一个普通医生一份详细的操作手册。
- 微调就像让一个已经有行医资格的医生去专科进修几个月,变成专科专家。
为什么需要微调
不是所有任务都需要微调,先搞清楚什么时候该用,什么时候不该用。
Prompt 工程的局限
Prompt 工程很强大,但它有几个难以突破的天花板。
第一个局限是上下文窗口限制。你的提示词和示例必须塞进模型的上下文窗口里(比如 4K、8K、128K、1024K token)。如果你的任务需要几百个示例才能说清楚,Prompt 就装不下了。
-
第二个局限是效果不稳定。同一个提示词,换个问法结果可能差异很大。模型心情好(采样随机性)时输出很好,心情不好时可能完全跑题。
-
第三个局限是推理成本高。每次推理都要把长长的提示词送进去,token 消耗大,响应速度慢。如果是高并发场景,成本会急剧上升。
-
第四个局限是遗忘问题。模型虽然看了你的提示词,但前面海量的预训练数据可能还在拉后腿。比如你让它输出 JSON,它可能还是会加上很多自然语言解释。
微调的适用场景
当你的任务符合以下特征时,微调是值得考虑的。
特征一:任务定义清晰,输出格式固定。比如"把技术文档翻译成通俗易懂的博客"、"根据客户服务记录生成工单摘要"、"将自然语言转换成 SQL 查询"。
-
特征二:有几百到几千条高质量标注数据。太少数据微调不出效果,太多又没必要——一般 500-5000 条是比较理想的范围。
-
特征三:需要在特定领域表现专业。比如医疗报告解读、法律文书摘要、金融新闻分析。通用模型可能不懂行业术语和惯例,微调能让它"入行"。
-
特征四:对响应速度和成本敏感。微调后的小模型可能比通用大模型效果更好,而且推理速度快 10 倍,成本低 100 倍。
-
特征五:需要严格遵循输出格式。比如必须输出 JSON、必须用特定的语气、必须包含某些字段。微调能让模型"记住"这些要求,不需要每次都在提示词里重复。
微调的成本收益分析
做微调之前,先算一笔账。
成本主要来自三个方面:数据准备、计算资源、人工调试。数据准备通常占最大工作量——你需要收集、清洗、标注数据。计算资源现在已经很便宜了,用 LoRA + QLoRA 的话,一张消费级显卡(RTX 3090/4090)就能微调 7B/13B 模型。
收益体现在几个方面:更好的效果、更低的推理成本、更快的响应速度、更稳定的输出。如果你的模型要对外提供服务,这些收益会持续累积。
什么时候不应该微调?如果任务变化很快,数据格式每周都在变,那 Prompt 工程更灵活。如果只是做一次性探索,或者只有几十条数据,也不值得微调。
| 因素 | 更适合 Prompt | 更适合微调 |
|---|---|---|
| 数据量 | < 100 条 | > 500 条 |
| 任务稳定性 | 经常变化 | 相对固定 |
| 使用频率 | 偶尔用 | 高频使用 |
| 成本敏感度 | 低 | 高 |
| 效果要求 | 可用就行 | 追求最佳 |
全量微调(Full Fine-tuning)
先了解最传统的微调方式,再看为什么今天我们很少用它。
原理与流程
全量微调的思路很简单:拿预训练模型的所有参数,在你的数据集上继续训练,更新所有权重。
流程大致是这样的:
第一步,准备数据。把你的任务数据整理成"输入-输出"对的形式。
-
第二步,加载预训练模型。比如下载 LLaMA-2-7B、Mistral-7B 这样的开源模型。
-
第三步,设置训练参数。学习率要设得很小(比如 1e-5 到 5e-5),因为不想把预训练学到的知识完全覆盖掉。
-
第四步,开始训练。让模型在你的数据上跑几个 epoch(完整遍历数据集几次)。
-
第五步,保存模型。训练完得到一个全新的权重文件,大小和原模型一样。
实例
# 全量微调概念演示(伪代码,不依赖框架)
# 展示核心思路,实际代码会用 HuggingFace
# ============================================
def full_finetuning_concept():
"""全量微调的核心概念演示"""
# 1. 加载预训练模型(所有参数都可训练)
model = load_pretrained_model("llama-2-7b")
# 模型有 70 亿个参数,每个参数都会更新
print(f"模型参数总数:{count_parameters(model)}")
# 输出:模型参数总数:7000000000
# 2. 准备你的特定任务数据
train_data = [
{"input": "产品:保温杯\n生成电商描述",
"output": "这款高品质保温杯采用 304 不锈钢内胆,真空隔热技术,保温时长可达 12 小时。"},
{"input": "产品:无线耳机\n生成电商描述",
"output": "真无线蓝牙耳机,蓝牙 5.3 稳定连接,主动降噪,续航 24 小时,佩戴舒适。"},
# ... 几百几千条类似数据
]
# 3. 设置优化器和很小的学习率
optimizer = create_optimizer(model, learning_rate=2e-5)
# 4. 训练循环
for epoch in range(3): # 通常训练 2-5 个 epoch
for batch in create_batches(train_data):
# 前向传播
loss = model.compute_loss(batch)
# 反向传播,更新所有参数!
loss.backward()
optimizer.step()
optimizer.zero_grad()
print(f"Epoch {epoch} 完成")
# 5. 保存完整模型(70 亿个参数,约 13GB)
model.save("llama-2-7b-ecommerce-finetuned")
print("全量微调完成,新模型已保存")
# 这只是概念演示,实际训练需要大量显存
灾难性遗忘问题
全量微调有一个严重的问题:灾难性遗忘(Catastrophic Forgetting)。
什么意思?当你在你的数据上训练时,模型会逐渐"忘记"它在预训练阶段学到的通用知识。就像一个人为了准备数学考试,拼命做数学题,结果把之前学的语文和英语都忘了。
为什么会发生?因为全量微调会更新所有参数。预训练学到的知识都编码在这些参数里,大幅改动会破坏原有知识。
结果就是:微调后的模型在你的任务上表现很好,但在其他通用任务上表现下降。更糟糕的是,它可能失去一些基本能力,比如遵循指令、理解复杂问题。
怎么缓解?可以混合预训练数据一起训练,但这又回到了"需要海量数据"的老问题。或者用正则化约束参数不要变化太大,但效果有限。
显存需求计算
全量微调的另一个问题是显存需求太大。让我们算一算:
训练一个模型需要的显存 = 模型参数 + 梯度 + 优化器状态 + 激活值。
对于 LLaMA-7B 模型,全精度(FP32)的话,70 亿个参数 × 4 字节 = 28 GB。梯度也是 28 GB,优化器状态(Adam 优化器)需要 56 GB(梯度的 2 倍)。激活值随 batch size 和序列长度变化,至少也需要几 GB。
加起来,全量微调 7B 模型需要约 100+ GB 显存——这意味着至少需要 8 张 A100(40GB)或者 2 张 A100(80GB)。
如果用半精度(FP16/BF16),可以减半,但还是需要约 50-60 GB 显存。这仍然不是普通人能负担的。
| 模型大小 | 全量微调显存(FP16) | 需要的 GPU |
|---|---|---|
| 7B | 50-60 GB | 2×A100 (40GB) 或 1×A100 (80GB) |
| 13B | 90-100 GB | 3×A100 (40GB) 或 2×A100 (80GB) |
| 70B | 400+ GB | 12×A100 (40GB) 或 6×A100 (80GB) |
全量微调不是不能用,但它成本高、风险大。除非你有充分的理由(比如数据量非常大,需要改变模型的根本行为),否则应该优先考虑参数高效微调(PEFT)方法。
参数高效微调(PEFT)
PEFT(Parameter-Efficient Fine-Tuning)是一类只训练少量参数的方法的总称。它既保留了微调的效果,又大幅降低了成本。
PEFT 的核心思想
PEFT 的核心洞察是:预训练模型已经包含了足够的知识,我们不需要修改所有参数,只需要"调整"一小部分参数来适应新任务。
就像一架已经造好的钢琴,你不需要重新制造整个钢琴,只需要微调几根琴弦,就能弹出你想要的音色。
PEFT 的好处很明显:
第一,显存需求大幅降低。原来需要 60 GB,现在可能只需要 6 GB。
-
第二,不会灾难性遗忘。原模型参数不动,只是加了点新东西,原有知识不会被破坏。
-
第三,存储成本低。PEFT 通常只保存新增的少量参数(几 MB 到几百 MB),不用保存整个模型。
-
第四,可以做"任务组合"。训练多个小适配器(Adapter),推理时按需加载,一个模型可以当多个模型用。
主流 PEFT 方法概览
PEFT 不是一种方法,而是一类方法的统称。让我们看看几种主流方法:
第一种是 Adapter(适配器)。在 Transformer 的每一层中间插入小的神经网络模块。训练时只训练这些 Adapter,原模型参数冻结。这是早期的 PEFT 方法,现在用得少了。
-
第二种是 Prefix Tuning(前缀微调)。在模型的每一层前面加上可学习的"前缀"向量。这些前缀向量只占很小一部分,但能引导模型的输出。
-
第三种是 Prompt Tuning(提示微调)。只在输入层加上可学习的软提示(Soft Prompt)。这是最简单的方法,但效果也相对有限。
-
第四种是 LoRA(Low-Rank Adaptation,低秩适配)。在模型的注意力层旁边增加低秩矩阵。这是目前最流行、效果最好的 PEFT 方法。
-
第五种是 QLoRA(量化 LoRA)。在 LoRA 的基础上再加上模型量化,进一步降低显存需求。这是今天的主流选择。
| 方法 | 原理 | 可训练参数量 | 效果 |
|---|---|---|---|
| 全量微调 | 更新所有参数 | 100% | 好,但可能遗忘 |
| Adapter | 插入小网络 | 0.1%-1% | 中等 |
| Prefix Tuning | 添加前缀向量 | 0.1%-1% | 中等 |
| Prompt Tuning | 添加软提示 | < 0.1% | 一般 |
| LoRA | 低秩矩阵适配 | 0.1%-1% | 接近全量微调 |
| QLoRA | 量化 + LoRA | 0.1%-1% | 接近全量微调 |
今天的最佳实践很明确:首选 LoRA,显存不够就用 QLoRA。这两种方法效果好、成本低、生态成熟,大多数情况下是最优选择。
LoRA(Low-Rank Adaptation)
LoRA 是目前最主流的微调方法,理解它的原理很重要。
低秩分解原理(直觉理解)
先理解什么是"秩"(Rank),什么是"低秩"。
假设你有一个 100 × 100 的矩阵,它里面的数据可能不是完全随机的。如果这些数据可以由很少几个"模式"组合而成,我们就说这个矩阵的秩很低。
举个例子:如果每一行都是第一行的倍数,那么这个矩阵的秩是 1。如果每一行都是前两行的线性组合,那么秩是 2。以此类推。
LoRA 的核心洞察是:大模型在适应新任务时,权重的变化量通常是"低秩"的——也就是说,这种变化可以用很少几个参数来表示。
这就像,虽然有 1000 种不同的电商产品描述,但它们的写作风格可能只是由几个核心因素决定的:语气、长度、强调点。不需要用 1000 个不同的方向去描述变化,用 8 个、16 个、32 个就够了。
所以 LoRA 的做法是:冻结原模型的所有参数,在注意力层旁边增加两个小矩阵 A 和 B。A 是 d × r,B 是 r × d,其中 r 是秩(Rank),通常取 8、16、32 这样的小数字。
原模型输出 = 原模型前向传播。
LoRA 输出 = 原模型前向传播 + (输入 × A × B) × 缩放系数。
训练时只训练 A 和 B,原模型参数完全不动。
LoRA 数学推导
不用怕,数学推导很简单,只有几行。
假设原模型的某个层是一个线性变换:h = W₀x,其中 W₀ 是预训练好的权重矩阵,形状是 d × d。
微调时,我们想更新 W₀,但又不想更新太多。LoRA 的做法是把 W₀ 的变化量表示为两个小矩阵的乘积:ΔW = BA。
所以微调后的输出是:h = W₀x + (BA)x。
这里 B 的形状是 d × r,A 的形状是 r × d,r 就是秩(Rank)。
更精确一点,LoRA 论文中的公式是:
h = W₀x + α * (BA)x
其中 α 是一个缩放系数,通常设为 r 的某个倍数,这样当改变 r 时不需要重新调整超参数。
训练时,W₀ 完全冻结,只有 A 和 B 是可训练的。
初始化时,A 用随机高斯分布初始化,B 初始化为零,这样训练开始时 LoRA 部分的输出为零,不影响原模型行为。
实例
# LoRA 核心原理的极简实现
# 不依赖任何框架,纯 NumPy 演示
# ============================================
import numpy as np
class LoRALayer:
"""LoRA 层的极简实现"""
def __init__(self, d: int, r: int, alpha: float = 1.0):
"""
d: 输入/输出维度(比如 4096)
r: 秩(比如 8, 16, 32)
alpha: 缩放系数
"""
self.d = d
self.r = r
self.alpha = alpha
# 冻结的原始权重(模拟预训练模型)
self.W_0 = np.random.randn(d, d) * 0.01
self.W_0.flags.writeable = False # 标记为不可训练
# LoRA 的 A 和 B 矩阵,可训练
# A: r × d,用随机高斯初始化
self.A = np.random.randn(r, d) * 0.01
# B: d × r,初始化为零(训练初期不影响原模型)
self.B = np.zeros((d, r))
def forward(self, x: np.ndarray) -> np.ndarray:
"""前向传播:原模型输出 + LoRA 输出"""
# 原模型输出
original_output = x @ self.W_0.T
# LoRA 输出:(x @ A.T) @ B.T = x @ (B @ A).T
lora_output = (x @ self.A.T) @ self.B.T
# 合并输出
return original_output + self.alpha * lora_output
def get_trainable_params(self) -> dict:
"""获取可训练参数(只有 A 和 B)"""
return {"A": self.A, "B": self.B}
def count_params(self) -> dict:
"""统计参数数量"""
total_original = self.d * self.d
total_lora = self.r * self.d + self.d * self.r
return {
"original_params": total_original,
"lora_params": total_lora,
"ratio": total_lora / total_original * 100,
}
# ============================================
# 演示 LoRA 的参数节省效果
# ============================================
# 假设 d = 4096(类似 LLaMA-7B 的隐藏层维度)
# r = 8(常用的秩大小)
d = 4096
r = 8
lora = LoRALayer(d=d, r=r, alpha=r)
params_info = lora.count_params()
print(f"原始参数数量:{params_info['original_params']:,}")
print(f"LoRA 参数数量:{params_info['lora_params']:,}")
print(f"参数占比:{params_info['ratio']:.4f}%")
# 输出示例:
# 原始参数数量:16,777,216
# LoRA 参数数量:65,536
# 参数占比:0.3906%
你看,LoRA 只需要训练 0.4% 的参数,却能达到接近全量微调的效果。这就是它的魔力所在。
r(秩)和 α 参数的选择
LoRA 有两个关键超参数:秩 r 和缩放系数 α。
r 决定了 LoRA 的容量。r 越大,能表示的变化越复杂,但参数量也越大。
常见的 r 取值:8、16、32、64。对大多数任务,r = 8 或 16 就足够了。r 超过 64 通常收益递减,而且会增加训练成本。
怎么选 r?一个实用的建议是:先试 r = 8,如果效果不好,再增加到 16、32。不要一开始就用很大的 r。
α 是缩放系数。常见的设置是 α = r,这样 ΔW = BA 的尺度大致保持稳定。或者设 α = 1,让用户自己调整。
其他需要考虑的参数:
目标层(target_modules):LoRA 应该加在哪些层上?通常只加在注意力层的 query 和 value 投影上(q_proj, v_proj),效果就很好。也可以加在更多层上,但这样会增加参数量。
Dropout:LoRA 可以加 dropout 防止过拟合,常见值是 0.05 或 0.1。
| 参数 | 常见取值 | 说明 |
|---|---|---|
| r(秩) | 8, 16, 32 | 越大容量越大,但参数量也越大 |
| alpha | r 或 1 | 缩放系数,通常设为 r |
| target_modules | ["q_proj", "v_proj"] | 要加 LoRA 的层 |
| lora_dropout | 0.0, 0.05, 0.1 | 防止过拟合 |
合并 LoRA 权重
训练好 LoRA 后,推理时有两种选择。
-
第一种是保持 LoRA 分开。推理时加载原模型,再加载 LoRA 权重,动态计算合并输出。这种方式的好处是可以灵活切换多个 LoRA,缺点是推理会稍微慢一点(通常可以忽略)。
-
第二种是合并 LoRA 权重。把 LoRA 的 ΔW = BA 加到原模型的 W₀ 上,得到 W = W₀ + α×BA。这样就得到了一个单一的模型文件,推理时和普通模型一样。
合并的好处是推理速度快,不需要特殊的 LoRA 支持。坏处是一旦合并就不能撤销,而且如果有多个 LoRA,需要分别合并成不同的模型文件。
实例
# LoRA 权重合并演示
# ============================================
import numpy as np
def merge_lora_weights(W_0: np.ndarray, A: np.ndarray, B: np.ndarray,
alpha: float = 1.0) -> np.ndarray:
"""
将 LoRA 权重合并到原始权重中
W = W_0 + alpha * (B @ A)
参数:
W_0: 原始权重 (d, d)
A: LoRA A 矩阵 (r, d)
B: LoRA B 矩阵 (d, r)
alpha: 缩放系数
返回:
合并后的权重
"""
# 计算 delta_W = alpha * (B @ A)
delta_W = alpha * (B @ A)
# 合并到原始权重
W_merged = W_0 + delta_W
return W_merged
# ============================================
# 演示合并过程
# ============================================
d = 4096 # 隐藏层维度
r = 8 # LoRA 秩
# 模拟原始权重和 LoRA 权重
W_0 = np.random.randn(d, d) * 0.01
A = np.random.randn(r, d) * 0.01
B = np.zeros((d, r)) # 实际中 B 是训练后的结果
# 合并权重
W_merged = merge_lora_weights(W_0, A, B, alpha=r)
print(f"原始权重形状:{W_0.shape}")
print(f"合并后权重形状:{W_merged.shape}")
print("权重合并完成,可以像普通模型一样使用")
QLoRA(量化 LoRA)
QLoRA 把 LoRA 的低成本推向了极致——在消费级显卡上就能微调 70B 模型。
4-bit 量化 + LoRA
QLoRA 的思路很简单:把模型量化到 4-bit,然后在上面做 LoRA 微调。
什么是量化?就是把浮点数(FP16/BF16,每个占 2 字节)转换成更低精度的表示。4-bit 量化就是用 4 个比特(0.5 字节)来表示一个权重。
这样一来,模型大小可以减少 75%:7B 模型从 13 GB 降到约 3.5 GB,13B 从 26 GB 降到约 6.5 GB,70B 从 140 GB 降到约 35 GB。
但直接量化会损失精度。QLoRA 的创新是用了一种叫 NF4(Normalized Float 4)的数据类型,它能更好地保留神经网络权重的信息。
QLoRA 的做法是:
-
1. 把预训练模型量化到 4-bit(NF4 格式)。
-
2. 在上面加 LoRA 适配器。
-
3. 训练时,4-bit 量化的原模型完全冻结,只训练 LoRA 的 FP16 权重。
这样做的效果:显存需求再降 75%,同时效果几乎没有损失。
bitsandbytes 库
bitsandbytes 是实现量化的核心库,由 Tim Dettmers 开发。
它提供了:
-
1. 4-bit 量化(NF4)。
-
2. 双量化(Double Quantization)——把量化常数也量化,再省一点显存。
-
3. 分页优化器(Paged Optimizer)——显存不够时把部分数据转移到 CPU 内存。
普通用户不需要直接和 bitsandbytes 打交道,peft 库和 transformers 库已经把它封装好了。但知道它的存在很有用。
消费级 GPU 微调方案
有了 QLoRA,普通人也能玩得起微调了。
RTX 3090/4090(24GB VRAM)可以轻松微调 7B/13B 模型,甚至 70B 模型也能尝试(需要更长的序列长度或者更小的 batch size)。
显存需求估算:
| 模型大小 | QLoRA 显存需求 | 适合的 GPU |
|---|---|---|
| 7B | 5-8 GB | RTX 3060/4060 及以上 |
| 13B | 8-12 GB | RTX 3070/4070 及以上 |
| 34B | 15-20 GB | RTX 3090/4090 |
| 70B | 30-40 GB | RTX 3090/4090 × 2 或 A100 |
QLoRA 是游戏规则改变者。它把大模型微调从"只有大公司能做"变成了"普通人在自己的游戏本上就能做"。这是开源 AI 最重要的进步之一。
训练数据准备
数据准备是微调过程中最耗时、最关键的部分。垃圾进,垃圾出——数据质量决定了模型的上限。
数据格式(Alpaca、ShareGPT 格式)
常见的数据格式有几种,选择一种然后保持一致。
第一种是 Alpaca 格式。这是最流行的格式之一,由 Stanford Alpaca 项目推广。
格式是:instruction(指令)、input(输入,可选)、output(输出)。
实例
# Alpaca 数据格式示例
# ============================================
alpaca_data = [
{
"instruction": "生成一个电商产品描述",
"input": "产品名称:智能运动手表\n功能:心率监测、睡眠追踪、GPS定位、50米防水",
"output": "这款智能运动手表是运动爱好者的理想伴侣。它配备精准的心率监测和科学的睡眠追踪功能,帮助你全面了解自己的健康状态。内置 GPS 让你无需携带手机也能记录运动轨迹。50米防水等级,游泳也能放心佩戴。"
},
{
"instruction": "将以下文本改写成更口语化的表达",
"input": "本产品采用先进技术,具有高性能和低功耗的特点。",
"output": "这款产品用了很先进的技术,性能强还省电!"
},
# ... 更多数据
]
print("Alpaca 格式包含三个字段:")
print("- instruction:任务指令(必填)")
print("- input:额外输入(可选,没有的话可以为空字符串)")
print("- output:期望输出(必填)")
第二种是 ShareGPT 格式。这种格式用于对话数据,支持多轮对话。
格式是:conversations(对话列表),每一轮有 from(角色)和 value(内容)。
实例
# ShareGPT 数据格式示例
# ============================================
sharegpt_data = [
{
"conversations": [
{
"from": "human",
"value": "你好,我想了解一下你们的智能手表"
},
{
"from": "gpt",
"value": "您好!我们的智能手表有心率监测、睡眠追踪、GPS 定位等功能,还有 50 米防水。您有什么具体想了解的吗?"
},
{
"from": "human",
"value": "电池续航怎么样?"
},
{
"from": "gpt",
"value": "正常使用可以续航 7 天,开启 GPS 运动模式可以续航 12 小时。"
}
]
},
# ... 更多对话
]
print("ShareGPT 格式用于多轮对话数据:")
print("- conversations:对话列表")
print("- from:角色(通常是 human 和 gpt)")
print("- value:对话内容")
还有一种简单的输入-输出对格式,适合简单任务。
实例
# 简单输入-输出对格式
# ============================================
simple_data = [
{
"text": "用户:生成产品描述\n产品:保温杯\n\n助手:这款高品质保温杯采用..."
},
{
"text": "用户:生成产品描述\n产品:无线耳机\n\n助手:真无线蓝牙耳机,蓝牙 5.3..."
},
# ... 更多数据
]
print("这种格式直接把所有内容拼接到一个 text 字段里,最灵活但也最不标准化。")
哪种格式最好?Alpaca 格式最通用,大多数训练框架都支持。如果是对话任务,ShareGPT 更合适。
数据清洗要点
数据清洗比想象中重要得多。你的模型表现如何,80% 取决于数据质量。
-
第一,去重。重复的数据会让模型过度拟合那些重复出现的模式。检查并删除完全重复或高度相似的数据。
-
第二,过滤低质量数据。删除太短、太长、格式混乱、有明显错误的数据。比如输出只有几个字的,或者有乱码的。
-
第三,统一格式。比如有的输出用中文引号,有的用英文引号;有的用句号结尾,有的不用。尽量统一,这样模型学得更快。
-
第四,平衡分布。如果某类数据特别多,某类特别少,模型会偏向多数类。可以考虑过采样少数类,或者欠采样多数类。
-
第五,检查数据泄露。确保测试集和验证集没有出现在训练集里。
实例
# 数据清洗简单示例
# ============================================
import json
from typing import List, Dict
def clean_alpaca_data(data: List[Dict]) -> List[Dict]:
"""简单的数据清洗函数"""
cleaned = []
seen_outputs = set()
for item in data:
# 1. 检查必要字段是否存在
if "instruction" not in item or "output" not in item:
continue
instruction = item["instruction"].strip()
output = item["output"].strip()
# 2. 过滤太短的输出
if len(output) < 10:
continue
# 3. 过滤太长的输出(根据任务调整)
if len(output) > 2000:
continue
# 4. 去重(用输出做近似去重)
if output in seen_outputs:
continue
seen_outputs.add(output)
# 5. 简单的格式统一
# 比如把中文引号改成英文引号
output = output.replace(""", "\"").replace(""", "\"")
# 去除首尾空白
output = output.strip()
cleaned.append({
"instruction": instruction,
"input": item.get("input", "").strip(),
"output": output
})
return cleaned
# 加载数据
with open("data/training_data_raw.json", "r", encoding="utf-8") as f:
raw_data = json.load(f)
# 清洗数据
cleaned_data = clean_alpaca_data(raw_data)
print(f"原始数据量:{len(raw_data)}")
print(f"清洗后数据量:{len(cleaned_data)}")
print(f"过滤了 {len(raw_data) - len(cleaned_data)} 条低质量数据")
# 保存清洗后的数据
with open("data/training_data_cleaned.json", "w", encoding="utf-8") as f:
json.dump(cleaned_data, f, ensure_ascii=False, indent=2)
print("清洗后的数据已保存到 data/training_data_cleaned.json")
数据量的经验规则
需要多少数据?这是最常问的问题之一。
答案是:看任务难度。
-
简单任务(比如把一段文本改成更口语化):100-300 条可能就够了。
-
中等任务(比如生成特定格式的摘要):300-1000 条比较合适。
-
复杂任务(比如专业领域的问答):1000-5000 条比较理想。
当然,数据越多通常越好,但边际收益递减。1000 条数据比 500 条好很多,但 10000 条不一定比 5000 条好多少。
一个实用的建议是:先准备 500 条高质量数据,试跑一次。如果效果不好,再分析是数据不够、数据质量差、还是超参数问题,然后针对性改进。
数据质量 >> 数据数量。1000 条精心编写的数据,比 10000 条凑合的数据效果好得多。
数据准备阶段多花时间是值得的。模型训练只是"按个按钮"的事,但数据质量决定了那个按钮按下去有没有价值。
监督微调(SFT)实战
终于到了实战环节。我们用 Unsloth 或 LLaMA-Factory 来实现完整的微调流程。
环境配置
第一步,安装必要的库。
环境配置
# 安装依赖(推荐用 conda 创建新环境)
# ============================================
# 创建新的 conda 环境
conda create -n finetuning python=3.10 -y
conda activate finetuning
# 安装 PyTorch(根据你的 CUDA 版本选择合适的命令)
# 访问 https://pytorch.org/get-started/locally/ 获取最新命令
conda install pytorch pytorch-cuda=12.1 -c pytorch -c nvidia
# 安装 HuggingFace 生态库
pip install transformers datasets accelerate peft bitsandbytes
# 安装 Unsloth(可选,更快的训练速度)
pip install "unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git"
pip install --no-deps trl xformers
# 或者用 LLaMA-Factory(一站式训练框架)
git clone --depth 1 https://github.com/hiyouga/LLaMA-Factory.git
cd LLaMA-Factory
pip install -e ".[torch,metrics]"
echo "环境配置完成!"
使用 Unsloth/LLaMA-Factory
Unsloth 是一个优化的训练库,速度快、显存省。LLaMA-Factory 是一个一站式训练框架,支持多种模型和方法。
我们先看用 Unsloth + peft 训练的代码:
实例
# 使用 Unsloth 进行 LoRA 微调(完整版)
# 可以在 RTX 3090/4090 上运行
# ============================================
import torch
from datasets import load_dataset, Dataset
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
TrainingArguments,
BitsAndBytesConfig,
)
from peft import LoraConfig, get_peft_model
from trl import SFTTrainer
import json
# ============================================
# 1. 配置参数
# ============================================
MODEL_NAME = "unsloth/mistral-7b-v0.2-bnb-4bit" # 或其他模型
DATA_PATH = "data/training_data_cleaned.json" # 你的数据
OUTPUT_DIR = "output/runoob-ecommerce-model" # 输出目录
# 4-bit 量化配置
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16
)
# LoRA 配置
lora_config = LoraConfig(
r=16, # 秩,8-64 都可,16 是不错的起点
lora_alpha=16, # 缩放系数,通常等于 r
target_modules=[ # 要加 LoRA 的层,这些是常用选择
"q_proj",
"k_proj",
"v_proj",
"o_proj",
"gate_proj",
"up_proj",
"down_proj",
],
lora_dropout=0.05, # Dropout 防止过拟合
bias="none",
task_type="CAUSAL_LM" # 因果语言模型
)
# 训练参数
training_args = TrainingArguments(
output_dir=OUTPUT_DIR,
per_device_train_batch_size=4, # 每张卡的 batch size
per_device_eval_batch_size=4,
gradient_accumulation_steps=4, # 梯度累积,等效更大的 batch
learning_rate=2e-5, # 学习率,2e-5 到 5e-5 是常用范围
num_train_epochs=3, # 训练轮数,2-5 轮合适
logging_steps=10, # 每 10 步记录一次
evaluation_strategy="epoch", # 每个 epoch 评估一次
save_strategy="epoch", # 每个 epoch 保存一次
fp16=True, # 用混合精度训练
bf16=False, # 如果是 Ampere 及以上显卡(30/40 系),可以设 bf16=True
optim="paged_adamw_32bit", # 分页优化器,防止 OOM
report_to="none", # 不向 wandb 等平台报告
save_total_limit=3, # 最多保存 3 个 checkpoint
load_best_model_at_end=True, # 最后加载最佳模型
metric_for_best_model="eval_loss", # 用 eval loss 选最佳模型
)
# ============================================
# 2. 加载数据并格式化
# ============================================
def format_example(example):
"""将 Alpaca 格式数据格式化成模型输入"""
instruction = example["instruction"]
input_text = example.get("input", "")
output = example["output"]
if input_text.strip() == "":
# 没有额外输入的情况
prompt = f"""Below is an instruction that describes a task. Write a response that appropriately completes the request.
### Instruction:
{instruction}
### Response:
"""
else:
# 有额外输入的情况
prompt = f"""Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request.
### Instruction:
{instruction}
### Input:
{input_text}
### Response:
"""
# 完整文本是 prompt + output
return {
"prompt": prompt,
"completion": output,
"text": prompt + output
}
# 加载数据
dataset = load_dataset("json", data_files=DATA_PATH, split="train")
# 划分训练集和验证集(90% 训练,10% 验证)
dataset = dataset.train_test_split(test_size=0.1, seed=42)
train_dataset = dataset["train"]
eval_dataset = dataset["test"]
# 格式化数据
train_dataset = train_dataset.map(format_example)
eval_dataset = eval_dataset.map(format_example)
print(f"训练集大小:{len(train_dataset)}")
print(f"验证集大小:{len(eval_dataset)}")
# ============================================
# 3. 加载模型和 tokenizer
# ============================================
print("正在加载模型...")
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
tokenizer.pad_token = tokenizer.eos_token # 设置 pad token
tokenizer.padding_side = "right" # 从右边 padding
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
use_auth_token=True, # 如果需要 HuggingFace 登录
)
# 包装 LoRA
model = get_peft_model(model, lora_config)
model.print_trainable_parameters() # 打印可训练参数比例
# ============================================
# 4. 创建训练器并开始训练
# ============================================
trainer = SFTTrainer(
model=model,
train_dataset=train_dataset,
eval_dataset=eval_dataset,
args=training_args,
tokenizer=tokenizer,
peft_config=lora_config,
dataset_text_field="text", # 哪个字段包含完整文本
max_seq_length=2048, # 最大序列长度
packing=False, # 不打包样本
)
print("开始训练...")
trainer.train()
print("训练完成!")
# ============================================
# 5. 保存模型
# ============================================
# 保存 LoRA 适配器(体积小,几 MB 到几十 MB)
model.save_pretrained(f"{OUTPUT_DIR}/lora_adapter")
tokenizer.save_pretrained(f"{OUTPUT_DIR}/lora_adapter")
print(f"LoRA 适配器已保存到 {OUTPUT_DIR}/lora_adapter")
# 如果想合并 LoRA 权重(可选)
# merged_model = model.merge_and_unload()
# merged_model.save_pretrained(f"{OUTPUT_DIR}/merged_model")
# tokenizer.save_pretrained(f"{OUTPUT_DIR}/merged_model")
# print(f"合并后的模型已保存到 {OUTPUT_DIR}/merged_model")
如果你想用更简单的方式,可以用 LLaMA-Factory,它提供了 Web UI 和命令行两种方式:
实例
# 使用 LLaMA-Factory 训练
# ============================================
# 方式一:启动 Web UI(最直观)
cd LLaMA-Factory
python src/train_web.py
# 方式二:命令行训练(适合自动化)
python src/train.py \
--model_name_or_path mistralai/Mistral-7B-v0.2 \
--dataset runoob_ecommerce \
--dataset_dir ./data \
--output_dir ./output/runoob-model \
--finetuning_type lora \
--quantization_bit 4 \
--lora_rank 16 \
--lora_alpha 16 \
--lora_dropout 0.05 \
--lora_target q_proj,v_proj,k_proj,o_proj,gate_proj,up_proj,down_proj \
--per_device_train_batch_size 4 \
--gradient_accumulation_steps 4 \
--lr_scheduler_type cosine \
--logging_steps 10 \
--save_strategy epoch \
--learning_rate 2e-5 \
--num_train_epochs 3 \
--fp16 true \
--plot_loss true
训练超参数设置
超参数选择是一门艺术,但也有一些经验规则。
学习率(Learning Rate):这是最重要的超参数。对 LoRA 来说,2e-5 到 5e-5 是安全的起点。如果训练不稳定(loss 震荡),就调小一点。如果训练太慢(loss 下降很慢),就调大一点。
Batch Size:越大越好,但受显存限制。per_device_train_batch_size 设为 2、4、8 都可以。如果显存不够,就用 gradient_accumulation_steps 来累积梯度。比如 batch_size=2,accumulation_steps=4,等效 batch_size=8。
Epochs:2-5 轮比较合适。太少模型没学会,太多可能过拟合。可以看 eval loss,当 eval loss 不再下降时就可以停止了。
LoRA Rank(r):8-64 都可以。16 是一个不错的起点。小任务用 8,复杂任务用 32 或 64。
Max Sequence Length:根据你的数据决定。512、1024、2048、4096 都常见。越长需要的显存越多。
| 超参数 | 推荐起始值 | 调整方向 |
|---|---|---|
| learning_rate | 2e-5 | loss 震荡就减小,下降太慢就增大 |
| per_device_train_batch_size | 4 | 显存够就增大,不够就减小 |
| gradient_accumulation_steps | 4 | 等效 batch = batch_size × accumulation |
| num_train_epochs | 3 | 过拟合就减少,欠拟合就增加 |
| lora_rank (r) | 16 | 简单任务 8,复杂任务 32-64 |
| max_seq_length | 1024 或 2048 | 根据你的数据长度调整 |
训练监控与 Loss 解读
训练时要关注几个指标。
Train Loss:训练集上的损失,应该 steadily 下降。如果下降很快,说明学习率可能太大或者数据太少。如果下降很慢,说明学习率太小或者模型容量不够。
-
Eval Loss:验证集上的损失,这是最重要的指标。它应该和 train loss 一起下降。如果 eval loss 开始上升而 train loss 继续下降,说明过拟合了,应该停止训练。
过拟合的表现:eval loss 先降后升,而 train loss 持续下降。解决方法:减少 epoch、增加 dropout、增加数据、减小 LoRA rank。
欠拟合的表现:train loss 和 eval loss 都很高,而且下降很慢。解决方法:增加 epoch、增大 LoRA rank、增加数据、稍微增大学习率。
微调效果评估
训练完了,怎么知道模型好不好?
评估指标选择
评估方式取决于你的任务类型。
第一种是人工评估。让人来读模型的输出,给质量打分。这是最可靠的方式,但也是最耗时的。
可以做一个简单的评分表:
实例
# 简单的人工评估模板
# ============================================
evaluation_prompts = [
{
"instruction": "生成一个电商产品描述",
"input": "产品名称:便携咖啡机\n功能:一键萃取、便携设计、USB充电",
"reference": "这款便携咖啡机让你随时随地享受现磨咖啡。一键萃取操作简单,小巧便携的设计适合办公室、旅行、户外使用。USB 充电设计,无需担心电源问题。"
},
{
"instruction": "生成一个电商产品描述",
"input": "产品名称:机械键盘\n特点:青轴、RGB背光、87键、PBT键帽",
"reference": "这款 87 键机械键盘采用清脆的青轴,打字手感出色。全键 RGB 背光,夜晚使用也清晰。PBT 键帽耐用不打油,长期使用依然如新。"
},
# ... 10-20 个测试用例
]
def evaluate_model(model, tokenizer, prompts):
"""用一组提示词评估模型"""
results = []
for item in prompts:
# 生成输出
inputs = format_example_for_inference(item)
output = generate_with_model(model, tokenizer, inputs)
results.append({
"instruction": item["instruction"],
"input": item["input"],
"model_output": output,
"reference": item["reference"],
# 留空让人来评分
"score_relevance": None, # 1-5,相关性
"score_quality": None, # 1-5,质量
"score_format": None, # 1-5,格式正确性
})
return results
# 运行评估
results = evaluate_model(model, tokenizer, evaluation_prompts)
# 保存结果,后续人工打分
with open("evaluation_results.json", "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print("评估结果已保存,请人工打分后再分析")
第二种是自动评估。用 BLEU、ROUGE、BLEURT 等指标。这些指标不完美,但可以做快速迭代。
第三种是 A/B 测试。把旧模型和新模型的输出混在一起让人盲评,看新模型是不是真的更好。
防止过拟合
过拟合是微调最常见的问题。表现是训练集上表现很好,但新数据上表现很差。
防止过拟合的方法:
1. 早停(Early Stopping):监控 eval loss,当它停止下降时就停止训练。
2. 增加数据:更多、更多样的数据是最好的防过拟合方法。
3. 数据增强:改写现有数据,创造变化。
4. 减小模型容量:减小 LoRA rank,减少 LoRA 的层数。
5. 增加 Dropout:LoRA 配置里的 lora_dropout 设为 0.05 或 0.1。
6. 权重衰减(Weight Decay):在训练参数里加 weight_decay=0.01。
微调模型部署
模型训练好了,怎么用起来?
加载 LoRA 进行推理
第一种方式是加载原模型,再加载 LoRA 适配器。
实例
# 加载 LoRA 并进行推理
# ============================================
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
def load_finetuned_model(base_model_name, lora_adapter_path):
"""加载基础模型和 LoRA 适配器"""
# 加载 tokenizer
tokenizer = AutoTokenizer.from_pretrained(base_model_name)
tokenizer.pad_token = tokenizer.eos_token
# 加载基础模型
model = AutoModelForCausalLM.from_pretrained(
base_model_name,
device_map="auto",
torch_dtype=torch.float16,
trust_remote_code=True,
)
# 加载 LoRA 适配器
model = PeftModel.from_pretrained(model, lora_adapter_path)
# 合并权重(可选,这样推理更快)
model = model.merge_and_unload()
return model, tokenizer
def generate_product_description(model, tokenizer, product_name, features):
"""生成电商产品描述"""
# 构造提示词(和训练时一样!)
prompt = f"""Below is an instruction that describes a task. Write a response that appropriately completes the request.
### Instruction:
生成一个电商产品描述
### Input:
产品名称:{product_name}
功能:{features}
### Response:
"""
# 生成
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=200,
temperature=0.7, # 控制随机性,0-1,越大越随机
top_p=0.9,
do_sample=True,
pad_token_id=tokenizer.eos_token_id,
)
# 解码输出
generated_text = tokenizer.decode(outputs[0], skip_special_tokens=True)
# 只取 Response 部分
response = generated_text.split("### Response:")[-1].strip()
return response
# ============================================
# 使用示例
# ============================================
BASE_MODEL = "mistralai/Mistral-7B-v0.2"
LORA_PATH = "output/runoob-ecommerce-model/lora_adapter"
print("正在加载模型...")
model, tokenizer = load_finetuned_model(BASE_MODEL, LORA_PATH)
print("模型加载完成!")
# 测试生成
product_name = "智能扫地机器人"
features = "激光导航、自动回充、APP控制、2000Pa大吸力"
description = generate_product_description(
model, tokenizer, product_name, features
)
print(f"\n产品名称:{product_name}")
print(f"生成的描述:\n{description}")
部署方式
部署有几种常见选择。
-
第一种是用 vLLM。这是一个高性能的推理引擎,支持 LoRA、连续批处理等功能。生产环境推荐。
-
第二种是用 Text Generation Inference。HuggingFace 官方的推理服务,功能全面。
-
第三种是用 FastAPI 自己封装。简单直接,适合小规模部署。
-
第四种是用 Ollama。把模型转成 GGUF 格式,用 Ollama 运行,适合本地部署。
