ACP 大模型应用开发之用蒸馏让小模型掌握专业能力实战上篇

代码策略:能落在 Java 工程里的部分(教师 API 调用、标注校验、评测指标、数据加载)用 JDK 17 + Jackson + HttpClient 实现;LoRA 训练、ms-swift、ModelScope 本地推理 等目前仍以 Python 生态 为主。

写在前面:我为什么要整理这篇

做 AI 应用落地时,我习惯先把链路拆清楚:RAG 查政策、Agent 调工具,问答能力有了,并不等于线上就省心。真实用户很少「一条消息一个意图」——报销和出差挤在一句话里、VPN 连不上却不说急不急,系统得先读懂路由,然后才是 RAG 或工具。

我一开始也和大厂做法一样:每条消息都走一遍大模型做请求理解。效果稳,但账单不好看——很多流量在「还没开始回答问题」时就烧掉了 API 额度。自然想到:能不能用更小、更便宜的模型专门干「读懂 + 结构化」?

试过直接把 Qwen3-0.6B 这类小模型顶上去,很快撞墙:JSON 经常格式错误,多意图识别也不稳。后来把精力放在蒸馏上:用大模型当老师生成标注,再训小模型。下面是我梳理后的完整笔记,希望对在 Java 栈做 AI 应用、又要控推理成本的朋友有点帮助。

员工提问(真实口语) 系统需要先理解什么
我下周三要出差去杭州,顺便催一下上个月的报销 两个意图、目的地、时间 → 多意图拆分
VPN 连不上急死了 IT 支持、高紧急度 → 优先直连处理
新来的实习生需要开通哪些系统权限 入职 + 权限 → 多半走 RAG 查制度

蒸馏在这里的角色很直白:不是让小模型凭空学会业务,而是把大模型已经做对的判断,沉淀成训练数据,再「搬」给小模型。

这篇文章会写什么

整篇按「能落地、能复现」组织,主要包含:

  • 任务怎么定:把自然语言提问收成 JSON 工单(意图、部门、紧急度、实体、路由)。
  • 蒸馏在干什么:和微调差在哪、黑盒数据合成为什么适合 API 教师。
  • 数据从哪来:教师标注、Schema 过滤、ms-swift 训练格式——附 Java / Python 代码。
  • 怎么证明有效:JSON 合规率、意图 F1、路由准确率;基座 0.6B vs 教师模型的 Baseline 对比。
  • Java 侧怎么接:调用、校验、评测;训练与推理仍用 Python 栈时的分工。
  • 上线怎么想:小模型扛高频理解、失败时 fallback 大模型或转人工。

动手前:环境怎么配

我本地复现时的分工是:Java 只跑教师调用 + 校验 + 评测;要跑通「合成数据 → 基座评测 → LoRA」再开 GPU + Python。下面是我实际用到的依赖清单。

GPU / Python 训练环境(可选)

  • 云 GPU:阿里云 PAI-DSW、AutoDL、本地工作站等均可;显存建议 ≥ 24GB(0.6B LoRA 训练相对轻量)。
  • 镜像:带 PyTorch 2.3+、CUDA 12.x 的 Python 3.11 环境即可;若用 Qwen3,注意 transformers>=4.51,<5.0

加载 API Key 与训练依赖

Python(GPU / 实验环境)

import os
from openai import OpenAI

# 建议通过环境变量注入,勿写入代码仓库
# export DASHSCOPE_API_KEY=你的密钥
client = OpenAI(
    api_key=os.getenv("DASHSCOPE_API_KEY"),
    base_url=os.getenv(
        "DASHSCOPE_BASE_URL",
        "https://dashscope.aliyuncs.com/compatible-mode/v1"
    )
)

print(f'API Key 已加载:{os.environ["DASHSCOPE_API_KEY"][:5]+"*"*5}')
# 安装训练专用依赖(基础依赖已由安装脚本完成,此处仅安装 LoRA 训练所需的额外组件)
#
# 版本约束说明:
#   transformers >=4.51  — Qwen3 架构支持(4.45 没有 qwen3,加载模型会报 architecture not recognized)
#   transformers <5.0    — 5.x 要求 PyTorch>=2.4,需与当前镜像中的 PyTorch 版本匹配
#   ms-swift >=3.0       — Qwen3 训练支持(2.x 不支持 Qwen3,且参数名与 4.x 不同)
#   modelscope >=1.20    — 旧版 modelscope 加载 Qwen3 时会委托给 transformers 失败
%pip install -i https://mirrors.aliyun.com/pypi/simple/ 'transformers>=4.51,<5.0' 'ms-swift>=3.0' accelerate autoawq autoawq-kernels 'modelscope>=1.20.0'

