《互联网产品的 AI 架构·模型接入与能力边界》10.3 降级演练与用户可见性

「回声」的一次故障开关开了三周,没人敢关,所有用户都在看模板摘要。本节用确定性故障注入、带过期时间的开关和恢复断言,把降级策略变成可以反复演练的代码,并复盘一次只检查动作、忽略用户文案的演练失败,说明恢复结果为什么必须写进演练报告,而不是留成一句口头承诺。

没人敢关的那个开关

「回声」的内容摘要有一套降级开关。主模型不稳定时,值班同学打开 degrade.summary,摘要切到模板;主模型恢复后,再把开关关掉。

一次区域性故障里,开关被打开了。故障两个小时后恢复,但没有人关开关。三天后,产品经理发现所有摘要都是同一句「摘要生成中,请先查看原文」,用户投诉「摘要功能坏了」。排查时值班群里出现了三种声音:

  • 「我以为提供方恢复后会自动切回来。」
  • 「这个开关是谁加的?不知道能不能关。」
  • 「关了万一又超时怎么办,先留着吧。」

开关最终开了三周。它没有造成数据事故,但它让一个降级状态变成了默认状态。复盘时大家同意的结论是:问题不是某个人忘了关,而是开关本身没有过期时间、没有负责人、也没有任何演练能证明关掉是安全的。

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

演练要回答的三个问题

降级演练不是「把模型停掉看看会不会报错」。它至少要回答三个问题。

问题要验证的内容失败后的典型表现
故障时动作对不对能力是否按分级进入 degrade、hide 或 block审核故障时发布仍然成功
用户看到什么文案是否跟随动作出现,而不是空白或旧界面入口消失但没有任何解释
恢复是否自动故障移除后是否回到 serve,开关是否过期故障结束三天仍在降级

三个问题对应三类断言:信号到动作的映射、动作到文案的绑定、故障移除后的恢复。只做第一类,演练会通过,用户仍然一脸茫然;只做第三类,恢复很快,但故障期间可能已经放行了不该放行的内容。

演练脚本里的期望值必须手写,不能从实际结果反推。expect: "healthy" 是演练作者对系统的承诺;如果实际是 unavailable,报告应该失败,而不是把期望改成实际值让报告变绿。

故障注入为什么必须确定性

真实的模型故障不可复现:同一个请求,这次超时,下次可能成功;同一个错误,这次发生在首帧,下次发生在流末尾。用真实网络做演练,失败无法稳定重放,成功了也说明不了什么。

确定性的做法是在提供方外面包一层故障注入器:

  • 按能力注入,只影响目标能力,其他能力保持正常。
  • 按错误类别注入,timeout、unavailable、content-blocked 覆盖不同分支。
  • 支持 skipCalls,先放行前 N 次,验证「故障发生前已经成功」的边界。
  • 不 sleep、不访问网络,同样的脚本在任何机器上得到同样的结果。

content-blocked 需要单独说明。它表示模型可用,但输出被内容策略拦截,属于「变弱」而不是「不可用」。如果把它归成不可用,一次正常的拦截会触发阻断型能力的全量阻塞;归成变弱,产品会走降级并提示用户。错误分类是演练的一部分,不是实现细节。

开关必须自带过期时间

「没人敢关」的根因不是勇气,而是开关没有默认结束条件。一个只能手动关闭的开关,在故障结束后会变成组织惰性的载体。

「回声」后来给演练开关加了三条规定:

  1. 必须有负责人。没有 owner 的开关不允许创建。
  2. 必须有 TTL。单次最长 15 分钟,到期自动关闭。
  3. 关闭要留痕。expire() 返回本次被关闭的开关 id,写进演练报告。

TTL 不是「建议」,而是硬约束。故障可能持续超过 15 分钟,那就续期;续期是一个显式动作,会留下记录,也会提醒值班同学「这个降级还在生效」。自动过期解决的是「忘记关」,续期记录解决的是「不知道怎么关」。

把演练写成代码

