主题
专题 12 | 模型微调入门 — 从通用模型到领域专用模型
本专题面向"已经能用 Prompt 工程和 RAG 构建 Agent,但发现模型在特定场景下总是不够听话"的前端工程师。它要解决的核心问题不是"怎么训练一个大模型",而是怎么用最小的成本和数据量,把一个通用模型调教成你业务场景的专用模型——让它准确输出你要的格式、理解你行业的术语、遵循你设定的行为模式。微调是专题 11 自托管部署的自然延伸:先把模型搬到自己的环境,再把模型调成自己想要的样子。
12.1 何时需要微调
在投入时间和 GPU 资源之前,必须先想清楚:你的问题真的需要微调来解决吗?大多数情况下,Prompt 工程和 RAG 已经够用了。微调是最后一道手段,也是效果上限最高的手段。
微调 vs Prompt 工程 vs RAG 的决策框架
| 方案 | 适用场景 | 成本 | 效果上限 |
|---|---|---|---|
| Prompt 工程 | 通用任务、快速迭代 | 零成本 | 中 |
| RAG | 知识密集型、数据频繁更新 | 低(向量库运维) | 中高 |
| 微调 | 特定格式输出、领域术语、行为风格定制 | 高(GPU + 数据准备) | 高 |
| RAG + 微调 | 领域知识 + 行为定制 | 最高 | 最高 |
这四种方案不是互斥的,而是层层递进的。实践中最常见的路径是:先用 Prompt 工程快速验证想法,发现知识不够就加 RAG,发现行为不够就加微调,最终形成 RAG + 微调的组合拳。
微调的典型场景
微调真正发挥价值的场景,往往是 Prompt 工程反复调了几十版还是搞不定的时候:
- 格式遵循:模型总是无法严格遵循特定输出格式(如 JSON Schema、XML 标签),无论你在 Prompt 里怎么强调。这是微调最立竿见影的场景——50 条训练数据就能让模型学会稳定输出指定格式。
- 领域术语:金融里的"头寸"、医疗里的"主诉"、法律里的"不可抗力"——通用模型经常理解错误或用词不准。微调让模型学会你行业的"方言"。
- Tool Calling 行为模式:你希望模型在特定条件下调用特定工具,但 Prompt 工程只能覆盖最常见的情况。微调可以让模型内化这些调用模式。
- 品牌语气定制:客服 Agent 需要保持"专业但亲和"的语气,创作 Agent 需要特定的写作风格。这类"说不清但能举例"的需求,微调比写 Prompt 高效得多。
- 小模型替代大模型:用 GPT-4 级别的输出作为训练数据,微调一个 8B 的小模型来复现 80% 的效果——这就是知识蒸馏的思路,能把推理成本降低一个数量级。
什么时候不该微调
微调不是万能的,以下场景应该优先使用其他方案:
- Prompt 工程就能解决的问题:如果你还没认真优化过 Prompt(Few-shot、Chain-of-Thought、结构化指令),不要急着微调。很多时候一个好的 Prompt 模板就够了。
- 数据不足:低于 50 条高质量样本时,微调效果不可靠。模型容易过拟合到少数样本的特征上,泛化能力反而下降。
- 需要实时更新的知识:微调是把知识"烧录"进模型权重,更新成本高。如果知识库每周都在变(如产品文档、价格表),用 RAG 动态检索是更好的选择。
- 通用能力已够用:如果模型在你的场景下表现已经不错,微调的边际收益可能不值得投入。
前端迁移提示:Prompt 工程就像 CSS 调样式——不改源码,纯靠外部配置改变表现;RAG 就像接 API 补数据——运行时动态获取需要的信息;微调就像改组件源码——深入底层重塑行为。层层递进,越深改动越大但效果越彻底。
12.2 微调核心概念
理解微调的原理不需要数学背景,只需要抓住一个核心问题:大模型有几十亿个参数,微调时应该改哪些、改多少?
全量微调 vs LoRA vs QLoRA
| 方法 | 原理 | 显存需求 | 训练速度 | 适合场景 |
|---|---|---|---|---|
| 全量微调 | 更新所有参数 | 极高(8B→60GB+) | 慢 | 大规模数据、企业级 |
| LoRA | 冻结原始参数,训练低秩矩阵 | 中(8B→16GB) | 中 | 通用微调首选 |
| QLoRA | LoRA + 4bit 量化基础模型 | 低(8B→8GB) | 较慢 | 消费级硬件首选 |
全量微调的想法最直接:把所有参数都拿来训练。问题是一个 8B 模型的全部参数以 fp16 精度存储就需要 16GB 显存,加上优化器状态和梯度,总显存需求超过 60GB——普通显卡根本扛不住。
LoRA(Low-Rank Adaptation)的核心思路是:模型在微调时的参数变化量其实是低秩的,不需要更新全部参数。它冻结原始模型权重,在旁边插入两个很小的矩阵来学习"改动量"。训练完成后,这两个小矩阵可以合并回原始权重,推理时不增加任何开销。
QLoRA 在 LoRA 基础上更进一步:把冻结的原始模型权重从 fp16 量化到 4bit,显存占用直接砍掉四分之三。代价是训练速度略慢(需要反量化计算梯度),但效果损失很小。
LoRA 直觉理解
假设原始模型有一个 1000×1000 的权重矩阵(100 万个参数):
- 全量微调:修改全部 100 万个参数。像重新装修整栋房子。
- LoRA(r=8):只训练两个小矩阵——1000×8 和 8×1000——合计 1.6 万个参数,仅占原始参数量的 1.6%。像只换家具和软装,不动墙体结构。
- 效果:大量实验表明 LoRA 的效果接近全量微调。这说明微调需要的"改动量"确实是低秩的——你不需要改变模型的全部知识,只需要在特定方向上做微调。
LoRA 中的 r(rank,秩)控制小矩阵的大小。r 越大,能表达的改动越丰富,但训练的参数也越多、显存消耗越大。实践中 r=8 到 r=32 覆盖了绝大多数场景。
前端迁移提示:LoRA 就像 CSS 变量覆盖——不改组件源码(原始权重冻结),只在外层注入一小组"补丁参数"(低秩矩阵)就能改变整体行为。合并后(CSS 变量展开后),运行时性能完全一样。
12.3 硬件要求与环境准备
微调对硬件有明确要求,但门槛比你想象的低——得益于 QLoRA,一张消费级显卡就能微调 8B 模型。
微调硬件需求速查
| 模型大小 | 方法 | 最低显存/内存 | 推荐硬件 |
|---|---|---|---|
| 3B | QLoRA | 6GB | RTX 3060 / M2 16GB |
| 7-8B | QLoRA | 8-10GB | RTX 3090/4090 / M2 Pro 32GB |
| 7-8B | LoRA | 16GB | RTX 4090 / M3 Pro 36GB |
| 14B | QLoRA | 16GB | RTX 4090 / M3 Max 48GB |
| 70B+ | QLoRA | 48GB+ | A100 / 云 GPU |
Apple Silicon 用户注意:M 系列芯片的统一内存架构意味着"内存就是显存"。一台 32GB 的 MacBook Pro 可以用 Unsloth 微调 8B 模型,虽然速度比 NVIDIA GPU 慢,但完全可用于学习和小规模微调。
没有本地 GPU 也没关系:Google Colab 免费版提供 T4 GPU(16GB 显存),足以完成 8B 模型的 QLoRA 微调。很多人的第一次微调都是在 Colab 上完成的。
环境搭建
微调生态几乎全在 Python 中,这对前端工程师来说是个跨语言的挑战。好消息是你不需要精通 Python,只需要会执行脚本和读懂基本语法。
bash
# 创建 Python 虚拟环境(类似 node_modules 的隔离机制)
python3 -m venv finetune-env
source finetune-env/bin/activate
# 安装 Unsloth(自动处理 PyTorch、transformers 等依赖)
pip install unsloth
# 验证安装
python -c "from unsloth import FastLanguageModel; print('Unsloth ready')"Python 虚拟环境的概念和 Node.js 项目的 node_modules 类似——每个项目有独立的依赖环境,不会互相污染。source activate 相当于进入这个隔离环境,之后所有 pip install 的包都装在这里面。
微调的工作流中,Python 负责模型训练和导出,但数据准备和效果评估完全可以用你熟悉的 TypeScript 完成。下一节会展示这种混合工作流。
前端迁移提示:微调需要 Python 就像 SSR 需要 Node.js——它是这个领域的运行时,不需要精通但要会用。
venv是 Python 的node_modules,pip是npm,requirements.txt是package.json。
12.4 微调数据准备
微调的效果上限由训练数据决定,而不是训练参数。准备高质量的训练数据,是整个微调流程中最重要也最耗时的环节。
数据格式:对话格式(ChatML)
主流微调框架统一使用 ChatML 对话格式,每条训练样本是一段完整的多轮对话:
json
{
"messages": [
{"role": "system", "content": "你是一个金融分析助手,擅长解读财报数据和市场趋势。回答时先给结论,再列支撑数据。"},
{"role": "user", "content": "分析一下贵州茅台最新一季的营收情况"},
{"role": "assistant", "content": "贵州茅台本季度营收同比增长 15.3%,主要受益于直销渠道占比提升和系列酒放量。具体数据:营业收入 425.68 亿元,归母净利润 206.3 亿元,毛利率维持在 91.5% 的高位..."}
]
}模型会从这些对话中学习:什么样的 system prompt 下,面对什么样的用户输入,应该生成什么样的回复。训练数据的质量直接决定了微调后模型的行为质量。
数据格式:Tool Calling 格式
如果你的 Agent 需要调用工具,训练数据中应该包含 Tool Calling 的示例:
json
{
"messages": [
{"role": "user", "content": "查询北京今天的天气"},
{
"role": "assistant",
"content": null,
"tool_calls": [{
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\": \"北京\"}"
}
}]
},
{"role": "tool", "content": "{\"weather\": \"晴\", \"temperature\": 25, \"humidity\": 40}"},
{"role": "assistant", "content": "北京今天是晴天,气温 25°C,湿度 40%,非常适合户外活动。"}
]
}这种数据教会模型三件事:什么时候应该调用工具(而不是直接回答)、调用哪个工具并传什么参数、拿到工具返回后如何整合成自然语言回复。
用 TypeScript 准备微调数据
数据准备是前端工程师完全能主导的环节——你熟悉的 TypeScript 在这里大有用武之地。以下是一个将业务数据转为训练格式的实用脚本:
typescript
// generate-finetune-data.ts
// 将业务数据库中的 Q&A 记录转为微调训练格式
interface ChatMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string | null;
tool_calls?: ToolCall[];
}
interface ToolCall {
type: 'function';
function: { name: string; arguments: string };
}
interface TrainingSample {
messages: ChatMessage[];
}
interface BusinessQA {
question: string;
answer: string;
category: string;
}
const SYSTEM_PROMPT = '你是XX领域的专业助手,回答准确、简洁,使用行业标准术语。';
function generateTrainingSamples(rawData: BusinessQA[]): TrainingSample[] {
return rawData.map(item => ({
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: item.question },
{ role: 'assistant', content: item.answer },
]
}));
}
function validateSample(sample: TrainingSample): boolean {
if (sample.messages.length < 2) return false;
const hasUser = sample.messages.some(m => m.role === 'user');
const hasAssistant = sample.messages.some(m => m.role === 'assistant');
return hasUser && hasAssistant;
}
// 从业务数据源加载(JSON 文件、数据库导出、API 抓取等)
const rawData: BusinessQA[] = JSON.parse(
Deno.readTextFileSync('business-qa-export.json')
);
const samples = generateTrainingSamples(rawData).filter(validateSample);
// 打乱顺序,避免模型学到数据排列的模式
const shuffled = samples.sort(() => Math.random() - 0.5);
// 按 90/10 切分训练集和验证集
const splitIdx = Math.floor(shuffled.length * 0.9);
const trainSet = shuffled.slice(0, splitIdx);
const evalSet = shuffled.slice(splitIdx);
// 输出为 JSONL 格式(每行一个 JSON 对象,微调框架的标准输入)
Deno.writeTextFileSync(
'train.jsonl',
trainSet.map(s => JSON.stringify(s)).join('\n')
);
Deno.writeTextFileSync(
'eval.jsonl',
evalSet.map(s => JSON.stringify(s)).join('\n')
);
console.log(`训练集: ${trainSet.length} 条, 验证集: ${evalSet.length} 条`);JSONL(JSON Lines)格式是微调框架的标准输入格式——每行一个完整的 JSON 对象,不需要外层数组包裹。这种格式方便流式读取,不用一次性把整个文件加载到内存。
数据质量要点
数据量不是越多越好,质量才是关键:
- 最低量:50-100 条高质量样本即可开始看到效果。不要为了凑数量而降低标准——10 条精心编写的数据比 100 条粗制滥造的数据更有价值。
- 多样性:覆盖目标任务的各种变体。如果你要训练一个客服 Agent,训练数据应该包含咨询、投诉、退款、技术支持等各类场景,而不是都集中在某一类。
- 一致性:所有样本的格式统一,标注规范。如果有的样本用 Markdown 格式回答,有的用纯文本,模型会困惑。
- 去噪:移除矛盾或低质量样本。如果两条数据对同一个问题给出了相反的回答,模型不知道该学谁。人工检查虽然耗时,但回报最高。
前端迁移提示:微调数据准备就像 Storybook 的 stories 编写——你需要为每种使用场景准备精心设计的示例,覆盖正常路径、边界情况和异常处理。stories 写得越好,组件越可靠;训练数据越好,微调效果越好。
12.5 使用 Unsloth 进行 QLoRA 微调
为什么选 Unsloth
微调框架有很多(Hugging Face TRL、Axolotl、LLaMA-Factory),但对入门者来说 Unsloth 是最友好的选择:
- 速度快:通过内核优化实现 2 倍训练速度提升,节省 60% 显存占用。
- 代码简洁:一行代码加载模型 + 配置 LoRA 适配器,不需要手动拼装复杂的训练流水线。
- 模型覆盖广:支持 Llama、Qwen、Mistral、Gemma 等主流开源模型家族。
- 导出友好:训练完成后一行代码导出为 GGUF 格式,可直接导入 Ollama 使用。这和专题 11 的部署方案无缝衔接。
完整微调脚本
python
# finetune.py — 使用 Unsloth 对 Qwen3-8B 进行 QLoRA 微调
from unsloth import FastLanguageModel
from datasets import load_dataset
from trl import SFTTrainer
from transformers import TrainingArguments
# 1. 加载基础模型 + 配置 LoRA 适配器
model, tokenizer = FastLanguageModel.from_pretrained(
model_name="unsloth/Qwen3-8B",
max_seq_length=2048,
load_in_4bit=True, # QLoRA:用 4bit 量化加载基础模型
)
model = FastLanguageModel.get_peft_model(
model,
r=16, # LoRA 秩:16 是通用默认值
lora_alpha=16, # 缩放因子:通常设为与 r 相同
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
lora_dropout=0, # Unsloth 优化下 dropout=0 效果最好
)
# 2. 加载训练数据(上一步生成的 JSONL 文件)
dataset = load_dataset("json", data_files="train.jsonl", split="train")
# 3. 配置训练参数并启动训练
trainer = SFTTrainer(
model=model,
train_dataset=dataset,
args=TrainingArguments(
per_device_train_batch_size=2, # 每个设备的批大小
gradient_accumulation_steps=4, # 梯度累积(等效 batch_size=8)
warmup_steps=5, # 学习率预热步数
max_steps=60, # 总训练步数
learning_rate=2e-4, # 学习率
fp16=True, # 混合精度训练
logging_steps=5, # 每 5 步打印一次 loss
output_dir="outputs", # 输出目录
),
)
trainer.train()
# 4. 保存为 GGUF 格式(可直接导入 Ollama)
model.save_pretrained_gguf(
"outputs/gguf",
tokenizer,
quantization_method="q4_k_m" # 4bit 量化,平衡质量和大小
)
print("微调完成,GGUF 模型已保存到 outputs/gguf/")这个脚本做了四件事:加载模型并挂载 LoRA 适配器 → 读取训练数据 → 训练 → 导出。在 RTX 4090 上,60 步训练大约需要 10-15 分钟;在 M2 Pro 上大约 30-45 分钟。
关键参数解释
| 参数 | 说明 | 建议值 | 调整方向 |
|---|---|---|---|
| r (rank) | LoRA 秩,越大能学到的变化越多 | 8-32 | 8 轻量任务,16 通用,32 复杂任务 |
| lora_alpha | 缩放因子,控制 LoRA 权重的影响力 | 等于 r | 通常不需要单独调 |
| max_steps | 训练步数 | 50-200 | 数据少时减小,loss 没降够时增加 |
| learning_rate | 学习率 | 1e-4 到 5e-4 | loss 震荡就降低,loss 不降就提高 |
| per_device_train_batch_size | 批大小 | 1-4 | 显存不够就降低 |
| load_in_4bit | QLoRA 4bit 量化 | True | 消费级硬件必须开启 |
参数调整的优先级:先确保训练能跑起来(batch_size 和显存匹配),再看 loss 曲线调 max_steps 和 learning_rate,最后根据效果调 r。
12.6 微调模型的评估
训练完成不等于大功告成。微调模型可能过拟合(训练集上完美但新问题上一塌糊涂),也可能欠拟合(训练不充分、效果不明显)。评估是确认微调成功的必要步骤。
基础评估方法
- Loss 曲线:训练过程中 loss 应稳步下降并趋于平稳。如果 loss 降到很低(< 0.1)但验证集 loss 开始上升,说明过拟合了——减少 max_steps 或增加训练数据。
- 人工评估:准备 20-30 条不在训练集中的测试 case,分别用基础模型和微调模型回答,人工对比打分。这是最可靠的评估方式,没有之一。
- A/B 对比:同一问题同时发给基础模型和微调模型,看微调模型是否在目标维度(格式、术语、风格)上有明显改善,同时通用能力没有明显退化。
自动评估脚本
对于格式遵循、关键词命中等可量化的维度,可以用脚本批量评估:
typescript
// evaluate-finetune.ts
// 自动对比基础模型和微调模型的表现
interface EvalCase {
input: string;
expectedKeywords: string[]; // 期望回复中包含的关键词
expectedFormat?: RegExp; // 期望的输出格式(正则)
tags: string[]; // 分类标签,用于分维度统计
}
interface EvalResult extends EvalCase {
actual: string;
keywordHits: number;
formatMatch: boolean;
}
async function evaluate(
model: string,
cases: EvalCase[]
): Promise<EvalResult[]> {
const results: EvalResult[] = [];
for (const c of cases) {
const res = await fetch('http://localhost:11434/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages: [{ role: 'user', content: c.input }],
temperature: 0.1, // 评估时用低温度,减少随机性
}),
});
const data = await res.json();
const actual = data.choices[0].message.content;
const keywordHits = c.expectedKeywords.filter(kw =>
actual.toLowerCase().includes(kw.toLowerCase())
).length;
const formatMatch = c.expectedFormat
? c.expectedFormat.test(actual)
: true;
results.push({ ...c, actual, keywordHits, formatMatch });
}
const keywordAccuracy = results.reduce(
(sum, r) => sum + r.keywordHits / r.expectedKeywords.length, 0
) / results.length;
const formatAccuracy = results.filter(r => r.formatMatch).length / results.length;
console.log(`模型: ${model}`);
console.log(` 关键词命中率: ${(keywordAccuracy * 100).toFixed(1)}%`);
console.log(` 格式遵循率: ${(formatAccuracy * 100).toFixed(1)}%`);
return results;
}
// 加载测试用例
const testCases: EvalCase[] = JSON.parse(
Deno.readTextFileSync('eval-cases.json')
);
// 对比基础模型 vs 微调模型
console.log('=== 基础模型 ===');
const baseResults = await evaluate('qwen3:8b', testCases);
console.log('\n=== 微调模型 ===');
const ftResults = await evaluate('qwen3-finetuned:latest', testCases);自动评估不能替代人工评估,但可以快速发现大方向上的问题。特别是格式遵循率——如果微调的目的就是让模型稳定输出特定格式,这个指标最有说服力。
12.7 部署微调模型到 Ollama
微调完成后,最后一步是把模型部署起来,让你的 Agent 代码能调用它。如果你已经按照专题 11 搭建了 Ollama 环境,部署微调模型只需要三条命令。
从 GGUF 导入 Ollama
bash
# 1. 创建 Modelfile(Ollama 的模型描述文件,类似 Dockerfile)
cat > Modelfile << 'EOF'
FROM ./outputs/gguf/unsloth.Q4_K_M.gguf
TEMPLATE """{{ if .System }}<|im_start|>system
{{ .System }}<|im_end|>
{{ end }}<|im_start|>user
{{ .Prompt }}<|im_end|>
<|im_start|>assistant
"""
PARAMETER temperature 0.7
PARAMETER stop "<|im_end|>"
PARAMETER num_ctx 4096
EOF
# 2. 导入到 Ollama(构建本地模型镜像)
ollama create my-finetuned-model -f Modelfile
# 3. 验证模型可用
ollama run my-finetuned-model "用你的领域问题测试一下"Modelfile 中的 TEMPLATE 部分定义了对话模板,需要和训练时使用的模板一致。Qwen 系列模型使用 ChatML 格式(<|im_start|> / <|im_end|> 标签),Llama 系列使用不同的模板。模板不匹配会导致模型输出乱码或行为异常——这是初学者最常踩的坑。
代码中无缝切换
部署完成后,你的 Agent 代码只需要改一个字符串:
typescript
// 微调前:使用基础模型
const response = await client.chat.completions.create({
model: 'qwen3:8b',
messages: conversationHistory,
});
// 微调后:切换为微调模型,其他代码完全不变
const response = await client.chat.completions.create({
model: 'my-finetuned-model', // 只改这一行
messages: conversationHistory,
});这就是专题 11 中强调的"baseURL + model 可配置"架构的价值——无论是云端模型、本地基础模型还是微调模型,业务代码都不需要改动。
前端迁移提示:微调模型部署到 Ollama 就像发布 npm 包——打包(导出 GGUF)、注册(
ollama create)、引用(改 model 名称),使用方式与之前完全相同。你的"消费者代码"不需要知道这个包的内部实现变了。
12.8 云端微调对比:OpenAI Fine-tuning API
并不是所有微调都需要本地 GPU。OpenAI 提供了 Fine-tuning API,用 API 调用的方式完成微调。了解两种方案的差异有助于你根据实际情况做选择。
本地微调 vs 云端微调
| 维度 | 本地微调(Unsloth + Ollama) | 云端微调(OpenAI Fine-tuning) |
|---|---|---|
| 成本结构 | GPU 硬件一次性投入,边际成本低 | 按训练 token 计费,每次微调都花钱 |
| 数据隐私 | 数据完全留在本地 | 训练数据上传到 OpenAI 服务器 |
| 参数控制 | 完全控制(r、alpha、lr、steps 等) | 有限参数可调(epochs、lr multiplier) |
| 上手难度 | 需要 Python 环境 + GPU 或 Colab | 准备 JSONL 文件 + API 调用即可 |
| 模型选择 | 任意开源模型(Qwen、Llama、Mistral...) | 仅 OpenAI 模型(GPT-4o-mini 等) |
| 导出能力 | 可导出为 GGUF,自由部署 | 只能通过 OpenAI API 调用 |
| 迭代速度 | 本地即时测试,分钟级反馈 | 提交训练任务,等待排队完成 |
选择建议:如果你的场景允许数据上云且预算充足,OpenAI Fine-tuning API 的上手门槛最低——准备好 JSONL 文件,调一个 API 就行。但如果你需要数据隐私、灵活的模型选择或长期成本控制,本地微调是更好的投资。
两种方案的训练数据格式是一样的(ChatML JSONL),所以你在数据准备上的投入可以复用。
12.9 常见失败模式与排查
微调不是一次就能成功的。以下是最常见的五种失败模式和对应的排查方法:
1. 过拟合
现象:训练 loss 降到很低,但面对新问题时回答质量反而变差,甚至直接复述训练数据中的回答。
原因:数据太少 + 训练步数太多。模型"背住了"训练数据而非"学会了"规律。
解决:减少 max_steps(先试 30-50 步看效果);增加训练数据量;在训练数据中混入 5-10% 的通用对话数据(防止遗忘)。
2. 灾难性遗忘
现象:微调后模型在目标任务上表现不错,但通用能力明显下降——不会做数学了、常识问答出错、语言组织变差。
原因:微调过度覆盖了原始权重中的通用知识。这在数据集高度同质化时最容易发生。
解决:降低 learning_rate(从 2e-4 降到 5e-5);降低 r 值(从 16 降到 8);在训练数据中混入多样化的通用样本。
3. 格式不学习
现象:微调的目标是让模型输出特定格式(如 JSON),但微调后格式遵循率没有明显提升。
原因:通常是训练数据中格式不一致——有的样本严格遵循了目标格式,有的没有。模型无法从不一致的信号中学到稳定的模式。
解决:逐条检查训练数据的格式一致性;确保每条样本都严格遵循目标格式;考虑增加 system prompt 中的格式说明。
4. OOM(显存不足)
现象:训练启动后报 CUDA out of memory 或 MPS out of memory。
原因:模型 + LoRA 适配器 + 优化器状态 + 批数据超出了可用显存。
解决:降低 per_device_train_batch_size(先试 1);确认 load_in_4bit=True(QLoRA);减小 max_seq_length(从 2048 降到 1024);减小 r 值。
5. 收敛过慢
现象:训练了很多步但 loss 几乎不动。
原因:learning_rate 太低、数据质量有问题、或模型和数据格式不匹配。
解决:提高 learning_rate(从 1e-4 到 5e-4);检查数据是否正确加载(打印前几条看看);确认对话模板和模型匹配。
12.10 面试表达模板
以下是几种场景下的表达参考:
微调经验
"我使用 QLoRA 对 Qwen3-8B 进行了领域微调,用 200 条高质量对话数据训练了约 100 步。微调后在格式遵循和术语准确性上有明显提升——同样的 30 个测试 case,格式遵循率从 65% 提升到 95%。训练在一张 RTX 4090 上完成,全程不到 20 分钟。"
技术选型
"在选择微调 vs Prompt 工程时,我会先评估三个维度:格式遵循度、领域术语准确性和行为一致性。如果 Prompt 工程在任何一个维度上反复调优仍达不到业务要求,才会考虑微调。微调不是默认选项,而是最后一道手段。"
数据准备
"微调的核心不在训练本身,而在数据准备。我的做法是先从生产日志中筛选高质量的 Q&A 对,用 TypeScript 脚本清洗和转换为 ChatML 格式的 JSONL,然后人工审核每一条。100 条精心准备的数据比 1000 条噪声数据效果好得多。"
诚实说明局限
"微调也有其局限性。首先是数据准备成本——高质量训练数据需要领域专家参与标注,不是工程师能独立完成的。其次是灾难性遗忘的风险——微调过度会损害模型的通用能力。我的做法是在训练集中混入通用数据作为'正则化',并在评估时同时检查通用能力的保持度。"
12.11 验收清单
- [ ] 能解释何时该微调 vs Prompt 工程 vs RAG,给出具体判断依据而非泛泛而谈。
- [ ] 能说明 LoRA/QLoRA 的原理——冻结原始权重,训练低秩矩阵——以及为什么这样做效果接近全量微调。
- [ ] 能用 TypeScript 准备符合 ChatML 格式的微调训练数据(JSONL),包含数据验证和训练/验证集切分。
- [ ] 能用 Unsloth 完成一次 QLoRA 微调,理解 r、lora_alpha、max_steps、learning_rate 等关键参数的作用。
- [ ] 能通过 Loss 曲线和 A/B 对比评估微调效果,识别过拟合和灾难性遗忘。
- [ ] 能将微调模型导出为 GGUF 格式并导入 Ollama,在 Agent 代码中无缝切换使用。