Skip to content

12. 模型微调与训练

面向大模型应用工程师/Agent 开发工程师的高频面试题 每个知识点:问题 → 答案 → 追问,难度标记:⭐基础 ⭐⭐进阶 ⭐⭐⭐高级


一、微调基础

Q: 什么是微调(Fine-tuning)?为什么需要微调?⭐

答:

微调是在一个已经预训练好的大模型基础上,用特定领域或任务的数据继续训练,让模型学会"你的专属技能"。

类比理解: 预训练模型就像一个大学毕业生,什么都会一点但不精通。微调就像让他去某个公司实习——他不需要重新学认字、学算数(这些预训练已经教会了),只需要学习这家公司的业务知识和工作规范。

为什么需要微调?

场景不微调的效果微调后的效果
医疗问答泛泛而谈,可能给出错误建议准确引用医学知识,措辞专业
客服对话通用回答,不符合品牌调性语气一致,熟悉产品细节
代码生成通用风格,不了解你的框架遵循团队代码规范,使用内部 API
特定格式输出输出不稳定,格式混乱始终输出指定 JSON/XML 格式

微调的核心价值:

  1. 领域适配:让模型掌握专业术语和知识
  2. 风格对齐:让模型的输出符合特定语气、格式
  3. 能力增强:在特定任务上超越通用模型
  4. 成本降低:微调后的小模型可能比大模型 prompt 效果更好,推理成本更低
python
# 简单理解微调流程
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments, Trainer

# 1. 加载预训练模型(大学毕业生)
model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2-7B")
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2-7B")

# 2. 准备你的领域数据(实习材料)
# 这些数据教会模型你的"业务"
training_data = [
    {"instruction": "请分析这张CT影像", "output": "该CT影像显示..."},
    {"instruction": "解释血常规报告中的白细胞升高", "output": "白细胞升高可能表示..."},
]

# 3. 训练(实习过程)
training_args = TrainingArguments(
    output_dir="./medical_model",
    num_train_epochs=3,
    per_device_train_batch_size=4,
    learning_rate=2e-5,
)
# trainer = Trainer(model=model, args=training_args, train_dataset=dataset)
# trainer.train()

追问:微调和从头训练(Pre-training)有什么区别?

维度预训练(Pre-training)微调(Fine-tuning)
数据量万亿 token几千到几十万条
计算成本数百万美元几十到几千美元
目标学会语言的通用规律学会特定任务/领域
训练方式无监督(自回归/MLM)有监督(指令-回答对)
时间数周到数月数小时到数天

Q: 全量微调 vs 参数高效微调(PEFT)的区别?⭐⭐

答:

全量微调(Full Fine-tuning):更新模型的所有参数。就像把一栋大楼全部拆了重建。

参数高效微调(PEFT, Parameter-Efficient Fine-Tuning):只更新模型的一小部分参数,冻结大部分原始参数。就像只装修几个房间,大楼结构不变。

全量微调:  [全部参数 70亿] 都参与训练 → 需要巨大显存
PEFT微调:  [训练参数 0.1亿] + [冻结参数 69.9亿] → 显存大幅减少

主流 PEFT 方法对比:

方法原理可训练参数占比代表工作
LoRA在权重矩阵旁加低秩适配器0.1%~1%LoRA
QLoRALoRA + 4bit 量化0.1%~1%QLoRA
Prefix Tuning在输入前加可训练前缀向量0.1%~1%Prefix-Tuning
Prompt Tuning只训练 prompt embedding<0.01%P-Tuning v2
Adapter在 Transformer 层间插入小模块1%~5%Adapter
IA3缩放 K/V/FFN 的激活值<0.01%IA3
python
# 全量微调 - 需要的显存
# 7B 模型 FP16 训练 ≈ 7B × 2 bytes × (参数 + 梯度 + 优化器状态)
# ≈ 7B × 2 × 4 ≈ 56 GB 显存(至少需要 A100 80G)

# LoRA 微调 - 需要的显存
from peft import LoraConfig, get_peft_model, TaskType

lora_config = LoraConfig(
    task_type=TaskType.CAUSAL_LM,
    r=16,                    # 低秩维度
    lora_alpha=32,           # 缩放系数
    lora_dropout=0.05,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
                    "gate_proj", "up_proj", "down_proj"],
)

model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2-7B")
peft_model = get_peft_model(model, lora_config)

# 打印可训练参数量
peft_model.print_trainable_parameters()
# 输出: trainable params: 13,107,200 || all params: 7,615,616,000 || trainable%: 0.172%
# 只需 ~16 GB 显存,单张 3090/4090 即可

追问:什么时候该用全量微调,什么时候用 PEFT?

  • 用 PEFT:显卡资源有限、数据量不太大(<10万条)、多个任务需要不同适配器、快速迭代实验
  • 用全量微调:数据量充足(>10万条)、追求极致效果、目标和预训练差异很大(如中文模型做英文任务)
  • 实际工程:90% 的场景用 PEFT(尤其是 LoRA/QLoRA)就够了

Q: 什么时候用 RAG,什么时候用微调?⭐⭐

答:

这是面试中极高频的问题。核心区别在于:

  • RAG:不改模型,给模型"开卷考试"——每次回答时先检索相关文档,再让模型参考文档回答
  • 微调:改模型本身,让模型"闭卷考试"——把知识和能力内化到模型参数里
RAG:  用户提问 → 检索相关文档 → 拼成 prompt → 模型回答
微调: 用户提问 → 模型直接回答(知识已内化在参数中)

选型决策树:

你的需求是什么?
├── 知识经常更新(新闻、产品信息、政策法规)
│   └── → RAG(更新文档即可,不用重新训练)
├── 需要引用原文/出处
│   └── → RAG(天然支持溯源)
├── 数据量很少(<100条)
│   └── → RAG(微调数据太少容易过拟合)
├── 需要改变模型的风格/语气/格式
│   └── → 微调(RAG 改变不了模型的行为模式)
├── 需要压缩模型到更小的尺寸
│   └── → 微调(知识蒸馏)
├── 需要学习复杂的新能力(如新语言、新推理模式)
│   └── → 微调
└── 既要最新知识又要风格对齐
    └── → RAG + 微调 组合使用(最佳实践)

实际案例对比:

场景推荐方案原因
公司内部知识问答RAG知识会更新,需要引用来源
客服机器人(固定话术)微调需要统一语气,内容相对固定
法律文书生成RAG + 微调RAG 提供法条,微调学格式风格
代码助手微调学习编程模式,不需要实时检索
电商商品问答RAG商品信息频繁变化
python
# RAG 方案示意
from langchain.vectorstores import FAISS
from langchain.embeddings import HuggingFaceEmbeddings

# 建立知识库索引
embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-base-zh")
vectorstore = FAISS.from_documents(documents, embeddings)

# 用户提问时检索
query = "退货政策是什么?"
relevant_docs = vectorstore.similarity_search(query, k=3)
context = "\n".join([doc.page_content for doc in relevant_docs])

# 构造 prompt
prompt = f"""基于以下参考资料回答问题:
{context}

问题:{query}
回答:"""
# 交给 LLM 生成回答

# 微调方案示意(适合风格/格式固定场景)
training_examples = [
    {"instruction": "写一封退款确认邮件",
     "output": "尊敬的客户,您好!\n\n感谢您的反馈。我们已收到您的退款申请..."},
    # 更多风格一致的训练数据
]

追问:RAG 和微调能同时用吗?效果会更好吗?

可以!这是工业界的最佳实践。微调让模型更擅长"理解指令"和"生成格式化回答",RAG 提供最新、最准确的领域知识。相当于模型既有能力(微调),又有资料(RAG)。例如:先用内部客服对话数据微调模型的对话风格,再用 RAG 检索产品手册来补充具体产品信息。


二、LoRA 与 QLoRA

Q: LoRA 的原理是什么?为什么能大幅减少训练参数?⭐⭐

答:

LoRA(Low-Rank Adaptation)的核心思想来自一个重要的观察:模型在微调时,权重的变化量是低秩的

类比理解: 想象一个 1000×1000 的大表格(权重矩阵),全量微调要更新所有 100 万个格子。但 LoRA 发现,微调导致的变化其实可以用两个小表格(1000×16 和 16×1000)相乘来近似表达。存储两个小表格只需要 32000 个数字,远小于 100 万个。

原始权重 W: [d × d] = [1000 × 1000] = 1,000,000 参数

LoRA 分解: W + ΔW = W + B × A
  A: [r × d] = [16 × 1000] = 16,000 参数
  B: [d × r] = [1000 × 16] = 16,000 参数
  共计: 32,000 参数(减少 97%!)