下面的实现包含三部分:故障注入器、开关板和演练执行器。故障注入器实现统一的 ChatProvider 接口,因此可以直接包在真实提供方外面;开关板注入时钟,过期判断不依赖真实时间;演练执行器按脚本逐步注入故障、探测信号、映射动作与文案,最后强制移除故障并断言恢复。

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

import { AiError } from "../core/errors.ts";
import type { AiErrorKind } from "../core/errors.ts";
import type { ChatRequest, ChatResponse, Clock, ModelProfile } from "../core/types.ts";
import type { ChatProvider } from "../protocol/chat.ts";
import { planFor } from "./plan.ts";
import type { Availability, CapabilityPolicy, DegradationPlan } from "./plan.ts";

export interface FaultPlan {
  /** 只对该能力注入;省略表示全局故障。 */
  capability?: string;
  kind: AiErrorKind;
  /** 前 N 次调用放行,用于演练“故障发生前的成功路径”。 */
  skipCalls?: number;
}

export interface FaultProvider extends ChatProvider {
  readonly calls: number;
  setFault(plan: FaultPlan | null): void;
}

/** 在真实提供方外面套一层确定性故障注入,演练不依赖网络与 sleep。 */
export function createFaultProvider(
  inner: ChatProvider,
  initial: FaultPlan | null = null,
): FaultProvider {
  let fault = initial;
  let calls = 0;
  let matched = 0;

  return {
    id: `drill:${inner.id}`,
    get calls() {
      return calls;
    },
    setFault(next: FaultPlan | null) {
      fault = next;
      matched = 0;
    },
    async complete(request: ChatRequest, model: ModelProfile): Promise<ChatResponse> {
      calls += 1;
      if (fault && (fault.capability === undefined || fault.capability === request.capability)) {
        matched += 1;
        if (matched > (fault.skipCalls ?? 0)) {
          throw new AiError(fault.kind, `演练注入 ${fault.kind}`, {
            retryAfterMs: fault.kind === "rate-limit" ? 1_000 : undefined,
          });
        }
      }
      return inner.complete(request, model);
    },
  };
}

/** content-blocked 说明模型可用但结果不可用,属于“变弱”而不是“不可用”。 */
export function signalFromError(error: unknown): Availability {
  if (error instanceof AiError && error.kind === "content-blocked") {
    return "degraded";
  }
  return "unavailable";
}

/** 跑一次探针:成功看响应的 degraded 标记,失败按错误类别归并。 */
export async function probe(
  provider: ChatProvider,
  request: ChatRequest,
  model: ModelProfile,
): Promise<Availability> {
  try {
    const response = await provider.complete(request, model);
    return response.degraded ? "degraded" : "healthy";
  } catch (error) {
    return signalFromError(error);
  }
}

export interface SwitchInput {
  id: string;
  enabled: boolean;
  /** 相对当前时间的有效期;演练开关不允许永不过期。 */
  ttlMs: number;
  owner: string;
}

export interface FeatureSwitch {
  id: string;
  enabled: boolean;
  expiresAt: number;
  owner: string;
}

export interface SwitchBoard {
  set(input: SwitchInput): FeatureSwitch;
  isEnabled(id: string): boolean;
  /** 关闭所有已到期开关并返回其 id,供演练报告记录。 */
  expire(): string[];
  snapshot(): FeatureSwitch[];
}

export interface SwitchBoardOptions {
  clock: Clock;
  /** 单个开关允许的最长有效期,默认 15 分钟。 */
  maxTtlMs?: number;
}