Java(本地 / Spring 服务侧:API 调用与评测)

和日常 Spring 项目一样,引入 Jackson,用环境变量注入密钥即可:

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.17.2</version>
</dependency>
export DASHSCOPE_API_KEY=你的密钥
# 可选:export DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode

下载基座模型

从 ModelScope 下载 Qwen3-0.6B 到本地,用于基座模型评测与后续 LoRA 训练

说明:模型下载与本地 GPU 推理目前依赖 ModelScope / PyTorch 生态,保留 Python 命令

# 下载 Qwen3-0.6B 模型(约 1.2GB,下载约 1-2 分钟)
!modelscope download --model Qwen/Qwen3-0.6B --local_dir /mnt/workspace/model

一、任务设计:把「读懂问题」单独拎出来

1.1 为什么值得单独做一个「请求理解」层

在我做过的企业答疑架构里,RAG 和 Agent 各管一块;但真正上线后,第一条消息进来,往往要先回答:该直连答复、走 RAG、拆成多段,还是转人工?

  • 简单的事实问题(「公司 WiFi 密码是多少」)→ 直接回答
  • 需要查政策的问题(「年假可以跨年使用吗」)→ RAG
  • 包含多个独立请求(「请假 + 催报销」)→ 拆分后分别处理
  • 「我要投诉我的主管」→ 转人工

这个判断过程就是「请求理解」:把一句自然语言,转成结构化的工单信息。

同仓库里的 MediaOpsIntentGateApplicationService 是类似思路:规则先拦风险,再由 LLM 输出 JSON 决定放行 / 澄清 / 阻断——和上文这套「请求理解 + 路由」是同一类问题,只是业务字段不同。

1.2 结构化工单的定义

这类场景里,我习惯把工单收成下面五个字段(也是后文蒸馏与评测的锚点):

{
  "intents": ["差旅申请", "报销催办"],
  "department": "行政",
  "urgency": "中",
  "entities": {"出差日期": "下周三", "目的地": "杭州"},
  "route": "multi_intent_split"
}
字段 类型 取值范围 说明
intents 字符串数组 入职办理、考勤请假、差旅申请、报销催办、年假查询、IT支持、权限申请、制度咨询 可多选
department 字符串 HR、行政、IT、财务 主要负责部门
urgency 字符串 高、中、低 根据时间敏感度和措辞判断
entities 对象 自由键值对 从提问中提取的关键参数(日期、金额、人名等)
route 字符串 direct_answer、rag_query、multi_intent_split、escalate 路由决策

1.3 这个任务为什么适合蒸馏

「请求理解」有三个特点,决定了它适合用小模型来做:

  1. 输出格式固定(JSON schema 确定,不需要开放式生成)
  2. 不依赖外部知识(判断意图和紧急度只看输入文本本身)
  3. 每条请求都要执行(高频调用链路,成本敏感)

大模型已经能做好这件事了。蒸馏的目标是:把大模型在这个任务上的判断能力,迁移给一个推理成本更低的小模型。


二、蒸馏原理:和微调差在哪

2.1 我理解的「蒸馏」一句话

微调更像「教会模型做一件事」,数据多半来自人工标注。蒸馏则是「让强模型当老师」,用它的输出当训练集,再训小模型——训练流程仍是 SFT,差别主要在数据从哪来

微调 蒸馏
数据来源 人工标注 教师模型生成
数据成本 高(需要领域专家) 低(API 调用费)
数据规模 通常有限 可大规模生成
质量上限 取决于标注者水平 取决于教师模型能力

我实践下来的结论是:不必先把教师模型训到极致,直接用当前够强的 API(我用的 qwen3.6-plus)批量打标,把精力放在过滤坏样本、覆盖场景上,性价比更高。

蒸馏在生产降本中已有大量应用:

  • 阿里巴巴发布的 DistilQwen2.5 系列(0.5B–7B)通过多个大模型协作做教师蒸馏,蒸馏后的轻量模型在指令遵循能力上显著优于同参数量的原始基座。
  • DeepSeek-R1 将 671B 参数模型的 80 万条推理轨迹蒸馏到 7B 学生模型,使其在数学推理上超越了 LLaMA-2 70B。

2.2 三条蒸馏路径

根据「复制」的内容不同,蒸馏可以分为三种方式:

路径一:数据合成蒸馏 / 黑盒蒸馏(我这次采用的主线)

