写在前面:接上篇之后要做什么
上篇走完「教师模型打标 → 过滤 → Baseline」,基座 0.6B 的 JSON 合规率和路由准确率往往惨不忍睹,而教师模型已经稳定。本篇要做的事很直白:
- 搞懂微调在学什么(损失、梯度、LoRA 为什么省参)
- 用 LoRA 把教师能力灌进 0.6B(ms-swift + GPU)
- 同一套尺子复评,看蒸馏模型能否逼近教师
- 算一笔账,决定 API 还是自建小模型
读完本篇,你能学会什么?
| 主题 | 你会学到 |
|---|---|
| 微调原理 | 损失函数、梯度下降、学习率对收敛的影响 |
| LoRA | 为什么只训 0.4% 参数仍能学会任务、如何避免灾难性遗忘 |
| 训练状态判断 | 用 train/eval loss 区分欠拟合、过拟合、训练失败 |
| LoRA 实战 | ms-swift 参数含义、调参方向、loss 曲线解读 |
| 蒸馏复评 | 基座 / 教师 / 学生三方对比,JSON 合规率与路由准确率的意义 |
| 能力边界 | 什么任务适合蒸馏、什么该用 RAG 或大模型 |
| 成本决策 | API 按 token 计费 vs GPU 按实例时长计费的盈亏平衡点 |
一、微调原理:模型是怎么学会「读懂请求」的
在开始训练之前,先弄清模型是如何通过训练数据学习的。蒸馏在数据侧与微调不同(教师生成 vs 人工标注),训练侧仍是标准的监督微调(SFT)。
1.1 模型如何学习
损失函数
模型训练的核心是一个迭代优化过程:给定输入,模型生成预测;将预测与期望输出对比,计算损失(loss);然后调整模型参数,使损失变小。损失函数量化了「预测与期望的差距」——损失越小,说明模型输出越接近训练数据中的期望输出(本场景即 assistant 角色的结构化 JSON)。

梯度下降
如何找到让损失最小的参数?梯度下降从一个随机起点出发,每一步沿「损失下降最快的方向」调整参数,逐步逼近较优解。
每一步的调整幅度由学习率(learning rate)决定:
| 学习率 | 现象 | 后果 |
|---|---|---|
| 合适 | 损失稳步下降 | 在较短时间内找到可用参数 |
| 过低 | 能收敛,但步幅小 | 训练轮次多、耗时与算力消耗大 |
| 过高 | 在最优解附近震荡或跳过最低点 | 损失不降甚至上升,训练失败 |

1.2 LoRA:高效微调
全参数微调需要更新模型的所有权重。大模型动辄数十亿参数,计算和存储成本都很高。LoRA(Low-Rank Adaptation) 是一种高效的替代方案。
LoRA 的核心思想:不直接修改原始权重矩阵 W,而是在旁边「挂」一对小的低秩矩阵来学习变化量 ΔW。

假设原始权重矩阵 W 的维度是 d × d,LoRA 将变化量 ΔW 分解为两个小矩阵的乘积:
ΔW = A × B
其中 A 的维度是 d × r,B 的维度是 r × d,r 远小于 d(r 即 lora_rank)。
例如,当 d = 1024、r = 8 时:
- 全参数微调:约 1024 × 1024 ≈ 100 万 个参数要更新
- LoRA:约 1024 × 8 × 2 ≈ 1.6 万 个参数 → 约占该层全参的 1.56%(全模型只对部分层挂 LoRA 时,整体可训练参数占比可低至约 0.4%)
Java:单层 LoRA 参数量估算
/** LoRA 可训练参数量 ≈ 2×d×r;全参微调 ≈ d² */
public static LoraParamEstimate estimateParams(int matrixDim, int loraRank) {
long full = (long) matrixDim * matrixDim;
long lora = 2L * matrixDim * loraRank;
double pct = full > 0 ? lora * 100.0 / full : 0;
return new LoraParamEstimate(matrixDim, loraRank, full, lora, pct);
}
// 使用:d=1024, r=8 → full=1_048_576, lora=16_384, 占该层≈1.56%
var est = estimateParams(1024, 8);
System.out.printf("全参=%d, LoRA=%d, 占该层=%.2f%%%n",
est.fullFineTuneParams(), est.loraTrainableParams(), est.loraRatioPercent());
lora_rank(即 r) 控制低秩矩阵的维度:r 越大,可学习的变化量越多,训练成本也越高。对「请求理解」这类结构化提取任务,r = 8 通常够用。LoRA 还有一个重要优势:只训练「补丁」参数,原始模型权重不变,能保留基座绝大部分通用能力,有效减轻灾难性遗忘。
1.3 训练状态判断
训练过程中关注两个指标:
| 指标 | 含义 |
|---|---|
| training loss | 模型在训练数据上的表现 |
| evaluation loss | 模型在未参与训练的验证集上的表现(本示例用 split_dataset_ratio 0.1 自动切分) |
结合两者变化趋势判断状态:

二、LoRA 训练实战
环境依赖与基座模型下载见上篇「动手前:环境怎么配」。训练流程分四步:

| 步骤 | 说明 |
|---|---|
| 1. 准备数据 | 确认 resources/4_1/train.jsonl 可访问(messages 格式) |
| 2. 启动训练 | 运行下方 swift sft 命令;A10 级 GPU 上约 10–15 分钟 |
| 3. 监控 Loss | 观察训练日志中的损失变化,判断训练状态 |
| 4. 查看结果 | 模型和日志保存在 output/model/<训练目录>/(见下方说明),loss 曲线图在其 images/ 子目录,TensorBoard 日志在 runs/ 子目录 |
ms-swift 会为每次训练自动建目录,,命名格式为 v{版本号}-{日期}-{时间}。命名形如
v1-20260415-145518。训练完成后在output/model/下找到对应目录即可。
2.1 准备训练数据
确保 train.jsonl 在当前工作目录下可访问。每条样本为 system / user / assistant 三角色对话(格式见上篇 §3.6)。
2.2 开始训练
使用 ms-swift 进行 LoRA 微调。关键参数说明:
| 参数 | 本示例取值 | 含义 |
|---|---|---|
--model |
基座模型本地路径 | 上篇下载的 Qwen3-0.6B |
--model_type qwen3 |
显式指定类型 | 从本地路径加载时 ms-swift 可能无法自动识别 |
--split_dataset_ratio 0.1 |
10% 作验证集 | 监控过拟合 |
--lora_rank 8 |
LoRA 秩 | 结构化多字段任务,8 比 4 更稳 |
--num_train_epochs 5 |
训练轮次 | 验证 loss 上升则减少 |
--learning_rate 5e-5 |
学习率 | loss 不降可试 1e-4,震荡可试 1e-5 |
--per_device_train_batch_size 8 |
每卡 batch | 显存不足改为 4 |
--max_length 512 |
最大序列长 | 由 token 统计决定 |
--eval_steps 20 |
每 20 步算 eval loss | 观察过拟合 |
调参方向参考(非固定值,需结合数据规模与任务复杂度):
| 参数 | 调优方向 |
|---|---|
lora_rank |
数据不足 200 条且输出字段简单可试 4;超过 2000 条或任务更复杂可试 16 |
learning_rate |
loss 不降 → 调高;loss 震荡 → 调低 |
num_train_epochs |
eval loss 上升 → 减轮次;仍在下降 → 可加轮次 |
per_device_train_batch_size |
显存不足 → 减小 |
本示例训练集约百条,但「请求理解」需同时学习 8 类多选意图、部门、紧急度、实体、路由 五维联合映射,复杂度高于单标签分类,故选 r=8 而非 r=4。
Java:生成训练命令(不执行,便于复制到 GPU 机器)
public static String buildSwiftSftShell(LoraTrainConfig c) {
return """
swift sft \\
--model %s \\
--model_type %s \\
--dataset %s \\
--split_dataset_ratio %s \\
--learning_rate %s \\
--lora_rank %d \\
--num_train_epochs %d \\
--per_device_train_batch_size %d \\
--max_length %d \\
--eval_steps %d \\
--save_strategy %s \\
--save_steps %d
""".formatted(
c.modelPath(), c.modelType(), c.datasetPath(),
"0.1", "5e-5", c.loraRank(), c.numTrainEpochs(),
c.perDeviceTrainBatchSize(), c.maxLength(),
c.evalSteps(), c.saveStrategy(), c.saveSteps())
.strip();
}
LoraTrainConfig cfg = new LoraTrainConfig(
"/mnt/workspace/model", "resources/4_1/train.jsonl",
"qwen3", 0.1, 5e-5, 8, 5, 8, 512, 20, "steps", 20);
System.out.println(buildSwiftSftShell(cfg));
启动命令(Python / GPU,需在 shell 中执行)
# LoRA 微调 Qwen3-0.6B(A10 约 10–15 分钟)
# 建议在 GPU 机器上后台运行,避免终端长时间刷屏
cd /path/to/your/workspace
nohup swift sft \
--model /mnt/workspace/model \
--model_type qwen3 \
--dataset resources/4_1/train.jsonl \
--split_dataset_ratio 0.1 \
--learning_rate 5e-5 \
--lora_rank 8 \
--num_train_epochs 5 \
--per_device_train_batch_size 8 \
--max_length 512 \
--eval_steps 20 \
--save_strategy steps \
--save_steps 20 \
> /tmp/swift_train.log 2>&1 &
echo "训练 PID: $!"
# 训练中: tail -20 /tmp/swift_train.log
# 是否结束: pgrep -f "swift sft"
2.3 训练结果观察
训练完成后,ms-swift 会在训练日志末尾打印输出目录路径(images_dir),例如:
images_dir: /mnt/workspace/aliyun_acp_learning/大模型ACP认证教程/C4_交付上线/output/model/vx-xxxxxxxx-xxxxxx/images
其中 vx-xxxxxxxx-xxxxxx 是 ms-swift 自动生成的训练目录名(格式 v{版本}-{YYYYMMDD}-{HHMMSS}),每次训练的目录名不同,以你实际看到的为准。
有三种方式查看 loss 变化:
- 曲线图(最直观):在 DSW 文件浏览器中打开上述 images/ 目录,双击 loss 曲线图即可查看。
- TensorBoard 日志:同一训练目录下的 runs/ 子目录保存了 TensorBoard 日志。在 Terminal 中运行。
tensorboard --logdir output/model/ 可启动交互式面板。 - 训练日志:上方 cell 的输出中包含每一步的 loss 值,可以直接观察数字是否在下降。
对照 §1.3:train loss 与 eval loss 同步下降 为正常学习;eval loss 先降后升 为过拟合信号。
2.4 合并 LoRA 参数
训练完成后,将 LoRA 权重与基座合并,得到可独立部署的完整模型(输出目录一般为 checkpoint-xxx-merged)。
Python(等价 glob 逻辑,在 GPU 环境执行 merge)
import glob
# 自动查找最新的 LoRA checkpoint(排除已合并的目录)
all_ckpts = sorted(glob.glob("output/model/*/checkpoint-*"))
ckpts = [c for c in all_ckpts if not c.endswith('-merged')]
if not ckpts:
print("未找到 checkpoint,请先运行 Section 6.2 的训练命令。")
else:
latest_ckpt = ckpts[-1]
print(f"找到 checkpoint:{latest_ckpt}")
print(f"开始合并 LoRA 参数...")
!swift export --adapters {latest_ckpt} --merge_lora true
2.5 常见问题
| 问题 | 可能原因 | 处理方法 |
|---|---|---|
| 训练损失不降 | 学习率过高 | 降至 1e-5 重试 |
| 验证损失上升 | 过拟合 | 减少 num_train_epochs 或增加数据 |
| JSON 格式崩坏 | 训练数据 JSON 不统一 | 检查过滤与 messages 格式 |
| 意图遗漏 | 多意图样本不足 | 训练集中多意图样本建议 ≥ 30% |
| 显存不足 | batch 或 max_length 过大 |
per_device_train_batch_size 改为 4 |
二、6、试试用 Qwen Code 完成训练(可选的另一种方式约定好边界,使用Qwen Code去实现)
你可以直接运行下方代码 cell 手动启动训练,也可以试试另一种方式:把提示词复制到 Qwen Code 中,让它帮你完成训练,并在过程中逐步讲解每个阶段正在发生什么。
启动后,将以下提示词粘贴到 Qwen Code 中:
我正在学习模型蒸馏,需要你帮我完成一次 LoRA 微调训练。请按以下步骤执行,每一步都向我解释你在做什么、为什么这么做。
环境信息
- 基座模型路径:/mnt/workspace/model(Qwen3-0.6B,0.6B 参数)
- 训练数据:resources/4_1/train.jsonl(messages 格式,每条包含 system/user/assistant 三个角色)
- 框架:ms-swift(系统环境已安装,直接使用 swift 命令即可)
- GPU:A10(30GB 显存)
- 工作目录:/mnt/workspace/aliyun_acp_learning/大模型ACP认证教程/C4_交付上线/
- ⚠️ 不要激活 llm_learn 虚拟环境,不要运行 source activate。venv 中的 PyTorch 版本与系统 CUDA 不兼容,激活后 GPU 不可用。直接使用系统默认环境。
重要约束
- 不要读取 notebook 文件(.ipynb)来查看已有的训练命令或参数。请根据你自己对数据的分析来独立决定参数值。
- 不要运行 swift --version(会报错)、不要运行 swift sft --help(输出不完整,没有参考价值)。
- 不要访问 GitHub 或其他外部网站查文档(当前环境无法访问外网)。
- 训练参数的决策依据应来自你对训练数据的实际分析(样本数、token 长度等),而非查阅文档。
请执行以下步骤
第 1 步:验证环境
- 确认当前没有激活任何虚拟环境(命令行提示符前不应有
(llm_learn)等前缀) - 验证 GPU 是否可用:运行
python3 -c "import torch; print('GPU可用:', torch.cuda.is_available(), '| 设备数:', torch.cuda.device_count())" - 如果输出 False,检查是否误激活了虚拟环境,运行
deactivate退出后重试 - GPU 验证通过后才能继续下一步
- 确认 swift 命令存在(运行
which swift,不要运行 swift --version) - 切换到工作目录
- 确认基座模型文件存在
- 确认训练数据文件存在并统计样本条数
第 2 步:分析训练数据并决定训练参数
先对训练数据做以下分析(用 Python 脚本,不要用 swift 命令):
- 统计总样本数
- 读取 5-10 条样本,了解数据格式,向我解释 system/user/assistant 三个角色各自的内容
- 用 tokenizer 计算每条样本的 token 长度分布(最小值、最大值、平均值、P95),代码示例:
然后根据分析结果,为以下参数选择值,并向我解释每个参数的含义和你选择该值的理由:from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("/mnt/workspace/model", trust_remote_code=True) # 对每条样本拼接 system+user+assistant 内容后 tokenize,统计长度
--lora_rank:LoRA 低秩矩阵维度。考虑因素:任务需要同时学习 5 个字段(intents/department/urgency/entities/route)的联合映射
--learning_rate:学习率。考虑因素:数据规模和模型大小
--num_train_epochs:训练轮次。考虑因素:数据量大小,轮次过多会过拟合
--per_device_train_batch_size:每张 GPU 的批次大小。考虑因素:A10 显存 30GB,模型 0.6B 参数
-
--max_length:最大序列长度。根据你上面统计的 token 长度 P95 来决定,留出一定余量即可
以下参数请固定使用这些值(用于确保生成 loss 曲线图和验证集评估):
--model /mnt/workspace/model
--model_type qwen3
--dataset resources/4_1/train.jsonl
--split_dataset_ratio 0.1
--eval_steps 20
--save_strategy steps
--save_steps 20
第 3 步:启动训练
⚠️ 训练约需 10-15 分钟。必须以后台方式运行,否则日志持续输出会导致终端闪屏,且命令可能因超时被终止。
按以下方式启动:
cd /mnt/workspace/aliyun_acp_learning/大模型ACP认证教程/C4_交付上线/
nohup swift sft [你决定的参数] > /tmp/swift_train.log 2>&1 &
echo "训练 PID: $!"
启动后,每隔 2-3 分钟运行 tail -20 /tmp/swift_train.log 查看最新日志,向我汇报:
- 当前训练进度(第几个 epoch、第几步)
- loss 的变化趋势(是否在正常下降)
- 如果出现异常(如 loss 突然飙升),提醒我并建议处理方式
判断训练是否结束:运行 pgrep -f "swift sft" 检查进程是否还在。进程消失说明训练已完成,此时再查看日志末尾确认最终状态。
第 4 步:训练完成后的分析
训练结束后:
- 找到输出目录(output/model/ 下最新的目录)
- 在该目录的 images/ 子目录下找到 loss 曲线图片
- 分别展示 train/loss 和 eval/loss 的曲线图
- 对曲线进行解读:
- 训练是否正常收敛?
- 有没有过拟合迹象?(eval/loss 是否出现上升)
- 最终的 loss 值是否合理?
- 根据 loss 曲线给出你的判断:这次训练算成功吗?
第 5 步:准备下一步
- 告诉我合并后的模型会保存在哪里
- 简要说明接下来可以做什么(合并 LoRA 参数、评测效果)
如果你选择使用 Qwen Code 完成训练,可以跳过下方代码 cell。如果你更习惯手动执行,直接运行下方命令即可,参数含义已在上文说明。
LoRA 微调 Qwen3-0.6B(A10 GPU 约 10 分钟)
!swift sft
--model /mnt/workspace/model
--model_type qwen3
--dataset resources/4_1/train.jsonl
--split_dataset_ratio 0.1
--learning_rate 5e-5
--lora_rank 8
--num_train_epochs 5
--per_device_train_batch_size 8
--max_length 512
--eval_steps 20
--save_strategy steps
--save_steps 20
三、蒸馏效果评测
训练并合并 LoRA 后,用与上篇相同的测试集与指标评测蒸馏模型,并与基座、教师对比。
3.1 评测流程
推理阶段(加载合并后的小模型)依赖 ModelScope + PyTorch,保留 Python:
import glob
import json
from modelscope import AutoModelForCausalLM, AutoTokenizer
import torch
merged_dirs = sorted(glob.glob("output/model/*/checkpoint-*-merged"))
if not merged_dirs:
print("未找到合并后的 checkpoint,请先执行 §2.4 合并。")
else:
student_path = merged_dirs[-1]
print(f"加载蒸馏模型:{student_path}")
student_tokenizer = AutoTokenizer.from_pretrained(student_path, trust_remote_code=True)
student_model = AutoModelForCausalLM.from_pretrained(
student_path, dtype=torch.float16, device_map="auto", trust_remote_code=True
)
test_data = []
with open("resources/4_1/test_30.jsonl", "r", encoding="utf-8") as f:
for line in f:
test_data.append(json.loads(line))
student_preds = []
for i, item in enumerate(test_data):
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": item["query"]},
]
text = student_tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True, enable_thinking=False
)
inputs = student_tokenizer(text, return_tensors="pt").to(student_model.device)
with torch.no_grad():
outputs = student_model.generate(
**inputs, max_new_tokens=512, temperature=0.1, do_sample=True
)
response = student_tokenizer.decode(
outputs[0][inputs.input_ids.shape[1]:], skip_special_tokens=True
)
parsed = validate_label(response)
student_preds.append({"raw": response, "parsed": parsed})
print(f" [{i+1}/{len(test_data)}] {'OK' if parsed else 'FAIL'} | {item['query'][:50]}...")
ground_truths = [item["ground_truth"] for item in test_data]
student_results = evaluate_predictions(student_preds, ground_truths)
print_eval_results(student_results, "蒸馏模型")
打分与对比表(与上篇同一套指标逻辑):
public static double computeIntentF1(List<String> pred, List<String> truth) {
Set<String> p = new HashSet<>(pred), t = new HashSet<>(truth);
if (p.isEmpty() || t.isEmpty()) return 0.0;
Set<String> inter = new HashSet<>(p);
inter.retainAll(t);
double precision = inter.size() * 1.0 / p.size();
double recall = inter.size() * 1.0 / t.size();
return 2 * precision * recall / (precision + recall);
}
public static EvalResult evaluatePredictions(
List<Prediction> predictions, List<Map<String, Object>> groundTruths) {
int jsonValid = 0, routeCorrect = 0;
double intentF1Sum = 0;
for (int i = 0; i < groundTruths.size(); i++) {
Map<String, Object> parsed = predictions.get(i).parsed();
if (parsed == null) continue;
jsonValid++;
intentF1Sum += computeIntentF1(
(List<String>) parsed.get("intents"),
(List<String>) groundTruths.get(i).get("intents"));
if (Objects.equals(parsed.get("route"), groundTruths.get(i).get("route"))) {
routeCorrect++;
}
}
int n = groundTruths.size();
return new EvalResult(jsonValid, intentF1Sum / n, routeCorrect, n);
}
public static String renderComparisonMarkdown(List<EvalResult> results, List<String> names) {
StringBuilder sb = new StringBuilder();
sb.append("| 指标 | ").append(String.join(" | ", names)).append(" |\n");
sb.append("| --- | ").append(" --- |".repeat(names.size())).append("\n");
// 各行:JSON 合规率、intents F1、route 准确率
return sb.toString();
}
// 博客参考值(无 GPU 时也可先出表)
EvalResult base = new EvalResult(15, 0.237, 5, 31);
EvalResult teacher = new EvalResult(31, 0.839, 28, 31);
EvalResult student = new EvalResult(31, 0.704, 24, 31);
System.out.println(renderComparisonMarkdown(
List.of(base, teacher, student),
List.of("基座 (0.6B)", "教师 (qwen3.6-plus)", "蒸馏 (0.6B)")));
3.2 三方对比(参考结果)
若尚未在本地跑完全部评测,可参考下表(与上篇 Baseline 同一测试集):
| 指标 | 基座模型 (0.6B) | 教师模型 (qwen3.6-plus) | 蒸馏模型 (0.6B) |
|---|---|---|---|
| JSON 合规率 | 15/31 (48%) | 31/31 (100%) | 31/31 (100%) |
| intents F1 | 23.7% | 83.9% | 70.4% |
| route 准确率 | 5/31 (16%) | 28/31 (90%) | 24/31 (77%) |
解读摘要:
- JSON 合规率:蒸馏后几乎都能解析为合法 JSON,格式问题从「最大短板」变为「基本达标」。
- intents F1 / route 准确率:相较基座大幅提升,说明学到了教师的判断逻辑;路由准确率对生产最关键——决定请求能否进对处理模块。
- 与教师的差距:0.6B 尚未完全追平教师,但已接近该参数量级上的能力天花板;若需更高精度,可换 1.5B / 4B 作学生。
3.3 蒸馏的能力边界
蒸馏不是万能的。以下情况蒸馏后仍容易出错:
| 类型 | 示例 | 原因 |
|---|---|---|
| 意图间有依赖 | 「请假后自动调整考勤」 | 多意图有因果关系,小模型难捕捉 |
| 训练集未覆盖的组合 | 「IT支持 + 差旅申请」且数据中少见 | 模型会猜错 |
| 极度含糊 | 「那个事情帮我处理一下」 | 缺少可抓取信号 |
0.6B 适合:高频、固定域、单步判断的专用任务。在系统中的角色是预处理器,不是小号通用助手。
3.4 你的任务适合蒸馏吗
投入算力前先快速自检:
适合蒸馏:
- 输出格式固定,可用规则 / Schema 自动校验
- 不依赖实时外部知识,只看输入文本
- 调用频次高,成本敏感
- 教师表现稳定,错误有规律(非随机噪声)
不适合及替代:

