《互联网产品的 AI 架构·推理性能与成本工程》6.2 蒸馏与小模型替代

学生模型在 24 条分能力样本上保留 0.9638 的质量、只花 0.25 的成本,但代码评审能力掉到 0.8312,被单能力下限拦下并自动回退。本节给出教师-学生逐条验收、分能力验收集、替代判据与四类运行时回退条件,并说明整体指标为何会掩盖单能力塌陷。

学生模型不是“小一号的教师”

「回声」训练了一个学生模型,成本只有教师的四分之一,延迟不到一半。上线前做了一次评测:整体质量保留 96%,团队认为可以替换。两周后代码评审的采纳率明显下降,客服也开始质疑摘要里的数字。

问题不在于蒸馏没用,而在于验收只看了整体。整体 96% 可能由三个能力接近满分、一个能力严重塌陷拼出来。学生模型不是教师的等比缩小版:它对长尾、难度和特定能力的损失并不均匀。

本节把替代拆成四个判断:逐条配对是否通过、分能力是否都在下限之上、关键样本是否零失败、运行时是否保留回退。文中数字均为「示例样本上的运行结果」,由确定性 mock 产生,不是生产实测。

教师-学生验收的三个层次

层次看什么不能替代什么
逐条配对同一输入上教师分与学生分的差不能只看两个模型的平均分
分能力聚合每个能力的保留率、通过率、关键失败不能用一个总保留率覆盖全部
关键样本业务上不可错的样本是否低于下限不能用平均值抵消单条事故

逐条配对是基础。教师分 0.9、学生分 0.86,差 0.04;如果只在两批样本上各算平均,难度差异会混进差值。分能力聚合回答“哪个能力不能换”,关键样本回答“有没有一票否决”。

按能力建验收集

示例用 4 个能力各 6 条,共 24 条。每条记录难度、教师分与是否关键。难度从 0 到 1 铺开,用来观察学生模型的长尾短板;只在简单样本上验收,保留率会虚高。

门槛写成常量:总数至少 24 条,每个能力至少 4 条。样本不足时结论是“证据不足”,不是“通过”。关键样本单独计数,低于 0.7 即失败,不参与平均值稀释。

验收集要版本化。学生模型换版本、教师模型换版本、评分器换版本,都必须重新跑同一批样本;否则两次验收的数字不可比。

替代判据

判据要求示例学生
总体保留率≥ 0.950.9638
单能力保留率≥ 0.90最低 0.9412
单条最大质量差≤ 0.080.0605
关键样本失败0 条0
延迟比例≤ 0.700.42
成本比例≤ 0.500.25

六个判据必须同时满足。只满足成本与延迟,说明它便宜但不够好;只满足质量,说明替代没有收益。示例学生在 24 条样本上六项全过,结论是通过。

换一个代码能力保留率只有 0.84 的学生,结果立刻变化:代码能力保留率 0.8312、总体 0.9363、单条最大差 0.1604,三条判据同时失败。它仍然便宜,但不能整体替换。

回退条件

替代不是“全量切换”,而是“默认学生、按条件回退教师”。回退判断放在请求进入前,避免先花一次学生成本,再把长输入送进装不下的窗口。

条件判据处理
能力被禁用在 disabledCapabilities 中直接走教师
置信度不足小于 0.7升级教师
输入超窗超过学生 contextWindow直接走教师
学生不健康健康检查失败整体回退

受保护能力即使指标达标也保留回退。示例里财务抽取与安全分类不在验收集内,仍被写入禁用清单。理由不是它们一定差,而是错误代价不可逆,不能用一次离线评测赌。

示例样本上的运行结果

指标合格学生弱学生
教师平均分0.88790.8879
学生平均分0.85580.8313
总体保留率0.96380.9363
逐条通过率100%75%
单条最大差0.06050.1604
关键样本失败00
结论通过拒绝