具体做法:

  1. 冻结原始权重 W 不动
  2. 在旁边加两个小矩阵 A 和 B
  3. 只训练 A 和 B
  4. 推理时,把 B×A 加回 W:output = W·x + B·A·x
python
import torch
import torch.nn as nn

class LoRALinear(nn.Module):
    """手写一个 LoRA 层来理解原理"""

    def __init__(self, original_linear: nn.Linear, r: int = 8, alpha: int = 16):
        super().__init__()
        self.original_linear = original_linear
        # 冻结原始权重
        self.original_linear.weight.requires_grad = False
        if self.original_linear.bias is not None:
            self.original_linear.bias.requires_grad = False

        d_out, d_in = original_linear.weight.shape

        # LoRA 的两个小矩阵
        self.lora_A = nn.Parameter(torch.randn(r, d_in) * 0.01)  # 降维
        self.lora_B = nn.Parameter(torch.zeros(d_out, r))         # 升维,初始化为0

        self.scaling = alpha / r  # 缩放系数

    def forward(self, x):
        # 原始输出 + LoRA 输出
        original_output = self.original_linear(x)
        lora_output = (x @ self.lora_A.T @ self.lora_B.T) * self.scaling
        return original_output + lora_output

# 使用示例
linear = nn.Linear(4096, 4096)  # 原始线性层: 4096×4096 = 16,777,216 参数
lora_linear = LoRALinear(linear, r=8)  # LoRA: 8×4096 + 4096×8 = 65,536 参数

# 参数量对比
print(f"原始参数: {4096*4096:,}")  # 16,777,216
print(f"LoRA参数: {8*4096 + 4096*8:,}")  # 65,536 (减少 99.6%)

追问:LoRA 为什么初始化 B 为 0?

因为初始化时希望 LoRA 不改变原始模型的行为。如果 B 初始化为 0,那么 B&times;A = 0,模型输出和原始模型完全一致。随着训练进行,B 逐渐变大,LoRA 的效果才慢慢显现。这保证了训练的稳定性——模型从"原始能力"出发,逐步学习新能力。


Q: LoRA 的数学原理?低秩分解是什么?⭐⭐⭐

答:

低秩(Low-Rank) 是线性代数中的概念。一个矩阵的秩(Rank)表示它包含的"独立信息量"。

类比理解: 想象一个 1000 人的考试成绩表,1000 科。如果所有科目都可以归结为"文科能力"和"理科能力"两个维度,那么虽然表格很大(1000×1000),实际独立变量只有 2 个(秩=2)。这就是低秩——大矩阵的信息可以用更少的维度来表达。

数学推导:

原始微调:W' = W + ΔW,其中 ΔW 是 d×d 的全秩矩阵,有 d² 个参数。

LoRA 假设 ΔW 是低秩的,可以分解为:

ΔW = B × A
其中 B ∈ R^(d×r), A ∈ R^(r×d), r << d

参数量从 d² 变为 2dr,当 r=8, d=4096 时:

  • 原始:4096² = 16,777,216
  • LoRA:2 × 4096 × 8 = 65,536
  • 减少 99.6%

为什么微调时 ΔW 是低秩的?

这是 LoRA 论文的核心贡献——通过实验观察到:

  1. 预训练模型已经学到了丰富的通用知识(全秩)
  2. 微调只是在此基础上做"小幅调整"
  3. 这个调整集中在少数几个方向上(低秩)
  4. Aghajanyan et al. (2020) 的研究证实:预训练模型的内在维度远低于参数量
python
import numpy as np

# 演示低秩矩阵
d = 1000  # 大矩阵维度
r = 4     # 秩

# 生成一个秩为 r 的矩阵
B = np.random.randn(d, r)
A = np.random.randn(r, d)
low_rank_matrix = B @ A  # 1000×1000 的矩阵,但秩只有 4

# 验证:奇异值分解
U, S, Vt = np.linalg.svd(low_rank_matrix)
print(f"非零奇异值个数: {np.sum(S > 1e-10)}")  # 输出: 4

# 这个大矩阵虽然有 100 万个元素,但本质上只有 4 个自由度
# 存储 B 和 A 只需要 2 × 1000 × 4 = 8000 个数字
# 远少于 100 万个

追问:α(alpha)参数是什么?为什么需要 scaling?

scaling = α/r,作用是控制 LoRA 的影响力度。α 固定时,增大 r 会降低每个 LoRA 参数的影响(因为 scaling 变小),这让调参更稳定。通常设 α = 2r(如 r=16, α=32)。直觉上,rank 越大,LoRA 的输出越大,需要 α 来做归一化,防止 LoRA 的效果过于剧烈。


Q: QLoRA 是什么?比 LoRA 好在哪里?⭐⭐

答:

QLoRA = Quantization(量化)+ LoRA。它在 LoRA 的基础上加了三个关键技术:

  1. 4-bit NormalFloat (NF4):用 4 比特量化预训练模型权重
  2. 双重量化 (Double Quantization):对量化常数也做量化,进一步省内存
  3. 分页优化器 (Paged Optimizers):利用 CPU 内存避免显存 OOM

类比理解: LoRA 是"只装修几个房间",QLoRA 是"先把整栋楼压缩成迷你模型,再装修几个房间"。装修完后,迷你模型展开回正常大小使用。

LoRA:  [原始模型 FP16 ≈ 14GB] + [LoRA 适配器 ≈ 0.1GB]  → 需要 ~16GB 显存
QLoRA: [原始模型 NF4  ≈ 3.5GB] + [LoRA 适配器 ≈ 0.1GB]  → 需要 ~6GB 显存

实际影响: 7B 模型微调从需要 A100 80G 降低到只需要 RTX 3090/4090 24G,甚至 16G 显卡也能跑。

python
# QLoRA 配置(使用 bitsandbytes 量化 + peft)
from transformers import BitsAndBytesConfig
import torch

# 4-bit 量化配置
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,                    # 启用 4-bit 加载
    bnb_4bit_quant_type="nf4",            # 使用 NF4 量化格式
    bnb_4bit_compute_dtype=torch.bfloat16,# 计算时用 BF16
    bnb_4bit_use_double_quant=True,       # 双重量化,再省 0.4bit/参数
)

# 加载量化模型
model = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2-7B",
    quantization_config=bnb_config,
    device_map="auto",
)

# 然后正常配置 LoRA
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training

model = prepare_model_for_kbit_training(model)  # 准备量化模型

lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)

model = get_peft_model(model, lora_config)
# 7B 模型 QLoRA 微调只需 ~6GB 显存!

追问:QLoRA 量化后精度损失大吗?

实验表明精度损失很小(<1%)。原因:(1) NF4 是专门为正态分布的神经网络权重设计的量化格式,比普通 INT4 更优;(2) LoRA 的适配器仍然用 FP16/BF16 训练,高精度梯度信号不受影响;(3) 推理时反量化回 FP16 再加上 LoRA 权重,精度几乎无损。


Q: LoRA 的 rank 怎么选?过大过小有什么影响?⭐⭐

答:

rank(r)是 LoRA 的核心超参数,决定了"适配器的容量"。

类比理解: rank 就像笔记本的页数。页数太少(rank 太小),记不下所有新知识;页数太多(rank 太大),浪费空间,而且可能把无关的噪声也记进去了。

rank 过小(如 r=1):模型容量不足,学不到复杂任务的细节 → 欠拟合
rank 过大(如 r=256):参数量增加,训练变慢,可能过拟合,优势不明显
rank 适中(如 r=8~64):平衡容量和效率,通常是最佳选择

选择经验:

场景推荐 rank原因
简单任务(情感分类、格式对齐)4~8任务简单,不需要大容量
中等任务(领域问答、对话)16~32大多数场景的默认选择
复杂任务(代码生成、数学推理)64~128需要更多参数来学习复杂模式
多任务微调32~128需要更大容量来容纳多种能力
python
# rank 对比实验
from peft import LoraConfig, get_peft_model

def count_lora_params(model_name, r):
    """计算不同 rank 下的可训练参数量"""
    model = AutoModelForCausalLM.from_pretrained(model_name)
    config = LoraConfig(r=r, target_modules=["q_proj", "v_proj"])
    peft_model = get_peft_model(model, config)
    trainable = sum(p.numel() for p in peft_model.parameters() if p.requires_grad)
    total = sum(p.numel() for p in peft_model.parameters())
    print(f"rank={r}: trainable={trainable:,} ({trainable/total*100:.2f}%)")
    return trainable