export function createSwitchBoard(options: SwitchBoardOptions): SwitchBoard {
  const maxTtlMs = options.maxTtlMs ?? 15 * 60_000;
  const switches = new Map<string, FeatureSwitch>();

  const autoOff = (current: FeatureSwitch): FeatureSwitch => {
    if (current.enabled && current.expiresAt <= options.clock.now()) {
      const next = { ...current, enabled: false };
      switches.set(next.id, next);
      return next;
    }
    return current;
  };

  return {
    set(input: SwitchInput): FeatureSwitch {
      if (input.owner.trim() === "") {
        throw new AiError("invalid-request", `${input.id}: 开关必须有负责人`);
      }
      if (input.ttlMs <= 0 || input.ttlMs > maxTtlMs) {
        throw new AiError("invalid-request", `${input.id}: TTL 必须在 1..${maxTtlMs}ms`);
      }
      const next: FeatureSwitch = {
        id: input.id,
        enabled: input.enabled,
        expiresAt: options.clock.now() + input.ttlMs,
        owner: input.owner,
      };
      switches.set(next.id, next);
      return next;
    },
    isEnabled(id: string): boolean {
      const current = switches.get(id);
      if (!current) {
        return false;
      }
      return autoOff(current).enabled;
    },
    expire(): string[] {
      const expired: string[] = [];
      for (const current of switches.values()) {
        const next = autoOff(current);
        if (current.enabled && !next.enabled) {
          expired.push(next.id);
        }
      }
      return expired;
    },
    snapshot(): FeatureSwitch[] {
      return [...switches.values()].map(autoOff);
    },
  };
}

export interface DrillCase {
  name: string;
  fault: FaultPlan | null;
  /** 演练脚本写死的期望信号,而不是从结果反推。 */
  expect: Availability;
}

export interface DrillCheck {
  name: string;
  expected: Availability;
  actual: Availability;
  action: DegradationPlan["action"];
  /** 用户实际会看到的文案;演练不能只断言动作。 */
  message?: string;
  ok: boolean;
}

export interface DrillReport {
  passed: boolean;
  checks: DrillCheck[];
}

export interface DrillOptions {
  provider: FaultProvider;
  model: ModelProfile;
  request: ChatRequest;
  policy: CapabilityPolicy;
}

/**
 * 按脚本注入故障、探测信号、映射到产品动作,并在末尾强制恢复。
 * 恢复检查是报告的一部分,不能靠人工记得关开关。
 */
export async function runDrill(cases: DrillCase[], options: DrillOptions): Promise<DrillReport> {
  const checks: DrillCheck[] = [];
  for (const item of cases) {
    options.provider.setFault(item.fault);
    const actual = await probe(options.provider, options.request, options.model);
    const plan = planFor(options.policy, actual);
    checks.push({
      name: item.name,
      expected: item.expect,
      actual,
      action: plan.action,
      message: plan.message,
      ok: actual === item.expect,
    });
  }

  options.provider.setFault(null);
  const recovered = await probe(options.provider, options.request, options.model);
  const recoveryPlan = planFor(options.policy, recovered);
  checks.push({
    name: "移除故障后回到正常路径",
    expected: "healthy",
    actual: recovered,
    action: recoveryPlan.action,
    message: recoveryPlan.message,
    ok: recovered === "healthy",
  });

  return { passed: checks.every((check) => check.ok), checks };
}

有三个细节值得注意。

第一,probe 把成功响应里的 degraded 标记也纳入信号。模型可能没有报错,但明确返回了降级结果,这时不应该算 healthy。

第二,DrillCheck 同时记录 action 和 message。只断言动作,可能出现「动作是 hide,但界面是空白」的通过;把文案放进报告,演练才能覆盖用户可见性。

第三,runDrill 在脚本结束后一定会执行恢复检查,并把它追加到报告里。恢复不是可选项,也不能靠人记得做。

测试与真实输出

测试覆盖七种情况:按能力注入与 skipCalls、错误分类、开关过期、开关校验、完整演练与恢复、错误期望、内容拦截走降级。在实验工程根目录执行:

node --test test/degrade/drill.test.ts

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

✔ 故障只注入指定能力,skipCalls 之前的调用放行 (0.772333ms)
✔ content-blocked 归为变弱,其余错误归为不可用 (0.118958ms)
✔ 开关到期自动关闭,expire 记录被关闭的 id (0.476791ms)
✔ 演练开关必须有负责人且在有效期上限内 (0.126833ms)
✔ 演练按脚本注入故障并强制做恢复断言 (0.244167ms)
✔ 期望写错时演练失败,但恢复检查仍然执行 (0.099458ms)
✔ 注入内容拦截时走降级而不是阻塞 (0.107208ms)
ℹ tests 7
ℹ pass 7
ℹ fail 0