分能力看,合格学生在排序、闲聊、摘要、代码评审上的保留率分别是 0.9817、0.9717、0.9608、0.9412。代码评审最低,但仍高于 0.90 的单能力下限。弱学生只有代码评审掉到 0.8312,其他三个能力不变;这正是分能力验收要抓的情况。

实现

模块做四件事:逐条模拟学生分、按能力聚合、按六项判据决策、生成回退计划。学生缺少某个能力的保留率时直接报错,不静默按零处理。

// file: src/perf/distill.ts
// tier: L2
// verify: node --test test/perf/distill.test.ts

import { AiError } from "../core/errors.ts";

/** 蒸馏验收集必须按能力分组:总平均分会把某个能力的塌陷平均掉。 */
export interface DistillSample {
  id: string; capability: string;
  /** 难度 0 到 1,由标注或规则给出,不在蒸馏阶段重新估计。 */
  difficulty: number;
  /** 教师模型在同一条样本上的质量分,0 到 1。 */
  teacherScore: number;
  /** 业务上不可错的关键样本。 */
  critical?: boolean;
}

export interface StudentProfile {
  id: string;
  /** 能力到质量保留率的映射;缺失的能力视为未通过蒸馏。 */
  retention: Record<string, number>;
  /** 难度每上升一档额外损失的质量,模拟学生模型的长尾短板。 */
  difficultyPenalty: number;
  /** 相对教师的 P95 延迟与成本系数。 */
  latencyRatio: number; costRatio: number; contextWindow: number;
}

export interface DistillAcceptancePolicy {
  minSamples: number; minSamplesPerCapability: number;
  /** 总体保留率与任一能力保留率的下限。 */
  minRetention: number; minCapabilityRetention: number;
  /** 单条样本允许的最大质量差;关键样本低于 criticalFloor 即失败。 */
  maxSampleDrop: number; criticalFloor: number; maxCriticalFailures: number;
  maxLatencyRatio: number; maxCostRatio: number;
  /** 这些能力即使指标达标也必须保留教师回退。 */
  protectedCapabilities: string[];
}

export const DEFAULT_DISTILL_POLICY: DistillAcceptancePolicy = {
  minSamples: 24,
  minSamplesPerCapability: 4,
  minRetention: 0.95,
  minCapabilityRetention: 0.9,
  maxSampleDrop: 0.08,
  criticalFloor: 0.7,
  maxCriticalFailures: 0,
  maxLatencyRatio: 0.7,
  maxCostRatio: 0.5,
  protectedCapabilities: ["finance.extract", "safety.classify"],
};

export interface StudentSampleResult {
  id: string; capability: string; critical: boolean; difficulty: number;
  teacherScore: number; studentScore: number; drop: number; passed: boolean;
}

export interface CapabilityDistillSummary {
  capability: string; samples: number; teacherMean: number; studentMean: number;
  retention: number; passRate: number; criticalFailures: number; maxDrop: number; protected: boolean;
}

export interface DistillDecision {
  accepted: boolean;
  reasons: string[];
}

export interface DistillFallbackPlan {
  /** 直接禁用学生模型、始终走教师的能力。 */
  disabledCapabilities: string[]; minConfidence: number;
  studentContextWindow: number; reasons: Record<string, string>;
}

export interface DistillEvaluation {
  studentId: string; samples: StudentSampleResult[];
  teacherMean: number; studentMean: number; retention: number; passRate: number;
  maxDrop: number; criticalFailures: number; latencyRatio: number; costRatio: number;
  byCapability: Record<string, CapabilityDistillSummary>;
  decision: DistillDecision; fallback: DistillFallbackPlan;
}

function fail(message: string): never {
  throw new AiError("invalid-request", `distill: ${message}`);
}

function fnv1a(text: string): number {
  let hash = 0x811c9dc5;
  for (let index = 0; index < text.length; index += 1) {
    hash ^= text.charCodeAt(index);
    hash = Math.imul(hash, 0x01000193) >>> 0;
  }
  return hash;
}