# 以 Qwen2-7B 为例
# rank=4:   trainable≈3,276,800   (0.04%)
# rank=8:   trainable≈6,553,600   (0.09%)
# rank=16:  trainable≈13,107,200  (0.17%)
# rank=64:  trainable≈52,428,800  (0.69%)
# rank=256: trainable≈209,715,200 (2.76%)

追问:有没有自动选择 rank 的方法?

有!LoRA+AdaLoRA 可以自适应调整每个层的 rank。AdaLoRA 的思路是:重要的层分配高 rank,不重要的层分配低 rank。它通过 SVD(奇异值分解)动态评估每个 LoRA 模块的重要性,自动裁剪不重要的维度。不过实际工程中,大多数人还是手动选择 r=16 或 r=32 作为起点。


Q: LoRA 的 target_modules 怎么选?⭐⭐

答:

target_modules 决定在模型的哪些层/模块上加 LoRA 适配器。

Transformer 架构回顾:

输入 → [Attention] → [FFN] → 输出
         ├── q_proj    ├── gate_proj
         ├── k_proj    ├── up_proj
         ├── v_proj    └── down_proj
         └── o_proj

选择策略:

python
# 策略 1: 只加在 Attention 的 Q 和 V 上(LoRA 原论文推荐)
config_v1 = LoraConfig(target_modules=["q_proj", "v_proj"])

# 策略 2: 加在 Attention 全部投影上(更常用)
config_v2 = LoraConfig(target_modules=["q_proj", "k_proj", "v_proj", "o_proj"])

# 策略 3: Attention + FFN 全加(效果最好,但参数更多)
config_v3 = LoraConfig(target_modules=[
    "q_proj", "k_proj", "v_proj", "o_proj",
    "gate_proj", "up_proj", "down_proj"
])

# 策略 4: 用通配符匹配(省事)
config_v4 = LoraConfig(target_modules="all-linear")  # PEFT 新版支持

不同策略的效果对比:

策略可训练参数效果适用场景
Q+V only最少一般快速实验
Q+K+V+O适中良好大多数场景
Attention + FFN较多最佳追求极致效果

追问:怎么知道模型有哪些可加 LoRA 的模块名?

python
# 打印模型结构找到所有线性层的名字
model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2-7B")
for name, module in model.named_modules():
    if isinstance(module, torch.nn.Linear):
        print(name)

# 输出类似:
# model.layers.0.self_attn.q_proj
# model.layers.0.self_attn.k_proj
# model.layers.0.self_attn.v_proj
# model.layers.0.self_attn.o_proj
# model.layers.0.mlp.gate_proj
# model.layers.0.mlp.up_proj
# model.layers.0.mlp.down_proj
# ...

三、数据工程

Q: 微调数据怎么准备?有哪些格式?⭐

答:

微调数据的格式取决于你用的训练框架。主流格式有三种:

1. Alpaca 格式(最常用):

json
{
    "instruction": "请将以下英文翻译成中文",
    "input": "Hello, how are you?",
    "output": "你好,你好吗?"
}

2. ShareGPT/对话格式:

json
{
    "conversations": [
        {"from": "human", "value": "解释量子纠缠"},
        {"from": "gpt", "value": "量子纠缠是一种量子力学现象..."},
        {"from": "human", "value": "能举个例子吗?"},
        {"from": "gpt", "value": "想象你有一对手套..."}
    ]
}

3. ChatML 格式(Qwen 等模型使用):

json
{
    "messages": [
        {"role": "system", "content": "你是一个专业的医疗助手"},
        {"role": "user", "content": "头痛怎么办?"},
        {"role": "assistant", "content": "头痛可能由多种原因引起..."}
    ]
}
python
# 数据准备的完整流程
import json

# 1. 原始数据收集
raw_data = []
with open("customer_service_logs.jsonl") as f:
    for line in f:
        raw_data.append(json.loads(line))

# 2. 转换为训练格式
training_data = []
for item in raw_data:
    training_data.append({
        "instruction": item["question"],
        "output": item["best_answer"],
    })

# 3. 保存为 JSONL
with open("train.jsonl", "w") as f:
    for item in training_data:
        f.write(json.dumps(item, ensure_ascii=False) + "\n")

# 4. 使用 Hugging Face datasets 加载
from datasets import load_dataset
dataset = load_dataset("json", data_files="train.jsonl", split="train")

追问:一条训练数据大概多长合适?

  • 指令长度:50~500 tokens 为宜,太长可以拆分
  • 回答长度:取决于任务,通常 100~2000 tokens
  • 总长度:不要超过模型的最大上下文长度(如 4096)
  • 太长的样本建议截断或拆分,否则浪费训练算力

Q: 数据质量 vs 数据数量?哪个更重要?⭐⭐

答:

数据质量远比数据数量重要。 这是 LLM 微调领域最重要的经验之一。

类比理解: 就像学英语——看 10 本语法错误百出的小说,不如精读 1 本地道的英文原著。1000 条高质量指令数据的效果,可能超过 10 万条低质量数据。

关键研究证据:

  • LIMA 论文(Meta, 2023)只用 1000 条精心挑选的高质量数据微调 LLaMA-65B,效果接近 GPT-4
  • Alpaca 用 52K 条 GPT-4 生成的数据微调,效果已经不错
  • 但如果你用 52K 条低质量数据(重复、矛盾、错误),模型反而会变差
质量优先级排序:
1. 正确性:答案必须是对的(最重要!)
2. 多样性:覆盖各种场景和问题类型
3. 一致性:相同类型的问题回答风格一致
4. 完整性:回答要充分,不能太简略
5. 数量:在保证以上四点的基础上,越多越好
python
# 常见的数据质量问题示例

# ❌ 差的数据 - 答案太简略
{"instruction": "什么是机器学习?", "output": "机器学习是AI的一个分支。"}

# ❌ 差的数据 - 答案有错误
{"instruction": "Python 中 list 和 tuple 的区别?",
 "output": "list 是不可变的,tuple 是可变的。"}  # 说反了!

# ❌ 差的数据 - 多条重复(换个说法问同一个问题)
{"instruction": "LoRA 是什么?", "output": "..."}
{"instruction": "请介绍 LoRA", "output": "..."}  # 和上面重复
{"instruction": "能说说 LoRA 吗?", "output": "..."}  # 又重复

# ✅ 好的数据 - 详细、正确、格式清晰
{
    "instruction": "什么是 LoRA?请用通俗的语言解释",
    "output": """LoRA(Low-Rank Adaptation)是一种高效微调技术。
    核心思想是:在预训练模型的权重矩阵旁边,加两个小矩阵来学习新知识。
    打个比方,就像你不需要重新印刷一本书,只需要在书页边写批注。
    优点:只训练 0.1%~1% 的参数,显存需求大幅降低。
    典型应用:用单张消费级显卡(如 RTX 3090)微调 7B 大模型。"""
}

追问:怎么衡量数据质量?

  1. 人工抽检:随机抽取 50~100 条,人工检查正确性和质量
  2. 模型打分:用 GPT-4 等强模型对每条数据评分(1-5 分),过滤低分数据
  3. 一致性检查:类似问题的回答不应该矛盾
  4. 长度分布:检查回答长度分布,过滤过短(<20字)和过长的异常数据
  5. 去重:用相似度检测去掉重复或近似重复的数据

Q: 什么是数据清洗?微调数据需要怎么清洗?⭐⭐

答:

数据清洗就是去除训练数据中的"垃圾",保证模型学到的是正确、干净的知识。

类比理解: 就像做饭前要洗菜——烂叶子扔掉,泥沙冲干净,农药残留去除。你不会把没洗的菜直接下锅。

微调数据需要清洗的维度:

python
import re
from difflib import SequenceMatcher

def clean_training_data(data: list[dict]) -> list[dict]:
    """微调数据清洗 pipeline"""
    cleaned = []
    seen_outputs = set()

    for item in data:
        instruction = item.get("instruction", "").strip()
        output = item.get("output", "").strip()

        # 1. 去除空数据
        if not instruction or not output:
            continue

        # 2. 去除过短数据(无学习价值)
        if len(instruction) < 5 or len(output) < 20:
            continue

        # 3. 去除过长数据(可能是爬虫抓到的长文本)
        if len(instruction) > 2000 or len(output) > 5000:
            continue

        # 4. 去除乱码/特殊字符过多的数据
        special_ratio = len(re.findall(r'[^\w\s\u4e00-\u9fff,。!?、;:""''()]', output)) / max(len(output), 1)
        if special_ratio > 0.3:
            continue

        # 5. 去除重复数据
        output_hash = hash(output[:200])
        if output_hash in seen_outputs:
            continue
        seen_outputs.add(output_hash)

        # 6. 去除包含敏感信息的数据(手机号、身份证等)
        if re.search(r'1[3-9]\d{9}', output):  # 手机号
            continue
        if re.search(r'\d{17}[\dXx]', output):  # 身份证
            continue

        cleaned.append({"instruction": instruction, "output": output})

    print(f"清洗前: {len(data)} 条, 清洗后: {len(cleaned)} 条, 去除: {len(data)-len(cleaned)} 条")
    return cleaned

