《互联网产品的 AI 架构·模型接入与能力边界》10.2 降级策略:小模型、规则、模板、排队

「回声」的主模型超时后自动切到小模型,摘要却变成了复读用户问题。本节把降级看成一条有预算、有质量下限、有风暴保护的链:小模型、规则、模板、排队各自分工,选择顺序由产品写死,并讨论预算、代价与降级风暴的规避,最后用可运行的 TypeScript 实现选链与逐步执行。

一次降级到小模型之后

「回声」的内容摘要有一条看起来很合理的降级链:主模型超时后切小模型,小模型再失败就用模板。上线当天主模型出现超时,链路按预期切到了小模型。

用户看到的摘要是这样的:

摘要:我的订单什么时候到?订单什么时候到?什么时候到?

这不是摘要,是把用户的问题复读了三遍。排查后发现三个原因叠在一起。第一,小模型的提示沿用了主模型的自由生成模板,没有针对「摘要」任务收紧输出格式。第二,链路把「小模型可用」当成了「小模型适合这个任务」,质量下限只写了 0.5,实际上没有拦住任何东西。第三,降级过程对用户完全不可见,用户不知道这是备用通道的输出。

这次事故说明,降级不是「再找一个模型」。降级是一条链,链上的每一步都有质量、延迟、成本和依赖,必须按预算筛选、按质量下限拦截、按顺序执行,还要防止所有请求同时涌向同一个备胎。

文中出现的质量分、耗时和成本,除非标注为本机运行输出,都是案例假设值,用来演示判断过程,不是任何真实产品的实测。

四种兜底手段的分工

把常见的兜底手段放在一起比较,差异比想象中大。

手段质量延迟成本依赖主要风险
小模型中高中中模型提供方与主模型同源,故障时一起不可用
规则中低极低自身服务覆盖面窄,边界情况容易误判
模板低极低零自身服务输出僵硬,容易被用户识别为敷衍
排队高高中模型提供方与队列用户等不起,结果过期后没有意义

四种手段不是互相替代,而是覆盖不同的失败场景。小模型适合「主模型太慢或太贵,但任务本身仍是生成式」;规则适合「判断逻辑可以枚举」;模板适合「只需要一个可读的占位结果」;排队适合「结果可以晚到,但不能没有」。

一个常见的错误是把小模型当成万能备胎。如果小模型和主模型来自同一家提供方、同一个区域,主模型故障时小模型很可能一起故障。降级链的冗余度不是由步骤数量决定的,而是由依赖是否真的独立决定的。第 2 章讨论过多模型并存的四个来源,到了降级链里,至少要保证确定性步骤(规则、模板)不依赖提供方。

选择顺序是产品决策

链的顺序不应该由公式算出来。质量、延迟、成本之间的换算,取决于产品这一刻更在意什么,这是产品决策,不是工程优化。

「回声」三条链的顺序如下:

能力链顺序理由
内容摘要主模型 → 小模型 → 规则 → 模板摘要是可弱能力,优先保质量,最后才接受占位
内容审核主模型 → 规则 → 阻塞审核不能靠模板放行,规则失败就必须停
私信回复主模型 → 小模型 → 隐藏回复是可选能力,质量不够时宁可不出现

顺序写死还有一个好处:演练时可以逐段验证。如果顺序是动态计算的,故障时很难说清「为什么这次走了规则而不是模板」,复盘会退化成猜测。

链上的每一步都要显式标注 requiresProvider。这不是给执行器看的,而是给风暴保护看的:当连续失败达到阈值,依赖提供方的步骤先被摘掉,只留下规则和模板。否则所有请求会在同一秒涌向小模型,把小模型也打挂。

预算与代价

降级链必须在请求的剩余预算内工作。预算至少有三个维度:剩余时间、剩余成本、可接受的最低质量。

  • 剩余时间来自端到端 deadline。排队步骤如果预计等待超过剩余时间,就不该进入链。
  • 剩余成本来自租户或请求的费用上限。小模型便宜,但不是免费;降级风暴时成本反而可能上升。
  • 最低质量是产品承诺。低于这条线的结果,不如不给。