function clamp(value: number, low: number, high: number): number {
  return Math.min(high, Math.max(low, value));
}

function rate(numerator: number, denominator: number): number {
  return denominator === 0 ? 0 : numerator / denominator;
}

function mean(values: readonly number[]): number {
  return rate(values.reduce((sum, value) => sum + value, 0), values.length);
}

function validateSample(sample: DistillSample): void {
  if (sample.id.trim() === "") fail("样本 id 不能为空");
  if (sample.capability.trim() === "") fail(`样本 ${sample.id} 的 capability 不能为空`);
  if (!(sample.difficulty >= 0 && sample.difficulty <= 1)) fail(`样本 ${sample.id} 的 difficulty 必须落在 [0, 1]`);
  if (!(sample.teacherScore >= 0 && sample.teacherScore <= 1)) fail(`样本 ${sample.id} 的 teacherScore 必须落在 [0, 1]`);
}

function validateProfile(profile: StudentProfile): void {
  if (profile.id.trim() === "") fail("学生模型 id 不能为空");
  for (const [capability, retention] of Object.entries(profile.retention)) {
    if (!(retention >= 0 && retention <= 1)) fail(`能力 ${capability} 的 retention 必须落在 [0, 1]`);
  }
  if (!Number.isFinite(profile.difficultyPenalty) || profile.difficultyPenalty < 0) fail("difficultyPenalty 必须是非负数");
  if (!(profile.latencyRatio > 0) || !(profile.costRatio > 0)) fail("latencyRatio 与 costRatio 必须是正数");
  if (!Number.isInteger(profile.contextWindow) || profile.contextWindow <= 0) fail("contextWindow 必须是正整数");
}

/**
 * 学生分数用确定性模型模拟:教师分 × 能力保留率 − 难度惩罚 + 样本噪声。
 * 关键点是逐条配对,而不是拿两个模型的平均分直接相减。
 */
function simulateStudent(sample: DistillSample, profile: StudentProfile, policy: DistillAcceptancePolicy): StudentSampleResult {
  const retention = profile.retention[sample.capability];
  if (retention === undefined) fail(`学生模型 ${profile.id} 没有能力 ${sample.capability} 的保留率`);
  const noise = 0.98 + (fnv1a(`${sample.id}:student`) % 5) / 100;
  const loss = profile.difficultyPenalty * sample.difficulty;
  const studentScore = clamp(sample.teacherScore * retention - loss + (noise - 1) * 0.1, 0, 1);
  const drop = sample.teacherScore - studentScore;
  return {
    id: sample.id, capability: sample.capability, critical: sample.critical === true,
    difficulty: sample.difficulty, teacherScore: sample.teacherScore, studentScore, drop,
    passed: studentScore >= sample.teacherScore - policy.maxSampleDrop,
  };
}

/** 运行时回退判据:能力被禁用、置信度不足、输入超出学生窗口、学生不健康。 */
export type FallbackReason = "capability-disabled" | "low-confidence" | "context-overflow" | "student-unhealthy";

export interface FallbackContext {
  capability: string;
  /** 学生对本次输出的置信度,0 到 1;低于阈值说明它自己也没把握。 */
  confidence: number;
  inputTokens: number;
  studentHealthy: boolean;
}

export interface FallbackDecision { fallback: boolean; reason: FallbackReason | null }

/**
 * 回退不是“学生失败再重试”,而是请求进入前就按能力与输入规模分流。
 * 这样避免先花一次学生成本,再把长输入送给一个装不下的窗口。
 */