教师只通过 API 出结果,拿不到权重,所以叫「黑盒」。对商业 API 场景最省事:生成标注 → 过滤 → SFT 小模型,整条链路不依赖教师权重。

路径二:知识蒸馏 KD / 白盒蒸馏(概念了解)

学生不仅学习教师的最终输出,还学习教师输出每个 token 时的概率分布(软标签)。比如教师在判断部门时,内部可能给出「HR 70%、行政 25%、IT 5%」这样的概率分布,最终输出选概率最高的「HR」,但这个分布本身包含了「HR 和行政有点像」这种隐含知识(暗知识),比只看最终答案信息量更大。

白盒蒸馏就是让学生去拟合教师的概率分布,而不仅仅学最终答案。之所以叫「白盒」,是因为需要访问教师模型的权重和内部状态。商业 API 拿不到权重,用不了这种方式,但如果教师模型开源,白盒蒸馏的精度通常更高。

路径三:推理压缩(概念了解)

学生学习教师的思维链(Chain-of-Thought)轨迹,而非仅学最终答案。DeepSeek-R1 就是用这种方式,将 800K 条推理轨迹蒸馏给 7B 学生模型,使其在推理任务上超越了 70B 基座模型。适合需要多步推理的任务。

路径 别名 需要什么 适用场景
数据合成蒸馏 黑盒蒸馏 只需教师 API 结构化任务、教师是商业 API
知识蒸馏 KD 白盒蒸馏 需要教师权重 开源教师、需要更高精度
推理压缩 需要教师推理输出 多步推理任务

2.3 为什么选数据合成蒸馏

落到「请求理解」这个具体任务上,我的选型理由是:

  • 教师(qwen3.6-plus)只有 API,没有权重 → 白盒 KD 不现实
  • 任务是单步判断(从输入直接到结构化输出),不需要多步推理 → CoT 轨迹没有必要
  • 结构化输出可以用 JSON schema 自动校验质量 → 数据质量有保障

因此,数据合成蒸馏(黑盒蒸馏) 是最合适的选择。


三、数据合成:教师打标 + 过滤(附代码)

蒸馏里我最花时间的是数据,不是训练命令。我自己的流程固定三步:

  1. 造一批风格多样的员工提问(含多意图、口语、含糊句)
  2. 教师模型按 Schema 出 JSON 标注
  3. 程序过滤不合格样本,再转成 ms-swift 的 messages 格式

完整跑一遍合成会消耗少量 DashScope 额度。若只想先看评测与训练,可直接用我整理的示例 train.jsonl(约 1400 条)跳过生成环节。

下面按步骤写,代码可直接拷到工程里改。

三、0 用 Qwen Code 合成训练数据--可以跳过也可以保留

以下部分内容主要是希望能够提供一个具体如何运用大模型来生成具体的问答场景数据以及格式化的范本,我是觉得挺重要的,方便去做蒸馏。

如果你在 Section 0 中安装了 Qwen Code,现在可以让它帮你完成数据合成的全过程——生成提问、教师标注、质量过滤、格式化保存,一气呵成。如果你不想使用 Qwen Code,可以跳过这一步,直接使用 resources/4_1/train.jsonl 中的预生成数据,阅读下方代码理解流程。

将以下提示词复制到 Qwen Code 中执行:

我正在做模型蒸馏的训练数据合成。请帮我完成以下任务,每步完成后向我展示结果。

环境信息

  • 工作目录:cd /mnt/workspace/aliyun_acp_learning/大模型ACP认证教程/C4_交付上线
  • 虚拟环境:source /mnt/workspace/llm_learn/bin/activate
  • API Key 已配置在环境变量 DASHSCOPE_API_KEY 中(通过 config/load_key.py 加载)
  • 教师模型:qwen3.6-plus(通过 DashScope 兼容 OpenAI 接口调用)
  • API 地址:https://dashscope.aliyuncs.com/compatible-mode/v1

任务背景

我们要为一个"员工请求理解"任务生成蒸馏训练数据。任务是把员工的自然语言提问转成结构化 JSON 工单,包含 5 个字段:intents(意图)、department(部门)、urgency(紧急度)、entities(实体)、route(路由)。

第 1 步:加载 API Key

运行以下 Python 代码加载 API Key 并初始化 client:

import os, json, re, sys
os.chdir(os.path.join(os.path.dirname(os.path.abspath('')), 'course_core'))
sys.path.insert(0, os.getcwd())

from config.load_key import load_key
load_key()
from openai import OpenAI
client = OpenAI(
    api_key=os.getenv("DASHSCOPE_API_KEY"),
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
)
print(f"API Key 已加载:{os.environ['DASHSCOPE_API_KEY'][:5]}*****")