快速验证流程:正式训练前,建议先用少量样本做一次快速实验。如果基座模型已经能基本完成需求,或微调后提升不明显,可以重新评估任务选择,或调整优化策略(比如增加训练数据的多样性、调整教师 prompt)。
数据分布与生产不匹配也是常见陷阱。合成数据的表达模式可能与真实用户的提问方式存在差异,导致模型在生产环境中效果下降。可以适当混入真实数据,帮助模型适应实际分布。
四、成本收益与部署决策
蒸馏的终极目标之一是降低生产推理成本。
4.1 成本对比
假设典型「请求理解」场景:日均 2 万次,平均输入 200 token(system + 提问),输出 100 token(JSON)。
方案 A:大模型 API(qwen3.6-plus 档)
按 DashScope 量级估算(输入约 2 元/百万 token,输出约 12 元/百万 token):
| 项 | 计算 | 日成本 |
|---|---|---|
| 输入 | 2 万 × 200 ÷ 10⁶ × 2 元 | ≈ 8 元 |
| 输出 | 2 万 × 100 ÷ 10⁶ × 12 元 | ≈ 24 元 |
| 合计 | ≈ 32 元/天(约 960 元/月) |
API 成本与请求量严格线性:量翻倍,钱翻倍。
方案 B:蒸馏小模型 GPU 部署
以 T4 4GB、按量约 3.6 元/小时 为例。0.6B FP16 权重约 1.2GB,4GB 显存可跑。GPU 按在线时长计费,不按 token:
| 部署方式 | 日成本 | 月成本 | 说明 |
|---|---|---|---|
| 工作时间 8h | 28.8 元 | 864 元 | 定时伸缩,仅工作时段在线 |
| 24h 常驻 | 86.4 元 | 2592 元 | 跨时区 / 全天候服务 |
4.2 盈亏平衡点
单次 API 调用成本约 0.0016 元(按上例均摊)。
| 部署模式 | GPU 日成本 | 与 API 打平的日均请求量(量级) |
|---|---|---|
| 8h 运行 | 28.8 元 | 约 1.8 万 |
| 24h 常驻 | 86.4 元 | 约 5.4 万 |
日均 2 万 时,8h GPU(约 864 元/月)已略低于 API(约 960 元/月)。日均 10 万 时,API 月成本可达约 4800 元,而 8h GPU 仍约 864 元——量越大,自建越划算。
成本不是唯一维度。以下场景即使单价不占优,蒸馏仍值得考虑:
| 因素 | 说明 |
|---|---|
| 延迟 | 本地推理约 50ms vs API 约 500ms,实时链路差异明显 |
| 数据安全 | 员工提问可能含人事信息,避免出域 |
| 离线 / 内网 | 无法调用云端 API |
| 稳定性 | 不依赖外部限流与可用性 |
4.3 部署决策与混合架构
| 场景特征 | 推荐方案 | 原因 |
|---|---|---|
| 高频、固定域、结构化输出 | 蒸馏小模型 | 成本低、延迟低、格式稳 |
| 知识每天更新 | RAG | 蒸馏权重不会自动更新 |
| 复杂推理 | 保留大模型 | 小模型推理能力有限 |
| 既要结构化又要最新知识 | 蒸馏 + RAG | 各司其职 |
在答疑机器人中的典型混合流程:
员工提问:"调岗到 AI 部门需要什么条件?"
↓
[蒸馏模型] 结构化理解 → {intents: ["制度咨询"], department: "HR", route: "rag_query"}
↓
[RAG] 按意图与部门检索政策文档
↓
[大模型](可选)基于检索结果生成最终回复
- 蒸馏模型:结构化提取的稳定与低延迟
- RAG:知识实时性(政策变更无需重训)
- 大模型:需要复杂推理的最终回答
合规提醒:若教师为开源模型(如 Qwen,Apache 2.0),蒸馏产出一般可商用。部分商业 API 的服务条款限制用其输出训练竞品,使用前请阅读协议。
五、本节小结
本篇文章围绕"请求理解"这个生产场景,完整走通了数据合成蒸馏的全流程。蒸馏的本质是把大模型的判断能力固化到小模型的权重中,与微调的区别在于训练数据来源不同:蒸馏用教师模型生成,微调用人工标注。
在数据合成阶段,通过"生成多样化查询 → 教师模型标注 → 质量过滤 → 格式化训练数据"的流水线构建了高质量训练集,数据质量比数量更重要。在训练阶段,我在 PAI-DSW 上使用 ms-swift 框架以 LoRA 方法训练 Qwen3-0.6B 模型,LoRA 只训练 0.4% 的参数,保留了模型 97% 的基础能力。最终通过 JSON 合规率、意图 F1、路由准确率三项指标量化验证了蒸馏效果:蒸馏模型从基座模型的"几乎不可用"大幅提升到接近教师模型的水平。
蒸馏不是替代 RAG,而是把系统中"可预测的、高频的、结构化的"工作环节交给专用小模型。它和 RAG、Agent 是互补关系,各自负责系统的不同环节。