公开文章

端侧模型微调不是把 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
  }
}

普通内容生成主要看表达质量;结构化生成要先看协议是否稳定。我现在判断一个微调项目,会先问:

这个模型能不能稳定输出我需要的结构?

01-why-fine-tune-on-device-model-01-stable-output

为什么 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;输出结构长期稳定、请求模式重复时,才值得微调。

01-why-fine-tune-on-device-model-02-prompt-vs-finetune

我一开始想错的地方|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_idingredient_group_idserving_size_id、媒体字段和状态字段;它们对数据库有意义,对生成任务没有意义。

用户只输入一个菜名,模型不可能知道真实的数据库主键。把这些字段放进 target,只会诱发模型编造内部 ID,也就是“幻觉字段”。所以 build_target 只保留模型能根据输入合理生成的语义字段。

比如保留:

  • 菜名
  • 语言
  • 难度
  • 食材
  • 步骤
  • 时间和份量
  • 营养信息

但去掉:

  • 后端主键
  • 翻译 id
  • 食材分组 id
  • 图片链接
  • 状态字段
  • 和生成无关的内部元信息

这一步决定的是模型的学习目标,不是简单删字段。训练目标定义错了,loss 继续下降也不代表线上结果可用。

微调的核心不是训练代码,而是训练目标设计。

训练代码只是实现这个目标。

01-why-fine-tune-on-device-model-03-clean-target

什么场景才值得微调|DECISION

一个任务是否值得微调,可以按下面四项检查。

01-why-fine-tune-on-device-model-04-decision-checklist

第一,输出有固定格式。

输出不是随便一段话,而是固定结构、固定字段,后面还要被程序继续处理。

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」,获取文章更新、配套资源和领取入口。 需要解锁时,文章页会生成专属口令。

唐人Console二维码扫码关注