学生模型不是“小一号的教师”
「回声」训练了一个学生模型,成本只有教师的四分之一,延迟不到一半。上线前做了一次评测:整体质量保留 96%,团队认为可以替换。两周后代码评审的采纳率明显下降,客服也开始质疑摘要里的数字。
问题不在于蒸馏没用,而在于验收只看了整体。整体 96% 可能由三个能力接近满分、一个能力严重塌陷拼出来。学生模型不是教师的等比缩小版:它对长尾、难度和特定能力的损失并不均匀。
本节把替代拆成四个判断:逐条配对是否通过、分能力是否都在下限之上、关键样本是否零失败、运行时是否保留回退。文中数字均为「示例样本上的运行结果」,由确定性 mock 产生,不是生产实测。
教师-学生验收的三个层次
| 层次 | 看什么 | 不能替代什么 |
|---|---|---|
| 逐条配对 | 同一输入上教师分与学生分的差 | 不能只看两个模型的平均分 |
| 分能力聚合 | 每个能力的保留率、通过率、关键失败 | 不能用一个总保留率覆盖全部 |
| 关键样本 | 业务上不可错的样本是否低于下限 | 不能用平均值抵消单条事故 |
逐条配对是基础。教师分 0.9、学生分 0.86,差 0.04;如果只在两批样本上各算平均,难度差异会混进差值。分能力聚合回答“哪个能力不能换”,关键样本回答“有没有一票否决”。
按能力建验收集
示例用 4 个能力各 6 条,共 24 条。每条记录难度、教师分与是否关键。难度从 0 到 1 铺开,用来观察学生模型的长尾短板;只在简单样本上验收,保留率会虚高。
门槛写成常量:总数至少 24 条,每个能力至少 4 条。样本不足时结论是“证据不足”,不是“通过”。关键样本单独计数,低于 0.7 即失败,不参与平均值稀释。
验收集要版本化。学生模型换版本、教师模型换版本、评分器换版本,都必须重新跑同一批样本;否则两次验收的数字不可比。
替代判据
| 判据 | 要求 | 示例学生 |
|---|---|---|
| 总体保留率 | ≥ 0.95 | 0.9638 |
| 单能力保留率 | ≥ 0.90 | 最低 0.9412 |
| 单条最大质量差 | ≤ 0.08 | 0.0605 |
| 关键样本失败 | 0 条 | 0 |
| 延迟比例 | ≤ 0.70 | 0.42 |
| 成本比例 | ≤ 0.50 | 0.25 |
六个判据必须同时满足。只满足成本与延迟,说明它便宜但不够好;只满足质量,说明替代没有收益。示例学生在 24 条样本上六项全过,结论是通过。
换一个代码能力保留率只有 0.84 的学生,结果立刻变化:代码能力保留率 0.8312、总体 0.9363、单条最大差 0.1604,三条判据同时失败。它仍然便宜,但不能整体替换。
回退条件
替代不是“全量切换”,而是“默认学生、按条件回退教师”。回退判断放在请求进入前,避免先花一次学生成本,再把长输入送进装不下的窗口。
| 条件 | 判据 | 处理 |
|---|---|---|
| 能力被禁用 | 在 disabledCapabilities 中 | 直接走教师 |
| 置信度不足 | 小于 0.7 | 升级教师 |
| 输入超窗 | 超过学生 contextWindow | 直接走教师 |
| 学生不健康 | 健康检查失败 | 整体回退 |
受保护能力即使指标达标也保留回退。示例里财务抽取与安全分类不在验收集内,仍被写入禁用清单。理由不是它们一定差,而是错误代价不可逆,不能用一次离线评测赌。
示例样本上的运行结果
| 指标 | 合格学生 | 弱学生 |
|---|---|---|
| 教师平均分 | 0.8879 | 0.8879 |
| 学生平均分 | 0.8558 | 0.8313 |
| 总体保留率 | 0.9638 | 0.9363 |
| 逐条通过率 | 100% | 75% |
| 单条最大差 | 0.0605 | 0.1604 |
| 关键样本失败 | 0 | 0 |
| 结论 | 通过 | 拒绝 |
分能力看,合格学生在排序、闲聊、摘要、代码评审上的保留率分别是 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 模型级联与成本收益 。
常见错误与边界
- 只看总体保留率。一个能力的塌陷会被其他能力的满分平均掉。
- 用两批样本各算均值。难度差异混进差值,结论不可复算。
- 在简单样本上验收。学生模型的长尾短板完全看不见。
- 样本量不足就宣布通过。证据不足与质量达标是两件事。
- 学生缺少能力保留率时按零处理。应该直接报错,暴露配置缺口。
- 把受保护能力交给离线指标决定。错误不可逆时,回退不是可选项。
- 先调用学生再判断是否回退。长输入会先浪费一次学生成本。
- 回退只做异常重试。能力禁用、超窗与健康度都应在请求前判断。
- 学生模型换版本不重跑验收集。两次数字不可比,替换结论失效。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。