清洗 checklist:

检查项说明处理方式
空值/缺失字段为空删除
重复数据多条内容相同或相似去重
乱码爬虫抓取的 HTML 标签等正则清洗
错误答案事实性错误人工审核/模型过滤
敏感信息PII 数据(姓名、电话等)脱敏或删除
语言不一致中文指令配英文回答过滤或翻译
格式问题缺少标点、断行异常格式化修复

追问:数据清洗大概要花多少时间?

实际项目中,数据工程(收集+清洗+标注)通常占整个微调项目 60%~80% 的时间。很多人低估了数据准备的工作量。建议先花 1 周做好数据清洗,可能比调 1 周超参数效果更好。


Q: 如何构造高质量的指令数据?⭐⭐

答:

高质量指令数据是微调成功的关键。构造方法有多种:

方法 1:人工编写(质量最高,成本最高)

python
# 人工编写的高质量指令示例
# 关键:指令要多样、回答要专业且详细

examples = [
    {
        "instruction": "解释 Python 中的 GIL(全局解释器锁)",
        "input": "",
        "output": """GIL(Global Interpreter Lock)是 CPython 解释器中的一把全局锁。

**核心机制:** 在任意时刻,只有一个线程能执行 Python 字节码。即使在多核 CPU 上,Python 多线程也无法实现真正的并行计算。

**为什么需要 GIL:** CPython 的内存管理不是线程安全的。GIL 简化了 CPython 的实现,让单线程程序运行更快。

**影响:**
- CPU 密集型任务:多线程几乎无加速效果,建议用 multiprocessing
- I/O 密集型任务:GIL 在等待 I/O 时会释放,多线程仍有加速

**替代方案:**
1. 使用 multiprocessing 实现真正并行
2. 使用 asyncio 处理 I/O 密集任务
3. 使用 C 扩展(如 NumPy)在底层释放 GIL
4. 使用其他 Python 实现(如 Jython, PyPy STM)"""
    },
]

方法 2:用 GPT-4/Claude 批量生成(性价比最高)

python
import openai

def generate_instruction_data(seed_topic: str, n: int = 100) -> list[dict]:
    """用 GPT-4 生成微调数据"""

    prompt = f"""你是一个高质量训练数据生成器。请围绕"{seed_topic}"主题,
生成 {n} 条高质量的问答对。

要求:
1. 问题要多样化(概念解释、实践操作、对比分析、故障排查等)
2. 回答要详细专业(200-500字),包含具体例子
3. 格式为 JSON 数组,每条包含 instruction 和 output 字段

请直接输出 JSON,不要其他内容。"""

    response = openai.chat.completions.create(
        model="gpt-4",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.8,  # 适度随机性保证多样性
    )

    data = json.loads(response.choices[0].message.content)
    return data

# 批量生成
topics = ["Python 性能优化", "数据库索引", "Docker 部署", "微服务架构"]
all_data = []
for topic in topics:
    all_data.extend(generate_instruction_data(topic, n=50))

方法 3:从现有数据中挖掘(如客服日志、文档)

python
def extract_from_logs(log_file: str) -> list[dict]:
    """从客服对话日志中提取训练数据"""

    training_data = []
    with open(log_file) as f:
        for line in f:
            log = json.loads(line)

            # 筛选高质量对话(有评分、解决率高的)
            if log.get("satisfaction_score", 0) >= 4:
                training_data.append({
                    "instruction": log["customer_question"],
                    "output": log["agent_best_response"],
                })

    return training_data

追问:如何用 self-instruct 方法生成数据?

Self-Instruct 是一种"模型自己教自己"的方法:先人工写 20~50 条种子指令,然后让模型基于种子指令生成更多指令和回答,人工筛选后再加入训练集。这样循环扩展。Alpaca 就是用类似方法生成的 52K 数据。关键是种子数据要高质量且多样化。


Q: 什么是数据增强?在 LLM 微调中怎么用?⭐⭐

答:

数据增强是通过对已有数据做变换来"创造"更多训练数据,增加数据多样性。

类比理解: 你有 100 张猫的照片,通过翻转、旋转、裁剪、调色,可以变出 500 张不同的照片。模型看到更多样的数据,泛化能力更强。

LLM 微调中的数据增强方法:

python
import random
import openai

# 方法 1: 改写指令(Paraphrase)- 用不同方式表达同一个问题
def augment_instruction(original_instruction: str, n: int = 3) -> list[str]:
    """用 LLM 改写指令,生成多种表达方式"""

    prompt = f"""请将以下指令改写为 {n} 种不同的表达方式,保持含义不变:
原始指令:{original_instruction}

要求:
1. 每种改写风格不同(正式、口语、简略等)
2. 不要改变指令的核心意图
3. 每行一条"""

    response = openai.chat.completions.create(
        model="gpt-4",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.9,
    )
    return response.choices[0].message.content.strip().split("\n")


# 方法 2: 回译(Back Translation)- 中→英→中
def back_translate(text: str) -> str:
    """通过回译增加文本多样性"""
    # 先翻译成英文
    en = translate(text, "zh", "en")
    # 再翻译回中文
    augmented = translate(en, "en", "zh")
    return augmented


# 方法 3: 添加干扰/噪声 - 让模型更鲁棒
def add_noise(instruction: str) -> str:
    """给指令加些噪声,模拟真实用户的输入"""
    noises = [
        lambda s: s + ",谢谢",            # 加礼貌后缀
        lambda s: "那个," + s,             # 加口语前缀
        lambda s: s.replace("。", ""),       # 去掉标点
        lambda s: s.replace(",", " "),      # 替换标点为空格
    ]
    return random.choice(noises)(instruction)


# 方法 4: 难度递增 - 从简单到复杂
def increase_difficulty(original_pair: dict) -> dict:
    """基于已有 QA 对生成更复杂的版本"""
    prompt = f"""基于以下问答对,生成一个更复杂、更深入的版本:
问题:{original_pair['instruction']}
回答:{original_pair['output']}

请生成更深入的问题和更详细的回答(要求更深入、更专业)。"""
    # ... 调用 LLM 生成

追问:数据增强会不会引入噪声导致效果下降?

会的,这是需要权衡的问题。建议:(1) 增强后的数据一定要抽检质量;(2) 增强倍数不宜过大,通常 2~5 倍为宜;(3) 改写/回译类增强比较安全,自动生成新数据风险较高;(4) 最好的增强是找更多真实数据。


四、训练技巧

Q: 学习率怎么设置?warmup 有什么用?⭐⭐

答:

学习率(Learning Rate) 是训练中最重要的超参数之一,控制模型参数每次更新的步长。

类比理解: 想象你在山上找最低点(最优解)。学习率就是你每一步跨多大——步子太大可能跨过最低点来回震荡,步子太小则走到天黑也到不了。

学习率过大:参数更新太剧烈 → loss 震荡甚至发散(NaN)
学习率过小:参数更新太慢 → 训练很慢,可能卡在局部最优
学习率合适:平稳收敛 → loss 稳定下降

Warmup 的作用:

训练初期,模型参数还是随机/预训练的初始状态,梯度可能很大且不稳定。如果一开始就用大学习率,容易导致训练崩溃。Warmup 就是前 N 步先把学习率从很小的值逐步增大到目标值。

学习率
  ^
  |      /‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\        ← Warmup + Cosine Decay
  |     /                    \
  |    /                      \
  |   /                        \
  |  /                          \___
  | /                               
  +----|---|------------------------→ 训练步数
       warmup
       阶段
python
from transformers import TrainingArguments

# 推荐配置
training_args = TrainingArguments(
    output_dir="./output",

    # 学习率设置
    learning_rate=2e-5,           # 微调的典型学习率(比预训练小很多)
    lr_scheduler_type="cosine",   # 余弦退火:先升后平滑下降
    warmup_ratio=0.05,            # 前 5% 的步数做 warmup
    warmup_steps=100,             # 或者直接指定步数(和 warmup_ratio 二选一)

    # 典型学习率范围:
    # 全量微调:1e-5 ~ 5e-5
    # LoRA 微调:1e-4 ~ 3e-4(可以稍大,因为只更新少量参数)
    # QLoRA 微调:2e-4 ~ 1e-3(可以更大)
)