export function decideFallback(context: FallbackContext, plan: DistillFallbackPlan): FallbackDecision {
  if (plan.disabledCapabilities.includes(context.capability)) return { fallback: true, reason: "capability-disabled" };
  if (!context.studentHealthy) return { fallback: true, reason: "student-unhealthy" };
  if (context.inputTokens > plan.studentContextWindow) return { fallback: true, reason: "context-overflow" };
  if (context.confidence < plan.minConfidence) return { fallback: true, reason: "low-confidence" };
  return { fallback: false, reason: null };
}

function decide(evaluation: Omit<DistillEvaluation, "decision" | "fallback">, policy: DistillAcceptancePolicy): DistillDecision {
  const reasons: string[] = [];
  if (evaluation.samples.length < policy.minSamples) reasons.push(`样本不足:${evaluation.samples.length}/${policy.minSamples}`);
  for (const summary of Object.values(evaluation.byCapability)) {
    if (summary.samples < policy.minSamplesPerCapability) reasons.push(`能力 ${summary.capability} 样本不足:${summary.samples}`);
    if (summary.retention < policy.minCapabilityRetention) {
      reasons.push(`能力 ${summary.capability} 保留率 ${summary.retention.toFixed(4)} < ${policy.minCapabilityRetention}`);
    }
  }
  if (evaluation.retention < policy.minRetention) {
    reasons.push(`总体保留率 ${evaluation.retention.toFixed(4)} < ${policy.minRetention}`);
  }
  if (evaluation.maxDrop > policy.maxSampleDrop) {
    reasons.push(`单条最大质量差 ${evaluation.maxDrop.toFixed(4)} > ${policy.maxSampleDrop}`);
  }
  if (evaluation.criticalFailures > policy.maxCriticalFailures) {
    reasons.push(`关键样本失败 ${evaluation.criticalFailures} > ${policy.maxCriticalFailures}`);
  }
  if (evaluation.latencyRatio > policy.maxLatencyRatio) {
    reasons.push(`延迟比例 ${evaluation.latencyRatio.toFixed(3)} > ${policy.maxLatencyRatio}`);
  }
  if (evaluation.costRatio > policy.maxCostRatio) {
    reasons.push(`成本比例 ${evaluation.costRatio.toFixed(3)} > ${policy.maxCostRatio}`);
  }
  return { accepted: reasons.length === 0, reasons };
}

function buildFallbackPlan(evaluation: Omit<DistillEvaluation, "decision" | "fallback">, profile: StudentProfile, policy: DistillAcceptancePolicy): DistillFallbackPlan {
  const disabled = new Set<string>();
  const reasons: Record<string, string> = {};
  for (const summary of Object.values(evaluation.byCapability)) {
    if (summary.retention < policy.minCapabilityRetention) {
      disabled.add(summary.capability);
      reasons[summary.capability] = `保留率 ${summary.retention.toFixed(4)} 低于下限`;
    }
    if (summary.criticalFailures > policy.maxCriticalFailures) {
      disabled.add(summary.capability);
      reasons[summary.capability] = `关键样本失败 ${summary.criticalFailures} 条`;
    }
  }
  for (const capability of policy.protectedCapabilities) {
    disabled.add(capability);
    if (reasons[capability] === undefined) reasons[capability] = "受保护能力,保留教师回退";
  }
  return { disabledCapabilities: [...disabled].sort(), minConfidence: 0.7, studentContextWindow: profile.contextWindow, reasons };
}

/**
 * 教师-学生验收:同一批样本逐条配对,按能力汇总保留率、通过率与关键失败数。
 * 学生模型缺少某个能力的保留率时直接报错,而不是悄悄按 0 处理。
 */
