公开文章
端侧模型微调不是把 ChatGPT 变小:真正要训练的是“稳定输出格式”
端侧模型微调实战合集第一篇。用 Recipe JSON 生成模型说明:微调不是把 ChatGPT 缩小,而是让小模型稳定遵守既定的数据结构和输出规则。
端侧模型微调不是把 ChatGPT 变小:真正要训练的是“稳定输出格式”
大家好,我是唐人。
最近我在做一个端侧模型微调项目:训练一个小模型,根据菜名生成结构完整的 Recipe JSON,再转成 GGUF,放到 Android 端侧离线推理。
用户只输入:
Sweet-and-sour chilli sauce
模型不能只回答“酸甜辣椒酱怎么做”,而要交付一份能被 App 解析、校验和展示的数据:食材、份量、步骤、营养、时长、难度,一个都不能乱。
端侧微调常被理解成:
我能不能把 ChatGPT 压缩成一个小模型,然后塞进手机里?
这不是这类项目的重点。这里做的是监督微调(SFT):用一批高质量的“输入 -> 输出”样本,让模型学会一个垂直任务的固定交付方式。
说得直白一点,就是训练“稳定输出格式”:
模型每次都按同一套格式输出,字段别乱、结构别变、JSON 别炸。
什么是稳定输出格式|STABLE OUTPUT
对于业务系统,模型会写内容只是起点;更关键的是下游系统能不能接住结果。
以 Recipe JSON 为例,这份数据实际要满足一套 schema 约束,也就是字段名、类型和层级都不能随意变化:
- 必须是合法 JSON
- 顶层字段要稳定
ingredients必须是数组step_groups不能随便改名- 每个步骤都要有清晰的动作、时长或说明
- 不要输出数据库里的内部主键
- 不要在 JSON 外面多解释一段话
聊天时,模型多解释一句问题不大;但下游执行 json.loads() 时,JSON 外多出一句话就会解析失败。App 还要把同一份结果拆进详情页、购物清单和营养卡片,因此需要类似下面的结构:
{
"title": "Sweet-and-sour chilli sauce",
"servings": 4,
"total_time_minutes": 15,
"ingredients": [
{
"name": "red chilli",
"amount": "200g"
}
],
"steps": [
{
"order": 1,
"instruction": "Chop the chillies and prepare the sauce base.",
"duration_minutes": 5
}
],
"nutrition": {
"calories": 120,
"protein_g": 2
}
}
普通内容生成主要看表达质量;结构化生成要先看协议是否稳定。我现在判断一个微调项目,会先问:
这个模型能不能稳定输出我需要的结构?

为什么 Prompt 不够|PROMPT IS NOT CONTRACT
这些规则当然可以先写进 prompt。项目里也有一段 system prompt:
SYSTEM_PROMPT = (
"You are a professional recipe generation assistant. "
"Generate a complete recipe in strict JSON format. "
"Use the recipe style implied by the user request. "
"Always return the same top-level fields in the same structure. "
"Do not output explanations outside the JSON."
)
Prompt 能约束一次请求,却无法替代模型对目标字段和样本分布的学习。模型没见过 Recipe 结构,就只能按通用经验猜:ingredients 可能变成一段文本,nutrition 可能缺失,JSON 外还可能多一段说明。
继续堆 prompt、schema 和 few-shot 示例能缓解,但会占用上下文、增加 token 和推理时间。对端侧小模型,这笔成本尤其明显。
微调不替代 prompt,而是把高频且稳定的要求提前学进模型参数:
Prompt 适合表达临时意图,微调适合固化长期格式。
需求每天变化、知识频繁更新时,优先考虑 prompt 或 RAG;输出结构长期稳定、请求模式重复时,才值得微调。