「期望写错时演练失败」这条测试值得单独看。脚本期望 healthy,实际注入的是 unavailable,报告 passed 为 false,但恢复检查仍然执行,调用次数是 2。这说明演练失败不会跳过清理,也不会把故障留在系统里。

「开关到期自动关闭」验证的是时间边界:开关在 1000 毫秒到期,时钟推进到 1000 毫秒时 expire() 返回它的 id,isEnabled 变成 false。如果比较写成 > 而不是 >=,到期瞬间仍然会返回 true,演练会漏掉这个边界。

一次演练暴露的可见性问题

「回声」第一次正式演练时,动作全部正确:摘要进入 degrade,私信回复进入 hide,审核进入 block。报告是绿色的。

但产品经理看了一会儿说:「用户不知道发生了什么。」摘要页只是换成了更短的文本,没有「简化模式」的提示;私信入口消失,没有任何说明;审核阻塞页沿用了一个通用的「网络错误」。

问题出在演练只断言了 action,没有断言 message。动作是系统内部状态,文案才是用户看到的事实。修复方式不是加更多人工检查,而是把 message 放进 DrillCheck,让每次演练都记录用户会看到什么。

这也解释了为什么第 10.1 节要把文案写进能力策略。文案如果由调用方临时拼,演练就无法稳定断言;文案跟动作一起由 planFor 返回,演练才能逐条比对。

演练的频率与边界

演练不是上线前做一次就结束。比较现实的节奏是:

  • 每次修改降级链或能力分级后,跑一遍对应能力的演练。
  • 每月做一次全能力演练,覆盖提供方不可用、超时和内容拦截三类故障。
  • 每次真实故障后,把当时的故障序列补成演练用例,防止同类问题再次静默通过。

演练也有边界。它验证的是「策略在给定信号下的行为」,不验证模型质量,也不验证容量。备用模型能不能承接翻倍的流量,要靠压测和容量测算;演练只能告诉你「切过去之后产品会做什么」。两者不能互相替代。

小结

降级演练要回答三个问题:故障时动作对不对、用户看到什么、恢复是否自动。确定性故障注入让这三个问题可以被稳定重放;带负责人和过期时间的开关让降级不会变成新的默认状态;恢复断言让「故障结束」成为一个必须验证的结果,而不是一句口头承诺。

「回声」的开关开了三周,说明只靠人的记性管理降级状态不可靠。把动作、文案和恢复都写进演练报告,才能让降级策略从纸面配置变成可运营的能力。

下一章讨论 AI 接入的另一个边界:密钥、租户与数据出域。降级解决的是「能力弱了怎么办」,下一章解决的是「能力根本不该被谁调用」。

阅读导航:上一节:10.2 降级策略:小模型、规则、模板、排队 · 下一节:11.1 密钥、租户与数据出域 。

常见错误与边界

  • 用真实网络做演练。故障不可复现,成功也无法证明策略有效,失败还可能污染真实数据。
  • 演练脚本从实际结果反推期望值。报告永远是绿的,却什么也没验证。
  • 只断言动作,不断言文案。动作正确但界面空白,用户仍然不知道发生了什么。
  • 把 content-blocked 归成不可用。一次正常的内容拦截会触发全量阻塞,故障范围被放大。
  • 开关没有负责人。出问题时没人敢关,也没人知道能不能关。
  • 开关没有过期时间。故障结束后降级状态会长期留存,最后变成默认体验。
  • TTL 到期比较写成 >。到期瞬间仍然返回开启,边界用例会被漏掉。
  • 演练结束后不强制恢复。故障注入器留在系统里,下一次请求可能莫名其妙失败。
  • 把演练当成容量验证。演练只验证策略行为,备用模型能不能扛住流量要靠压测。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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