追问:不同层可以用不同的学习率吗?

可以,这叫分层学习率(Layer-wise Learning Rate Decay)。原理是:越靠近输入的底层学的是通用特征,不需要大改;越靠近输出的顶层和任务更相关,需要更多更新。常见策略是底层用较小学习率,顶层用较大学习率。实现方式是在优化器中为不同参数组设置不同的 lr。


Q: 什么是梯度累积?为什么需要?⭐⭐

答:

梯度累积是把多个小 batch 的梯度加起来,等效于用一个大 batch 来训练。

类比理解: 你想用大卡车(大 batch)运货,但只有一辆小面包车(显存小)。解决方案是:小面包车跑好几趟,把货攒够了一起卸(更新参数)。对外看起来就像用了一辆大卡车。

不用梯度累积(batch_size=32):
  需要一次放 32 条数据进显存 → 显存不够,OOM

用梯度累积(batch_size=8, accumulation_steps=4):
  第1步: 前向+反向(8条)→ 梯度存着,不更新参数
  第2步: 前向+反向(8条)→ 梯度累加,不更新参数
  第3步: 前向+反向(8条)→ 梯度累加,不更新参数
  第4步: 前向+反向(8条)→ 梯度累加 → 更新参数!
  
  等效 batch_size = 8 × 4 = 32,但显存只需要 batch_size=8 的量
python
from transformers import TrainingArguments

training_args = TrainingArguments(
    output_dir="./output",
    per_device_train_batch_size=4,     # 每次只放 4 条进显存
    gradient_accumulation_steps=8,     # 累积 8 次
    # 等效 batch_size = 4 × 8 = 32

    # 对比:如果不用梯度累积
    # per_device_train_batch_size=32,  # 需要 8 倍显存!
    # gradient_accumulation_steps=1,
)

# 手动实现梯度累积(更灵活)
optimizer = torch.optim.AdamW(model.parameters(), lr=2e-5)
accumulation_steps = 8

for step, batch in enumerate(dataloader):
    outputs = model(**batch)
    loss = outputs.loss / accumulation_steps  # 注意要除以累积步数!
    loss.backward()

    if (step + 1) % accumulation_steps == 0:
        optimizer.step()
        optimizer.zero_grad()

追问:梯度累积和直接用大 batch 效果完全一样吗?

数学上等效(梯度值相同),但有一个细微差别:Batch Normalization 的统计量不同(因为每个小 batch 的均值/方差不同)。不过 LLM 主要用 LayerNorm 而不是 BatchNorm,所以影响可以忽略。实际训练中,梯度累积是几乎无损的近似。


Q: 什么是混合精度训练?FP16 vs BF16?⭐⭐

答:

混合精度训练是同时使用低精度(FP16/BF16)和高精度(FP32)浮点数来训练模型,兼顾速度和精度。

数值格式对比:

FP32: [1位符号][8位指数][23位尾数] = 32位 → 精度最高,速度最慢
FP16: [1位符号][5位指数][10位尾数] = 16位 → 速度快2倍,但指数范围小,容易溢出
BF16: [1位符号][8位指数][7位尾数]  = 16位 → 指数范围同FP32,精度稍低,但不容易溢出

类比理解: FP32 是高精度相机(照片细腻但处理慢),FP16 是普通相机(快但有时曝光过度/不足),BF16 是专门为运动设计的相机(不太精细但动态范围大,不会过曝)。

          精度高

            |   FP32(默认)
            |
            |       BF16(推荐用于训练)
            |
            |           FP16(推理常用)
            |
            +------------------------→ 速度快
python
from transformers import TrainingArguments

# FP16 训练(需要 loss scaling 防止下溢)
args_fp16 = TrainingArguments(
    output_dir="./output",
    fp16=True,              # 启用 FP16
    fp16_opt_level="O1",    # O1: 白名单用FP16, O2: 尽量用FP16
)

# BF16 训练(更稳定,推荐 A100/H100 显卡使用)
args_bf16 = TrainingArguments(
    output_dir="./output",
    bf16=True,              # 启用 BF16,不需要手动 loss scaling
)

# 自动选择最优精度
import torch
if torch.cuda.is_bf16_supported():
    print("使用 BF16(更稳定)")
    # 用 bf16=True
else:
    print("使用 FP16")
    # 用 fp16=True

FP16 vs BF16 选择:

特性FP16BF16
数值范围±65504±3.4×10³⁸
精度更高稍低
溢出风险高(需要 loss scaling)
硬件支持所有 GPUA100/H100/RTX 30系+
推荐场景推理、V100 训练A100/H100 训练

追问:为什么 BF16 更适合训练?

因为 BF16 的指数位和 FP32 一样都是 8 位,数值范围(动态范围)和 FP32 相同,不容易出现梯度上溢(NaN)或下溢(变成 0)的问题。FP16 只有 5 位指数,数值范围小,梯度容易溢出,需要额外的 loss scaling 机制来补偿。BF16 则省去了这个麻烦。


Q: 如何防止过拟合?⭐⭐

答:

过拟合是指模型在训练数据上表现很好,但在新数据上表现差——模型把训练数据"背下来了",而不是学会了泛化的规律。

类比理解: 一个学生把考试原题背得滚瓜烂熟,考满分。但换个同类型的题就不会了——这就是过拟合。

防止过拟合的方法:

python
from transformers import TrainingArguments

# 1. 正则化方法
training_args = TrainingArguments(
    output_dir="./output",
    # Dropout:训练时随机丢弃部分神经元
    # LoRA 自带 dropout
    # 通常设置 0.05~0.1

    # 权重衰减(Weight Decay):限制权重不要太大
    weight_decay=0.01,          # L2 正则化

    # 早停(Early Stopping):验证集效果不再提升就停止
    # 通过 Trainer 的 callback 实现
)

# 2. 早停实现
from transformers import EarlyStoppingCallback, TrainerCallback

class EarlyStoppingCallback(TrainerCallback):
    def __init__(self, patience=3):
        self.patience = patience
        self.best_loss = float('inf')
        self.wait = 0

    def on_evaluate(self, args, state, control, metrics=None, **kwargs):
        val_loss = metrics.get("eval_loss", float('inf'))
        if val_loss < self.best_loss:
            self.best_loss = val_loss
            self.wait = 0
        else:
            self.wait += 1
            if self.wait >= self.patience:
                control.should_training_stop = True
                print(f"早停触发!验证 loss 连续 {self.patience} 次未下降")

# 3. 数据层面
# - 增加数据量和多样性
# - 数据清洗去除噪声
# - 数据增强

# 4. 模型层面
# - 降低 LoRA rank(减少模型容量)
# - 减少训练轮数(epochs)
# - 降低学习率

# 5. 训练策略
training_args = TrainingArguments(
    num_train_epochs=3,              # 不要训练太多轮(通常 1~5 轮)
    learning_rate=2e-5,
    load_best_model_at_end=True,     # 训练结束后加载最好的 checkpoint
    metric_for_best_model="eval_loss",
    greater_is_better=False,
    save_strategy="steps",
    eval_strategy="steps",
    save_steps=200,
    eval_steps=200,
)

过拟合的信号:

训练 loss:   ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓  (持续下降)
验证 loss:   ↓ ↓ ↓ ↓ ↓ ↑ ↑ ↑ ↑ ↑  (先降后升 = 过拟合!)

                            过拟合开始点

追问:微调 LLM 时,通常训练几轮?

大多数情况下 1~3 轮就够了。LLM 已经在预训练阶段学到了大量知识,微调只需要"稍微调整"。训练太多轮容易过拟合,尤其是数据量少的时候。经验法则:数据量<1000 条,训练 1~2 轮;数据量 1K~10K,训练 2~3 轮;数据量>10K,可以训练 3~5 轮。


Q: DeepSpeed ZeRO 的三个阶段分别做什么?⭐⭐⭐

答:

DeepSpeed ZeRO(Zero Redundancy Optimizer)是微软提出的分布式训练优化技术,核心思想是消除冗余,把显存占用分摊到多个 GPU 上

类比理解: 10 个人合住一栋大房子(模型)。ZeRO-1 是把厨房里的食材(优化器状态)分成 10 份,每人管一份,用的时候互相借。ZeRO-2 再把食材清单(梯度)也分摊。ZeRO-3 连房子本身(模型参数)也分成 10 份。