我一开始想错的地方|WRONG START
这个项目早期走过一条弯路:训练时把大段 recipe metadata 喂给模型,再让它复原原来的 recipe。训练 loss 可能很好看,但真实用户只会给一个菜名,最多再补几个约束:
Create a recipe for Sweet-and-sour chilli sauce.
Difficulty: Easy.
Time limit: 15 minutes.
Diet preference: low sugar.
训练输入是“大段 metadata -> 复原 recipe”,线上输入却是“一个菜名 -> 生成 recipe”,这叫训练分布与推理分布不一致,也常被称为分布偏移(distribution shift)。模型学到的是复述,不是从有限条件生成完整菜谱。
这个教训很直接:
微调不是把你手里所有数据都塞进去,而是让训练输入尽可能接近真实输入。
于是后面我把训练范式改成了“短 prompt -> 完整 Recipe JSON”。
每条 recipe 会构造几种输入变体,比如纯菜名、明确创建指令、带饮食偏好和时长约束的指令。这样做不是为了显得数据多,而是为了让模型见过真实用户可能输入的几种方式。
用户真实会怎么问?产品真实需要模型怎么答?
先把这两个问题写清楚,再开始构造数据集。否则训练脚本跑得很顺,线上输入一换,结果就失效。
数据不是越完整越好|CLEAN TARGET
训练目标也不是字段越全越好。原始 recipe 里有 translation_id、ingredient_group_id、serving_size_id、媒体字段和状态字段;它们对数据库有意义,对生成任务没有意义。
用户只输入一个菜名,模型不可能知道真实的数据库主键。把这些字段放进 target,只会诱发模型编造内部 ID,也就是“幻觉字段”。所以 build_target 只保留模型能根据输入合理生成的语义字段。
比如保留:
- 菜名
- 语言
- 难度
- 食材
- 步骤
- 时间和份量
- 营养信息
但去掉:
- 后端主键
- 翻译 id
- 食材分组 id
- 图片链接
- 状态字段
- 和生成无关的内部元信息
这一步决定的是模型的学习目标,不是简单删字段。训练目标定义错了,loss 继续下降也不代表线上结果可用。
微调的核心不是训练代码,而是训练目标设计。
训练代码只是实现这个目标。

什么场景才值得微调|DECISION
一个任务是否值得微调,可以按下面四项检查。

第一,输出有固定格式。
输出不是随便一段话,而是固定结构、固定字段,后面还要被程序继续处理。
Recipe JSON 是这样,行业报告、工单摘要、质检记录、学习记录结构化草稿,也可能是这样。
第二,数据有样本。
需要一批稳定、干净、覆盖主要请求方式的高质量样本,让模型看到什么是正确输出。
我这个项目里,原始 recipe 是 1007 条,经过 prompt 变体后变成 4028 条训练样本。这个规模不算大,但对一个窄任务的小模型实验已经够用。
第三,结果能做字段级评测。
不能只看“回答像不像”。结构化任务至少要统计 JSON 解析成功率、必填字段完整率、字段类型正确率,以及单位和数值的合理性。这些指标才能说明微调是否真的有效。
第四,端侧有收益。
如果放云端更便宜、更稳、更容易维护,那就没必要硬上端侧。
端侧微调适合确实在意离线、隐私、延迟和边际成本的场景。
开放问答、知识每天变化、样本只有几十条或输出没有固定结构时,云端 API、RAG 或规则系统通常更合适。
完整代码在哪里|CODE
完整代码会放在 GitHub,公众号里只贴关键片段。
这篇主要对应仓库里的这些文件:
FINE_TUNING_GUIDE.md
recipe_constants.py
build_generation_dataset.py
其中 FINE_TUNING_GUIDE.md 是整个项目的实战总结。
recipe_constants.py 保存训练和推理共用的 system prompt。
build_generation_dataset.py 里最关键的是两个函数:
build_prompt_variants
build_target
前者决定模型在训练时看到什么输入。
后者决定模型到底要学什么输出。
它们分别定义训练输入和训练目标,比 LoRA 参数更早决定模型最后能交付什么。
这一篇想让你带走什么|TAKEAWAY
本文的结论很简单:端侧微调不是把小模型训练成通用助手,而是让它在一个明确任务中,稳定遵守输出格式。
下一篇开始讲数据:怎样清洗 recipe 字段,怎样把原始数据组织成模型真正学得会、线上也用得上的训练样本。
关注公众号
不错过后续文章和配套资源
扫码关注「唐人Console」,获取文章更新、配套资源和领取入口。 需要解锁时,文章页会生成专属口令。
扫码关注