第 2 步:用大模型生成 30 条员工提问

调用 qwen3.6-plus 生成多样化的员工提问。要求:

  • 覆盖 8 种意图:入职办理、考勤请假、差旅申请、报销催办、年假查询、IT支持、权限申请、制度咨询
  • 混合正式、口语化、含糊等表达风格
  • 约 30% 为多意图问题(一句话包含两个请求)
  • 部分问题包含具体日期、金额、人名、地点

生成完后展示前 10 条给我看看。

第 3 步:用教师模型标注

对每条提问调用 qwen3.6-plus 进行标注,使用以下 system prompt:

"""
你是一个请求理解助手。分析用户的提问,提取结构化工单信息。

严格按以下 JSON 格式输出,不要输出任何其他内容:

{
"intents": ["意图"],
"department": "部门",
"urgency": "紧急度",
"entities": {},
"route": "路由"
}

字段取值范围:

  • intents(可多选):入职办理、考勤请假、差旅申请、报销催办、年假查询、IT支持、权限申请、制度咨询
  • department:HR、行政、IT、财务
  • urgency:高、中、低
  • entities:从提问中提取的关键参数(日期、金额、人名、地点、系统名称等),如无则为空对象 {}
  • route:direct_answer(简单事实问题)、rag_query(需要查阅政策文档)、multi_intent_split(包含多个独立意图)、escalate(需要人工介入)
    """

调用时设 temperature=0.1。标注完后展示 3 条标注结果给我看看。

第 4 步:质量过滤

对每条标注做三项检查:

  1. JSON 能否正常解析
  2. 5 个必填字段(intents、department、urgency、entities、route)是否齐全
  3. 每个字段的值是否在上面 system prompt 定义的取值范围内

过滤掉不合格的样本,告诉我总共生成了多少条、过滤了多少条、保留了多少条。

第 5 步:格式化并保存

将合格样本转成 ms-swift 的 messages 训练格式:

{"messages": [{"role": "system", "content": "system prompt 内容"}, {"role": "user", "content": "员工提问"}, {"role": "assistant", "content": "JSON 标注结果"}]}

每条一行,保存到 resources/4_1/my_train.jsonl。保存完后告诉我文件路径和样本数量。

Qwen Code 生成的数据保存在 resources/4_1/my_train.jsonl。后续训练时,如果你想用自己生成的数据,将训练命令中的 train.jsonl 替换为 my_train.jsonl 即可。课程预生成的 train.jsonl(约 1400 条)覆盖更全面,建议正式训练时仍使用预生成数据。

这是我实际执行后的结果数据过程:

第一步:

第二步结果:

第三步:

第五步:

保存成功了。

无论你是否使用了 Qwen Code,下面我们来详细拆解数据合成的每一步,理解背后的设计思路。直接使用 resources/4_1/train.jsonl 中的预生成数据,阅读下方代码理解流程。

3.1 教师模型的 System Prompt

教师模型(qwen3.6-plus)需要一个精确的 system prompt 来规范输出格式。这个 prompt 定义了 JSON schema 和每个字段的取值范围。

Python

SYSTEM_PROMPT = """你是一个请求理解助手。分析用户的提问,提取结构化工单信息。

严格按以下 JSON 格式输出,不要输出任何其他内容:

{
  "intents": ["意图"],
  "department": "部门",
  "urgency": "紧急度",
  "entities": {},
  "route": "路由"
}

字段取值范围:
- intents(可多选):入职办理、考勤请假、差旅申请、报销催办、年假查询、IT支持、权限申请、制度咨询
- department:HR、行政、IT、财务
- urgency:高、中、低
- entities:从提问中提取的关键参数(日期、金额、人名、地点、系统名称等),如无则为空对象 {}
- route:direct_answer(简单事实问题)、rag_query(需要查阅政策文档)、multi_intent_split(包含多个独立意图)、escalate(需要人工介入)"""

print("System Prompt 已定义")
print(f"Prompt 长度:{len(SYSTEM_PROMPT)} 字符")

3.2 生成多样化的员工提问

数据多样性直接决定蒸馏上限。生成提问时,我一般用多组 prompt 模板,尽量覆盖:

  • 覆盖 8 种意图类型 × 4 个部门 的场景组合
  • 混合正式请求、口语化、含糊省略等多种表达风格
  • 30% 为多意图问题(一句话包含两个请求)
  • 部分问题包含具体日期、金额、人名、地点等实体信息

Python(数据生成脚本仍在 Python 侧)