估算值只用于入场过滤,实际消耗以步骤返回为准。这个区分很重要:估算可能偏乐观,如果执行时按估算扣预算,后面的步骤会看到一个虚假的剩余额度。下面的实现里,estimatedMs 和 estimatedMicros 决定「能不能进链」,StepOutput 里的实际值决定「扣多少」。

还有一个边界:链耗尽时要失败关闭,并带上已经花掉的成本。失败也是一次调用,账不能因为没拿到结果就不记。第 8 章的成本归因在这里同样适用。

避免降级风暴

降级风暴的典型过程是这样的:主模型超时,所有请求切到小模型;小模型瞬间被打到限流;限流触发重试;重试把流量再放大一倍;最后连规则服务都被拖垮。

保护手段有三层:

  1. 连续失败计数。达到阈值后进入风暴模式,摘掉所有依赖提供方的步骤。
  2. 确定性步骤优先。风暴模式下只保留规则和模板,它们不依赖外部模型,容量可预测。
  3. 成功即复位。任意一次成功都把计数清零,避免系统在恢复后长期停留在保守模式。

阈值不能拍脑袋。它应该来自「备用模型能承接多少额外流量」的容量测算,而不是「三次看起来差不多」。如果备用模型的余量只有 20%,阈值和切流比例必须一起设计,否则保护本身也会变成故障源。

把链写成代码

下面的实现分成三层:selectChain 负责排序、预算过滤和风暴保护;runChain 负责逐步执行、失败转移和真实成本记账;recordOutcome 负责维护连续失败计数。

selectChain 不执行任何步骤,因此可以在请求进入前单独测试。runChain 的处理器按 kind 注册,主模型、小模型、规则、模板可以各自实现,执行器不关心它们背后是 HTTP 还是本地函数。

// file: src/degrade/chain.ts
// tier: L1
// verify: node --test test/degrade/chain.test.ts

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

/** 降级链上允许出现的五种角色,顺序由编排者显式声明。 */
export type StrategyKind = "primary" | "small-model" | "rules" | "template" | "queue";

export interface StrategyStep {
  id: string;
  kind: StrategyKind;
  /** 越小越先执行;质量与成本的取舍是产品决策,不用公式代替。 */
  priority: number;
  /** 案例估计值,用于进入链之前的预算过滤。 */
  estimatedMs: number;
  estimatedMicros: number;
  /** 0..1 的案例估计值,不是离线评测分数。 */
  quality: number;
  /** 是否依赖模型提供方;降级风暴时这类步骤先被摘掉。 */
  requiresProvider: boolean;
}

export interface DegradeBudget {
  remainingMs: number;
  remainingMicros: number;
  /** 低于该质量的结果不返回给用户。 */
  minQuality: number;
}

export interface StormState {
  recentFailures: number;
  /** 连续失败达到该值进入风暴模式;0 表示关闭保护。 */
  threshold: number;
}

export type ChainMode = "normal" | "storm";

export interface ChainDecision {
  mode: ChainMode;
  chosen: StrategyStep[];
  skipped: { id: string; reason: string }[];
}

export interface StepOutput {
  text: string;
  costMicros: number;
  elapsedMs: number;
  quality: number;
}

export type StepHandler = (step: StrategyStep) => Promise<StepOutput> | StepOutput;

export interface ChainRun {
  text: string;
  usedStepId: string;
  kind: StrategyKind;
  degraded: boolean;
  quality: number;
  spentMicros: number;
  spentMs: number;
  failures: { id: string; reason: string }[];
}

function validateStep(step: StrategyStep): void {
  if (step.id.trim() === "") {
    throw new AiError("invalid-request", "降级步骤必须有 id");
  }
  if (step.quality < 0 || step.quality > 1) {
    throw new AiError("invalid-request", `${step.id}: quality 必须在 0..1 之间`);
  }
  if (step.estimatedMs < 0 || step.estimatedMicros < 0) {
    throw new AiError("invalid-request", `${step.id}: 预算估计不能为负`);
  }
}

/** 成功或失败都要更新风暴计数,失败清零意味着模型已经恢复。 */
export function recordOutcome(state: StormState, ok: boolean): StormState {
  return { ...state, recentFailures: ok ? 0 : state.recentFailures + 1 };
}