export function evaluateDistillation(samples: readonly DistillSample[], profile: StudentProfile, policy: DistillAcceptancePolicy = DEFAULT_DISTILL_POLICY): DistillEvaluation {
  if (samples.length === 0) fail("至少需要一条验收样本");
  validateProfile(profile);
  for (const sample of samples) validateSample(sample);

  const results = samples.map((sample) => simulateStudent(sample, profile, policy));
  const groups = new Map<string, StudentSampleResult[]>();
  for (const result of results) {
    const bucket = groups.get(result.capability) ?? [];
    bucket.push(result);
    groups.set(result.capability, bucket);
  }
  const byCapability: Record<string, CapabilityDistillSummary> = {};
  for (const [capability, bucket] of groups) {
    const teacherMean = mean(bucket.map((item) => item.teacherScore));
    const studentMean = mean(bucket.map((item) => item.studentScore));
    byCapability[capability] = {
      capability, samples: bucket.length, teacherMean, studentMean,
      retention: rate(studentMean, teacherMean),
      passRate: rate(bucket.filter((item) => item.passed).length, bucket.length),
      criticalFailures: bucket.filter((item) => item.critical && item.studentScore < policy.criticalFloor).length,
      maxDrop: Math.max(...bucket.map((item) => item.drop)),
      protected: policy.protectedCapabilities.includes(capability),
    };
  }

  const teacherMean = mean(results.map((item) => item.teacherScore));
  const studentMean = mean(results.map((item) => item.studentScore));
  const evaluation = {
    studentId: profile.id, samples: results, teacherMean, studentMean,
    retention: rate(studentMean, teacherMean),
    passRate: rate(results.filter((item) => item.passed).length, results.length),
    maxDrop: Math.max(...results.map((item) => item.drop)),
    criticalFailures: results.filter((item) => item.critical && item.studentScore < policy.criticalFloor).length,
    latencyRatio: profile.latencyRatio, costRatio: profile.costRatio, byCapability,
  };
  const fallback = buildFallbackPlan(evaluation, profile, policy);
  return { ...evaluation, decision: decide(evaluation, policy), fallback };
}

retention 是能力级常量,来自离线蒸馏结果,不是运行时推断。simulateStudent 用教师分乘保留率,再减难度惩罚与噪声;它不追求预测真实模型,只保证同一批样本上的比较可复现。

decideFallback 的顺序有讲究:先看能力是否被禁用,再看学生健康度,然后看输入是否超窗,最后才看置信度。顺序反了会出现“长输入先被算了一次置信度”的浪费。

测试与运行结果

测试覆盖合格学生通过、弱学生被单能力下限拦截、受保护能力回退、四类运行时回退、缺少能力保留率报错与关键样本失败。执行:

node --test test/perf/distill.test.ts

本机一次运行的真实输出摘要如下,耗时随机器变化:

ℹ tests 6
ℹ pass 6
ℹ fail 0

小结

蒸馏替代的核心不是“学生够不够小”,而是“哪些能力可以换、换到什么程度、什么时候必须回退”。示例里合格学生整体保留 0.9638、成本 0.25,六项判据全过;弱学生只塌陷一个能力,就被单能力下限拦下。整体指标会掩盖这种塌陷,分能力验收不会。

下一节把“先小后大”做成可计算的级联:不是所有请求都值得升级,也不是所有升级都能省钱,阈值要由误判成本与升级成本共同推出。

阅读导航:上一节:6.1 量化的精度与延迟权衡 · 下一节:6.3 模型级联与成本收益 。

常见错误与边界

  • 只看总体保留率。一个能力的塌陷会被其他能力的满分平均掉。
  • 用两批样本各算均值。难度差异混进差值,结论不可复算。
  • 在简单样本上验收。学生模型的长尾短板完全看不见。
  • 样本量不足就宣布通过。证据不足与质量达标是两件事。
  • 学生缺少能力保留率时按零处理。应该直接报错,暴露配置缺口。
  • 把受保护能力交给离线指标决定。错误不可逆时,回退不是可选项。
  • 先调用学生再判断是否回退。长输入会先浪费一次学生成本。
  • 回退只做异常重试。能力禁用、超窗与健康度都应在请求前判断。
  • 学生模型换版本不重跑验收集。两次数字不可比,替换结论失效。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查