query_prompt_example = """请生成15条不同员工向公司内部答疑机器人提出的问题。要求:
1. 覆盖场景:入职办理、考勤请假、差旅申请、报销催办
2. 表达风格多样:正式、口语化、含糊、简短、详细
3. 约30%为多意图问题(一句话包含两个请求)
4. 部分问题包含具体的日期、金额、人名、地点
每条问题单独一行,不要编号,不要其他说明。
注意:场景中涉及的系统、工具、平台只能使用虚拟名称或阿里系产品(如钉钉、阿里云等),不要提及任何非阿里旗下的真实公司或产品名称。"""

# 实际使用了 5 个不同侧重的模板,覆盖所有意图类型和表达风格
# 完整的数据生成脚本见 resources/4_1/generate_data.py

print("生成模板示例:")
print(query_prompt_example)

3.3 教师标注

对每条生成的提问,用教师模型(qwen3.6-plus)生成结构化标注。标注时使用 system prompt 严格约束输出格式,temperature=0.1 降低随机性以确保标注一致性,每条标注都经过 JSON schema 校验后才能进入训练集。

Python

def teacher_label(query, system_prompt=SYSTEM_PROMPT):
    """用教师模型标注一条查询"""
    response = client.chat.completions.create(
        model="qwen3.6-plus",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": query}
        ],
        temperature=0.1
    )
    return response.choices[0].message.content

test_query = "我下周三要出差去杭州,顺便催一下上个月的报销"
result = teacher_label(test_query)
print(f"输入:{test_query}")
print("教师标注:")
print(json.dumps(json.loads(result), ensure_ascii=False, indent=2))

3.4 质量过滤

教师模型不是 100% 完美的。即使是 qwen3.6-plus 这样的强模型,在批量标注几百条数据时也会出错:JSON 写到一半截断、漏掉某个字段、把部门写成不存在的值。这些「坏样本」一旦进训练集,小模型会把错误一并学走。所以我会固定加一层自动化 Schema 校验,不合格的直接丢弃。

以下是实际生产中最常见的三类问题:

问题类型 示例 处理方式
JSON 格式错误 输出被 markdown 包裹、括号不匹配 提取 JSON + 解析失败则丢弃
必填字段缺失 缺少 routeentities 字段齐全性检查
取值越界 department 写成「法务部」 枚举值校验

过滤用的是「结果校验」(检查输出是否符合 schema),而非「过程校验」(检查推理过程是否合理),对于结构化提取任务这种方式简单有效。

AI 辅助编程与传统编程的对比

维度 AI 编程助手(自然语言驱动) Python / Java 代码(手写逻辑)
上手难度 低,用自然语言描述规则 中,需要编写校验代码
灵活性 高,可随时调整规则描述 中,需要修改代码逻辑
可维护性 适合快速原型和实验 适合生产环境长期维护
执行效率 依赖 AI 的代码生成能力 直接执行,效率确定
适用场景 规则频繁变化、快速验证 规则稳定、需要版本控制

💡 提示:规则尚未稳定时,可先用自然语言驱动 AI 编程助手快速试过滤逻辑;规则确定后,再固化为 Java / Python 代码纳入版本管理。两种方式互补,不是二选一。

过滤代码实现

Python
VALID_INTENTS = {"入职办理", "考勤请假", "差旅申请", "报销催办", "年假查询", "IT支持", "权限申请", "制度咨询"}
VALID_DEPARTMENTS = {"HR", "行政", "IT", "财务"}
VALID_URGENCY = {"高", "中", "低"}
VALID_ROUTES = {"direct_answer", "rag_query", "multi_intent_split", "escalate"}

def validate_label(label_str):
    """校验教师标注是否合格"""
    try:
        if '```' in label_str:
            match = re.search(r'```(?:json)?\s*(.*?)\s*```', label_str, re.DOTALL)
            if match:
                label_str = match.group(1)
        label = json.loads(label_str.strip())
    except json.JSONDecodeError:
        return None

    required = ["intents", "department", "urgency", "entities", "route"]
    if not all(k in label for k in required):
        return None
    if not isinstance(label["intents"], list) or len(label["intents"]) == 0:
        return None
    if not all(i in VALID_INTENTS for i in label["intents"]):
        return None
    if label["department"] not in VALID_DEPARTMENTS:
        return None
    if label["urgency"] not in VALID_URGENCY:
        return None
    if label["route"] not in VALID_ROUTES:
        return None
    if not isinstance(label["entities"], dict):
        return None
    return label

3.5 查看训练数据样本

合成并过滤完成后,建议先抽样查看数据内容与分布,避免带着系统性偏差进入训练。