训练一个模型需要的显存 = 参数 + 梯度 + 优化器状态

以 7B 模型 FP16 为例:
  参数(FP16):7B × 2 bytes = 14 GB
  梯度(FP16):7B × 2 bytes = 14 GB
  优化器状态(Adam, FP32):7B × (4+4+4) bytes = 84 GB(参数副本 + 一阶动量 + 二阶动量)
  总计:≈ 112 GB

ZeRO-1:分摊优化器状态 → 84/N GB(N 为 GPU 数)
ZeRO-2:+ 分摊梯度 → (84+14)/N GB
ZeRO-3:+ 分摊参数 → 112/N GB
json
// ZeRO Stage 1 配置
{
    "zero_optimization": {
        "stage": 1,
        "overlap_comm": true,
        "contiguous_gradients": true
    }
}

// ZeRO Stage 2 配置(最常用)
{
    "zero_optimization": {
        "stage": 2,
        "overlap_comm": true,
        "contiguous_gradients": true,
        "reduce_bucket_size": 5e8,
        "allgather_bucket_size": 5e8
    }
}

// ZeRO Stage 3 配置(显存极度紧张时)
{
    "zero_optimization": {
        "stage": 3,
        "overlap_comm": true,
        "contiguous_gradients": true,
        "stage3_max_live_parameters": 1e9,
        "stage3_max_reuse_distance": 1e9,
        "stage3_prefetch_bucket_size": 5e8,
        "stage3_param_persistence_threshold": 1e6
    }
}

// ZeRO-Offload:把部分数据卸载到 CPU 内存
{
    "zero_optimization": {
        "stage": 2,
        "offload_optimizer": {
            "device": "cpu",
            "pin_memory": true
        },
        "offload_param": {
            "device": "cpu",  // Stage 3 才支持
            "pin_memory": true
        }
    }
}

三个阶段对比:

阶段分摊内容显存节省通信开销适用场景
Stage 1优化器状态~4x显存勉强够用
Stage 2+梯度~8x最常用的方案
Stage 3+模型参数~N倍超大模型/极少显存

追问:ZeRO-3 和模型并行(Tensor Parallelism)有什么区别?

ZeRO-3 是在数据并行的基础上切分模型参数——每个 GPU 存一部分参数,计算时临时收集完整参数,用完就释放。Tensor Parallelism 是把单个矩阵运算切分到多个 GPU 上——每个 GPU 计算矩阵的一部分,需要频繁通信中间结果。ZeRO-3 更省内存但通信更多,Tensor Parallelism 通信更高效但每个 GPU 都要存完整参数。实践中通常结合使用。


五、评估与部署

Q: 微调后怎么评估效果?⭐⭐

答:

微调后的评估需要从多个维度进行,不能只看 loss。

评估维度:

python
# 1. 定量评估:用指标衡量
# 自动指标
import numpy as np
from rouge_score import rouge_scorer
from nltk.translate.bleu_score import sentence_bleu

def evaluate_model(model, test_data):
    """评估微调模型的多个维度"""

    results = {
        "rouge_scores": [],
        "bleu_scores": [],
        "exact_match": [],
    }

    for sample in test_data:
        prediction = generate(model, sample["instruction"])
        reference = sample["output"]

        # ROUGE 分数(衡量内容覆盖度)
        scorer = rouge_scorer.RougeScorer(['rouge1', 'rouge2', 'rougeL'], use_stemmer=True)
        scores = scorer.score(reference, prediction)
        results["rouge_scores"].append(scores)

        # BLEU 分数(衡量生成质量)
        bleu = sentence_bleu([reference.split()], prediction.split())
        results["bleu_scores"].append(bleu)

        # 精确匹配(分类/抽取任务)
        if prediction.strip() == reference.strip():
            results["exact_match"].append(1)
        else:
            results["exact_match"].append(0)

    print(f"ROUGE-L: {np.mean([s['rougeL'].fmeasure for s in results['rouge_scores']]):.3f}")
    print(f"BLEU: {np.mean(results['bleu_scores']):.3f}")
    print(f"Exact Match: {np.mean(results['exact_match']):.3f}")


# 2. 定性评估:人工评估(最重要!)
def human_evaluation_samples(model, test_data, n=50):
    """生成人工评估样本"""
    samples = random.sample(test_data, n)
    results = []
    for sample in samples:
        prediction = generate(model, sample["instruction"])
        results.append({
            "instruction": sample["instruction"],
            "reference": sample["output"],
            "prediction": prediction,
            "评分维度": ["准确性", "完整性", "流畅性", "格式正确性"],
        })

    # 导出为 Excel 给人工评分
    pd.DataFrame(results).to_excel("human_eval.xlsx", index=False)


# 3. 任务特定评估
# 分类任务 → 准确率、F1
# 生成任务 → ROUGE、BLEU、人工评分
# 推理任务 → 数学题正确率、代码通过率

评估最佳实践:

方法说明适用场景
自动指标ROUGE/BLEU/Perplexity快速筛选,大规模评估
人工评估标注员打分最终效果确认
LLM-as-Judge用 GPT-4 评分中等规模,成本适中
A/B 测试线上对比部署前验证
安全评估红队测试上线前必须做

追问:loss 降了但效果没提升,可能是什么原因?

常见原因:(1) 评估数据和训练数据分布不同;(2) 评估指标不适合你的任务(如 ROUGE 不适合创意写作);(3) 过拟合了——训练 loss 下降但泛化能力变差;(4) 数据泄漏——测试数据混入了训练集;(5) 生成参数(temperature、top_p)没调好。


Q: 什么是灾难性遗忘?怎么缓解?⭐⭐

答:

灾难性遗忘(Catastrophic Forgetting)是指模型在学习新任务时,把之前学过的知识"忘掉"了。

类比理解: 你教一个人学法语,结果他把之前会的英语给忘了。或者更极端——教他新菜谱,结果他连怎么开火都不会了。

原始模型能力: [通用知识 ████████████████] 100%
微调后:       [通用知识 ████████____] 60%  ← 忘了一部分!
              [新任务   ████████████████] 100%

为什么会发生?

微调时,模型参数被更新以适应新数据。但这些参数之前编码了通用知识,更新后通用知识可能被覆盖。尤其是当新数据和原始预训练数据分布差异大时,遗忘更严重。

缓解方法:

python
# 方法 1: 降低学习率和训练轮数(最简单有效)
training_args = TrainingArguments(
    learning_rate=2e-5,        # 用小学习率,少训练几轮
    num_train_epochs=2,        # 不要超过 3 轮
)

# 方法 2: 使用 LoRA/PEFT(天然缓解遗忘)
# 因为只更新少量参数,大部分原始参数被冻结
# 冻结的参数保持了原始知识

# 方法 3: 混入通用数据(Replay)
def mix_general_data(domain_data, general_data, ratio=0.1):
    """混入一定比例的通用数据,防止遗忘"""
    n_general = int(len(domain_data) * ratio)
    general_samples = random.sample(general_data, n_general)

    mixed_data = domain_data + general_samples
    random.shuffle(mixed_data)
    return mixed_data

# 混合比例建议:领域数据 90% + 通用数据 10%
train_data = mix_general_data(domain_data, general_data, ratio=0.1)

# 方法 4: 正则化方法(EWC - Elastic Weight Consolidation)
# 对"重要"的参数施加更大的约束,不让它们变化太大
class EWCLoss:
    """EWC: 评估每个参数的重要性,重要参数少改"""
    def __init__(self, model, old_params, fisher_matrix):
        self.model = model
        self.old_params = old_params
        self.fisher = fisher_matrix  # 参数重要性矩阵

    def penalty(self):
        loss = 0
        for name, param in self.model.named_parameters():
            if name in self.old_params:
                # 重要的参数改太多会受惩罚
                loss += (self.fisher[name] * (param - self.old_params[name]) ** 2).sum()
        return loss

追问:LoRA 能完全避免灾难性遗忘吗?

不能完全避免,但能显著缓解。因为 LoRA 冻结了大部分原始参数,只更新极少量参数,所以原始知识保留得比较好。但如果 rank 太大、训练轮数太多、学习率太大,仍然可能发生遗忘。最佳实践:LoRA + 小学习率 + 少量轮数 + 混入通用数据。


Q: 微调后的模型怎么部署?⭐⭐

答:

微调后的模型部署流程取决于你的微调方式:

LoRA 微调后的部署:

python
# 方式 1: 合并权重后部署(推荐,最简单)
# 把 LoRA 权重合并回原始模型,得到一个完整的模型文件

