一次越降越贵的降级
「回声」的内容摘要有一条四步降级链:主模型、更大模型、小模型、规则。主模型超时后,编排器先按 maxRuns 重试三次,每次 120 微元;三次都失败后切到“更大模型”,单次 360 微元;再失败切小模型,60 微元;最后规则成功,10 微元。
用户最终看到的是一条规则生成的摘要,质量 0.65。这一次请求的总成本是 790 微元,而规则本身只要 10 微元。单看一次,790 微元几乎可以忽略;按每分钟 600 次、持续 10 分钟计算,就是 474 万微元。降级链没有写错顺序,它缺的是成本约束。
卷一 10.2 讲降级链时,已经给了预算、质量下限和风暴保护。那一节回答的是“切到哪条通道”;这一节回答的是“切过去之后,钱为什么还会继续涨”。两个问题在单请求上看起来是一回事,在规模化下完全不是。
文中成本、耗时与质量,除非标注为示例样本上的运行结果,都是案例假设值,用来演示判断过程。
降级的四个成本问题
把降级链的成本问题拆开,可以收敛成四类。它们不是同一个 bug,修复手段也不同。
| 问题 | 表现 | 根因 | 约束 |
|---|---|---|---|
| 无限重试 | 一次请求打六次、八次 | 只有单步重试,没有全局次数 | maxAttempts + 每步 maxRuns |
| 成本倒挂 | 备用步骤比主步骤更贵 | 把“更大模型”当成降级 | 成本只能持平或下降 |
| 质量不够 | 便宜但不可用,仍然返回 | 质量下限缺失或过低 | qualityFloor |
| 没有退出 | 链跑完才停,预算早就超了 | 只按步骤耗尽退出 | 预算、时间、次数、结果四条件 |
四类问题里,成本倒挂最隐蔽。它通常不是有人故意把贵模型放进降级链,而是“主模型不稳定,换一个更大的模型更稳”这种看起来合理的判断。更大模型可能确实更稳,但它不是降级,它是第二条主路径;把它接在降级链后面,等于让每一次主模型失败都触发一次更贵的调用。
成本只能持平或下降
降级的定义是“质量下降,但仍有结果”。如果成本同时上升,这个动作就不是降级,而是升级到另一条主路径。两条路径应该分别做容量与预算,而不是用一条链把失败流量导过去。
实现上用 maxCostInflation 控制:后一步的估算成本不得超过前一步的该倍数,默认 1,表示只能持平或更便宜。计划阶段就拒绝,而不是等到执行时再看余额。计划阶段拒绝的好处是配置错误在发布前暴露;执行阶段拒绝只能减少损失,不能解释为什么链里会出现一个更贵的步骤。
“更大模型”应该怎么处理?如果它确实是产品需要的主路径,就把它做成独立的路由目标,有自己的预算、限流和灰度;如果它只是“主模型失败时的备胎”,那它必须比主模型便宜,否则备胎的存在会让故障日的成本高于正常日。
重试次数要写进链里
重试不是降级步骤,它是同一步骤的重复。很多链只写了“主模型、小模型、规则”,却在代码里给主模型加了三次自动重试。结果链上看起来只有三步,实际执行了五次,成本是估算的两倍多。
正确做法是把重试显式写进步骤:maxRuns 表示这一步最多执行几次,默认 1;maxAttempts 表示整个请求最多尝试几次,包含首次与所有重试。两个上限同时存在,因为它们的约束对象不同:maxRuns 防止单步无限重试,maxAttempts 防止所有步骤加起来失控。
成本公式因此变得可算:
requestCost = Σ (stepRuns × stepCost) + failedAttemptCost
失败尝试也要计入。超时的请求已经消耗了输入 token,提供方不会因为没返回结果就退回。把失败成本记成零,会让“重试三次”看起来免费,而它恰恰是故障日最贵的一部分。
四个退出条件
降级链必须在四种情况下停下来。少任何一个,都会出现一种特定的失控方式。
| 退出条件 | 触发 | 缺少时的失控 |
|---|---|---|
| 拿到合格结果 | 质量 ≥ qualityFloor | 低质量结果被当成成功返回 |
| 预算耗尽 | 累计成本 ≥ budgetMicros | 链继续跑,账单继续涨 |
| 时间耗尽 | 累计耗时 ≥ deadlineMs | 用户早已离开,后台还在重试 |
| 次数用尽 | 尝试数 ≥ maxAttempts | 单步或整条链无限重试 |
四个条件之外还有一个“步骤耗尽”,它是兜底而不是主路径。如果一条链经常走到步骤耗尽,说明预算、质量或次数设置得不合理,而不是“链设计得很完整”。
退出时要返回已经花掉的成本,而不是返回零。失败也是一次调用,账不能因为没拿到结果就不记。这一点与卷一 12.2 的计量口径一致:失败请求同样有 usage。
实现
下面的模块把“计划”和“执行”分开。计划阶段做四件事:按优先级排序、过滤低于质量下限的步骤、拒绝成本倒挂、计算最坏成本。执行阶段按实际返回值记账,并在四个条件中的任意一个满足时停止。
// file: src/perf/degrade-cost.ts
// tier: L1
// verify: node --test test/perf/degrade-cost.test.ts
import { AiError } from "../core/errors.ts";
export type DegradeKind = "primary" | "small-model" | "rules" | "template" | "queue";
/** 降级链上的一步:成本、时间、质量与重试次数都必须显式声明。 */
export interface CostedStep {
id: string; kind: DegradeKind;
/** 越小越先执行;顺序是产品决策,不由成本公式自动排序。 */
priority: number;
/** 入场成本估计,整数微元;实际成本以处理器返回为准。 */
estimatedMicros: number; estimatedMs: number;
/** 0..1 的质量估计,不是离线评测分数。 */
quality: number; requiresProvider: boolean;
/** 同一请求最多执行几次,默认 1;重试必须显式写出来。 */
maxRuns?: number;
}
export interface DegradeCostPolicy {
budgetMicros: number; deadlineMs: number; qualityFloor: number;
/** 单请求总尝试次数上限,包含首次与重试。 */
maxAttempts: number;
/** 后一步成本不得超过前一步的该倍数;1 表示只能持平或更便宜。 */
maxCostInflation: number;
/** 每花 1 微元至少要换回的质量;0 表示关闭该判据。 */
minQualityPerMicro: number;
}
export const DEFAULT_DEGRADE_COST: DegradeCostPolicy = {
budgetMicros: 1_000, deadlineMs: 3_000, qualityFloor: 0.6,
maxAttempts: 3, maxCostInflation: 1, minQualityPerMicro: 0,
};
export type PlanRejectReason =
| "duplicate" | "quality-floor" | "budget" | "deadline" | "cost-inversion" | "efficiency";
export interface PlanRejection { id: string; reason: PlanRejectReason; detail: string; }
export interface CostedPlan {
steps: CostedStep[]; rejected: PlanRejection[];
estimatedMicros: number;
/** 按 maxRuns 与 maxAttempts 推演的最坏成本,不超过预算。 */
worstCaseMicros: number;
}
function fail(message: string): never {
throw new AiError("invalid-request", `degrade-cost: ${message}`);
}
function finite(value: number, field: string, min = 0): number {
if (!Number.isFinite(value) || value < min) fail(`${field} must be a finite number >= ${min}`);
return value;
}
function integer(value: number, field: string, min = 0): number {
if (!Number.isInteger(value) || value < min) fail(`${field} must be an integer >= ${min}`);
return value;
}
function validateStep(step: CostedStep): void {
if (step.id.trim() === "") fail("step id must not be empty");
finite(step.priority, `${step.id}.priority`);
integer(step.estimatedMicros, `${step.id}.estimatedMicros`);
integer(step.estimatedMs, `${step.id}.estimatedMs`);
finite(step.quality, `${step.id}.quality`);
if (step.quality > 1) fail(`${step.id}.quality must be in [0, 1]`);
if (step.maxRuns !== undefined) integer(step.maxRuns, `${step.id}.maxRuns`, 1);
}
/**
* 降级链的两条硬约束:质量不能低于承诺,成本不能越降越贵。
* 估算只用于入场过滤;真正花出去的钱由执行阶段按实际返回值记账。
*/
export function planCostedChain(
steps: readonly CostedStep[],
policy: DegradeCostPolicy = DEFAULT_DEGRADE_COST,
): CostedPlan {
finite(policy.budgetMicros, "budgetMicros");
finite(policy.deadlineMs, "deadlineMs");
finite(policy.qualityFloor, "qualityFloor");
if (policy.qualityFloor > 1) fail("qualityFloor must be in [0, 1]");
integer(policy.maxAttempts, "maxAttempts", 1);
finite(policy.maxCostInflation, "maxCostInflation");
finite(policy.minQualityPerMicro, "minQualityPerMicro");
const rejected: PlanRejection[] = [];
const accepted: CostedStep[] = [];
const seen = new Set<string>();
const ordered = [...steps].sort(
(left, right) => left.priority - right.priority || left.id.localeCompare(right.id),
);
for (const step of ordered) {
if (seen.has(step.id)) {
rejected.push({ id: step.id, reason: "duplicate", detail: "duplicate step id" });
continue;
}
seen.add(step.id);
validateStep(step);
if (step.quality < policy.qualityFloor) {
rejected.push({ id: step.id, reason: "quality-floor", detail: `${step.quality} < ${policy.qualityFloor}` });
continue;
}
if (step.estimatedMicros > policy.budgetMicros) {
rejected.push({ id: step.id, reason: "budget", detail: `${step.estimatedMicros} > ${policy.budgetMicros}` });
continue;
}
if (step.estimatedMs > policy.deadlineMs) {
rejected.push({ id: step.id, reason: "deadline", detail: `${step.estimatedMs}ms > ${policy.deadlineMs}ms` });
continue;
}
const previous = accepted[accepted.length - 1];
if (previous && step.estimatedMicros > previous.estimatedMicros * policy.maxCostInflation) {
rejected.push({
id: step.id, reason: "cost-inversion",
detail: `${step.estimatedMicros} > ${previous.id} ${previous.estimatedMicros} × ${policy.maxCostInflation}`,
});
continue;
}
if (
policy.minQualityPerMicro > 0 && step.estimatedMicros > 0 &&
step.quality / step.estimatedMicros < policy.minQualityPerMicro
) {
rejected.push({ id: step.id, reason: "efficiency", detail: "quality per micros is too low" });
continue;
}
accepted.push(step);
}
let worstCaseMicros = 0;
let runs = 0;
for (const step of accepted) {
for (let run = 0; run < (step.maxRuns ?? 1) && runs < policy.maxAttempts; run += 1) {
worstCaseMicros += step.estimatedMicros;
runs += 1;
}
}
return {
steps: accepted, rejected,
estimatedMicros: accepted.reduce((sum, step) => sum + step.estimatedMicros, 0),
worstCaseMicros: Math.min(worstCaseMicros, policy.budgetMicros),
};
}
export type StepResult =
| { ok: true; quality: number; costMicros: number; elapsedMs: number; text: string }
| { ok: false; costMicros: number; elapsedMs: number; error: string };
export type CostedHandler = (step: CostedStep) => StepResult;
export type HandlerMap = Record<string, CostedHandler>;
export type StopReason = "served" | "budget" | "deadline" | "attempts" | "exhausted";
export interface CostedRecord {
stepId: string; run: number; ok: boolean; costMicros: number; elapsedMs: number; quality: number;
}
export interface CostedRun {
outcome: "served" | "shed"; usedStepId: string | null; quality: number;
costMicros: number; elapsedMs: number; attempts: number;
stopReason: StopReason; records: CostedRecord[];
}
function shed(
stopReason: StopReason, spent: number, elapsed: number, attempts: number, records: CostedRecord[],
): CostedRun {
return {
outcome: "shed", usedStepId: null, quality: 0,
costMicros: spent, elapsedMs: elapsed, attempts, stopReason, records,
};
}
/**
* 执行降级链。四个退出条件必须同时存在:拿到合格结果、钱花完、时间到、次数用尽。
* 少了任何一个,降级就会变成“无限重试”或“越降越贵”。
*/
export function runCostedChain(
plan: CostedPlan, handlers: HandlerMap, policy: DegradeCostPolicy = DEFAULT_DEGRADE_COST,
): CostedRun {
let spent = 0; let elapsed = 0; let attempts = 0;
const records: CostedRecord[] = [];
for (const step of plan.steps) {
for (let run = 1; run <= (step.maxRuns ?? 1); run += 1) {
if (attempts >= policy.maxAttempts) return shed("attempts", spent, elapsed, attempts, records);
if (spent + step.estimatedMicros > policy.budgetMicros) {
return shed("budget", spent, elapsed, attempts, records);
}
if (elapsed + step.estimatedMs > policy.deadlineMs) {
return shed("deadline", spent, elapsed, attempts, records);
}
const handler = handlers[step.id];
if (!handler) {
records.push({ stepId: step.id, run, ok: false, costMicros: 0, elapsedMs: 0, quality: 0 });
break;
}
const result = handler(step);
attempts += 1;
spent += result.costMicros;
elapsed += result.elapsedMs;
records.push({
stepId: step.id, run, ok: result.ok, costMicros: result.costMicros,
elapsedMs: result.elapsedMs, quality: result.ok ? result.quality : 0,
});
if (result.ok && result.quality >= policy.qualityFloor) {
return {
outcome: "served", usedStepId: step.id, quality: result.quality,
costMicros: spent, elapsedMs: elapsed, attempts, stopReason: "served", records,
};
}
if (spent >= policy.budgetMicros) return shed("budget", spent, elapsed, attempts, records);
if (elapsed >= policy.deadlineMs) return shed("deadline", spent, elapsed, attempts, records);
}
}
return shed("exhausted", spent, elapsed, attempts, records);
}
worstCaseMicros 不是把所有步骤的成本简单相加,而是按 maxRuns 与 maxAttempts 推演,并截断在预算以内。它回答的是“最坏情况下会花多少”,不是“预计会花多少”。两个数字都要有:前者用于预算审批,后者用于日常容量。
示例样本上的运行结果
样本使用同一条链:主模型 120 微元、更大模型 360 微元、小模型 60 微元、规则 10 微元、模板 0 微元但质量 0.4。主模型最多重试三次,其余步骤各一次。处理器脚本固定:主模型、更大模型、小模型都超时,规则成功,模板质量不达标。
| 策略 | 计划接受的步骤 | 尝试 | 成本 | 耗时 | 结果 |
|---|---|---|---|---|---|
| 宽松(预算 2000,上限 8 次,允许 6 倍涨价) | 主模型、更大模型、小模型、规则 | 6 | 790 | 2305ms | 规则,质量 0.65 |
| 有界(预算 500,上限 5 次,不允许涨价) | 主模型、小模型、规则 | 5 | 430 | 1405ms | 规则,质量 0.65 |
两种策略拿到的是同一个规则结果,成本相差 1.837 倍。差距只来自一处:有界策略在计划阶段拒绝了 360 微元的更大模型。总尝试从 6 次降到 5 次,少掉的正是这条被拒绝的步骤,不是主模型重试。主模型在两种策略里都跑了三次,因为没有它先失败,链不会走到规则。
计划阶段的拒绝记录也很有用:在有界策略下,larger 的原因是 cost-inversion,template 的原因是 quality-floor。这两条记录直接告诉配置作者,为什么某个步骤没有进入链,而不是等到故障日才发现“它怎么没生效”。
另外三条边界由测试固定:预算 400 时,主模型重试三次花掉 360 微元后停止,不再执行小模型;maxAttempts 设为 2 时,两次主模型失败后停止;deadlineMs 设为 1000 时,两次 400ms 的主模型尝试后停止。三种情况的 stopReason 分别是 budget、attempts、deadline,不会互相覆盖。
与卷一 10.2 的分工
卷一 10.2 的链模块已经支持预算过滤、质量下限和风暴保护,但它的预算是“进入链之前”的过滤,执行时只按实际消耗扣减;它没有规定成本必须单调,也没有全局尝试上限。单请求场景下,这两点通常不会出事;规模化后,它们决定了故障日的成本曲线。
可以把两节的关系记成一句话:10.2 决定“先试谁”,12.2 决定“试几次、花到哪停”。前者是顺序问题,后者是预算问题。顺序错了,用户拿到差结果;预算错了,公司拿到高账单。两个都要对。
小结
降级链的成本约束可以压成四条:成本只能持平或下降,重试次数必须显式写进步骤,质量下限必须能拦住“便宜但不可用”的结果,预算、时间、次数、结果四个退出条件必须同时存在。
示例样本上,宽松链用 6 次尝试、790 微元拿到规则结果;有界链用 5 次尝试、430 微元拿到同一个结果。差距不是来自更聪明的模型,而是来自计划阶段拒绝成本倒挂、执行阶段限制重试次数。
下一节把这两节的机制放进一次真实的事故时间线:当 429、跨供应商重试和成本倒挂同时发生,账单会以什么速度上涨,复盘又该怎样把结论变成门禁。
阅读导航:上一节:12.1 供应商限流与分流 · 下一节:12.3 一次成本失控的复盘 。
常见错误与边界
- 把更大模型接在降级链后面。它不是降级,是第二条主路径,会把失败流量导到更贵的地方。
- 只在执行阶段检查预算。配置错误应该在计划阶段暴露,执行阶段只能止损。
- 重试不写进步骤。链上三步、实际五次,估算和账单永远对不上。
- 只有单步重试上限,没有全局尝试上限。每一步都不超,加起来仍然失控。
- 失败尝试不计成本。超时已经消耗了 token,记零会让重试看起来免费。
- 质量下限设得过低。便宜但不可用的结果仍然会返回,成本花了,问题没解决。
- 只按步骤耗尽退出。预算早就超了,链还在按顺序往下走。
- 退出时返回零成本。失败也是一次调用,账不能因为没结果就不记。
- 把
worstCaseMicros当预计成本。它是预算审批用的上界,不是日常容量。 - 认为有了预算过滤就不需要成本单调。预算是总量约束,单调是方向约束,两者不能互替。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。