Python

train_data = []
with open("resources/4_1/train.jsonl", "r", encoding="utf-8") as f:
    for line in f:
        train_data.append(json.loads(line))

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))

print(f"训练集:{len(train_data)} 条")
print(f"测试集:{len(test_data)} 条")
# ... 意图 / 路由 / 部门分布统计逻辑同上

3.6 训练数据格式

训练数据采用 ms-swift 框架要求的 messages 格式,每条样本包含三个角色的对话:

{
  "messages": [
    {"role": "system", "content": "你是一个请求理解助手..."},
    {"role": "user", "content": "我下周三要出差去杭州..."},
    {"role": "assistant", "content": "{\"intents\": [\"差旅申请\"], ...}"}
  ]
}

模型训练时,system 和 user 部分作为输入,assistant 部分作为期望输出。模型学习的是:给定 system prompt 和用户提问,生成正确的结构化 JSON

说明:LoRA 训练使用 ms-swift CLI,依赖 GPU + Python 环境;参数与命令因版本而异,以 ms-swift 官方文档 为准。


四、怎么评测:先立尺子,再训模型

训练前我会先跑一轮 Baseline:同一套测试集上,看教师「天花板」和基座 0.6B「起点」差多少。指标不用 BLEU 那套,而是围绕结构化 JSON 设计。

4.1 评测指标

原因很实际:输出是固定 Schema 的 JSON,字符串像不像意义不大,能不能解析、字段对不对、路由对不对才决定能不能上线。

指标 含义 为什么重要
JSON 合规率 输出能否解析为合法 JSON 且包含所有必填字段 格式不对就没法接入下游路由
intents F1 意图识别的 F1 值 意图是多选的,F1 同时反映「找全了」和「没找错」
route 准确率 路由决策是否正确 路由错了,后续处理模块全跑偏

评测时我按语义集合算分,不按字符串逐字匹配。例如意图同时包含「差旅申请」和「报销催办」,顺序不同也算对。

Java(评测核心逻辑)

import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

public final class RequestUnderstandingEvalSupport {

    public record Prediction(String raw, Map<String, Object> parsed) {
    }

    public record EvalResult(int jsonValid, double intentF1, int routeAccuracy, int total) {
    }

    public static double computeIntentF1(List<String> predIntents, List<String> trueIntents) {
        Set<String> pred = new HashSet<>(predIntents == null ? List.of() : predIntents);
        Set<String> truth = new HashSet<>(trueIntents == null ? List.of() : trueIntents);
        if (pred.isEmpty() && truth.isEmpty()) {
            return 1.0;
        }
        if (pred.isEmpty() || truth.isEmpty()) {
            return 0.0;
        }
        Set<String> inter = new HashSet<>(pred);
        inter.retainAll(truth);
        double precision = inter.size() * 1.0 / pred.size();
        double recall = inter.size() * 1.0 / truth.size();
        if (precision + recall == 0) {
            return 0.0;
        }
        return 2 * precision * recall / (precision + recall);
    }

    @SuppressWarnings("unchecked")
    public static EvalResult evaluatePredictions(List<Prediction> predictions, List<Map<String, Object>> groundTruths) {
        int n = groundTruths.size();
        int jsonValid = 0;
        double intentF1Sum = 0;
        int routeCorrect = 0;

        for (int i = 0; i < n; i++) {
            Map<String, Object> parsed = predictions.get(i).parsed();
            if (parsed == null) {
                continue;
            }
            jsonValid++;
            List<String> predIntents = (List<String>) parsed.getOrDefault("intents", List.of());
            List<String> trueIntents = (List<String>) groundTruths.get(i).getOrDefault("intents", List.of());
            intentF1Sum += computeIntentF1(predIntents, trueIntents);
            if (parsed.get("route") != null && parsed.get("route").equals(groundTruths.get(i).get("route"))) {
                routeCorrect++;
            }
        }
        return new EvalResult(jsonValid, n > 0 ? intentF1Sum / n : 0, routeCorrect, n);
    }

    public static void printEvalResults(EvalResult r, String modelName) {
        System.out.printf("%n=== %s 评测结果 ===%n", modelName);
        System.out.printf("  JSON 合规率: %d/%d (%.0f%%)%n",
            r.jsonValid(), r.total(), r.jsonValid() * 100.0 / r.total());
        System.out.printf("  intents F1: %.1f%%%n", r.intentF1() * 100);
        System.out.printf("  route 准确率: %d/%d (%.0f%%)%n",
            r.routeAccuracy(), r.total(), r.routeAccuracy() * 100.0 / r.total());
    }