from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer

# 加载基础模型
base_model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2-7B")
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2-7B")

# 加载 LoRA 适配器
model_with_lora = PeftModel.from_pretrained(base_model, "./lora_output")

# 合并权重 → 得到完整的 7B 模型
merged_model = model_with_lora.merge_and_unload()

# 保存合并后的模型
merged_model.save_pretrained("./merged_model")
tokenizer.save_pretrained("./merged_model")

# 方式 2: 用 vLLM 部署(高性能推理引擎)
# pip install vllm
from vllm import LLM, SamplingParams

llm = LLM(model="./merged_model", tensor_parallel_size=1)
sampling_params = SamplingParams(temperature=0.7, max_tokens=512)
outputs = llm.generate(["你好,请问..."], sampling_params)

# 方式 3: 用 Ollama 部署(本地部署最简单)
# 先创建 Modelfile
modelfile_content = """
FROM ./merged_model
PARAMETER temperature 0.7
PARAMETER top_p 0.9
SYSTEM "你是一个专业的助手"
"""
with open("Modelfile", "w") as f:
    f.write(modelfile_content)
# 然后: ollama create my-model -f Modelfile
# 启动: ollama run my-model

完整部署方案选择:

方案适用场景特点
vLLM高并发线上服务PagedAttention,高吞吐
TGI (Text Generation Inference)HuggingFace 生态简单易用
Ollama本地/小规模部署一键部署
TensorRT-LLM极致推理速度NVIDIA 生态优化
SGLang复杂推理管线结构化生成优化
python
# vLLM 生产部署示例(OpenAI 兼容 API)
# 启动: python -m vllm.entrypoints.openai.api_server \
#          --model ./merged_model \
#          --served-model-name my-finetuned-model \
#          --port 8000

# 客户端调用
import openai

client = openai.OpenAI(base_url="http://localhost:8000/v1", api_key="dummy")
response = client.chat.completions.create(
    model="my-finetuned-model",
    messages=[{"role": "user", "content": "你好"}],
    temperature=0.7,
)
print(response.choices[0].message.content)

追问:合并 LoRA 权重后,模型大小会变化吗?

不会。合并后的模型大小和原始基础模型完全一样(如 7B 还是 7B)。因为 LoRA 权重 B&times;A 会被加回原始权重 W,得到 W' = W + B&times;A,维度不变。这也是 LoRA 的一个优势——部署时不需要额外的适配器文件,和原始模型完全兼容。


六、实战难题

难题 1:微调后模型输出变啰嗦/重复 ⭐⭐

问题描述: 微调后模型回答变得很啰嗦,总是重复同样的话,或者回答跑题。

原因分析:

  • 训练数据中存在大量冗余/重复的回答
  • 训练轮数过多,模型过度学习了训练数据的模式
  • 生成参数(repetition_penalty、temperature)没调好

解决方案:

python
# 1. 数据层面:去除训练数据中的重复模式
# 检查输出中是否有大量重复的句式/模板
from collections import Counter
sentence_patterns = [get_first_sentence(item["output"]) for item in train_data]
print(Counter(sentence_patterns).most_common(10))  # 找到最常见的开头句式

# 2. 训练层面:减少训练轮数
training_args = TrainingArguments(
    num_train_epochs=1,  # 从 1 轮开始尝试
)

# 3. 推理层面:调整生成参数
from transformers import GenerationConfig

gen_config = GenerationConfig(
    max_new_tokens=512,
    repetition_penalty=1.15,    # 重复惩罚(>1 减少重复)
    no_repeat_ngram_size=3,     # 不允许 3-gram 重复
    temperature=0.7,
    top_p=0.9,
)

# 4. 在训练数据中加入"简洁回答"的示范
{
    "instruction": "什么是 Docker?",
    "output": "Docker 是一个容器化平台,可以将应用及其依赖打包成标准容器,在任何环境中一致运行。"  # 简洁明了
}

难题 2:微调数据量太少(<500 条)效果不好 ⭐⭐

问题描述: 只有几百条标注数据,微调后效果不理想,过拟合严重。

解决方案:

python
# 策略 1: 用 QLoRA + 小 rank(减少可训练参数,降低过拟合风险)
lora_config = LoraConfig(
    r=4,              # 用小 rank
    lora_alpha=8,
    lora_dropout=0.1, # 加大 dropout
    target_modules=["q_proj", "v_proj"],  # 只加在少量模块
)

# 策略 2: 数据增强(用 GPT-4 扩充 3~5 倍数据)
augmented_data = []
for item in original_data:
    augmented_data.append(item)
    # 生成改写版本
    for paraphrase in generate_paraphrases(item["instruction"], n=3):
        augmented_data.append({
            "instruction": paraphrase,
            "output": item["output"],
        })

# 策略 3: 少训练 + 强正则化
training_args = TrainingArguments(
    num_train_epochs=1,
    learning_rate=1e-4,    # QLoRA 可以用较大 lr
    weight_decay=0.1,      # 加大权重衰减
    warmup_ratio=0.1,
)

# 策略 4: 如果数据实在太少,考虑用 RAG 代替微调
# <100 条数据通常 RAG 效果更好

难题 3:多卡训练显存不均/通信问题 ⭐⭐⭐

问题描述: 多卡训练时某些 GPU 显存占用特别高,或 NCCL 通信超时。

解决方案:

python
# 1. 使用 device_map 均匀分配
model = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2-7B",
    device_map="auto",  # 自动分配到各 GPU
    torch_dtype=torch.bfloat16,
)

# 2. DeepSpeed ZeRO Stage 2 配置
ds_config = {
    "train_batch_size": 32,
    "gradient_accumulation_steps": 4,
    "fp16": {"enabled": False},
    "bf16": {"enabled": True},
    "zero_optimization": {
        "stage": 2,
        "allgather_partitions": True,
        "allgather_bucket_size": 2e8,
        "overlap_comm": True,
        "reduce_scatter": True,
        "reduce_bucket_size": 2e8,
        "contiguous_gradients": True,
    },
}

# 3. 常见 NCCL 问题排查
import os
os.environ["NCCL_DEBUG"] = "INFO"                    # 打印调试信息
os.environ["NCCL_SOCKET_IFNAME"] = "eth0"            # 指定网卡
os.environ["NCCL_IB_DISABLE"] = "1"                  # 禁用 InfiniBand(如果没有)
os.environ["NCCL_P2P_DISABLE"] = "1"                 # 禁用 P2P(如果没有 NVLink)

# 4. 训练脚本启动
# torchrun --nproc_per_node=4 train.py
# 或
# deepspeed --num_gpus=4 train.py

难题 4:微调后中文模型变"英文脑" ⭐⭐

问题描述: 用英文或中英混合数据微调中文模型后,模型开始频繁输出英文。

原因分析: 训练数据中英文占比过高,模型的中文能力被稀释。

解决方案:

python
# 1. 确保训练数据的语言分布
import re

def check_language_ratio(data):
    """检查数据的语言分布"""
    zh_count = 0
    en_count = 0
    for item in data:
        # 简单判断:中文字符占比
        zh_chars = len(re.findall(r'[\u4e00-\u9fff]', item["output"]))
        en_chars = len(re.findall(r'[a-zA-Z]', item["output"]))
        if zh_chars > en_chars:
            zh_count += 1
        else:
            en_count += 1
    print(f"中文: {zh_count} ({zh_count/len(data)*100:.1f}%)")
    print(f"英文为主: {en_count} ({en_count/len(data)*100:.1f}%)")

# 2. 混入中文通用数据
# 在训练集中加入 10%~20% 的纯中文对话数据
chinese_general_data = load_chinese_chat_data(n=1000)

# 3. 在系统提示中强调中文
system_prompt = "你是一个中文助手,请始终使用中文回答。"

# 4. 训练数据中确保回答都是中文
for item in train_data:
    if has_too_much_english(item["output"]):
        item["output"] = translate_to_chinese(item["output"])

难题 5:训练 loss 不下降/变成 NaN ⭐⭐

问题描述: 训练开始后 loss 不下降,或者突然变成 NaN。

排查流程:

python
# 1. 检查数据(最常见原因!)
# 确保 label 正确设置,不要把 padding token 也算进 loss
def check_data(dataset, tokenizer):
    """检查训练数据是否有问题"""
    sample = dataset[0]
    print(f"Input IDs: {sample['input_ids'][:20]}")
    print(f"Labels: {sample['labels'][:20]}")
    print(f"Decoded input: {tokenizer.decode(sample['input_ids'][:50])}")

    # 检查是否有 NaN
    for key, val in sample.items():
        if hasattr(val, 'isnan'):
            assert not val.isnan().any(), f"{key} contains NaN!"

