《互联网产品的 AI 架构·推理性能与成本工程》12.2 降级链的成本约束

「回声」的主模型超时后,降级链先重试三次,再切到单次价格三倍的更大模型,最后用规则给出答案:一次十微元的规则结果花了七百九十微元。本节把成本单调性、重试上限、质量下限与四个退出条件写进降级链,并在示例样本上对比宽松与有界两种策略的真实运行结果。

一次越降越贵的降级

「回声」的内容摘要有一条四步降级链:主模型、更大模型、小模型、规则。主模型超时后,编排器先按 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 倍涨价)主模型、更大模型、小模型、规则67902305ms规则,质量 0.65
有界(预算 500,上限 5 次,不允许涨价)主模型、小模型、规则54301405ms规则,质量 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 当预计成本。它是预算审批用的上界,不是日常容量。
  • 认为有了预算过滤就不需要成本单调。预算是总量约束,单调是方向约束,两者不能互替。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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