    public static String renderComparisonMarkdown(List<EvalResult> results, List<String> modelNames) {
        StringBuilder sb = new StringBuilder();
        sb.append("| 指标 | ").append(String.join(" | ", modelNames)).append(" |\n");
        sb.append("| --- | ").append(" --- |".repeat(modelNames.size())).append("\n");
        sb.append(row("JSON 合规率", results, r -> "%d/%d (%.0f%%)".formatted(
            r.jsonValid(), r.total(), r.jsonValid() * 100.0 / r.total())));
        sb.append(row("intents F1", results, r -> "%.1f%%".formatted(r.intentF1() * 100)));
        sb.append(row("route 准确率", results, r -> "%d/%d (%.0f%%)".formatted(
            r.routeAccuracy(), r.total(), r.routeAccuracy() * 100.0 / r.total())));
        return sb.toString();
    }

    private interface RowFormatter {
        String format(EvalResult r);
    }

    private static String row(String label, List<EvalResult> results, RowFormatter fmt) {
        StringBuilder sb = new StringBuilder("| ").append(label).append(" | ");
        for (EvalResult r : results) {
            sb.append(fmt.format(r)).append(" | ");
        }
        return sb.append("\n").toString();
    }
}

4.2 教师模型评测(qwen3.6-plus)

先看看教师模型在测试集上的表现,这是蒸馏的「能力上限」。

Java

// 伪代码结构:读取 test_30.jsonl → 逐条 teacherLabel → validateLabel → evaluatePredictions
public class TeacherEvalDemo {
    public static void main(String[] args) throws Exception {
        // List<TestItem> testData = loadTestJsonl("resources/4_1/test_30.jsonl");
        // List<Prediction> preds = testData.stream()
        //     .map(item -> new Prediction(raw, LabelValidator.validateLabel(raw)))
        //     .toList();
        // EvalResult r = RequestUnderstandingEvalSupport.evaluatePredictions(preds, groundTruths);
        // RequestUnderstandingEvalSupport.printEvalResults(r, "教师模型(qwen3.6-plus)");
    }
}

4.3 基座小模型评测(Qwen3-0.6B)

再看看未经训练的小模型的表现,这是蒸馏前的「起点」。基座模型需事先下载到本地(如 /mnt/workspace/model),下方通过 ModelScope + PyTorch 加载并进行本地推理。

说明:JVM 侧目前没有与 ms-swift / ModelScope 等价的「一行代码加载 Qwen3 并 generate」成熟方案;本地 0.6B 推理保留 Python

实现思路(与 §4.2 教师评测对齐)

  1. 加载:从本地目录读入 Qwen3-0.6B 权重与分词器(需 GPU,见 §「下载基座模型」)。
  2. 逐条推理:只读测试集里的 query,拼上与训练/教师一致的 SYSTEM_PROMPT不把 ground_truth 喂给模型。
  3. 解码:用 chat 模板生成回复,截掉 prompt 部分,得到纯 assistant 文本。
  4. 校验validate_label 判断是否为合法 JSON 工单(与 §3.4 同一套规则)。
  5. 打分evaluate_predictions 对比 ground_truth,得到 JSON 合规率、意图 F1、route 准确率。

test_data 来自 test_30.jsonl(每行 query + ground_truth),与 train.jsonl 不重叠,用于衡量「未蒸馏前」的真实水平。

from modelscope import AutoModelForCausalLM, AutoTokenizer
import torch

# ---------- 1. 加载基座模型 ----------
# model_path:ModelScope 下载目录(见前文 modelscope download)
model_path = '/mnt/workspace/model'
# trust_remote_code=True:Qwen 系列自定义 tokenizer / chat 模板在仓库代码里
tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
base_model = AutoModelForCausalLM.from_pretrained(
    model_path,
    dtype=torch.float16,      # 半精度,省显存;0.6B 单卡足够
    device_map='auto',        # 自动把层放到可用 GPU(无 GPU 会走 CPU,很慢)
    trust_remote_code=True,
)

# ---------- 2. 在测试集上逐条生成 ----------
# test_data:已加载的 list[dict],元素形如 {"query": "...", "ground_truth": {...}}
print("正在评测基座模型(Qwen3-0.6B 本地推理)...")
base_preds = []  # 收集每条:原始输出 raw + 校验后的 parsed(失败则为 None)

