第07章 - 大模型微调
微调不是「把模型再训一遍」,而是用几百到几千条高价值样本,把通用模型的行为约束到你的业务口径上。这一章从选型开始:什么时候该用提示工程、什么时候非微调不可;再把同一套方法论落到四个对象上——SAM 3/3.1 做领域分割、Qwen3-VL 2B/4B 做多模态理解、用 DeepSeek V4 当教师蒸馏出能上生产的小模型,以及后训练时代的三段式路线(SFT → 偏好对齐 → 蒸馏)。每条路径都给数据构造脚本、训练参数、评估口径与显存成本账,并反复强调一条省钱顺序:先用现成量化版验证任务可行性,再决定花不花训练的钱。最后两节讲训练工程与怎么用 DeepSeek Harness(命令行 dsh)把实验管起来,避免「训了一周,只有一张 loss 图」。
学习目标:能判断一个任务该走提示工程、RAG 还是微调;能独立跑通 SAM 3/3.1 领域分割微调与 Qwen3-VL 2B/4B 的 LoRA 微调;能先用现成量化版跑出可行性结论,再决定是否投入训练;能设计一条「大模型标注 → 小模型学习」的蒸馏链路,并判断要不要在 SFT 之后补偏好对齐;能用显存与成本公式反推可行配置,说清一次实验花多少钱。
前置知识:PyTorch 训练循环(optimizer / scheduler / AMP)、至少完成过一次 LoRA 微调、Shapefile 与 GeoTIFF 的读写(rasterio 或 GDAL)、Linux 命令行与
nvidia-smi的基本使用。
7.1 微调基础与选型
选型错了,后面所有调参都是在错误的解空间里搜索。这一节给你判断顺序、显存账和训练目标的对应关系,目的是在花掉第一张卡时之前,先把「该不该微调」和「用哪种微调」这两件事定下来。
7.1.1 四级阶梯:提示工程、RAG、微调、从零训练
把「让模型表现更好」的手段排成一条投入递增的阶梯,判断的核心只有一条:你要改的是知识,还是行为。知识(今天这家店开没开门、这条法规第几条)应该走检索;行为(输出必须固定 12 个字段、必须以「疑似」而不是「确认」开头、必须拒绝回答越权问题)才应该走微调。
数据与算力投入 →
提示工程 ──► RAG ──► 参数高效微调 ──► 全参微调 ──► 从零训练
(几小时) (几天) (几天~一周) (一到两周) (数月)
▲
│
知识会变 ─────────────► 走 RAG(外挂、可热更新)
行为要改 ─────────────► 走微调(内化、离线可用)
| 需求特征 | 首选方案 | 判断依据 | 常见反例 |
|---|---|---|---|
| 知识频繁更新(POI、价格、审批规则) | RAG | 更新一条知识只需重建索引,不用重训 | 用微调记 POI,三个月后全错 |
| 输出格式严格(固定字段的巡检报告) | SFT 微调 | 格式约束是行为,检索管不了 | 在提示词里堆 800 字格式说明,模型仍然漏字段 |
| 领域术语理解差(遥感、内部缩写) | 少量领域语料继续预训练 + SFT | 术语是分布问题,不是检索问题 | 直接上 RAG,检索到了也读不懂 |
| 风格、语气、拒答边界 | SFT 后接 DPO | 偏好要靠成对样本表达 | 只写「回复要专业」,模型全程说套话 |
| 边缘部署、离线、低时延 | 蒸馏得到的小模型 + 微调 | 2B 级模型单卡可跑,成本可控 | 拿 70B + 提示词硬扛 500 ms 预算 |
| 数据分布与通用图像差异极大 | 领域自适应或专用架构 | 自然图像预训练的归纳偏置不匹配 | 不做任何适配,直接零样本跑无人机俯视图 |
落到工程上,动手前先做三件事,不做完不要碰训练脚本:
- 攒 30~50 条 golden set(黄金评测集):从真实业务里抽,覆盖高频场景和已知难点,标注答案。这份数据全程不参与训练。
- 跑通用模型的零样本基线:能调用的最强通用模型直接上,把每条的输出和 golden set 对齐打分,记录错误类型。
- 给错误分类:把错例归到「知识缺口」「格式/行为不符」「术语读不懂」「模态不匹配」四类。前两类中,「知识缺口」走 RAG,「行为不符」走 SFT;后两类才考虑微调或换架构。
如果 50 条错例里有 40 条属于知识缺口,微调是浪费钱:你会训练出一个把旧知识背得很牢、并且更难纠正的模型。
坑:把「检索能用」当「微调没必要」。反过来也成立——很多团队先微调再补 RAG,结果模型学会了在缺知识时编造。正确顺序是先确认基线错误的类型分布,再决定投入方向。
7.1.2 参数高效微调:全参、LoRA、QLoRA 的显存账
LoRA(Low-Rank Adaptation)的核心假设是:微调带来的权重变化 ΔW 是低秩的,可以用两个小矩阵的乘积近似。前向计算变成:
原层: h = W·x
LoRA: h = W·x + (alpha / r) · B·(A·x)
W ∈ R^{d_out × d_in} 冻结,不产生梯度
A ∈ R^{r × d_in} 随机初始化(高斯)
B ∈ R^{d_out × r} 零初始化 → 训练起点 ΔW = 0,不破坏预训练行为
可训练参数 = r · (d_in + d_out)
当 r << min(d_in, d_out) 时,可训练参数通常只占总参数的 0.1% ~ 1%
alpha / r 这个缩放系数决定了 LoRA 分支的实际幅度。实践中常按 alpha = 2r(如 r=16、alpha=32)起步;改 r 时同步改 alpha,可以让有效学习率大体不变,避免「加秩之后 loss 直接崩」。
| 方案 | 可训练参数 | 基础权重显存(第 P 亿参数模型) | 优化器状态 | 24 GB 单卡能干什么 | 适用场景 |
|---|---|---|---|---|---|
| 全参微调 | 100% | P × 2 字节(bf16) | 每参数约 12 字节(fp32 master + Adam m/v) | 只能到 1B 级且要梯度检查点 | 数据量 > 10 万条、要改底层能力 |
| LoRA | 0.1%~1% | P × 2 字节(冻结但要在显存里) | 只对 LoRA 参数,量很小 | 4B 级舒服,7B 级要压缩序列 | 绝大多数领域适配 |
| QLoRA | 0.1%~1% | P × 0.5 字节左右(NF4 量化) | 同上,可再开 paged optimizer | 7B~8B 级可跑,4B 级余量充足 | 显存紧、只想验证数据是否有效 |
target_modules 的选择直接决定「能不能学会」:
| 任务 | 建议 target_modules | 理由 |
|---|---|---|
| 通用指令/格式对齐(语言模型) | q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj |
覆盖注意力与 MLP,容量最稳 |
| 只调风格、省显存 | q_proj, v_proj |
参数少一半以上,风格类任务够用 |
| 多模态图文对齐(如 Qwen3-VL) | 语言层全套 + 视觉投影层(merger / projector 一类模块名以模型实现为准) |
只调语言层时,模型常「看见了但说不出来」 |
| 分割(SAM 系列) | mask decoder 的 attention + prompt encoder 相关层 | image encoder 冻结,避免破坏通用特征 |
显存估算不要靠感觉,用下面这份结构拆开算,再留 15%~20% 的余量给 CUDA context 与碎片:
训练显存 ≈ 基础权重 + 可训练参数状态 + 激活 + 临时缓冲
基础权重 = P × b_weights # bf16: 2;NF4: 约 0.5 再加约 0.016 的量化常数
可训练参数状态 = P_lora × 14 ~ 16 字节 # bf16 梯度 2 + fp32 master 4 + Adam 一阶/二阶各 4
激活 = k × B × S × H × L × 2 字节 # k 由框架与梯度检查点决定,实测校准
临时缓冲 ≈ 0.8 ~ 1.5 GB # CUDA context、通信缓冲、碎片
其中 P 为总参数量(十亿),B=batch,S=序列长度,H=隐藏维度,L=层数
开梯度检查点:激活除以约 3~5,代价是重算,训练速度降 20%~35%
两个算例,同一个 4B 模型、序列 2048、单卡 24 GB:
| 配置 | 基础权重 | LoRA 状态 | 激活(开梯度检查点) | 缓冲 | 合计估算 | 结论 |
|---|---|---|---|---|---|---|
| 4B + LoRA(r=16,全线性层,bf16) | 8.0 GB | 约 0.5 GB | 约 2.5 GB | 1.2 GB | 约 12 GB | batch=2、梯度累积 8 可跑 |
| 4B + QLoRA(NF4,r=16,bf16 计算) | 约 2.2 GB | 约 0.5 GB | 约 2.5 GB | 1.5 GB | 约 7 GB | batch=4、序列可上 4096 |
注意 QLoRA 省的是「权重常驻」,不是「激活」。序列长度和 batch 一旦拉大,激活仍然会把你顶到 OOM。所以显存不够时的调整顺序是:先降 batch 上梯度累积 → 再开梯度检查点 → 再压序列长度 → 最后才换 QLoRA。
坑:只把
r调大就以为模型会变强。rank 从 8 加到 64,可训练参数涨 8 倍,显存和过拟合风险一起涨,而数据只有 800 条时几乎必然掉点。先固定 r=16 把数据洗干净,再动 rank。第二个坑是忘了alpha与r的耦合,换 rank 不换 alpha,等效学习率直接变化,看到 loss 不降先查这里。7.1.3 训练目标:SFT、DPO、GRPO 与蒸馏各管一段
这几类训练目标不是替代关系,是流水线上的不同工位。用错目标的典型症状是:该用 SFT 定格式的阶段上了 DPO,结果模型学会了更长更啰嗦的回答,格式反而更飘;该上 GRPO 的可验证任务却硬做成对样本,白花标注钱。
| 目标 | 数据形态 | 损失函数(简化) | 解决什么 | 不解决什么 | 成本(相对 SFT) |
|---|---|---|---|---|---|
| SFT(监督微调) | 指令 + 标准答案 | 只对答案 token 算交叉熵 | 格式、口径、任务定义 | 细微偏好、排序 | 1× |
| DPO(直接偏好优化) | 同一输入 + 好答案 / 差答案对 | -log σ(β·(log π(y_w)/π_ref(y_w) - log π(y_l)/π_ref(y_l))) | 「两个都能交差时选哪个」 | 模型完全不会的任务 | 2×~3×(要参考模型) |
| GRPO(群组相对策略优化) | 同一输入 + 可自动判分的奖励函数 | 一组采样输出按组内相对优势加权,再乘奖励 | 「有客观对错、但写不出标准答案」的任务 | 没有奖励函数就做不了 | 3×~5×(每步多次采样) |
| 蒸馏(数据) | 教师模型的输出(或标注) | 学生模型的 SFT 交叉熵 | 小模型获得大模型的能力 | 教师也不会的东西 | 1×~2×(含教师推理) |
| 蒸馏(logits) | 教师 logits + 学生 logits | KL(教师 ‖ 学生),温度 T 平滑 | 小模型逼近教师的分布 | 教师错误会一起被学走 | 2×(要跑教师前向) |
SFT 的损失细节值得说清,因为「loss 只对答案算」是新手最常错的地方:
# SFT 样本拼接与 loss mask 的核心逻辑(框架无关,PyTorch 写法)
# 假设 tokenizer 已定义;chat 模板以所用模型官方 template 为准
prompt_ids = tokenizer.apply_chat_template(messages[:-1], add_generation_prompt=True)
answer_ids = tokenizer(messages[-1]["content"], add_special_tokens=False).input_ids
answer_ids = answer_ids + [tokenizer.eos_token_id]
input_ids = prompt_ids + answer_ids
labels = [-100] * len(prompt_ids) + answer_ids # 关键:prompt 部分 mask 成 -100
# 训练时不要把 prompt 的 loss 算进去:
# 否则模型会去学"用户会怎么提问",浪费容量,还会让 loss 曲线看起来更平滑
loss = torch.nn.functional.cross_entropy(
logits[:, :-1, :].reshape(-1, logits.size(-1)).float(),
labels_tensor[:, 1:].reshape(-1),
ignore_index=-100,
)
DPO 的门槛不在实现,在数据:你需要「同一输入、两个都合理但一好一差」的成对样本。这类样本从业务里捞最便宜——线上系统两个版本各跑一遍,人工只判「哪个更好」,不做重写。经验阈值:成对样本 500~2000 对能看到稳定效果,低于 300 对基本是噪声。beta 从 0.1 起步,取值越大越贴近参考模型(越保守)。
坑:DPO 之前必须已有 SFT 版本,且参考模型(reference)要冻结成 SFT 后的快照。拿基座当 reference,模型会先学会「不说人话」再去对齐偏好,最后两边的损失互相拉扯。
7.1.4 数据:质量、去重、格式统一、评测集独立
微调的胜负手在数据。同样 4B 模型、同样 r=16,800 条干净样本常常打赢 8000 条脏样本。开工前用下面的体检脚本过一遍,能省掉后面一半的 debug 时间。
# data_audit.py —— 训练前数据体检:长度分布、近似去重、格式校验、标签泄漏
import json, hashlib, re, sys
from collections import Counter
from difflib import SequenceMatcher
def norm(text: str) -> str:
return re.sub(r"\s+", " ", text.strip().lower())
def shingles(text: str, k: int = 5) -> set:
t = norm(text)
return {t[i:i + k] for i in range(max(0, len(t) - k + 1))}
def jaccard(a: set, b: set) -> float:
return len(a & b) / max(1, len(a | b))
def audit(path: str, max_chars: int = 4096):
rows, seen_hash, lens, role_err, empty = [], set(), [], 0, 0
with open(path, encoding="utf-8") as f:
for lineno, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError as e:
print(f"[格式错误] 第 {lineno} 行: {e}")
continue
msgs = obj.get("messages") or obj.get("conversations") or []
if not msgs:
role_err += 1
continue
joined = "\n".join(str(m.get("content", "")) for m in msgs)
if not joined.strip():
empty += 1
continue
h = hashlib.md5(norm(joined).encode()).hexdigest()
if h in seen_hash:
print(f"[完全重复] 第 {lineno} 行")
seen_hash.add(h)
lens.append(len(joined))
rows.append((lineno, joined))
# 近似重复:采样对比,避免 O(n^2) 全量比较
step = max(1, len(rows) // 400)
sample = rows[::step]
sh = [(ln, shingles(t)) for ln, t in sample]
dup = 0
for i in range(len(sh)):
for j in range(i + 1, len(sh)):
if jaccard(sh[i][1], sh[j][1]) > 0.85:
dup += 1
lens.sort()
p = lambda q: lens[int(len(lens) * q)] if lens else 0
print(f"样本数={len(rows)} 空样本={empty} 角色错误={role_err}")
print(f"长度分位: p50={p(0.5)} p90={p(0.9)} p99={p(0.99)} max={lens[-1] if lens else 0}")
if lens and p(0.99) > max_chars:
print(f"[警告] p99 超过 {max_chars} 字符,训练前需截断,否则 padding 会拖慢一半吞吐")
print(f"近似重复(采样 {len(sh)} 条,阈值 0.85): {dup} 对")
if empty or role_err:
sys.exit(1)
if __name__ == "__main__":
audit(sys.argv[1])
数据规则,按优先级排:
| 规则 | 具体要求 | 违反后的症状 |
|---|---|---|
| 评测集独立 | 训练集与评测集按「场景/区域/时间」切分,不是随机切 | 评测 0.92、上线 0.6,典型泄漏 |
| 完全去重 + 近似去重 | 相似度 > 0.85 的样本只留信息量最大的一条 | 模型复读训练集原句 |
| 格式统一 | 同一任务只用一种 chat template、一种字段命名 | 模型在两种格式间跳变 |
| 答案唯一口径 | 同一问题的答案必须一致;矛盾的样本要么删要么改 | loss 卡在某个值不降 |
| 长度受控 | 按 p99 截断,超长样本单独处理或拒绝 | 显存抖动、吞吐腰斩 |
| 规模 | 单任务 500~3000 条起步,质量优先;> 2 万条再考虑全参 | 800 条就上全参,过拟合到背数据 |
关于规模,给一组经验值(4B 级模型、单任务、LoRA):格式/口径类 300~800 条已能看到稳定效果;带推理链的复杂任务 2000~5000 条;多模态定位/计数类 3000 条以上且要求覆盖长尾。样本不够时的正确做法是「先用教师模型扩数据 + 人工审核」,而不是「重复采样凑条数」。
评测集设计建议按 3:1 拆成「域内测试」与「留出压力集」:域内测试用来判断「这次改完有没有变好」,压力集只在大版本发布前跑,用来防止你对着测试集调参。
坑:把同一批无人机影像裁出来的相邻瓦片分别放进训练集和测试集。相邻瓦片高度相关,测试分数会虚高 10~20 个点。按「架次」或「区域」切分,并按文件名里的架次号核对一遍。
7.2 Segment Anything 3 分割微调
分割模型的微调和其他任务的判断逻辑一样:先确认零样本基线差在哪,再决定动哪一部分。SAM 系列的结构决定了「冻结什么、训什么」有明确的性价比排序。
7.2.1 SAM → SAM 2 → SAM 3/3.1:能力差异与适用边界
| 版本 | 提示方式 | 输出 | 能连续跟踪 | 文本/概念提示 | 典型用法 |
|---|---|---|---|---|---|
| SAM | 点、框、粗掩码 | 单帧掩码(多候选) | 否 | 否 | 交互式抠图、矢量成果辅助提取 |
| SAM 2 | 点、框、掩码 + 视频记忆 | 视频掩码序列 | 是(记忆库跨帧传播) | 否 | 视频目标跟踪、逐帧分割 |
| SAM 3(2025-11) | 概念/文本提示 + 几何提示 | 同类目标实例掩码(图像与视频) | 是 | 是(开放词表概念分割) | 一句话分割同类目标、批量提取 |
| SAM 3.1(2026-03) | 同 SAM 3 | 同 SAM 3;视频多目标按桶联合处理 | 是(Object Multiplex 多目标联合跟踪) | 是 | 多目标长视频跟踪、批量出图 |
版本能力的细节(支持的分辨率、最大实例数、是否支持视频概念传播)以官方发布说明与模型卡为准,不要照搬博客里的数字。工程上要抓住的结构差异是:SAM 与 SAM 2 的「类别」不在模型里,靠你给的提示决定;SAM 3 引入了概念提示,把「分割这个点」变成「分割这一类」,这正好让「新类别、批量提取」变得可行,但也意味着概念词的选择直接决定效果。
SAM 3.1(2026-03-27 发布)不是新任务范式,而是把视频侧的多目标处理做快:Object Multiplex 把目标按固定容量分桶后联合处理,官方 release notes 的口径是单张 H100、128 个目标相对 2025-11 版本约 7 倍加速,同时补上 torch.compile 支持、批处理视觉编码器与更少的 CPU-GPU 同步。权重在 Hugging Face 的 facebook/sam3.1,代码仍是 github.com/facebookresearch/sam3,完整说明见仓库里的 RELEASE_SAM3p1.md。选版本时的判断很简单:只做图像分割,SAM 3 与 3.1 都能用;要做视频多目标跟踪或批量出图,直接上 3.1,别在 3.0 的逐目标路径上耗推理预算。
┌────────────────────────────────────────────┐
图像 ─────────►│ image encoder(ViT,冻结的大头,占显存最多) │
└───────────────┬────────────────────────────┘
│ 图像特征
概念/文本提示 ──► 文本编码器 ──┤
点 / 框 / 掩码 ─► prompt encoder ┤
▼
┌──────────────────┐
│ mask decoder │ ──► 掩码 + IoU 预测
│ (轻量,几 M 参数) │
└──────────────────┘
微调时:冻结 image encoder,只训 prompt encoder + mask decoder(可选加 LoRA)
7.2.2 为什么领域图像必须微调
自然图像预训练模型在遥感与无人机俯视图上的掉点,主要来自四个分布差异,每一个都能单独解释一类失败:
| 差异 | 表现 | 对策略的影响 |
|---|---|---|
| 尺度 | 同一目标在 0.05 m 与 0.5 m 分辨率下像素面积差 100 倍 | 训练时按分辨率分层采样,小目标单独加权 |
| 视角 | 正射俯视缺少「立面」线索,纹理趋同 | 需要更多同区域负样本,避免把道路误判为建筑 |
| 长宽比 | 建筑、田块、道路常是极端长条 | 掩码后处理要限制简化容差,否则长条会被掐断 |
| 类别定义 | 「临时建筑」「堆料场」这类内部口径,通用模型没概念 | 概念提示词要用内部术语,并给正反例 |
判断「该不该微调」的量化门槛:在 200 张带标注的领域图上跑零样本,逐类算 IoU。如果整体 IoU 低于 0.6,或者你关心的关键类别(比如「光伏板」)低于 0.4,微调是值得的;如果整体已到 0.75 且错例集中在边界 1~2 像素,先修后处理更省钱。
坑:用「看起来像」的通用类别词做概念提示,比如把厂区里的堆料场叫「sand pile」。概念词与内部口径不一致,模型给的是「沙堆」而不是「堆料场」,验收时按你的矢量成果算 IoU 必然不合格。概念词必须来自你的标注规范。
7.2.3 用已有矢量成果自动生成掩码
GIS 团队最大的优势是手里有矢量成果。把道路、建筑、光伏板这些面图层栅格化成掩码,比从零标注快一个数量级。要点有三:栅格化对齐(尺寸、仿射变换完全一致)、边界容差(矢量边界与影像边缘常有 1~3 像素偏移)、忽略区域(云、阴影、施工区标 ignore_index,不参与 loss)。
# make_masks.py —— 用矢量面 + 影像生成 (image, mask, class_name) 训练样本
# 依赖:rasterio、geopandas、numpy、Pillow;按瓦片生成,直接用 512×512 滑窗切
import json
import numpy as np
import rasterio
from rasterio.features import rasterize
from rasterio.windows import Window
import geopandas as gpd
from PIL import Image
CLASS_MAP = { # 内部口径 -> (类别 id, 概念提示词)
"building": (1, "a building rooftop"),
"road": (2, "a paved road surface"),
"solar_panel": (3, "a solar photovoltaic panel array"),
"stockpile": (4, "a construction material stockpile"),
}
TILE = 512
STRIDE = 384 # 重叠 25%,保证目标不被切断
MIN_PIXELS = 64 # 掩码像素数低于此值的瓦片丢弃(噪声标注)
def build(imagery: str, vectors: str, layer: str, out_dir: str, index: str):
gdf = gpd.read_file(vectors, layer=layer)
with rasterio.open(imagery) as src:
gdf = gdf.to_crs(src.crs) # 关键:先对齐坐标系
shapes = []
for _, row in gdf.iterrows():
cls = row.get("category") or row.get("type")
if cls not in CLASS_MAP:
continue
shapes.append((row.geometry, CLASS_MAP[cls][0]))
# 整图栅格化,再按窗口切,避免每块重复算几何
full_mask = rasterize(
shapes, out_shape=(src.height, src.width),
transform=src.transform, fill=0, dtype="uint8", all_touched=False,
)
full_mask = np.where(full_mask == 255, 0, full_mask) # 防御:无类别值归 0
records = []
n = 0
for row_off in range(0, src.height - TILE + 1, STRIDE):
for col_off in range(0, src.width - TILE + 1, STRIDE):
win = Window(col_off, row_off, TILE, TILE)
img = src.read([1, 2, 3], window=win)
if img.shape != (3, TILE, TILE):
continue
msk = full_mask[row_off:row_off + TILE, col_off:col_off + TILE]
present = [cid for cid in CLASS_MAP.values() if (msk == cid[0]).sum() >= MIN_PIXELS]
present = [c for c in present if (msk == c[0]).sum() >= MIN_PIXELS]
if not present:
continue # 全背景瓦片不进训练集
tid = f"tile_{n:06d}"
Image.fromarray(np.transpose(img, (1, 2, 0))).save(f"{out_dir}/{tid}.png")
Image.fromarray(msk, mode="L").save(f"{out_dir}/{tid}_mask.png")
for cid, prompt in present:
records.append({
"image": f"{tid}.png",
"mask": f"{tid}_mask.png",
"class_id": cid,
"prompt": prompt,
"n_pixels": int((msk == cid).sum()),
})
n += 1
with open(index, "w", encoding="utf-8") as f:
for r in records:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
print(f"生成瓦片 {n} 张,样本 {len(records)} 条")
if __name__ == "__main__":
build("data/ortho_2024.tif", "data/vectors.gpkg", "facilities",
"data/tiles", "data/train_index.jsonl")
生成的索引一行一个「图像 + 掩码 + 类别 + 概念提示」,SAM 3 的概念提示训练正好吃这种格式。三个必须核对的点:
| 检查项 | 怎么查 | 不合格的后果 |
|---|---|---|
| 仿射变换一致 | src.transform 与 rasterize(transform=...) 用同一个 |
掩码整体平移,模型学到「掩码永远偏 40 像素」 |
| 坐标系一致 | gdf.to_crs(src.crs),并打印两者 CRS 对比 |
掩码全空或全图,loss 变成常数 |
| 边界容差 | 抽 20 张叠图看,允许 1~2 像素误差 | 边界错位会让边界 F-score 永远上不去 |
如果矢量来自不同年份、与影像不同期,先做变化检测剔除,否则你在训练模型「把已拆除的建筑分割出来」。
7.2.4 微调策略:冻结编码器,只训解码器
显存与收益的性价比排序很明确:image encoder 参数量占 90% 以上,冻结它省显存,也保护通用特征;prompt encoder 与 mask decoder 只有几 M 到几十 M 参数,正好承载「你的类别、你的边界习惯」。数据量到 5000 条以上、且要与通用能力合并部署时,才考虑给 encoder 加 LoRA。SAM 3.1 没有改变这条路线:仍然是冻结视觉编码器、只训 prompt encoder 与 mask decoder(必要时加 LoRA);3.1 的变化在推理侧的多目标并行方式,不改变训练侧的可训练参数选择。
下面是与官方仓库解耦的训练骨架(伪代码级,接口名以所用实现为准)。核心是:只把 decoder 与 prompt encoder 的参数设为可训练,损失用 focal + dice,focal 压背景、dice 抓整体形状。
# train_sam3_lora.py —— 训练骨架(伪代码级:模型加载与前向签名以官方实现为准)
import torch
import torch.nn.functional as F
def focal_loss(logits, target, alpha=0.25, gamma=2.0):
# logits: (B, 1, H, W);target: (B, 1, H, W) 取值 {0,1},忽略区为 -1
valid = (target >= 0).float()
p = torch.sigmoid(logits)
ce = F.binary_cross_entropy_with_logits(logits, target.clamp(0, 1), reduction="none")
p_t = p * target + (1 - p) * (1 - target)
loss = ce * ((1 - p_t) ** gamma)
loss = loss * (alpha * target + (1 - alpha) * (1 - target))
return (loss * valid).sum() / valid.sum().clamp(min=1.0)
def dice_loss(logits, target, eps=1e-6):
valid = (target >= 0).float()
p = torch.sigmoid(logits) * valid
t = target.clamp(0, 1) * valid
inter = (p * t).sum(dim=(1, 2, 3))
union = p.sum(dim=(1, 2, 3)) + t.sum(dim=(1, 2, 3))
return 1 - ((2 * inter + eps) / (union + eps)).mean()
def build_optimizer(model, lr=1e-4, weight_decay=0.01):
trainable = []
for name, p in model.named_parameters():
# 冻结策略:image encoder 全部冻结;decoder/prompt encoder 可训
is_encoder = "image_encoder" in name
is_lora = "lora_" in name
p.requires_grad = (not is_encoder) or is_lora
if p.requires_grad:
trainable.append(p)
n = sum(p.numel() for p in trainable)
print(f"可训练参数 {n/1e6:.2f} M / 总参数 {sum(p.numel() for p in model.parameters())/1e6:.1f} M")
return torch.optim.AdamW(trainable, lr=lr, weight_decay=weight_decay)
def train_step(model, batch, weight_focal=20.0, weight_dice=1.0):
# batch: image, prompt(点/框/概念词), gt_mask
pred = model(image=batch["image"], prompt=batch["prompt"], multimask_output=True)
loss_total = 0.0
for logits in pred["masks"]: # 多候选输出要全部参与监督或取最优候选后监督
loss_total = loss_total + weight_focal * focal_loss(logits, batch["gt_mask"]) \
+ weight_dice * dice_loss(logits, batch["gt_mask"])
return loss_total / len(pred["masks"])
# 典型超参(SAM 系微调,需按数据量微调)
CONFIG = {
"lr": 1e-4, # decoder-only 可到 3e-4;带 LoRA 时降到 1e-4
"weight_decay": 0.01,
"batch_size": 4, # 512×512、24 GB 卡的安全值
"grad_accum": 4,
"epochs": 20,
"warmup_ratio": 0.05,
"scheduler": "cosine",
"bf16": True,
"grad_checkpoint": False, # encoder 冻结后通常不必要
}
超参先动哪三个:学习率先降一个数量级看 loss 是否还发散;batch 与梯度累积保持有效 batch 在 16~32;focal 权重按类别不平衡程度调,背景占 95% 以上时可以到 20,接近平衡时用 2~5。
坑:把多候选掩码(multimask)全当成正样本监督。模型输出 3 个候选,只有一个是对的,其余可能是「前景 vs 背景」的不同切法;一律当正样本会让边界变糊。正确做法是按与 GT 的 IoU 选最优候选再回传,或者只监督 IoU 预测最高的那一路。
7.2.5 评估:per-class IoU、边界 F-score、小目标召回
单看 mIoU 会被背景与大面积类别掩盖问题。分割验收至少三个指标并排看,再加一个按尺寸分层的召回表。
| 指标 | 定义 | 看什么 | 经验合格线(领域微调后) |
|---|---|---|---|
| per-class IoU | 逐类交并比,再宏平均 | 哪些类真的学会了 | 关键类 ≥ 0.70,次要类 ≥ 0.55 |
| 边界 F-score(BF-score) | 预测边界与 GT 边界的匹配精度/召回,容差 2 像素 | 几何成果能不能直接用 | ≥ 0.65 才算「能出图」 |
| 小目标召回 | 面积 < 32×32 像素的目标检出率 | 遥感场景的核心指标 | 与基线比提升 ≥ 10 个点 |
| 实例级 AP | 按实例匹配(IoU 阈值 0.5) | 计数类任务 | ≥ 0.6 |
# eval_seg.py —— per-class IoU、BF-score、按尺寸分层的召回率
import numpy as np
from scipy import ndimage
from skimage.segmentation import find_boundaries
def boundary_f1(pred: np.ndarray, gt: np.ndarray, tol: int = 2) -> float:
pb, gb = find_boundaries(pred, mode="inner"), find_boundaries(gt, mode="inner")
if pb.sum() == 0 and gb.sum() == 0:
return 1.0
if pb.sum() == 0 or gb.sum() == 0:
return 0.0
dt_gt = ndimage.distance_transform_edt(~gb)
dt_pred = ndimage.distance_transform_edt(~pb)
precision = float((dt_gt[pb] <= tol).mean())
recall = float((dt_pred[gb] <= tol).mean())
return 0.0 if precision + recall == 0 else 2 * precision * recall / (precision + recall)
def size_bucket(area_px: int) -> str:
if area_px < 32 * 32:
return "small"
if area_px < 96 * 96:
return "medium"
return "large"
def evaluate(pairs, class_ids=(1, 2, 3, 4)):
"""pairs: [(pred_mask, gt_mask), ...],掩码已在同一坐标系、同一尺寸"""
per_class, buckets = {}, {"small": [0, 0], "medium": [0, 0], "large": [0, 0]}
for cid in class_ids:
inter = union = 0
bfs = []
for pred, gt in pairs:
p, g = (pred == cid), (gt == cid)
inter += int((p & g).sum())
union += int((p | g).sum())
if g.any():
bfs.append(boundary_f1(p, g))
for inst in ndimage.label(g)[0].nonzero()[0][1:]:
pass
iou = inter / union if union else float("nan")
per_class[cid] = {"iou": round(iou, 4), "bf": round(float(np.mean(bfs)) if bfs else 0.0, 4)}
# 小/中/大目标召回:按 GT 连通域面积分桶
for pred, gt in pairs:
for cid in class_ids:
g = (gt == cid)
if not g.any():
continue
labeled, n = ndimage.label(g)
for idx in range(1, n + 1):
inst = labeled == idx
area = int(inst.sum())
b = size_bucket(area)
buckets[b][1] += 1
if ((pred == cid) & inst).sum() / area >= 0.5: # 命中阈值 0.5
buckets[b][0] += 1
recall = {k: round(v[0] / v[1], 4) if v[1] else None for k, v in buckets.items()}
miou = float(np.nanmean([per_class[c]["iou"] for c in class_ids]))
return {"per_class": per_class, "mIoU": round(miou, 4), "recall_by_size": recall}
if __name__ == "__main__":
import pickle, json
with open("preds.pkl", "rb") as f:
pairs = pickle.load(f)
print(json.dumps(evaluate(pairs), ensure_ascii=False, indent=2))
评估必须跑三组对比才有结论:零样本基线、微调后、微调后 + 后处理(形态学开运算去毛刺、最小面积过滤)。很多「微调没效果」的案例,其实是没加后处理,毛刺把 BF-score 拉低了一半。
7.2.6 部署:导出、吞吐与掩码转矢量
从训练产物到能给业务用,中间还有三步:导出成推理格式、对标定吞吐与显存、把掩码转成可入库的面要素。
| 环节 | 做法 | 关键参数 | 常见失败 |
|---|---|---|---|
| 导出 ONNX | torch.onnx.export,固定 1024×1024 输入 |
opset 17+,dynamic_axes 只对 batch 开 |
动态 H/W 导致 mask decoder 的插值算子无法导出 |
| TensorRT | trtexec 或框架内导出 FP16/INT8 |
显存池按最大 batch 的一次性峰值设 | INT8 校准集用训练集的图,量化偏差小但容易过拟合校准集 |
| 批处理推理 | 按瓦片队列 + 固定 batch,重叠 25% 拼接 | batch 8~16(1024×1024、FP16、24 GB 卡) | 直接整图推理,显存随影像尺寸线性爆炸 |
| 掩码转矢量 | rasterio.features.shapes + shapely 简化平滑 |
简化容差 0.5~1 像素,最小面积 30 ㎡ | 不设最小面积,输出上万个碎片小面 |
# mask2polygon.py —— 掩码转矢量:轮廓提取、简化、平滑、面积过滤
import json
import numpy as np
import rasterio
from rasterio.features import shapes
from shapely.geometry import shape, mapping
from shapely.ops import unary_union
from shapely.validation import make_valid
def mask_to_polygons(mask: np.ndarray, transform, class_id: int,
min_area_m2: float = 30.0, simplify_px: float = 0.8,
smooth_round: int = 1):
"""mask: uint8 类别掩码;transform: rasterio 仿射变换(用于面积与坐标)"""
binary = (mask == class_id).astype("uint8")
geoms = []
for geom, val in shapes(binary, transform=transform):
if val != 1:
continue
g = make_valid(shape(geom))
# 简化单位跟着栅格分辨率走:像素大小 × 容差,避免固定经纬度值
tol = abs(transform.a) * simplify_px
if smooth_round > 0:
g = g.buffer(tol * 0.5, join_style=1).buffer(-tol * 0.5, join_style=1)
g = g.simplify(tol, preserve_topology=True)
if g.is_empty or g.area < min_area_m2:
continue
geoms.append(g)
merged = unary_union(geoms) if geoms else None
return [] if merged is None else [
{"type": "Feature", "properties": {"class_id": class_id},
"geometry": mapping(g)} for g in getattr(merged, "geoms", [merged])
]
def run(mask_path: str, out_geojson: str, class_ids=(1, 2, 3, 4)):
with rasterio.open(mask_path) as src:
mask = src.read(1)
feats = []
for cid in class_ids:
# 面积阈值按像元换算:pixel_area = |a·e - b·d|,米制投影下即平方米
t = src.transform
px_area = abs(t.a * t.e - t.b * t.d)
feats += mask_to_polygons(mask, t, cid, min_area_m2=30.0 / px_area * px_area)
with open(out_geojson, "w", encoding="utf-8") as f:
json.dump({"type": "FeatureCollection", "features": feats}, f, ensure_ascii=False)
print(f"输出要素 {len(feats)} 个 -> {out_geojson}")
if __name__ == "__main__":
run("pred_mask.tif", "pred_polygons.geojson")
吞吐与显存要给业务方一个可核算的数字。参考量级(1024×1024 瓦片、FP16、单张 24 GB 卡):SAM 级 decoder-only 微调后推理,batch 8 时约 20~40 瓦片/秒,显存占用 8~12 GB;开 INT8 后吞吐提升 1.5~2 倍,但小目标召回通常会掉 2~5 个点,需要按你的验收线决定是否接受。这些数字必须在你的卡上实测,别直接用别人的。
坑:掩码转矢量时用经纬度坐标算面积。EPSG:4326 下 1 平方度不等于任何固定平方米数,最小面积过滤会完全失效。要么在米制投影里做几何运算再转回,要么用
pyproj.Geod算真实面积。
7.2.7 SAM 3.1 的推理优化与版本选择
训练收益能不能落到业务上,取决于推理路径。SAM 3.1 在 2026-03-27 的更新里把视频侧的多目标处理从「逐目标各跑一遍」改成 Object Multiplex:目标按固定容量分桶后联合处理,官方口径是单张 H100、128 个目标相对 2025-11 版本约 7 倍加速,同时补了 torch.compile 支持、批处理视觉编码器与更少的 CPU-GPU 同步。权重在 Hugging Face 的 facebook/sam3.1,代码仍是 github.com/facebookresearch/sam3。
这对微调的含义很直接:训练侧省下来的算力,会被推理侧的老路径吃回去。微调只改 prompt encoder 与 mask decoder;如果上线仍按「逐帧、逐目标单独过视觉编码器」来跑,目标级关联与掩码计算会随目标数线性膨胀,模型精度再高也补不回推理时间。
| 3.1 的优化 | 省在哪 | 部署时要做的动作 |
|---|---|---|
| Object Multiplex(多目标分桶联合处理) | 多目标场景的重复计算 | 升级到 3.1 权重与最新代码,按显存配置每批目标数(桶容量) |
| torch.compile | 算子调度与显存搬运 | 首次编译并预热后再压测,别把编译时间算进时延 |
| 批处理视觉编码器 | 多帧、多瓦片的编码吞吐 | 固定瓦片尺寸与 batch,避免动态 shape 反复重编译 |
| 减少 CPU-GPU 同步 | 逐目标后处理时的调度空转 | 后处理整体放 GPU 侧或批量搬回,别逐目标取值 |
多目标视频的推理开销(单卡 H100,官方口径):
逐目标路径:目标级关联与掩码计算随目标数线性增长
3.1 联合路径:128 个目标约 7× 加速(相对 2025-11 版本)
推论:目标数越多,升级 3.1 的收益越大;单目标、单帧场景收益接近零
版本兼容是这一步最容易踩的坑:3.1 的权重不能用旧版代码直接加载,要先把仓库更新到最新(git pull)再按 README 重新安装,否则会在加载阶段报缺键。训练与推理环境分开维护时,把两边代码版本都写进实验目录的 versions.txt(见 7.6.1)。
7.3 Qwen3-VL 2B/4B 微调
分割解决「在哪里」,多模态理解解决「是什么、怎么样、为什么」。Qwen3-VL 的 2B/4B 档位正好落在「单卡能训、边缘能跑」的区间,适合做巡检报告的图文理解、影像要素问答、截图类单据的结构化抽取。这一节的落点是:一套可复用的数据格式、一份能在 24 GB 卡上跑通的 LoRA 配置、一份防止灾难性遗忘的评测方案。
7.3.1 能力边界:什么能交给它,什么不能
2B/4B 档位的多模态模型在四类任务上表现差异很大,先按表分工,别让它做超纲的事:
| 任务类型 | 2B/4B 的表现 | 建议 | 兜底方案 |
|---|---|---|---|
| 单图主体识别、场景分类 | 稳定,微调后可靠 | 直接上 | — |
| 图文问答(这栋建筑有几层、有无裂缝) | 中等,依赖分辨率与微调数据 | 微调 + 结构化输出约束 | 人工复核低置信样本 |
| 细粒度计数(图中 37 块光伏板里坏了几块) | 弱,易多数/少数 | 不要直接让它数 | 用分割模型出实例数,模型只做描述 |
| 密集小字 OCR(远距离航拍标识牌) | 弱,低于专用 OCR | 不适用 | 切图 + OCR,模型做语义整合 |
| 定位/检测(给出目标框) | 有限,坐标精度依赖训练格式 | 只做「粗定位 + 描述」 | 检测模型给框,模型做归因 |
| 长文档/多图对比 | 中等,受上下文与图像 token 数限制 | 控制单次图像数与分辨率 | 分块处理再汇总 |
一句话原则:让多模态模型做「语义判断」,让专用模型做「几何测量与计数」。把这条边界写进接口设计,后面能省掉大量「模型数不对」的返工。
7.3.2 多模态 SFT 数据格式
Qwen3-VL 系列走的是「messages 内嵌 image 条目」的对话格式,图像以本地路径或 URL 传给 processor,image 条目由 chat template 展开成视觉 token。下面是可直接送进训练框架的 JSONL 样例,一条一个任务。
{"messages": [{"role": "user", "content": [{"type": "image", "image": "data/images/tile_000123.png"}, {"type": "text", "text": "这张航拍图里有几处施工围挡?分别描述它们的状态,用 JSON 输出 {\"count\": 数字, \"items\": [{\"desc\": 字符串}]}。"}]}, {"role": "assistant", "content": [{"type": "text", "text": "{\"count\": 2, \"items\": [{\"desc\": \"图左上角蓝色围挡,完整无破损\"}, {\"desc\": \"图右下角围挡,中段缺失约三块板\"}]}"}]}], "meta": {"source": "inspection_2024", "task": "count_and_describe"}}
{"messages": [{"role": "user", "content": [{"type": "image", "image": "data/images/tile_000456.png"}, {"type": "text", "text": "判断图中光伏阵列是否存在明显遮挡或破损。若存在,指出大致位置(左上/右上/左下/右下/中部)。"}]}, {"role": "assistant", "content": [{"type": "text", "text": "存在遮挡。位置:左下。现象:约三排组件被堆放的建材覆盖,边缘有反光,疑似组件表面积灰。"}]}], "meta": {"source": "inspection_2024", "task": "defect_qa"}}
{"messages": [{"role": "user", "content": [{"type": "image", "image": "data/images/road_01.png"}, {"type": "image", "image": "data/images/road_02.png"}, {"type": "text", "text": "对比这两张同路段影像(间隔 6 个月),说明新增了哪些设施,以及是否有设施消失。"}]}, {"role": "assistant", "content": [{"type": "text", "text": "新增:图二在道路右侧新增一处临时板房(约两层),并在路口新增两处交通锥。消失:图一左侧的蓝色施工围挡在图二中已移除。"}]}], "meta": {"source": "change_detect", "task": "multi_image_compare"}}
组织数据的四条硬规则:
- image 条目数必须与图像数量一致:消息里写了 2 个
image,实际却只传 1 张图,processor 会错位拼接,loss 能降但推理结果乱。 - 答案要可解析:要求 JSON 输出时,训练答案也必须是严格 JSON(无注释、无尾逗号),并在 prompt 里给字段定义。字段名前后不一致是「模型输出格式随机」的第一原因。
- 拒绝样本也要有:对于「图中无法判断」的样本,答案写成明确的「无法判断,原因:分辨率不足」,否则模型会强行编造,上线后你无法区分「看不清」和「看错了」。
- 类别词表统一:同一概念在全部样本里用同一个词(统一用「围挡」而不是「围栏/护栏/挡板」混用),否则模型学不到稳定概念。
单图、多图、纯文本三类样本的混比建议按业务占比配,但通用能力回归样本要占 5%~10%:从公开评测集或少量的通用图文问答里抽一批,掺进训练集,是防灾难性遗忘最便宜的手段。
坑:把大图直接塞进去。一张 4000×3000 的航拍图会被切成一两千个视觉 token,既吃显存又把有效上下文挤掉。训练前统一缩到长边 1024~1536,需要细节时改用「先切瓦片,再把关键瓦片作为多图输入」的写法。
7.3.3 LoRA 配置与训练命令
关键在 target_modules:只调语言层,模型常出现「描述泛泛而谈、不落到图上细节」;把视觉侧的投影/融合层一起打开,图文对齐才会被真正拉动。下面给一份以 ms-swift / LLaMA-Factory 这类开源框架为参照的配置,具体参数名以所用框架版本文档为准。
# qwen3vl_2b_lora.yaml —— 2B 多模态 LoRA 参考配置(24 GB 单卡)
model: Qwen3-VL-2B-Instruct # 4B 把这里换掉,其余按显存表调整
train_type: lora
# 数据
dataset: data/train.jsonl # 格式见 7.3.2
val_dataset: data/val.jsonl # 必须独立,按场景切分
max_length: 2048 # 图像 token 已计入,别按纯文本 4096 设
truncation_strategy: delete # 超长样本直接丢,避免被截断的样本教坏格式
# LoRA
lora_rank: 16 # 8~16 起步;数据 < 1000 条时用 8
lora_alpha: 32 # 约 2×rank
lora_dropout: 0.05
target_modules: [q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj,
merger, projector]
# 说明:merger / projector 是视觉与语言之间的投影与融合层,
# 实际模块名以模型实现为准,训练前用 model.named_modules() 打印确认。
# 训练
torch_dtype: bfloat16
per_device_train_batch_size: 1 # 2B、24 GB、2048 长度下的安全值
gradient_accumulation_steps: 16 # 有效 batch = 16
learning_rate: 1.0e-4
lr_scheduler_type: cosine
warmup_ratio: 0.05
num_train_epochs: 3 # 数据 < 2000 条时 3~5 轮
gradient_checkpointing: true
logging_steps: 5
save_steps: 200
eval_steps: 200
save_total_limit: 3
seed: 42
2026 年的 ms-swift 已覆盖 200+ 多模态模型的 LoRA / 全参 / DPO / GRPO 训练入口,下面这组命令只用到其中最小的一部分;换模型前先查它当前的模型支持列表,别照抄模型名。
以 ms-swift 为例的训练入口(命令与参数以官方 README 为准,不同版本可能有差异):
# 训练(单卡,24 GB)
swift sft \
--model Qwen3-VL-2B-Instruct \
--train_type lora \
--dataset data/train.jsonl \
--val_dataset data/val.jsonl \
--torch_dtype bfloat16 \
--num_train_epochs 3 \
--per_device_train_batch_size 1 \
--gradient_accumulation_steps 16 \
--learning_rate 1e-4 \
--lora_rank 16 \
--lora_alpha 32 \
--lora_dropout 0.05 \
--target_modules all-linear \
--gradient_checkpointing true \
--max_length 2048 \
--output_dir out/qwen3vl_2b_lora \
--logging_steps 5 \
--save_steps 200
# 推理验证(合并 LoRA 后跑评测集)
swift infer \
--adapters out/qwen3vl_2b_lora \
--val_dataset data/val.jsonl \
--max_new_tokens 512 \
--temperature 0
同一套框架也有 DPO / GRPO 的训练入口(子命令与参数以官方 README 为准),SFT 之后要做偏好对齐不必换框架,选型见 7.5。
--target_modules all-linear 是省事写法,会把所有线性层打开(含视觉侧),代价是参数多、过拟合风险高。想精确控制视觉投影层时,改用显式列表,并先打印模块名确认:
# 训练前确认 target_modules 是否命中:打印名字里含 merger/projector 的模块
from transformers import AutoModelForImageTextToText
model = AutoModelForImageTextToText.from_pretrained("Qwen3-VL-2B-Instruct", torch_dtype="bfloat16")
hits = [n for n, _ in model.named_modules() if any(k in n.lower() for k in ("merger", "projector", "visual"))]
print("\n".join(hits[:40]))
print(f"命中 {len(hits)} 个模块,请从中挑出投影/融合层写入 target_modules")
学习率的经验区间:2B 用 1e-4~2e-4,4B 用 5e-5~1e-4;LoRA dropout 0.05 起步,数据少于 800 条时提到 0.1。训练 3 轮还不够就加数据,而不是加轮数——多模态任务的过拟合通常在 5 轮内出现,症状是评测集描述变得越来越长但准确率不动。
坑:验证集 loss 一直降但生成质量变差。多模态任务的
eval_loss对「答案变长」过于宽容,必须同时看生成侧指标(见 7.3.5)。只盯 loss 选 checkpoint,十次有六次选到过拟合那版。
7.3.4 2B 与 4B 在 24 GB 卡上的可行配置
同一张 24 GB 卡,2B 与 4B 的余量差别比参数比例更大,因为激活与视觉 token 是固定开销。下表是开梯度检查点、bf16 的参考值,实测按你的 seq 长度 ±20% 波动。
| 模型 | 序列长度 | batch × 累积 | 图像数/样本 | 估算显存 | 24 GB 卡结论 |
|---|---|---|---|---|---|
| 2B LoRA | 2048 | 1 × 16 | 1 | 约 13 GB | 舒服,可提到 batch=2 |
| 2B LoRA | 4096 | 1 × 16 | 1 | 约 19 GB | 可行,关梯度检查点会 OOM |
| 2B LoRA | 2048 | 2 × 8 | 2 | 约 17 GB | 多图对比任务可用 |
| 4B LoRA | 2048 | 1 × 16 | 1 | 约 20 GB | 可行,batch 只能 1,不能再开评估批次 |
| 4B LoRA | 4096 | 1 × 16 | 1 | 约 27 GB | 单卡不可行,需 QLoRA 或降序列 |
| 4B QLoRA(NF4) | 2048 | 2 × 8 | 1 | 约 15 GB | 推荐起步配置 |
| 4B QLoRA(NF4) | 4096 | 1 × 16 | 2 | 约 19 GB | 多图长文本的可行上限 |
吞吐参考(2B、2048 序列、batch=1、开梯度检查点):单卡约 2.5~4 样本/秒,实际取决于数据加载与图像解码。图像解码常常比模型前向更慢——把大图预缩放并缓存成 1024 长边的 JPEG/PNG,能让吞吐提升 30% 以上。
坑:开
eval_strategy时忘记per_device_eval_batch_size。默认值可能与训练 batch 相同甚至更大,评估阶段显存峰值超过训练阶段,训练跑到第 200 步就 OOM,看起来像「训练不稳定」。把 eval batch 显式设为 1。
7.3.5 评测:领域集 + 通用回归 + 人工抽检
多模态的评测不能只看一个准确率数字,要分三层:领域任务、通用能力、人工抽检。三层都过,才允许发布。
| 层 | 数据 | 指标 | 通过线 |
|---|---|---|---|
| 领域任务 | 100~300 条,按场景分层,覆盖高频与难点 | 字段抽取准确率(严格 JSON 可解析 + 字段值对)、描述类用 LLM-as-judge 加人工校准 | 抽取 ≥ 0.90,描述 ≥ 0.75 |
| 通用能力回归 | 200~500 条公开或自建通用图文问答 | 准确率相对基座下降幅度 | 下降 ≤ 3 个点 |
| 人工抽检 | 从领域集随机抽 30~50 条 | 事实性错误率、幻觉率 | 事实性错误 ≤ 5%,幻觉 0 |
| 稳定性 | 同一输入跑 3 次(temperature=0 与 0.7 各 1 次) | 输出可解析率、字段一致性 | temperature=0 时 100% 可解析 |
下面这段评测脚本的核心是「严格解析 + 逐字段比对 + badcase 落盘」,不做任何宽松匹配——宽松匹配会让上线后的解析失败率被严重低估。
# eval_vlm.py —— 字段抽取型评测:严格 JSON 解析 + 逐字段比对 + badcase 导出
import json
FIELD_SPEC = {"count": int, "items": list} # 按你的任务改
FENCE = chr(96) * 3 # 三个反引号,避免正则转义麻烦
def parse_strict(text: str):
"""剥掉代码围栏后严格解析;失败返回 None"""
t = text.strip()
if t.startswith(FENCE):
t = t[len(FENCE):]
if t.rstrip().endswith(FENCE):
t = t.rstrip()[:-len(FENCE)]
t = t.strip()
try:
return json.loads(t)
except json.JSONDecodeError:
i, j = t.find("{"), t.rfind("}")
if i >= 0 and j > i:
try:
return json.loads(t[i:j + 1])
except json.JSONDecodeError:
return None
return None
def score_one(pred_text: str, gold: dict) -> dict:
obj = parse_strict(pred_text)
if obj is None:
return {"parsable": 0, "field_hit": 0, "field_total": len(gold), "exact": 0}
hit = sum(1 for k, v in gold.items() if obj.get(k) == v)
return {"parsable": 1, "field_hit": hit, "field_total": len(gold),
"exact": int(hit == len(gold))}
def report(records):
n = len(records)
if not n:
return {"parsable_rate": 0.0, "field_acc": 0.0, "exact_rate": 0.0}
p = sum(r["parsable"] for r in records) / n
fh = sum(r["field_hit"] for r in records)
ft = sum(r["field_total"] for r in records) or 1
return {"parsable_rate": round(p, 4), "field_acc": round(fh / ft, 4),
"exact_rate": round(sum(r["exact"] for r in records) / n, 4)}
if __name__ == "__main__":
rows = [json.loads(line) for line in open("eval_preds.jsonl", encoding="utf-8")]
res = [score_one(r["pred"], r["gold"]) for r in rows]
print(json.dumps(report(res), ensure_ascii=False))
bad = [r for r, s in zip(rows, res) if not s["parsable"] or s["exact"] == 0]
with open("badcase.jsonl", "w", encoding="utf-8") as f:
for b in bad[:200]:
f.write(json.dumps(b, ensure_ascii=False) + "\n")
print(f"badcase {len(bad)} 条,前 200 条已写出,交人工复核")
人工抽检别省。用 LLM-as-judge 打分只能当粗筛,抽检 30 条、每条 1 分钟,能发现「模型把所有围挡都描述成蓝色」这类系统性幻觉——这类错误在自动指标里表现为「字段对但内容错」,只有人眼能抓住。
7.3.6 部署与验证:先用现成量化版,再决定是否微调
上线前有两件事要按顺序做:先用现成量化版把任务可行性验证掉,再把微调后的模型按服务指标压测。前者决定「要不要花训练的钱」,后者决定「能不能上线」。
2026 年的部署生态让第一步变得很便宜:Ollama 已经分发 qwen3-vl 的 2B / 4B 量化版,一条命令就能在本机拿到可调用的多模态接口;ms-swift 则覆盖 200+ 多模态模型的训练入口。验证与微调用同一族模型,验证阶段定下的 prompt、字段定义与错例分类,可以直接迁移到训练数据的设计上。
# 1) 拉取并启动现成量化版(tag 名以 ollama.com/library/qwen3-vl 为准)
ollama pull qwen3-vl:2b
ollama run qwen3-vl:2b
# 2) 用 OpenAI 兼容接口批量跑评测集(Ollama 默认监听 11434)
# 逐条记录原始输出,严格解析后再统计,不要边跑边改 prompt
python eval_ollama.py --base-url http://127.0.0.1:11434/v1 --model qwen3-vl:2b --data data/golden.jsonl
验证不需要复杂设计,三条就够:
| 验证项 | 做法 | 结论怎么用 |
|---|---|---|
| 任务可行性 | 用 100~300 条 golden set 跑量化版,算严格可解析率与字段准确率 | 已过线 → 先别微调,做工程优化;差 5~15 个点 → 微调收益可预期 |
| 数据格式 | 用与训练完全相同的 prompt 与字段定义 | 量化版都跑不出格式,先修 prompt,别急着训 |
| 能力上限 | 挑最难的 30 条看错在哪 | 错在「看不清、数不清」说明分辨率或模态不够,微调也救不回来 |
这套顺序的实际价值是压缩判断成本:几小时推理就能回答「数据值不值得花几天训」,而不是训完才发现错例集中在分辨率不足上,只能推翻重来。
验证通过、确认值得微调之后,再回答服务侧的两个数:首 token 时延(TTFT)和稳定吞吐(tokens/s)。2B/4B 档位在单张 24 GB 卡上完全可以用 vLLM 常驻,关键是选对量化与并发。
| 方案 | 权重显存(2B / 4B) | TTFT 约(1024 图 + 200 字) | 吞吐(tokens/s,batch 8) | 精度损失 | 适用 |
|---|---|---|---|---|---|
| bf16 原权重 | 约 4.5 / 9 GB | 120 / 200 ms | 900 / 500 | 基线 | 精度敏感、显存够 |
| AWQ INT4 | 约 1.5 / 3 GB | 90 / 140 ms | 1300 / 800 | 抽取类 1~3 点 | 推荐默认 |
| GPTQ INT4 | 约 1.5 / 3 GB | 95 / 150 ms | 1200 / 750 | 与 AWQ 接近 | AWQ 不稳时的备选 |
| INT8(W8A8) | 约 2.5 / 5 GB | 110 / 180 ms | 1000 / 600 | 通常 < 1 点 | 有 INT8 算子支持时优先 |
# 1) 启动 OpenAI 兼容服务(vLLM,参数以当前版本文档为准)
vllm serve Qwen3-VL-2B-Instruct \
--max-model-len 8192 \
--limit-mm-per-prompt image=2 \
--gpu-memory-utilization 0.85 \
--port 8000
# 2) 压测:并发 8、请求 200 条,关注 ttft 与 output throughput
python benchmarks/benchmark_serving.py \
--backend openai-chat \
--model Qwen3-VL-2B-Instruct \
--endpoint /v1/chat/completions \
--dataset-name hf --dataset-path data/stress.jsonl \
--num-prompts 200 --request-rate 8
# 3) 量化(AWQ,以 autoawq / llm-compressor 当前用法为准)
python -m awq.entry --model_path Qwen3-VL-2B-Instruct \
--w_bit 4 --q_group_size 128 --run_awq --dump_awq out/awq_2b
服务侧还要设三个护栏:max_tokens 上限(防跑飞烧算力)、图像长边上限(进服务前统一缩放,避免超大图打爆视觉 token)、超时与重试(模型输出卡住时返回「无法判断」而不是空字符串)。上线首周按 temperature=0 跑,稳定后再对描述类放开到 0.3~0.5。
坑:拿训练时的 prompt 直接上线,但忘了带上训练时用的 system 提示与字段定义。多模态模型对 prompt 结构敏感,字段定义一换,输出格式的可解析率可能从 99% 掉到 70%。把 prompt 做成版本化模板文件,训练与推理共用同一份。
7.4 用大模型训练专用小模型
这是本章投入产出比最高的一节。业务上真正要部署的往往不是「更强的大模型」,而是「够用、够便宜、够快的小模型」。本节的路径是:让大模型当老师,把它的判断沉淀成数据与分布,蒸馏给一个能塞进边缘设备的学生模型。
7.4.1 三条蒸馏路径:数据、logits、特征
| 路径 | 教师侧要什么 | 学生侧怎么学 | 实现难度 | 数据效率 | 能否跨架构 | 适用场景 |
|---|---|---|---|---|---|---|
| 数据蒸馏 | 只要推理 API / 输出文本 | 把教师输出当标签做监督训练 | 低 | 中 | 能(跨模态、跨架构都行) | 通用首选,尤其教师是闭源或多模态时 |
| logits 蒸馏 | 教师的 logits 或概率分布 | KL 散度拟合 softened 分布 | 中 | 高(同样的数据学到更多) | 弱(词表/结构要对齐) | 同架构或同词表,分类/生成任务 |
| 特征蒸馏 | 教师中间层特征 | 对齐中间层表征 + 任务损失 | 中高 | 高 | 弱(要设计映射层) | 表征学习、检测/分割骨干替换 |
选择顺序:先看教师能不能给 logits。能给且同架构,就上 logits 蒸馏,几万条数据能达到数据蒸馏十几万条的效果;只能拿到文本输出,就走数据蒸馏,把精力花在「教师的输出质量」上;做骨干网络替换(比如把大 backbone 换成轻量 backbone 还要保住特征分布)时才用特征蒸馏。
| 判断条件 | 走哪条 | 理由 |
|---|---|---|
| 教师是闭源 API / 多模态模型 | 数据蒸馏 | 拿不到 logits |
| 教师与学生同架构、同词表 | logits 蒸馏 | 分布信息量最大 |
| 目标是「教师能力迁移 + 教师错误尽量不学」 | logits 蒸馏 + 真标签混合 | soft target 缓解教师噪声 |
| 目标是换更小的视觉骨干 | 特征蒸馏 | 要对齐中间表征 |
| 只有几千条真标签,任务明确 | 数据蒸馏(教师标注扩数据) | 成本最低 |
7.4.2 数据蒸馏:让大模型标注,人来抽检
数据蒸馏的工程重点不是「怎么调 API」,而是「怎么保证标出来的数据能训」。先选教师:2026 年最省事的组合是 DeepSeek 的两个档位,按模态和上下文长度分工。
| 教师 | 适合的标注任务 | 定价口径(2026-11 官方定价页,美元/百万 token) | 限制 |
|---|---|---|---|
DeepSeek V4-Pro(deepseek-v4-pro) |
整篇报告、规范、巡检单据的长文本标注;需要跨章节归纳意图 | 输入 0.66(缓存未命中)/ 0.022(命中),输出 1.98 | 不支持视觉,图像标注不能直接用 |
DeepSeek V4-Flash(deepseek-flash) |
大批量图文标注、字段抽取、描述生成 | 输入 0.15(缓存未命中)/ 0.003(命中),输出 0.6 | 单条质量不如 Pro,长尾样本建议用 Pro 复核 |
| 自建开源 VLM | 图像密集、数据不能出内网 | 按 GPU 小时算,见 7.6.3 | 吞吐受推理框架与显存限制 |
V4-Pro 的 1M 上下文是它在这条链路里的核心价值:一份 200 页的巡查报告、一本内部规范,可以整篇塞进去让教师做「跨章节的字段抽取与意图归纳」,不用先切片再拼接——切片会切断跨段落的条件,标出来的答案经常自相矛盾。V4-Flash 的定位相反:单图、短 prompt、几十万次调用,用价格换吞吐,而且它支持视觉,能直接吃图。并发上限也不同(Flash 约 2500、Pro 约 500),大批量跑的时候,并发往往比单价更早成为瓶颈。价格会调整,写预算时以 https://api-docs.deepseek.com/quick_start/pricing 当天页面为准,off-peak 时段是峰值价的一半。
学生侧的选择在 2026 年很清晰:要「看懂图并说人话」用 Qwen3-VL 2B/4B;要「测几何」用领域专用的检测或分割小模型(SAM 3/3.1 微调后的 decoder 就是一个);要「认字」用专用 OCR。教师与学生不要求同架构——数据蒸馏本来就是跨架构的。
选完教师,再看流水线。四步,每步都要有落库与可回溯:
① 采样 ② 教师标注 ③ 质检 ④ 回流
业务未标注数据 ──► 大模型批量推理 ──► 规则校验 + 人工抽检 ──► 训练集
(按场景分层) (并发 + 重试) (解析率/一致性/置信度) (版本化)
▲ │
└────────────── 低置信样本 / 新增场景 ◄──────────────────────┘
# distill_label.py —— 用教师模型批量标注,落库 + 重试 + 解析校验
import json, time, hashlib, os
from concurrent.futures import ThreadPoolExecutor, as_completed
PROMPT_TMPL = open("prompts/teacher_label_v3.txt", encoding="utf-8").read()
def call_teacher(client, image_path: str, model: str, retries: int = 3) -> str:
for attempt in range(retries):
try:
resp = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": f"file://{os.path.abspath(image_path)}"}},
{"type": "text", "text": PROMPT_TMPL},
]}],
temperature=0.2, # 标注任务不要 0,0 容易在边界样本上过度自信
max_tokens=1024,
timeout=120,
)
return resp.choices[0].message.content
except Exception as e:
if attempt == retries - 1:
return f"__ERROR__:{type(e).__name__}"
time.sleep(2 ** attempt) # 指数退避
return "__ERROR__"
def parse_and_check(text: str):
"""返回 (ok, obj_or_reason);规则校验在这里一次做完"""
if text.startswith("__ERROR__"):
return False, "api_error"
body = text.strip().strip(chr(96) * 3).strip()
try:
obj = json.loads(body)
except json.JSONDecodeError:
return False, "json_error"
if not isinstance(obj, dict) or "count" not in obj:
return False, "schema_error"
if isinstance(obj["count"], int) and obj["count"] < 0:
return False, "negative_count"
return True, obj
def run(items, out_path, model, workers=8):
client = None # 按你用的 SDK 初始化
keep = 0
with open(out_path, "w", encoding="utf-8") as f, ThreadPoolExecutor(workers) as ex:
futs = {ex.submit(call_teacher, client, it["image"], model): it for it in items}
for fu in as_completed(futs):
it = futs[fu]
raw = fu.result()
ok, payload = parse_and_check(raw)
rec = {"image": it["image"], "scene": it.get("scene"),
"ok": ok, "raw": raw if not ok else None}
if ok:
rec["label"] = payload
keep += 1
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
print(f"成功 {keep}/{len(items)},失败样本已标 ok=false,需重跑或人工处理")
if __name__ == "__main__":
items = [json.loads(l) for l in open("data/unlabeled.jsonl", encoding="utf-8")]
run(items, "data/teacher_labels.jsonl", "teacher-vl-large")
质检的三个量化门槛(每条都要算,不达标就重跑或换 prompt):解析成功率 ≥ 95%;同一输入两次采样(temperature=0.2 与 0.7)的一致率 ≥ 85%;按场景分层看,最差场景的准确率不能低于平均值 15 个点。人工抽检按 5% 比例抽,抽检发现系统性错误(比如某类目标整类标错)就整类重标,不要只修几条。
坑:教师模型只覆盖了数据的「主流场景」,长尾场景被标得又少又错,学生模型于是比教师更偏。解决办法是按场景分层采样与分层抽检,并在训练集里给长尾场景加权(或复制到与主流场景同等数量级)。
7.4.3 数据飞轮与伪标签质量
模型上线后真正的资产是「生产数据 + 低置信样本」。飞轮转起来的前提是阈值策略稳定,否则回流的数据一年比一年脏。
生产流量 ──► 小模型推理 ──┬─ 高置信 + 多视图一致 ──► 直接进训练池(免审)
├─ 中置信 ──────────────► 大模型复核 ──► 训练池
└─ 低置信 / 冲突 ────────► 人工标注 ──► 评测集 + 训练池
每轮回流后:重训 → 在固定评测集上对比 → 只接受「不低于上一版」的模型上线
| 过滤手段 | 做法 | 阈值建议 | 作用 |
|---|---|---|---|
| 置信度过滤 | 取模型输出的置信度或多次采样的平均概率 | 高置信 ≥ 0.9,低置信 < 0.6 | 筛掉大部分错误 |
| 多视图一致性 | 同一图像做轻微增强(裁剪/亮度)再推理,比较结果 | 3 次中 ≥ 2 次一致 | 抗过拟合到单次错误 |
| 多次采样投票 | temperature=0.7 采 3~5 次,取多数 | 一致率 ≥ 0.8 | 适合分类/计数 |
| 规则校验 | 字段范围、几何约束(面积、拓扑) | 按业务定义 | 拦掉物理上不可能的答案 |
| 类别平衡 | 回流后按类别统计,限制单类占比 | 单类 ≤ 总量 40% | 防长尾消失 |
飞轮的节奏比算法更重要:每轮回流建议新增 10%~30% 的数据量,回流量太小看不到提升,一次性翻倍会让分布突变、评测集体失真。每轮都要记录「数据版本 → 模型版本 → 评测分数」,三轮不涨就停下来查数据而不是继续堆量。
坑:把低置信样本直接丢掉。低置信样本恰恰是模型的能力边界,丢掉的越多,飞轮越学不到新东西。正确做法是「低置信 → 人工标注 → 进评测集」,让它们成为下一轮的验收标准。
7.4.4 蒸馏损失:温度、权重、与真标签混合
logits 蒸馏的关键是「软化」教师分布:温度 T 把 logits 压平,让学生看到类别之间的相对关系,而不只是「哪个最大」。
```text温度软化: p_T = softmax(z / T) 蒸馏损失: L_kd = T² · KL(p_T^teacher ‖ p_T^student) 总损失: L = (1 - α) · L_ce(y, student) + α · L_kd ▲ ▲ 真标签的交叉熵 教师分布的 KL
T 的作用:T=1 等价于普通 softmax,类别间差异极陡;T=2~5 才让”第二可能答案”进入视野 T² 的作用:T 放大后梯度会缩小 1/T²,乘回 T² 才能让蒸馏项与 CE 项处在同一量级 α 的取法:数据干净、教师强 → α=0.7~0.9;教师有噪声、真标签可靠 → α=0.3~0.5
```python
# distill_kd.py —— logits 蒸馏核心片段:温度缩放 + KL + 真标签混合
import torch
import torch.nn.functional as F
def distill_loss(student_logits, teacher_logits, labels, T=3.0, alpha=0.7,
label_smoothing=0.0):
"""student/teacher_logits: (B, V);labels: (B,) 或 -100 表示该位置不计 CE"""
# 只对有效位置算 CE(SFT 里 prompt 部分被 mask 成 -100)
ce = F.cross_entropy(student_logits, labels, ignore_index=-100,
label_smoothing=label_smoothing)
t_p = F.softmax(teacher_logits.detach() / T, dim=-1) # 教师梯度必须 detach
s_log_p = F.log_softmax(student_logits / T, dim=-1)
kl = F.kl_div(s_log_p, t_p, reduction="batchmean") * (T * T)
return (1 - alpha) * ce + alpha * kl
# 关键细节 1:教师前向要 no_grad,否则显存翻倍
# with torch.no_grad(): teacher_logits = teacher(images, prompts)
# 关键细节 2:词表对齐,教师与学生共享 tokenizer 时才能直接 KL
# 关键细节 3:T 与 alpha 先扫小网格 (T∈{2,3,5}, alpha∈{0.3,0.5,0.7}),
# 每组跑 200 步看验证集,别拍脑袋定
跨界蒸馏(教师是多模态大模型、学生是检测或分割小模型)拿不到 logits,退化成「数据蒸馏 + 软标签」。可行的近似是让教师输出「每类的存在概率或计数」,转成软标签与学生分类头做 KL;几何任务(框、掩码)没有分布可对齐,只能走硬标签 + 一致性过滤。
7.4.5 收益账:什么场景值得做蒸馏
蒸馏的投入是「教师推理成本 + 标注质检 + 额外训练轮次」,收益是「推理成本与时延下降」。算清楚这笔账,再决定做不做。
| 维度 | 大模型直调(教师) | 蒸馏后小模型 | 差异 |
|---|---|---|---|
| 单次推理成本 | 按 token 计费,视觉任务单图约 0.005~0.05 元 | 自建 2B 模型,单图约 0.0002~0.002 元 | 降 1~2 个数量级 |
| 时延(P95) | 800~3000 ms(含网络) | 120~400 ms(本地/同机房) | 可进实时链路 |
| 离线可用 | 否 | 是 | 边缘、内网部署必需 |
| 精度 | 高(但领域任务不一定最高) | 领域任务可打平或反超,通用能力下降 | 需分任务看 |
| 数据隐私 | 图要出域 | 全内网 | 合规场景刚需 |
| 初期投入 | 0 | 教师推理 + 质检人工 + 2~5 个 GPU 日 | 一次性 |
值得做的三个判断条件(满足两个就动手):单日调用量 > 5000 次、时延要求 < 500 ms、有明确的数据不出内网要求。反之,日调用几百次、精度还在调、模型半年内可能换代的场景,先别蒸馏——你会发现刚训完,业务口径又变了。
一次典型投入(4B 教师 → 2B 学生、2 万条图文样本):教师推理约 40 GPU 小时等效或几十元 API 费;人工抽检 1000 条约 2 人日;学生模型 LoRA 训练 1~2 个 GPU 日。按每天 5000 次调用算,通常 2~4 个月回本。
教师 API 的成本可以直接算,不用估:
教师标注成本 ≈ 样本数 × (单条输入 token × 输入单价 + 单条输出 token × 输出单价) ÷ 1,000,000
× (1 + 重试与质检损耗率)
以 1 万条图文样本、单条输入 1200 token(含图像折算)、输出 300 token 为例,
按 2026-11 定价页的峰值价代入:
V4-Flash(输入 0.3、输出 1.2 美元/百万 token)
输入:10000 × 1200 × 0.3 / 1e6 = 3.6 美元
输出:10000 × 300 × 1.2 / 1e6 = 3.6 美元
小计 7.2 美元;按 30% 重试与复核再加,约 9.4 美元
V4-Pro(输入 1.32、输出 3.96 美元/百万 token)
输入:10000 × 1200 × 1.32 / 1e6 = 15.84 美元
输出:10000 × 300 × 3.96 / 1e6 = 11.88 美元
小计 27.72 美元,约为 Flash 的 3.8 倍
结论:图像样本走 Flash,长文本与跨章节任务走 Pro;别用 Pro 做「看一眼图打个标」的活。
按每天 5000 次推理、单次 0.005 元估算,教师侧一年是 9000 元量级——
比起训练与标注的人力,这笔钱通常不是决策因素,数据质量才是。
坑:把「蒸馏」当成「压缩」,以为学生模型能自动继承教师的所有能力。事实是学生只在训练分布内像教师,出分布就退化得更快。必须给学生配一套和教师同口径的「无法判断」样本,否则学生会比教师更爱编。
7.5 后训练时代:SFT → 偏好对齐 → 蒸馏
2026 年开源模型与微调框架的能力已经溢出:多数业务真正缺的不是「训一个模型」,而是把三件事排好队——先让模型会做(SFT),再让它在几种都能交差的答案里挑你要的那种(偏好对齐),最后把能力压进能上线的小模型(蒸馏)。这一节只讲排序与边界,损失函数的细节在 7.1.3、数据蒸馏的流水线在 7.4。
7.5.1 三段式流水线
① SFT ② 偏好对齐 ③ 蒸馏
指令 + 标准答案 ──► 成对数据 / 可验证奖励 ──► 教师输出 + 真标签
解决「会不会做」 解决「选哪种做法」 解决「便宜地做」
1 份数据、1 次训练 1.5~5× SFT 成本 0.5~1× SFT 成本
│ │ │
└──── 大多数项目停在这里 ───┴──── 基线足够高才继续 ─────┘
停在哪一步,看任务性质而不是看热度:
| 停在哪一步 | 满足什么条件 | 典型任务 |
|---|---|---|
| 只做 SFT | 有标准答案、格式与口径能被一对样本教出来,SFT 后严格可解析率 ≥ 95% | 字段抽取、缺陷描述、分类、固定格式报告 |
| SFT + 偏好对齐 | SFT 后「能交差但不好用」:答案太啰嗦、该拒答时硬答、多个合理答案里总选差的那种 | 巡检结论措辞、拒答边界、详略控制 |
| 三段全做 | 单次调用成本或时延已顶到业务天花板,且日调用量足够大(见 7.4.5 的判断条件) | 边缘部署、高并发 OCR / 检测、离线场景 |
一句话:SFT 解决「不会」,偏好对齐解决「不听话」,蒸馏解决「太贵」。顺序不能换——没有 SFT 的 DPO 是在噪声上学偏好,没有 SFT 的蒸馏是在教一个基础不牢的学生。
7.5.2 偏好对齐:DPO 与 GRPO 怎么选
2026 年的开源框架把 DPO、GRPO 的训练入口都做成了配置项,技术门槛不在实现,在「偏好数据从哪来」。两者的分工比很多人想的更清楚:
| 维度 | DPO | GRPO |
|---|---|---|
| 数据形态 | 同一输入下的一好一差两条答案 | 同一输入下的一组采样,靠奖励函数打分 |
| 偏好来源 | 隐式,来自人工标注的「哪个更好」 | 显式,来自规则校验、数值比对、IoU 阈值、格式检查 |
| 成本(相对 SFT) | 2×~3×(要跑参考模型) | 3×~5×(每步多次采样 + 奖励计算) |
| 适合 | 措辞、详略、拒答边界等「说不清但看得出来」的偏好 | 有客观判据的任务:JSON 能否解析、计数/面积误差是否达标、几何是否合法 |
| 不适合 | 有唯一正确答案的任务(直接 SFT 更省) | 写不出奖励函数、或奖励容易被钻空子的任务 |
选择顺序很实用:
- 先问「错了能不能自动判」:能(格式、数值、几何)→ GRPO 有空间;不能(措辞、详略)→ DPO。
- 再问「数据够不够」:DPO 成对样本低于 300 对先攒数据;GRPO 需要 1000 条以上有标准答案的输入才有稳定梯度。
- 最后问「SFT 之后还差多少」:SFT 版本已在验收线上、只是风格不稳,才值得做对齐;SFT 差 20 个点,先回去补数据。
GRPO 的本地化门槛是奖励函数要写成代码。一个可用的小骨架:
# reward_fn.py —— GRPO 的可验证奖励:可解析 + 字段齐全 + 值正确,三层叠加
def reward(pred_text: str, gold: dict) -> float:
obj = parse_strict(pred_text) # 复用 7.3.5 的严格解析
if obj is None:
return -1.0 # 不可解析给负分,防止模型学会输出空对象
score = 0.2 # 能给出合法 JSON 对象
score += 0.3 * float(set(gold) <= set(obj)) # 字段齐全
hit = sum(1 for k, v in gold.items() if obj.get(k) == v)
score += 0.5 * hit / max(1, len(gold)) # 字段值正确率
return score
坑:奖励函数写得松,模型会学会只满足奖励、不完成任务。比如「JSON 可解析」直接给满分,模型就永远输出
{}。奖励必须组合「可解析 + 字段齐全 + 内容正确」三层,并对空答案给负分;上线前用 SFT 版本的输出当基线,确认奖励给它的分明显高于随机输出。
7.5.3 蒸馏为什么放在最后
很多团队把蒸馏当第一步:先找个小模型,再想怎么让它变好。顺序反了——蒸馏的上限是教师,教师本身没在业务口径上对齐过,学生学到的只是「通用的像」,不是「你的业务要的对」。
正确做法是把蒸馏放最后:先在大模型侧完成 SFT(必要时再补偏好对齐),再把这套已经对齐的行为蒸馏进学生。此时学生要学的不是知识,而是教师的行为分布——数据量小得多,也不需要学生自己走一遍对齐。
| 顺序 | 教师侧 | 学生侧 | 结果 |
|---|---|---|---|
| 先蒸馏后对齐 | 未对齐的通用大模型 | 小模型 | 学生继承通用行为,只能靠 SFT 硬掰,容易回退 |
| 先对齐后蒸馏 | SFT(+ 偏好对齐)后的教师 | 小模型 | 学生直接继承对齐后的行为,收敛快、口径稳 |
约束也要说清:教师侧做偏好对齐很贵(每步多次采样),所以「先对齐后蒸馏」不等于「每个项目都要在教师侧跑完 GRPO」。多数项目的现实路径是:教师侧只做 SFT(数据便宜、效果可预期),蒸馏时用人工规则与过滤替代偏好对齐(只保留严格可解析、字段齐全的输出),学生侧再补一轮小规模 DPO。技术选型服从预算,不服从论文。
7.5.4 落到本项目的最小可行路线
把三条线落到本章的场景上,多数人只需要这样一条路线:
第 1 步:现成量化版(Ollama 或 vLLM)跑 golden set,拿到可行性结论与错例分类
第 2 步:用 DeepSeek V4-Flash / V4-Pro 标注 1000~10000 条,人工抽检 5%
第 3 步:学生模型(Qwen3-VL 2B/4B 或 SAM 3/3.1)LoRA SFT,按 7.2 / 7.3 的配置训
第 4 步:严格评测;SFT 已达标 → 上线;只是风格不稳 → 补 500~2000 对样本做 DPO
第 5 步:日调用量过线且时延不够 → 再按 7.4.5 的账算一轮蒸馏,值得才做
坑:把「后训练」理解成「必须上 RL」。2026 年的 RL 在可验证任务上确实有效,但成本是 SFT 的 3~5 倍,还要求奖励函数写得足够严;业务里大量「说不清但对错明显」的需求,用 DPO 加人工抽检就够。为了追新技术把两周的实验拖成两个月,是这一类项目最常见的翻车方式。
7.6 训练工程
同样一份数据,工程做得糙就复现不出来,做得细就能两周迭代五版。这一节把「可复现」拆成可执行的动作:配置版本化、显存优化顺序、成本估算、失败归因。
7.6.1 实验管理:配置、数据、种子、日志四件套
一次实验必须能回答四个问题:用什么配置、用什么数据、怎么种子、结果在哪。缺一个,两周后你自己都复现不了。
| 要素 | 落盘方式 | 必须记录 | 反例 |
|---|---|---|---|
| 配置 | YAML 全量入仓,禁止命令行覆盖关键项 | lr、batch、累积步数、rank、alpha、序列长度、框架版本 | 只在终端历史里,换机器就丢 |
| 数据 | 每次训练生成 data_version,存文件清单与哈希 | 样本数、类别分布、去重统计、切分方式 | 「用的是上周那份」,无法回溯 |
| 种子 | seed 同时设 Python/NumPy/PyTorch/CUDA | cudnn.deterministic 状态 | 只设 random.seed,结果每次不同 |
| 日志 | TensorBoard 或 wandb,目录按 exp_id 隔离 | loss 分量(ce/kd/reg)、lr、grad_norm、吞吐、显存峰值 | 只存最终 loss 一个数 |
| 产物 | checkpoint + 评测报告 + badcase 一起归档 | 评测脚本版本、评测集哈希 | 只有权重,不知道当时多少分 |
# 每次实验一个目录,目录名带时间与配置摘要,避免「final_v2_真正最终版」
EXP_ID="qwen3vl2b_lora_r16_$(date +%Y%m%d_%H%M)"
mkdir -p "runs/$EXP_ID"/ckpt "runs/$EXP_ID"/logs "runs/$EXP_ID"/eval
cp configs/qwen3vl_2b_lora.yaml "runs/$EXP_ID/config.yaml"
git rev-parse HEAD > "runs/$EXP_ID/git_commit.txt"
python -c "import torch,transformers;print(torch.__version__, transformers.__version__)" \
> "runs/$EXP_ID/versions.txt"
# 数据版本:清单 + 内容哈希,保证"这批数据"可复现
find data/train -type f \( -name '*.jsonl' -o -name '*.png' -o -name '*.jpg' \) | sort \
| xargs sha256sum > "runs/$EXP_ID/data_manifest.sha256"
wc -l data/train.jsonl > "runs/$EXP_ID/data_count.txt"
# seed_all.py —— 一次设全,避免"只设了一半"的随机性
import os, random
import numpy as np
import torch
def seed_all(seed: int = 42, deterministic: bool = True):
os.environ["PYTHONHASHSEED"] = str(seed)
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
if deterministic:
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
return seed
7.6.2 显存优化:按性价比排序动手
OOM 时不要乱试,按这个顺序逐个加,每加一个测一次吞吐,别一次上三个:
| 顺序 | 手段 | 省显存 | 代价 | 什么时候用 |
|---|---|---|---|---|
| 1 | 降 batch,加梯度累积 | 线性省激活 | 无(有效 batch 不变) | 一律先做这个 |
| 2 | 开梯度检查点 | 激活省 3~5 倍 | 慢 20%~35% | 序列 ≥ 2048 时 |
| 3 | bf16 混合精度 | 权重与激活省约一半 | 极小 | 卡支持 bf16 就默认开 |
| 4 | 序列打包(packing) | 省 padding 浪费,吞吐 +30%~70% | 需处理跨样本注意力掩码 | 变长文本且框架支持 |
| 5 | 冻结更多层(如视觉塔) | 省激活与优化器状态 | 可能掉点 | 数据量小、只想调语言侧 |
| 6 | QLoRA / 4bit | 权重常驻省 60%~75% | 略慢,精度损失小 | 上面都做了还 OOM |
| 7 | ZeRO-2/3 + CPU offload | 多卡下省状态与参数 | 通信与 PCIe 开销大 | 多卡、大模型、不在乎速度 |
序列打包有一个坑值得单独说:把多条短样本拼进一条序列能显著提升吞吐,但如果 attention mask 没按样本重置,模型会「看见」上一条样本的答案,loss 会虚假降低,评测时露馅。用框架的 packing 功能时,务必确认它按 position_ids 或 block-diagonal mask 做了隔离。
7.6.3 成本估算:两个算例
```text成本 = GPU 小时 × 单价 × 卡数 × 尝试倍数
尝试倍数是最容易被忽略的一项:一次就训好的实验几乎没有, 调 lr / 数据 / rank 通常要 5~10 次,成本估算时必须乘进去。
算例 A:4B 模型 LoRA,1 万条图文样本,单卡 24 GB(约 2 元/小时) 单轮耗时:1 万条 ÷ (3 样本/秒) ≈ 0.93 小时,3 轮 ≈ 2.8 小时 单次实验:2.8 × 1 卡 × 2 元 ≈ 5.6 元 含 8 次尝试:约 45 元,另加评测与数据准备人工
算例 B:SAM 级分割微调,2000 张 512×512(约 2 元/小时) 单轮耗时:2000 张 ÷ (12 张/秒) ≈ 0.05 小时,20 轮 ≈ 0.9 小时 单次实验:0.9 × 2 元 ≈ 1.8 元,20 次尝试约 36 元 大头不在算力,在标注:2000 张掩码按 1.5 元/张 ≈ 3000 元
结论很清楚:中小规模微调的算力成本可以忽略,真正贵的是「数据标注」和「人的时间」。所以优化方向应该是「把评测做扎实,减少无效尝试」,而不是斤斤计较 GPU 单价。
> **坑**:只看训练时长算成本,忘了评测与推理。多模态评测每轮要跑 300 条生成,耗时常常和训练一轮相当;把评测放进同一个预算表,否则你会发现「训练 3 小时,评测 5 小时」。
### 7.6.4 常见坑:OOM、loss 不降、评测不涨、多卡 hang
| 症状 | 最可能的原因 | 怎么确认 | 处理 |
| --- | --- | --- | --- |
| 第 1 步就 OOM | 基础权重 + 优化器状态已超 | nvidia-smi 看峰值是否在加载后就满 | 换 QLoRA,或先加载到 CPU 再分片 |
| 训练几十步后 OOM | 激活随序列长度或缓存增长 | 固定步数复现,看显存是否阶梯上升 | 开梯度检查点,检查是否有样本超长 |
| loss 不降(平在 2.3 附近) | 学习率太小 / 标签 mask 错 / 数据格式不匹配 | 打印一个 batch 的 input_ids 与 labels 人工看 | 先把 lr 调大 10 倍验证方向,再回头修 mask |
| loss 降但 token 准确率不涨 | 模型在背 prompt,答案 token 没参与 | 统计 labels 非 -100 的比例 | 修 loss mask,确保只有答案参与 |
| loss 降、评测不涨 | 数据泄漏 / 过拟合 / 评测口径错 | 训练集与评测集查重;对比训练集准确率 | 重切分;降 epoch;加通用样本 |
| 多卡 hang 在第 1 步 | NCCL 端口/网卡/主机名问题 | NCCL_DEBUG=INFO 看卡在哪一步 | 指定 NCCL_SOCKET_IFNAME,关掉不用的 IB |
| 多卡 loss 是单卡的 N 倍 | 梯度累积与 world size 重复缩放 | 对比单卡与多卡的 lr 与累积设置 | 有效 batch 变了要同步调 lr |
| 断点续训后效果变差 | 只恢复权重没恢复优化器/调度器 | 检查 checkpoint 里有没有 optimizer 状态 | 用框架的 resume 参数,别手动 load_state_dict |
> **坑**:多卡训练时把「有效 batch = 单卡 batch × 累积 × 卡数」忘了重算。8 卡、单卡 batch 2、累积 4,有效 batch 是 64 而不是 8,lr 还按 8 设就明显偏小,症状是 loss 下降极慢、看起来像「多卡没生效」。
## 7.7 用 DeepSeek Harness 做实验管理
微调实验的重复劳动集中在四处:生成配置矩阵、批量提交、聚合日志、对 badcase 做归因。这四件事都适合交给 DeepSeek Harness(命令行 `dsh`)——它能在你的工作区里读写文件、跑命令、写脚本,把「一次次手工改 yaml」变成「一次描述任务、产出可验收的产物」。前提是你给的任务书要能被验收:输入是什么、输出文件在哪、通过标准是什么。
### 7.7.1 生成配置矩阵并批量提交
把「扫 lr × rank × 累积步数」写成一段描述交给 DSH,让它生成配置矩阵脚本、跑完把结果汇总成一张表。任务书写法(headless 模式,跑完即退):
```bash
# 在本仓库工作区里用 headless 模式跑一个可验收的任务
dsh --profile headless "
读 configs/qwen3vl_2b_lora.yaml 作为基线,生成 6 份实验配置到 runs/grid/:
learning_rate 取 5e-5 与 1e-4,lora_rank 取 8 与 16,gradient_accumulation_steps
取 8 与 16,使有效 batch 覆盖 8/16 两档。每份配置文件名带参数摘要,
写一个 run_grid.sh 逐份调用 swift sft 并输出到 runs/grid/<同名目录>/。
不要修改 configs/ 下的原文件。最后把 6 份配置的关键参数打印成表格。"
这段任务书里有三个可验收点:产物路径(runs/grid/)、约束(有效 batch 的取值)、禁令(不改原文件)。没有这三样,DSH 给你的可能是一堆「看起来对」但没法跑的东西。
配置矩阵脚本的骨架,让 DSH 按这个形状产出,后续你自己扩参数也简单:
#!/usr/bin/env bash
# run_grid.sh —— 由 DSH 生成、人工核对后入库;每份配置独立目录,失败不中断
set -u
BASE="configs/qwen3vl_2b_lora.yaml"
OUT="runs/grid"
mkdir -p "$OUT"
for lr in 5e-5 1e-4; do
for rank in 8 16; do
for acc in 8 16; do
name="lr$lr-r$rank-acc$acc"
cfg="$OUT/$name.yaml"
python - "$BASE" "$cfg" "$lr" "$rank" "$acc" <<'PY'
import sys, yaml
base, out, lr, rank, acc = sys.argv[1], sys.argv[2], float(sys.argv[3]), int(sys.argv[4]), int(sys.argv[5])
cfg = yaml.safe_load(open(base, encoding="utf-8"))
cfg.update(learning_rate=lr, lora_rank=rank, lora_alpha=2 * rank,
gradient_accumulation_steps=acc)
yaml.safe_dump(cfg, open(out, "w", encoding="utf-8"), allow_unicode=True, sort_keys=False)
PY
echo "[提交] $name"
swift sft --config "$cfg" --output_dir "$OUT/$name" \
> "$OUT/$name.log" 2>&1 || echo "[失败] $name,详见日志"
done
done
done
注意 || echo 那一行:批量实验必须「单点失败不中断」,否则第 3 份配置因 OOM 挂掉,后面全白跑。
7.7.2 headless 做训练前数据体检与训练后报告,接进 CI
训练前体检和训练后报告都是「输入固定、输出可判定」的任务,最适合放进 CI。CI 里不需要人盯着对话,只需要一个退出码和一个报告文件。
# CI 里的一段:数据体检不通过就阻断训练
set -e
dsh --profile headless --permission-mode read-only "
审阅 data/train.jsonl 与 data/val.jsonl:
1) 统计样本数、长度分位、角色字段是否统一;
2) 用 5-gram shingle 检测训练集与验证集之间的近似重复,阈值 0.85;
3) 检查每条样本是否至少含 1 个 image 条目且与图片路径数量一致。
产出 data_audit.json(含上述统计与问题清单),并用退出码表达结论:
无问题返回 0,发现泄漏或格式错误返回 1。不要修改任何数据文件。"
训练后报告同理——把 loss 曲线、评测指标、badcase 目录一次性交给它,让它输出 Markdown 报告。关键是要求它「引用具体样本编号」而不是给泛泛结论:
dsh --profile headless "
读取以下产物并生成 eval/report.md:
- runs/latest/logs/trainer_state.json(loss 与 lr 曲线)
- eval/metrics.json(字段抽取准确率、可解析率、通用回归分数)
- eval/badcase.jsonl(最多 200 条)
要求:1) 用表格给出关键指标与基线对比;2) 从 badcase 里归纳不超过 5 类
错误模式,每类给出样本条数并引用 3 条具体样本的多模态输入路径;
3) 给出下一步建议,每条建议必须对应到上述证据。"
7.7.3 失败归因:把曲线、badcase、日志交给 DSH
人眼在几十条 badcase 之间找模式很慢,模型反而擅长这类归纳。用法是给它「证据」,而不是给它「问题」——只问「为什么效果不好」,它只能猜;给曲线和错例,它能给出可验证的假设。
dsh --profile headless "
这是 4B 模型 LoRA 的三个证据包:
- logs/run_a.log:loss 从 1.8 降到 0.9 后走平,第 200 步起 eval_loss 上升
- eval/badcase.jsonl:抽取失败样本 87 条
- data/train.jsonl 的前 200 行
请判断最可能的三个原因,按可能性排序;对每个原因给出验证命令或脚本,
以及预期看到的现象。不要修改文件,只输出分析。"
判断归因分析是否可信,看它有没有满足两条:给出「可验证的下一步」(一条命令或一段脚本),以及区分「数据问题」与「超参问题」。如果它只给出「建议增加数据量、调小学习率」这类通用建议,说明证据不够,补上 trainer_state.json 的完整曲线和 200 条 badcase 再问。
把这类流程沉淀成技能,下一次不用重写任务书:在 $DSH_HOME/skills 下建一个目录,写一份 SKILL.md,把「微调失败归因」的固定步骤(读哪些文件、按什么顺序判断、输出什么格式)固化下来,会话里按需加载即可。
坑:把 DSH 当成「自动调参器」,让它自己跑几十组实验。它对单点任务(生成配置、写体检脚本、归纳 badcase)很强,但「连续多轮自主实验」需要你把验收标准写得很死,否则它可能在错误方向上自信地跑一天。正确姿势是:人定实验矩阵,DSH 执行与汇总,人看报告决定下一步。
7.8 本章小结
- 选型有顺序:先攒 30~50 条 golden set 跑零样本基线,按错例类型分到「知识缺口」「行为不符」「术语/模态不匹配」,知识走 RAG、行为走 SFT,不要一上来就微调。
- LoRA 的
alpha与r要成比例改,target_modules决定能不能学会;显存不足时按「降 batch → 梯度检查点 → 压序列 → QLoRA」的顺序调,别先换方案。 - SAM 系列微调的性价比在「冻结 image encoder、只训 prompt encoder 与 mask decoder」;损失用 focal 加 dice,多候选掩码要按 IoU 选优再回传。
- 领域分割的数据可以向量化复用:用矢量面栅格化成掩码,但必须核对仿射变换与 CRS 一致,否则模型学到的是系统性偏移。
- 多模态微调的
target_modules要覆盖视觉投影/融合层,否则模型「看见了但说不出来」;验证集 loss 不能单独作为选 checkpoint 的依据。 - 蒸馏优先顺序是「logits 蒸馏(同架构)→ 数据蒸馏(闭源/跨模态)→ 特征蒸馏(换骨干)」;教师输出必须做严格解析与分层抽检。
- 数据飞轮的关键是阈值策略稳定:低置信样本不要丢,进人工标注并回流到评测集,三轮不涨就停手查数据。
- 可复现靠四件套:配置入仓、数据哈希、种子设全、日志记分量;OOM 与「loss 不降」都有明确的排查路径,不要盲试。
- 成本估算的大头是标注与人的时间,不是算力;实验成本一定要乘上尝试倍数。
- DSH 的用法是「人定矩阵、机器执行、人看报告」:给它可验收的任务书(路径、约束、禁令),让它产表格、产体检脚本、归纳 badcase。
- SAM 3.1(2026-03)的收益一半在训练、一半在推理:微调路线不变,但上线必须走 Object Multiplex 多目标联合处理与最新代码,否则训练省下的钱会被逐目标推理吃回去。
- 先验证后微调:用 Ollama 的 qwen3-vl 2B/4B 量化版在几小时内跑完 golden set,确认任务可行、错例可修,再决定要不要训练。
- 教师按模态分工:长文本与整篇归纳用 DeepSeek V4-Pro(1M 上下文),大批量图文标注用 V4-Flash(便宜且支持视觉);1 万条图文样本的教师成本约 7~10 美元量级,换成 Pro 会到 28 美元量级。
- 后训练排序固定:SFT(会不会)→ 偏好对齐 DPO/GRPO(听不听话)→ 蒸馏(贵不贵);多数业务停在 SFT,不要默认上 RL。
7.9 动手练习
练习 1:显存估算与验证
输入:一个 4B 多模态模型,序列 2048,计划 r=16 的 LoRA,24 GB 单卡。
要求:按 7.1.2 的公式算出基础权重、LoRA 状态、激活、缓冲四项估算值;再实跑 20 步,用 nvidia-smi 记录峰值显存。
验收:估算与实测误差在 20% 以内;能解释偏差来源(图像 token 数、CUDA context、碎片)。
练习 2:SAM 风格领域掩码数据构造 输入:一张 0.1 m 正射影像 + 一个包含建筑/道路的面图层(GeoPackage)。 要求:写脚本生成 512×512 瓦片与掩码,重叠 25%,最小像素阈值 64;输出 JSONL 索引。 验收:抽 20 张叠图检查,CRS 与仿射一致;掩码像素总数与矢量面积换算值误差 < 2%。
练习 3:多模态字段抽取评测
输入:100 条带标准答案的图文问答样本。
要求:按 7.3.5 的三层评测跑一遍,产出 metrics.json 与 badcase.jsonl,badcase 按错误类型归成不超过 5 类。
验收:严格可解析率 ≥ 95%(若未达标,先修 prompt 与答案格式再报告);每类错误都能给出 2 条具体样本编号。
练习 4:数据蒸馏小样
输入:500 张未标注影像 + 一个可调用的教师模型(DeepSeek V4-Flash,或内网部署的开源 VLM)。
要求:按 7.4.2 的流水线跑教师标注,解析校验后统计成功率,人工抽检 25 条;再用 7.4.5 的算式把这次实验的教师成本换算成 1 万条样本的量级。
验收:解析成功率 ≥ 95%,抽检准确率 ≥ 90%;产出 teacher_labels.jsonl 与质检报告,报告里给出失败样本的原因分布与成本估算过程。
练习 5:用 DSH 做一次失败归因
输入:一份 loss 曲线日志 + 200 条 badcase + 训练集前 200 行。
要求:让 DSH 输出归因分析,要求给出三个可能原因、各自的验证命令、预期现象。
验收:三条原因中至少一条能被你实际执行的命令证实或证伪;把该流程沉淀成 $DSH_HOME/skills 下的一份 SKILL.md。
7.10 延伸阅读
- SAM 3 官方仓库(模型、推理与微调示例)
- SAM 3.1 权重与模型卡(Hugging Face)
- SAM 3.1 发布说明(仓库内 RELEASE_SAM3p1.md)
- Hugging Face PEFT 文档(LoRA/QLoRA 参数与实现)
- Hugging Face TRL 文档(SFT、DPO 与 GRPO 训练接口)
- ms-swift 官方仓库(200+ 多模态模型的微调与推理)
- Ollama Qwen3-VL 模型库(2B/4B 量化版)
- DeepSeek API 定价页(V4-Pro / V4-Flash)