先小后大不是默认正确
「回声」上线了模型级联:小模型先答,置信度低再升级大模型。上线一个月,账单确实降了,但错误率也涨了。复盘发现两个问题:阈值是拍脑袋定的 0.6,升级后的请求把两个模型的成本都付了一遍,团队却只按“大模型单价”估算收益。
级联不是一条自然更优的路径,它是一笔用准确率换成本的交易。交易是否划算,取决于三个数:小模型一次多少钱、升级一次多花多少钱、一次错误答案的业务损失多少钱。三个数都写清楚,阈值才有出处。
本节用 40 条确定性样本比较三种策略:常驻小模型、常驻大模型、按盈亏平衡点级联。文中所有数字均为「示例样本上的运行结果」,成本单位为整数微元,延迟单位为毫秒。
级联的成本结构
| 路径 | 计算成本 | 延迟 | 结果来自 |
|---|---|---|---|
| 小模型直接采纳 | 小模型成本 | 小模型延迟 | 小模型 |
| 升级 | 小模型成本 + 大模型成本 | 小模型延迟 + 大模型延迟 | 大模型 |
升级路径的成本不是“大模型成本”,而是“两次调用之和”。小模型已经跑完,钱已经花了。很多级联估算只算大模型单价,结果高估收益。
延迟同理:升级请求要等两次。示例里小模型 P95 122ms、大模型 P95 600ms,全量升级的 P95 变成 706ms;按盈亏平衡点级联因为只升级一部分,P95 是 692ms。级联省的是未升级请求的钱,不是所有请求的延迟。
阈值从哪来
设小模型置信度为 p,一次错误成本为 E,升级成本为 L。采纳小模型的边际期望成本是 (1-p)×E,升级的边际成本是 L。两者相等时:
p* = 1 - L / E
示例 L=210、E=800,得到 p* = 1 - 210/800 = 0.7375。置信度低于 0.7375 时升级更划算,高于它则采纳更省。
这个公式只回答成本,不回答质量。它假设置信度是校准过的:说 0.8 的时候确实有八成正确。如果模型“自信地答错”,阈值会把错误答案留在小模型侧。示例特意保留了一条置信度 0.95 但答错的样本,用来提醒这一点。
当 L ≥ E 时,公式给出非正阈值,含义是升级永远不会在成本上回本。这时是否升级只能由质量要求决定,不能伪装成省钱。
误判成本怎么定
误判成本不是模型指标,是业务数字。可以用三类损失相加:一次人工纠正的工时、一次退款或补偿的金额、一次用户流失的期望损失。三者都换成微元,写进配置并注明来源。
示例取 800 微元,约为一次大模型调用成本的 3.8 倍。这个比例决定了级联是否有空间:错误成本越高,越值得升级;错误成本低于升级成本时,常驻大模型反而更省。
误判成本也不能一刀切。代码评审的错误可能是线上故障,闲聊的错误可能只是一句不准确。按能力分别配置 E,比全局一个数字更接近真实。
示例样本上的运行结果
40 条样本,小模型成本 30 微元、大模型成本 210 微元、错误成本 800 微元,盈亏平衡点 0.7375。
| 策略 | 准确率 | 升级率 | 计算成本 | 错误成本 | 总成本 | 平均成本 | P95 延迟 |
|---|---|---|---|---|---|---|---|
| 常驻小模型 | 32.5% | 0% | 1200 | 21600 | 22800 | 570.00 | 122ms |
| 常驻大模型 | 100% | — | 8400 | 0 | 8400 | 210.00 | 600ms |
| 盈亏平衡级联 | 97.5% | 67.5% | 6870 | 800 | 7670 | 191.75 | 692ms |
级联比常驻大模型省 730 微元,准确率低 2.5 个百分点;比常驻小模型省 15130 微元,准确率高 65 个百分点。它落在中间,不是无条件最优。
按 0.01 步长扫描 101 个阈值,纯成本最优点是 0.71,总成本 7460,比盈亏平衡点再省 210。原因是那条 0.72 置信度的样本恰好答对,而公式只按概率算,不知道这一条的真值。扫描结果会过拟合验收集,不能直接当成线上阈值。
如果业务要求准确率不低于 99%,可选阈值跳到 0.96 以上:所有请求都升级,总成本 9600,比常驻大模型还贵 1200,因为每个请求都先付了一次小模型成本。这说明“级联满足质量下限”不等于“级联比常驻大模型便宜”。
实现
模块包含盈亏平衡推导、单阈值模拟、三策略对比与阈值扫描。所有成本都是整数微元,错误成本按“错误条数 × 单次错误成本”结算。
// file: src/perf/cascade.ts
// tier: L2
// verify: node --test test/perf/cascade.test.ts
import { AiError } from "../core/errors.ts";
import { percentile } from "../observe/baseline.ts";
/** 一条级联样本同时记录两个模型的成本、延迟与正确性,阈值才能被逐条复算。 */
export interface CascadeSample {
id: string;
/** 难度 0 到 1,用于解释置信度分布,不参与阈值计算。 */
difficulty: number;
/** 小模型对这条样本的置信度,0 到 1。 */
smallConfidence: number;
smallCorrect: boolean; largeCorrect: boolean;
smallCostMicros: number; largeCostMicros: number;
smallLatencyMs: number; largeLatencyMs: number;
}
export interface CascadePolicy {
/** 低于该置信度就升级;可由 breakEvenConfidence 推出,也可按质量要求手动收紧。 */
confidenceThreshold: number;
/** 一次错误答案的业务成本,用整数微元表示。 */
errorCostMicros: number;
}
export interface ThresholdPoint {
threshold: number; escalationRate: number; accuracy: number;
computeCostMicros: number; errorCostMicros: number; totalCostMicros: number;
avgCostMicros: number; p95LatencyMs: number;
}
export interface CascadeResult extends ThresholdPoint {
/** 小模型被采纳的样本数。 */
accepted: number; escalated: number; errors: number;
}
export interface BreakEven {
/** 小模型置信度高于该值时,采纳它比升级更省。 */
threshold: number;
/** 升级成本高于一次错误成本时,纯经济口径下永远不值得升级。 */
escalationPaysOff: boolean; explanation: string;
}
function fail(message: string): never {
throw new AiError("invalid-request", `cascade: ${message}`);
}
function rate(numerator: number, denominator: number): number {
return denominator === 0 ? 0 : numerator / denominator;
}
function validateSample(sample: CascadeSample): void {
if (sample.id.trim() === "") fail("样本 id 不能为空");
if (!(sample.smallConfidence >= 0 && sample.smallConfidence <= 1)) {
fail(`样本 ${sample.id} 的 smallConfidence 必须落在 [0, 1]`);
}
if (!(sample.difficulty >= 0 && sample.difficulty <= 1)) {
fail(`样本 ${sample.id} 的 difficulty 必须落在 [0, 1]`);
}
for (const field of ["smallCostMicros", "largeCostMicros"] as const) {
if (!Number.isInteger(sample[field]) || sample[field] < 0) {
fail(`样本 ${sample.id} 的 ${field} 必须是非负整数`);
}
}
for (const field of ["smallLatencyMs", "largeLatencyMs"] as const) {
if (!Number.isFinite(sample[field]) || sample[field] < 0) {
fail(`样本 ${sample.id} 的 ${field} 必须是非负数`);
}
}
}
/**
* 盈亏平衡推导只看边际成本:
* 采纳小模型 = 小模型成本 + (1-p) × 错误成本;升级 = 小模型成本 + 大模型成本。
* 两者相等时 p* = 1 - 大模型成本 / 错误成本。
*/
export function breakEvenConfidence(
smallCostMicros: number,
largeCostMicros: number,
errorCostMicros: number,
): BreakEven {
if (!Number.isInteger(smallCostMicros) || smallCostMicros < 0) fail("smallCostMicros 必须是非负整数");
if (!Number.isInteger(largeCostMicros) || largeCostMicros < 0) fail("largeCostMicros 必须是非负整数");
if (!Number.isInteger(errorCostMicros) || errorCostMicros <= 0) fail("errorCostMicros 必须是正整数");
if (largeCostMicros >= errorCostMicros) {
return { threshold: 0, escalationPaysOff: false,
explanation: `升级成本 ${largeCostMicros} 不低于一次错误成本 ${errorCostMicros},纯经济口径下不应升级` };
}
const threshold = 1 - largeCostMicros / errorCostMicros;
return { threshold, escalationPaysOff: true,
explanation: `置信度低于 ${threshold.toFixed(4)} 时,期望错误成本高于升级成本` };
}
/** 按给定阈值跑一遍级联:小模型先答,低于阈值才升级。 */
export function simulateCascade(samples: readonly CascadeSample[], policy: CascadePolicy): CascadeResult {
if (samples.length === 0) fail("至少需要一条样本");
if (!(policy.confidenceThreshold >= 0 && policy.confidenceThreshold <= 1)) fail("confidenceThreshold 必须落在 [0, 1]");
if (!Number.isInteger(policy.errorCostMicros) || policy.errorCostMicros <= 0) fail("errorCostMicros 必须是正整数");
for (const sample of samples) validateSample(sample);
let accepted = 0;
let escalated = 0;
let errors = 0;
let computeCostMicros = 0;
const latencies: number[] = [];
for (const sample of samples) {
const useSmall = sample.smallConfidence >= policy.confidenceThreshold;
const correct = useSmall ? sample.smallCorrect : sample.largeCorrect;
if (useSmall) accepted += 1;
else escalated += 1;
computeCostMicros += sample.smallCostMicros + (useSmall ? 0 : sample.largeCostMicros);
latencies.push(sample.smallLatencyMs + (useSmall ? 0 : sample.largeLatencyMs));
if (!correct) errors += 1;
}
const errorCostMicros = errors * policy.errorCostMicros;
const totalCostMicros = computeCostMicros + errorCostMicros;
return {
threshold: policy.confidenceThreshold, accepted, escalated, errors,
escalationRate: rate(escalated, samples.length),
accuracy: rate(samples.length - errors, samples.length),
computeCostMicros, errorCostMicros, totalCostMicros,
avgCostMicros: rate(totalCostMicros, samples.length), p95LatencyMs: percentile(latencies, 0.95),
};
}
export interface StrategyComparison {
alwaysSmall: ThresholdPoint; alwaysLarge: ThresholdPoint; cascade: ThresholdPoint;
breakEven: BreakEven;
/** 级联相对常驻大模型节省的总成本,可为负。 */
savingVsAlwaysLargeMicros: number;
}
/** 常驻策略不经过阈值判断,单独计算,避免用 1.0001 这类哨兵值污染阈值口径。 */
function simulateFixed(samples: readonly CascadeSample[], useSmall: boolean, errorCostMicros: number): CascadeResult {
let errors = 0;
let computeCostMicros = 0;
const latencies: number[] = [];
for (const sample of samples) {
const correct = useSmall ? sample.smallCorrect : sample.largeCorrect;
computeCostMicros += useSmall ? sample.smallCostMicros : sample.largeCostMicros;
latencies.push(useSmall ? sample.smallLatencyMs : sample.largeLatencyMs);
if (!correct) errors += 1;
}
const errorCost = errors * errorCostMicros;
return {
threshold: useSmall ? 0 : 1, accepted: useSmall ? samples.length : 0,
escalated: useSmall ? 0 : samples.length, errors, escalationRate: useSmall ? 0 : 1,
accuracy: rate(samples.length - errors, samples.length),
computeCostMicros, errorCostMicros: errorCost, totalCostMicros: computeCostMicros + errorCost,
avgCostMicros: rate(computeCostMicros + errorCost, samples.length), p95LatencyMs: percentile(latencies, 0.95),
};
}
/** 三种策略放在同一批样本上比较:常驻小、常驻大、按盈亏平衡点级联。 */
export function compareStrategies(samples: readonly CascadeSample[], errorCostMicros: number): StrategyComparison {
if (samples.length === 0) fail("至少需要一条样本");
for (const sample of samples) validateSample(sample);
const smallCost = Math.round(samples.reduce((sum, item) => sum + item.smallCostMicros, 0) / samples.length);
const largeCost = Math.round(samples.reduce((sum, item) => sum + item.largeCostMicros, 0) / samples.length);
const breakEven = breakEvenConfidence(smallCost, largeCost, errorCostMicros);
const alwaysSmall = simulateFixed(samples, true, errorCostMicros);
const alwaysLarge = simulateFixed(samples, false, errorCostMicros);
const cascade = simulateCascade(samples, {
confidenceThreshold: breakEven.escalationPaysOff ? breakEven.threshold : 1,
errorCostMicros,
});
const pick = (result: CascadeResult): ThresholdPoint => ({
threshold: result.threshold,
escalationRate: result.escalationRate,
accuracy: result.accuracy,
computeCostMicros: result.computeCostMicros,
errorCostMicros: result.errorCostMicros,
totalCostMicros: result.totalCostMicros,
avgCostMicros: result.avgCostMicros,
p95LatencyMs: result.p95LatencyMs,
});
return {
alwaysSmall: pick(alwaysSmall),
alwaysLarge: pick(alwaysLarge),
cascade: pick(cascade),
breakEven,
savingVsAlwaysLargeMicros: alwaysLarge.totalCostMicros - cascade.totalCostMicros,
};
}
export interface ThresholdSweep {
points: ThresholdPoint[]; best: ThresholdPoint;
/** 同时满足准确率下限的最低成本点;没有满足的点时为 null。 */
qualityFloor: ThresholdPoint | null;
}
/**
* 阈值扫描不是“挑一个最省钱的点”,而是把所有候选点的成本、准确率与升级率摊开,
* 再由业务决定质量下限。返回的 best 只代表纯成本最优。
*/
export function sweepThresholds(
samples: readonly CascadeSample[],
errorCostMicros: number,
options: { step?: number; minAccuracy?: number } = {},
): ThresholdSweep {
if (samples.length === 0) fail("至少需要一条样本");
const step = options.step ?? 0.05;
if (!(step > 0 && step <= 0.5)) fail("step 必须落在 (0, 0.5]");
const points: ThresholdPoint[] = [];
for (let threshold = 0; threshold <= 1 + 1e-9; threshold += step) {
const rounded = Math.min(1, Math.round(threshold * 1000) / 1000);
const result = simulateCascade(samples, { confidenceThreshold: rounded, errorCostMicros });
points.push({
threshold: rounded,
escalationRate: result.escalationRate,
accuracy: result.accuracy,
computeCostMicros: result.computeCostMicros,
errorCostMicros: result.errorCostMicros,
totalCostMicros: result.totalCostMicros,
avgCostMicros: result.avgCostMicros,
p95LatencyMs: result.p95LatencyMs,
});
}
const best = points.reduce((left, right) => {
if (right.totalCostMicros !== left.totalCostMicros) {
return right.totalCostMicros < left.totalCostMicros ? right : left;
}
return right.accuracy > left.accuracy ? right : left;
});
const minAccuracy = options.minAccuracy;
const qualified =
minAccuracy === undefined
? []
: points.filter((point) => point.accuracy >= minAccuracy);
const qualityFloor = qualified.length === 0
? null
: qualified.reduce((left, right) =>
right.totalCostMicros < left.totalCostMicros ? right : left,
);
return { points, best, qualityFloor };
}
breakEvenConfidence 只做边际比较,不预测准确率。simulateCascade 在升级时累加两次成本与两段延迟,避免把升级请求算成单次调用。compareStrategies 的常驻策略单独计算,不用 1.0001 之类的哨兵阈值。
扫描函数返回全部候选点,而不是只返回一个“最佳阈值”。最佳点只代表样本内的成本最优;是否采用,还要看准确率下限、P95 延迟与线上校准。
测试与运行结果
测试覆盖盈亏平衡公式、升级不划算的边界、阈值 0 与 1 的常驻等价、级联总成本、三策略对比、101 点扫描与 99% 准确率下限。执行:
node --test test/perf/cascade.test.ts
本机一次运行的真实输出摘要如下,耗时随机器变化:
ℹ tests 8
ℹ pass 8
ℹ fail 0
上线顺序
第一步是离线配对:同一批线上请求同时送小模型与大模型,记录置信度、正确性与两次成本,先把校准曲线画出来。第二步用盈亏平衡点做影子级联,不影响用户,只比较预估账单。第三步按能力放量,每个能力单独配置错误成本与质量下限。
回滚条件提前写:准确率低于下限、P95 延迟超过交互预算、或级联总成本高于常驻大模型,任何一条触发就关闭级联。级联的收益很薄,一次错误的成本参数就能把它变成负收益。
小结
级联的收益来自“大部分请求不需要大模型”,成本来自“升级请求付两次钱”和“错误答案的业务损失”。阈值不该拍脑袋,而应由 1 - 升级成本 / 错误成本 推出;示例样本上的盈亏平衡点是 0.7375,级联总成本 7670,比常驻大模型省 730,准确率低 2.5 个百分点。
级联也不是终点。下一章把在线与离线的边界讲清楚:有些工作根本不必在请求路径上做,离线批处理能把单位成本压到在线级联做不到的水平。
阅读导航:上一节:6.2 蒸馏与小模型替代 · 下一节:7.1 在线与离线的边界 。
常见错误与边界
- 把升级成本只算成大模型一次调用。小模型已经花掉的钱不会退回。
- 用单一错误成本覆盖所有能力。代码故障与闲聊错误的代价不在一个量级。
- 阈值凭直觉设定。没有成本公式,无法解释为什么是 0.6 或 0.8。
- 假设置信度天然校准。自信答错的样本会把错误留在小模型侧。
- 只看平均成本,不看 P95 延迟。升级请求要等两次调用。
- 用扫描出的最低成本点直接上线。它可能过拟合验收集。
- 把“满足准确率下限”当成“比常驻大模型便宜”。全量升级仍付两次成本。
- 不设回滚条件。错误成本估高一倍,级联就从省钱变成烧钱。
- 把示例 mock 数字写成生产账单。所有结论都要在真实分布上复核。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。