/**
 * 选出本次可用的步骤序列。
 * 顺序来自 priority;过滤来自预算、质量下限与提供方健康度;
 * 风暴模式下额外摘掉依赖提供方的步骤,避免所有请求同时涌向备用模型。
 */
export function selectChain(
  steps: StrategyStep[],
  budget: DegradeBudget,
  storm: StormState = { recentFailures: 0, threshold: 0 },
): ChainDecision {
  const seen = new Set<string>();
  for (const step of steps) {
    validateStep(step);
    if (seen.has(step.id)) {
      throw new AiError("invalid-request", `${step.id}: 降级步骤重复`);
    }
    seen.add(step.id);
  }
  if (budget.minQuality < 0 || budget.minQuality > 1) {
    throw new AiError("invalid-request", "minQuality 必须在 0..1 之间");
  }

  const mode: ChainMode =
    storm.threshold > 0 && storm.recentFailures >= storm.threshold ? "storm" : "normal";
  const skipped: { id: string; reason: string }[] = [];
  const chosen: StrategyStep[] = [];
  const ordered = [...steps].sort((a, b) => a.priority - b.priority || a.id.localeCompare(b.id));

  for (const step of ordered) {
    if (mode === "storm" && step.requiresProvider) {
      skipped.push({ id: step.id, reason: "storm" });
      continue;
    }
    if (step.estimatedMs > budget.remainingMs) {
      skipped.push({ id: step.id, reason: "deadline" });
      continue;
    }
    if (step.estimatedMicros > budget.remainingMicros) {
      skipped.push({ id: step.id, reason: "cost" });
      continue;
    }
    if (step.quality < budget.minQuality) {
      skipped.push({ id: step.id, reason: "quality" });
      continue;
    }
    chosen.push(step);
  }

  return { mode, chosen, skipped };
}

/**
 * 按顺序执行降级链。估算只用于入场过滤,实际消耗以返回值为准并继续扣减,
 * 这样后面步骤看到的是真实剩余预算,而不是乐观估计。
 */
export async function runChain(
  chain: StrategyStep[],
  handlers: Partial<Record<StrategyKind, StepHandler>>,
  budget: DegradeBudget,
): Promise<ChainRun> {
  let remainingMs = budget.remainingMs;
  let remainingMicros = budget.remainingMicros;
  let spentMs = 0;
  let spentMicros = 0;
  const failures: { id: string; reason: string }[] = [];

  for (const step of chain) {
    const handler = handlers[step.kind];
    if (!handler) {
      failures.push({ id: step.id, reason: "no-handler" });
      continue;
    }
    if (step.estimatedMs > remainingMs) {
      failures.push({ id: step.id, reason: "deadline" });
      continue;
    }
    if (step.estimatedMicros > remainingMicros) {
      failures.push({ id: step.id, reason: "cost" });
      continue;
    }
    try {
      const output = await handler(step);
      spentMs += output.elapsedMs;
      spentMicros += output.costMicros;
      remainingMs -= output.elapsedMs;
      remainingMicros -= output.costMicros;
      if (output.quality < budget.minQuality) {
        failures.push({ id: step.id, reason: "quality" });
        continue;
      }
      return {
        text: output.text,
        usedStepId: step.id,
        kind: step.kind,
        degraded: step.kind !== "primary",
        quality: output.quality,
        spentMicros,
        spentMs,
        failures,
      };
    } catch (error) {
      failures.push({
        id: step.id,
        reason: error instanceof Error ? error.message : String(error),
      });
    }
  }

  throw new AiError("unavailable", "降级链已耗尽", {
    usage: { inputTokens: 0, outputTokens: 0, costMicros: spentMicros },
  });
}

有两个设计选择值得说明。

第一,排序只认 priority,不按质量或成本做加权。加权公式看起来更「智能」,但它把产品取舍藏进了一个系数里,评审时没人说得清为什么小模型排在规则前面。显式优先级至少能被讨论和修改。

第二,风暴模式在预算过滤之前判断。一个依赖提供方的步骤即使预算充足,也会在风暴模式下被摘掉;反过来,确定性步骤不会因为风暴被误伤。顺序错了,保护会失效。

测试与真实输出

测试覆盖九种情况:排序、时间与成本过滤、质量下限、风暴模式、成功复位、非法输入、失败转移、实际质量不达标、链耗尽。在实验工程根目录执行:

node --test test/degrade/chain.test.ts

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

✔ 按 priority 排序,不看输入顺序 (0.979666ms)
✔ 预算不足时跳过昂贵步骤,而不是让整条链失败 (0.11625ms)
✔ 质量下限会拦住模板兜底 (0.091375ms)
✔ 风暴模式摘掉依赖提供方的步骤 (0.104167ms)
✔ 成功一次即退出风暴模式,失败继续累加 (0.082916ms)
✔ 重复步骤与非法质量在选链前被拒绝 (0.292167ms)
✔ 执行链按顺序失败转移,并记录真实消耗 (0.203375ms)
✔ 实际质量低于下限时继续尝试下一步 (0.096625ms)
✔ 整条链耗尽时抛出 unavailable 并带上已花成本 (0.178792ms)
ℹ tests 9
ℹ pass 9
ℹ fail 0

「执行链按顺序失败转移」这条测试里,主模型抛错、小模型成功,最终 spentMicros 是 80,spentMs 是 500。这两个数字来自小模型的实际返回,不是估算值。如果执行器按估算扣预算,后面的步骤会看到一个偏小的剩余额度,可能在还有钱的时候提前放弃。

「实际质量低于下限时继续尝试下一步」验证了另一条边界:处理器成功返回不等于结果可用。质量不达标应该记一次失败并继续,而不是把低质量结果直接交给用户。

一次模板兜底把摘要变成复读

回到开头的事故。团队最初的修复方案是「给小模型加一段更严格的提示」,但演练时发现另一个问题:模板兜底同样会复读。

当时的模板是 摘要:{user_question},本意是给一个占位。用户问「我的订单什么时候到」,模板输出「摘要:我的订单什么时候到」,看起来像模型在重复问题。模板没有错,错在它被用在了「用户会认真读」的位置上。

最终修复分三层:

  • 小模型换成针对摘要任务微调过的版本,输出格式收紧为三句话以内。
  • 模板改成固定话术,例如「摘要生成中,请先查看原文」,不再拼接用户问题。
  • 质量下限从 0.5 提到 0.6,模板的质量估计是 0.4,因此不会在正常链里被选中,只在明确接受占位的场景使用。

第三点最容易被忽略。模板兜底的价值是「有一个可读结果」,不是「假装有摘要」。把它的质量估计写低,让它在链里自然被过滤掉,比在代码里写一堆 if 判断更可靠。

小结

降级策略不是「再找一个模型」,而是一条有顺序、有预算、有质量下限、有风暴保护的链。小模型、规则、模板、排队各自覆盖不同的失败场景,选择顺序是产品决策,不应该藏进加权公式。

「回声」的复读事故说明,备用通道的质量估计、提示适配和用户可见性必须一起设计。链上的每一步都要标注是否依赖提供方,连续失败达到阈值时先摘掉这类步骤,避免降级本身变成第二次故障。

下一节处理最后一个问题:这套链在真实故障里到底能不能工作。答案不能靠想象,要靠确定性故障注入、开关过期和恢复断言组成的演练。

阅读导航:上一节:10.1 模型不可用时产品该做什么 · 下一节:10.3 降级演练与用户可见性 。

常见错误与边界

  • 把降级理解成「换一个模型」。没有顺序、预算和质量下限的切换,只是把故障转移到了另一个地方。
  • 备用模型与主模型同源同区。主模型故障时它们一起不可用,链上的步骤数量不等于冗余度。
  • 用加权公式决定顺序。系数会把产品取舍藏起来,评审和复盘都说不清为什么这样排。
  • 只按估算扣预算。估算偏乐观时,后面的步骤会看到虚假余额,提前放弃还有预算的机会。
  • 质量下限设得过低。低于承诺的结果不如不给,低质量输出会消耗用户对产品的信任。
  • 链耗尽时不记成本。失败调用也花了钱,不记账会让成本归因在故障日出现缺口。
  • 风暴保护只看请求数,不看依赖。真正该摘掉的是依赖提供方的步骤,而不是所有步骤。
  • 成功之后不复位失败计数。系统会长期停留在保守模式,恢复后仍然只走规则和模板。
  • 让模板拼接用户原文。模板兜底应该输出稳定话术,而不是制造「复读」的错觉。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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