for i, item in enumerate(test_data):
    # 与教师标注、后续 SFT 训练保持同一对话结构
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": item["query"]},  # 仅用用户提问,不用 ground_truth
    ]
    # apply_chat_template:转成 Qwen3 认识的「带角色标记」的 prompt 字符串
    # add_generation_prompt=True:末尾加上 assistant 起始符,模型从该位置续写
    # enable_thinking=False:关闭 Qwen3 思考链,避免冗长推理段干扰 JSON
    text = tokenizer.apply_chat_template(
        messages, tokenize=False, add_generation_prompt=True,
        enable_thinking=False,
    )
    inputs = tokenizer(text, return_tensors="pt").to(base_model.device)

    with torch.no_grad():  # 推理阶段不算梯度,省显存
        outputs = base_model.generate(
            **inputs,
            max_new_tokens=512,   # 结构化 JSON 一般够用
            temperature=0.1,      # 低温,输出更稳定(与教师 API temperature 一致)
            do_sample=True,       # temperature>0 时需采样;要完全确定性可改 do_sample=False
        )

    # 只解码「新生成」的 token,去掉输入 prompt,避免把用户问题混进结果
    response = tokenizer.decode(
        outputs[0][inputs.input_ids.shape[1]:],
        skip_special_tokens=True,
    )

    # §3.4 同一套 Schema 校验;不通过则 parsed=None,JSON 合规率计为失败
    parsed = validate_label(response)
    base_preds.append({"raw": response, "parsed": parsed})

    status = "OK" if parsed else "FAIL"
    print(f"  [{i+1}/{len(test_data)}] {status} | {item['query'][:50]}...")

# ---------- 3. 与标准答案对比,输出 Baseline 指标 ----------
# ground_truth:测试集里预先固化的标签(教师打标或抽检修订),模型推理时不可见
ground_truths = [item["ground_truth"] for item in test_data]
base_results = evaluate_predictions(base_preds, ground_truths)
print_eval_results(base_results, "基座模型(Qwen3-0.6B)")

基座模型典型失败案例

评测分数是一个总体概括,更有价值的是看看基座模型具体错在哪里:

if 'base_preds' in dir():
    print("--- 基座模型典型失败案例 ---")
    shown = 0
    for i, (pred, truth) in enumerate(zip(base_preds, ground_truths)):
        if pred["parsed"] is None and shown < 3:
            print(f"\n[案例 {shown+1}] JSON 解析失败")
            print(f"  提问:{test_data[i]['query']}")
            print(f"  模型输出(前200字):{pred['raw'][:200]}")
            shown += 1

常见失败模式:

  • JSON 格式崩坏:缺括号、多余说明文字、字段错位
  • 意图识别错误:多意图漏识别、部门路由错误
  • 即使在格式正确的输出中,意图 F1 和 route 准确率仍然很低

4.4 Baseline 对比

把基座模型和教师模型的结果放在一起对比。这两组数据分别代表蒸馏的起点天花板

指标 基座模型 (Qwen3-0.6B) 教师模型 (qwen3.6-plus)
JSON 合规率 15/31 (48%) 31/31 (100%)
intents F1 23.7% 83.9%
route 准确率 5/31 (16%) 28/31 (90%)

上表是我当时在同一测试集上跑出的参考值;换业务域或测试集后,数字会变,但「基座 JSON 崩、教师 稳」的形态通常类似。

结论摘要

  • 基座模型的大部分输出无法解析为合法 JSON,格式崩坏是最突出的问题。
  • 即使在格式正确的输出中,意图识别和路由准确率也很低,意味着绝大多数请求会被分发到错误的处理模块。
  • 教师模型在各项指标上都表现稳定,说明大模型已经很好地掌握了这个任务。

蒸馏的目标:让 0.6B 模型从「几乎不可用」提升到「接近教师水平」。即使无法完全追平教师,只要在各项指标上有大幅提升,就已经具备生产部署价值。


五、训练和微调:其实是同一条路

Baseline 跑完,数据也过滤好了,后面就是常规的 SFT / LoRA——很多人问:这不就是微调吗?

是,也不完全是。 流程一样(数据 → 基座 → 训练),没错,蒸馏在训练流程上和微调几乎完全相同,都是准备训练数据、加载基座模型、执行 SFT 训练。唯一的区别在于数据来源:微调用人工标注的数据,蒸馏用教师模型生成的数据。

主要是为了强调数据来自教师 API,而不是人工一条条标。我接下来的计划是:

  • ms-swift LoRA 训练(Python + GPU)
  • 蒸馏后模型复评(同一套 JSON 合规率 / F1 / 路由准确率)
  • 成本收益估算(API 单价 × 日调用量 vs 小模型托管成本)

接下来的蒸馏步骤实现,我补充在下篇了,关注我更新最新的内容哦。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容