现在位置: 首页 > AI 入门教程 > 正文

模型微调 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
7B50-60 GB2×A100 (40GB) 或 1×A100 (80GB)
13B90-100 GB3×A100 (40GB) 或 2×A100 (80GB)
70B400+ GB12×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量化 + LoRA0.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越大容量越大,但参数量也越大
alphar 或 1缩放系数,通常设为 r
target_modules["q_proj", "v_proj"]要加 LoRA 的层
lora_dropout0.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
7B5-8 GBRTX 3060/4060 及以上
13B8-12 GBRTX 3070/4070 及以上
34B15-20 GBRTX 3090/4090
70B30-40 GBRTX 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_rate2e-5loss 震荡就减小,下降太慢就增大
per_device_train_batch_size4显存够就增大,不够就减小
gradient_accumulation_steps4等效 batch = batch_size × accumulation
num_train_epochs3过拟合就减少,欠拟合就增加
lora_rank (r)16简单任务 8,复杂任务 32-64
max_seq_length1024 或 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 运行,适合本地部署。