# 2. 降低学习率
training_args = TrainingArguments(
    learning_rate=1e-5,       # 从更小的 lr 开始
    max_grad_norm=1.0,        # 梯度裁剪
    warmup_ratio=0.1,         # 增加 warmup
)

# 3. 检查混合精度设置
# 如果用 FP16,确保有 loss scaling
# 如果用 BF16,通常不需要
training_args = TrainingArguments(
    bf16=True,                # 推荐 BF16(更稳定)
    # fp16=True,              # 如果用 FP16
    # fp16_opt_level="O1",    # 确保 loss scaling 正确
)

# 4. 检查梯度
# 在训练中监控梯度范数
for name, param in model.named_parameters():
    if param.grad is not None:
        grad_norm = param.grad.norm().item()
        if grad_norm > 100 or grad_norm == 0:
            print(f"⚠️ {name}: grad_norm = {grad_norm}")

# 5. 检查模型是否正确加载
# 量化模型偶尔会出问题,尝试 FP16 加载排查
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype=torch.float16,  # 先不用量化
    device_map="auto",
)

总结速查表

问题答案要点
什么时候用微调?需要改变风格/格式/领域知识,且有足够标注数据
LoRA vs 全量微调?90% 场景 LoRA 够用,省显存省时间
RAG vs 微调?知识更新频繁用 RAG,风格对齐用微调,最佳是组合
LoRA rank 怎么选?从 16 开始,简单任务 8,复杂任务 64
数据重要还是参数重要?数据质量 >> 参数调优
训练几轮?通常 1~3 轮,数据少就少训
怎么防过拟合?小学习率 + 少轮数 + dropout + 权重衰减
怎么防遗忘?LoRA + 混入通用数据 + 小学习率
部署用什么?合并权重后用 vLLM/TGI/Ollama 部署

七、微调实战指南

Q: 微调一个 7B 模型需要多少资源?成本怎么算? ⭐⭐

显存需求估算

7B 模型的显存需求:

1. 模型权重
   FP16: 7B × 2 bytes = 14 GB
   INT4 (QLoRA): 7B × 0.5 bytes = 3.5 GB

2. 优化器状态(Adam,全量微调)
   FP32 权重副本: 28 GB
   动量 + 方差: 56 GB
   优化器总计: 84 GB

3. 梯度: 14 GB (FP16)

4. 激活值: 4-8 GB(使用 activation checkpointing)

全量微调总计: ~120 GB → 需要 2×A100-80GB
QLoRA 微调总计: ~6-8 GB → 1×RTX 4090 即可

成本对比

方案硬件时间(1000条)成本
全量微调 7B2×A100-80GB4-8h$50-100
LoRA 7B1×A100-80GB1-2h$10-20
QLoRA 7B1×RTX 40902-4h$5-10
API 微调--$10-50

Q: LoRA、DoRA、rsLoRA 有什么区别? ⭐⭐

LoRA (2021):
W' = W + BA
优点: 简单有效,生态成熟
缺点: 高 rank 时效果不稳定

DoRA (2024):
W' = m × (W + BA) / ||W + BA||
核心: 分解权重为"方向"和"幅度"
优点: 更接近全参数微调效果
缺点: 略慢

rsLoRA (2024):
W' = W + (α/√r) × BA
核心: 缩放因子从 α/r 改为 α/√r
优点: 高 rank 时更稳定

选择:一般用 LoRA,追求效果用 DoRA,rank>64 用 rsLoRA


Q: 如何用 Unsloth 快速微调? ⭐⭐

Unsloth 是 2024 年最火的微调工具,核心优化:

  1. 手动反向传播:跳过 PyTorch 自动微分开销
  2. Triton 内核:自定义 GPU 计算内核
  3. 4-bit 量化:原生支持 QLoRA
python
from unsloth import FastLanguageModel

# 1. 加载模型(自动 4-bit 量化)
model, tokenizer = FastLanguageModel.from_pretrained(
    model_name="unsloth/Qwen2.5-7B-Instruct",
    max_seq_length=2048,
    load_in_4bit=True,
)

# 2. 添加 LoRA
model = FastLanguageModel.get_peft_model(
    model, r=16,
    target_modules=["q_proj","k_proj","v_proj","o_proj",
                    "gate_proj","up_proj","down_proj"],
    lora_alpha=16, lora_dropout=0, bias="none",
    use_gradient_checkpointing="unsloth",
)

# 3. 训练(用 TRL SFTTrainer)
from trl import SFTTrainer
trainer = SFTTrainer(model=model, tokenizer=tokenizer,
    train_dataset=dataset, max_seq_length=2048,
    args=TrainingArguments(
        per_device_train_batch_size=2,
        gradient_accumulation_steps=4,
        num_train_epochs=3, learning_rate=2e-4,
        bf16=True, output_dir="outputs",
    ))
trainer.train()

# 4. 保存(合并权重)
model.save_pretrained_merged("merged", tokenizer)

性能:速度 2x,显存 50%


Q: 微调的数据格式有哪些? ⭐⭐

Alpaca 格式(指令微调):

json
{"instruction": "翻译", "input": "Hello", "output": "你好"}

ShareGPT 格式(多轮对话):

json
{"conversations": [
    {"from": "human", "value": "什么是ML?"},
    {"from": "gpt", "value": "机器学习是..."}
]}

OpenAI 格式(API 兼容):

json
{"messages": [
    {"role": "system", "content": "你是翻译助手"},
    {"role": "user", "content": "Hello"},
    {"role": "assistant", "content": "你好"}
]}

数据量建议

任务最少推荐
风格迁移100500-1000
知识注入5002000-5000
任务适配2001000-3000
领域专业10005000-10000

Q: 微调后效果不好怎么排查? ⭐⭐

问题可能原因解决方案
Loss 不下降学习率太小/数据格式错增大学习率,检查格式
Loss 变 NaN学习率太大/梯度爆炸降低学习率,加梯度裁剪
输出重复过拟合/数据重复减少 epoch,增加多样性
输出变短数据输出普遍短检查数据分布
语言混乱多语言混合分语言训练
遗忘通用能力训练过度混合通用数据

Q: 什么时候不应该微调? ⭐⭐

需要微调吗?

├── Prompt Engineering 能解决吗?
│   └── 能 → 不微调
├── RAG 能解决吗?
│   └── 能 → 不微调
├── 有足够数据吗?(>200条)
│   └── 没有 → 先收集数据
├── 有足够预算吗?
│   └── 没有 → 先用 API
└── 都满足 → 微调

不应该微调的场景

  1. 知识检索:用 RAG 而非微调(知识会过时)
  2. 简单任务:用 Prompt + Few-shot
  3. 数据太少:< 100 条容易过拟合
  4. 需要实时更新:RAG + 工具调用

Q: 如何做 DPO 训练? ⭐⭐⭐

python
from trl import DPOTrainer, DPOConfig

# 偏好数据格式
preference_data = [
    {
        "prompt": "解释机器学习",
        "chosen": "机器学习是AI的分支,让计算机从数据中学习...",
        "rejected": "机器学习就是让机器学习。"
    }
]

# DPO 训练
config = DPOConfig(
    output_dir="dpo_model",
    learning_rate=5e-7,
    num_train_epochs=3,
    beta=0.1,  # KL 散度系数
    loss_type="sigmoid",
    bf16=True,
)

trainer = DPOTrainer(
    model=model,
    args=config,
    train_dataset=dataset,
    tokenizer=tokenizer,
)
trainer.train()

偏好数据收集

  1. 人工标注:标注员对多个回答排序
  2. AI 标注:用大模型评估选择 best/worst
  3. 用户反馈:收集点赞/点踩

Q: 分布式训练方案有哪些? ⭐⭐⭐

方案适用场景显存效率易用性
DDP数据并行简单
FSDP大模型
DeepSpeed ZeRO-2微调简单
DeepSpeed ZeRO-3超大模型最高
Megatron-LM预训练复杂

FSDP 实战

python
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP

model = FSDP(model, sharding_strategy=ShardingStrategy.FULL_SHARD)
# 启动: torchrun --nproc_per_node=4 train.py

DeepSpeed ZeRO-2 实战

json
{
    "zero_optimization": {
        "stage": 2,
        "offload_optimizer": {"device": "cpu"},
        "overlap_comm": true
    },
    "bf16": {"enabled": true},
    "gradient_accumulation_steps": 4
}

LLM 应用 & Agent 开发面试准备