《互联网产品的 AI 架构·模型接入与能力边界》10.1 模型不可用时产品该做什么

「回声」的智能回复超时后,前端自动重试三次,把一次故障拖成半小时;同一时刻审核调用也超时,发布按钮却仍然可点。本节把 AI 能力按可缺、可弱、必须阻塞分级,明确每种信号下的动作与用户文案,并用可运行的 TypeScript 把策略校验和批量编排固定下来。

从一次「重试一下」说起

「回声」的私信智能回复上线第四个月,模型提供方在一个下午出现了区域性超时。前端拿到 504 后的处理很简单:自动重试三次,间隔两百毫秒。

这个决定单独看没有问题,放进产品里就变成了事故。用户点一次「生成回复」,后端实际发起了四次请求;四次都超时后,界面显示「网络繁忙,请稍后再试」,但输入框里的草稿已经被清空。同一时间,内容审核接口也在超时,发布按钮却没有受到任何限制,四十分钟里有十几条未审核内容进入了内容流。

事后复盘,团队发现自己把三种完全不同的能力当成了同一种东西:

  • 私信智能回复可以没有,用户自己打字也能完成对话。
  • 摘要可以弱一点,用规则或模板给个粗略版本,用户仍能判断要不要点开原文。
  • 内容审核不能缺,缺了就必须阻止发布,否则问题会进入公开内容流。

模型不可用时,产品第一个要回答的不是「换哪个模型」,而是「这个能力允许缺到什么程度」。这个答案不是技术属性,而是产品承诺;它决定了前端展示什么、后端是否继续主流程、以及值班同学半夜要不要被叫醒。

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

三种能力,三种承诺

把「回声」的 AI 能力逐个过一遍,可以收敛成三档。

级别含义「回声」的例子模型不可用时的默认动作
可缺 optional没有它,主任务仍能完成私信智能回复、标题建议隐藏入口,主流程继续
可弱 degradable质量可以下降,但不能没有结果内容摘要、相关推荐切换到规则、模板或小模型
必须阻塞 blocking没有它,主流程不能继续内容审核、支付风控阻止发布或下单,明确告知

分级的难点不在定义,而在承认代价。把审核定成「必须阻塞」,意味着模型故障时发布量会直接掉到零,运营会来问为什么;把智能回复定成「可缺」,意味着故障时用户看不到任何 AI 入口,产品经理要接受这个事实。反过来,如果所有能力都被定成「可弱」,故障时满屏都是低质量结果,用户会以为产品坏了。

还有一个常见误区:按模型而不是按能力分级。同一个模型可能同时支撑摘要和审核,但它们在产品里的承诺完全不同。分级的主语必须是能力,模型只是实现手段。第 1 章的能力契约已经把这一点写进了类型,本节把它落到故障时刻的动作上。

用户看到什么,取决于动作

分级回答「能不能缺」,动作回答「缺的时候发生什么」。四种动作对应四种用户可见状态。

动作适用场景用户看到主流程
serve模型健康正常结果继续
degrade模型变弱或已切到备用通道结果加一条说明,例如「摘要由简化模式生成」继续
hide可缺能力完全不可用入口隐藏或置灰,附一句原因继续
block阻断型能力不可用明确的阻塞文案与下一步停止

这里最重要的一条是:降级必须可见。很多团队为了避免「打扰用户」,降级后仍然展示和正常路径一样的界面。短期看体验平滑,长期看是信任透支。用户不知道摘要是简化版,就会把「复读问题」当成产品变笨;一旦知道这是降级状态,同样的结果就变成可解释的。

「回声」曾经遇到过一次典型情况:主模型超时后自动切到小模型,摘要输出变成了「摘要:我的订单什么时候到」。如果界面没有任何提示,用户只会觉得这个功能很蠢;加上「摘要由简化模式生成,可能不完整」之后,至少投诉会变成「我知道它在降级」,客服也能解释。

文案不能由每个调用方自己拼。调用方只知道自己这一处,不知道全局状态;同一个故障下,私信页说「网络繁忙」、摘要页说「服务升级中」、审核页说「稍后重试」,用户看到的是三个互不相干的故事。文案应该跟动作一起,写在能力策略里。

把分级写成策略与计划

下面的实现把「策略声明」和「运行期决策」分开。策略在发布前校验,矛盾配置直接拒绝;计划函数是纯函数,同样的策略和信号一定得到同样的动作与文案。

策略里有四个关键字段:criticality 表示分级,fallbackAllowed 表示有没有备用通道,blocksFlow 表示是否中断主流程,copy 提供降级与不可用两种文案。校验规则故意写得很硬:可选能力不允许中断主流程,阻断型能力必须声明中断,可弱能力必须允许备用通道。这些约束看起来啰嗦,但它们能拦住「把审核改成可选」这种事后看来不可理喻、事前却很容易发生的改动。

批量编排时,缺少信号的能力按 unavailable 处理,也就是失败关闭。健康检查没上报不等于健康,尤其对阻断型能力,默认放行是最危险的选择。

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

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

/** 健康检查与错误率共同得出的三档信号,业务方只消费这三个值。 */
export type Availability = "healthy" | "degraded" | "unavailable";

/** 能力对主流程的约束级别:可缺、可弱、必须阻塞。 */
export type Criticality = "optional" | "degradable" | "blocking";

export interface CapabilityPolicy {
  capability: string;
  criticality: Criticality;
  /** 是否允许降级到备用通道(小模型、规则或模板)。 */
  fallbackAllowed: boolean;
  /** 不可用时是否中断主流程;只有 blocking 可以设为 true。 */
  blocksFlow: boolean;
  /** 用户能看到的两种文案:降级仍在服务、完全不可用。 */
  copy: { degraded: string; unavailable: string };
}

export type PlanAction = "serve" | "degrade" | "hide" | "block";

export interface DegradationPlan {
  capability: string;
  action: PlanAction;
  criticality: Criticality;
  continueFlow: boolean;
  /** 面向用户的文案;serve 时为 undefined。 */
  message?: string;
  reason?: "capability-unavailable" | "policy-blocked";
}

export interface AvailabilitySignal {
  capability: string;
  availability: Availability;
}

export interface DegradationReport {
  plans: DegradationPlan[];
  counts: Record<PlanAction, number>;
  flowBlocked: boolean;
}

/**
 * 校验策略本身,而不是等到线上才发现。
 * 可缺能力不允许阻断流程,阻断型能力必须声明阻断,可弱能力必须有备胎。
 */
export function validatePolicy(policy: CapabilityPolicy): void {
  if (policy.capability.trim() === "") {
    throw new AiError("invalid-request", "capability 不能为空");
  }
  if (policy.copy.degraded.trim() === "" || policy.copy.unavailable.trim() === "") {
    throw new AiError("invalid-request", `${policy.capability}: 降级与不可用文案都必须提供`);
  }
  if (policy.criticality === "optional" && policy.blocksFlow) {
    throw new AiError("invalid-request", `${policy.capability}: 可选能力不得中断主流程`);
  }
  if (policy.criticality === "blocking" && !policy.blocksFlow) {
    throw new AiError("invalid-request", `${policy.capability}: 阻断型能力必须声明中断主流程`);
  }
  if (policy.criticality === "degradable" && !policy.fallbackAllowed) {
    throw new AiError("invalid-request", `${policy.capability}: 可弱能力必须允许备用通道`);
  }
}

function serve(policy: CapabilityPolicy): DegradationPlan {
  return {
    capability: policy.capability,
    action: "serve",
    criticality: policy.criticality,
    continueFlow: true,
  };
}

/**
 * 单一决策点:同一份策略在不同信号下只有一个结果。
 * 文案跟着 action 走,避免调用方各自拼提示语。
 */
export function planFor(policy: CapabilityPolicy, availability: Availability): DegradationPlan {
  validatePolicy(policy);
  if (availability === "healthy") {
    return serve(policy);
  }

  if (availability === "degraded") {
    return {
      capability: policy.capability,
      action: "degrade",
      criticality: policy.criticality,
      continueFlow: true,
      message: policy.copy.degraded,
    };
  }

  if (policy.criticality === "blocking") {
    return {
      capability: policy.capability,
      action: "block",
      criticality: policy.criticality,
      continueFlow: !policy.blocksFlow,
      message: policy.copy.unavailable,
      reason: "policy-blocked",
    };
  }

  if (policy.criticality === "degradable") {
    return {
      capability: policy.capability,
      action: "degrade",
      criticality: policy.criticality,
      continueFlow: true,
      message: policy.copy.degraded,
      reason: "capability-unavailable",
    };
  }

  return {
    capability: policy.capability,
    action: "hide",
    criticality: policy.criticality,
    continueFlow: true,
    message: policy.copy.unavailable,
    reason: "capability-unavailable",
  };
}

/**
 * 批量编排:缺少信号的能力按 unavailable 处理(失败关闭),
 * 并统计有多少条主流程会被阻断。
 */
export function planCapabilities(
  policies: CapabilityPolicy[],
  signals: AvailabilitySignal[],
): DegradationReport {
  const seen = new Set<string>();
  const byCapability = new Map(signals.map((signal) => [signal.capability, signal.availability]));
  const plans = policies.map((policy) => {
    if (seen.has(policy.capability)) {
      throw new AiError("invalid-request", `${policy.capability}: 策略重复声明`);
    }
    seen.add(policy.capability);
    return planFor(policy, byCapability.get(policy.capability) ?? "unavailable");
  });
  const counts: Record<PlanAction, number> = { serve: 0, degrade: 0, hide: 0, block: 0 };
  for (const plan of plans) {
    counts[plan.action] += 1;
  }
  return {
    plans,
    counts,
    flowBlocked: plans.some((plan) => !plan.continueFlow),
  };
}

代码里有一个容易被忽略的细节:degraded 信号对所有级别都返回 degrade,包括阻断型能力。这意味着审核模型变慢、质量下降但仍在工作时,产品不会提前阻塞,而是带着提示继续服务。真正触发阻塞的是 unavailable,也就是模型完全不可用。把「变弱」和「不可用」混为一谈,会让一次抖动引发不必要的停摆。

测试与真实输出

测试覆盖八种情况:健康、三档能力在不可用时的动作、阻断型能力变弱时不提前阻塞、策略矛盾被拒绝、批量编排的计数与失败关闭、重复声明被拒绝。在实验工程根目录执行:

node --test test/degrade/plan.test.ts

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

✔ 健康时所有能力直接服务 (1.080875ms)
✔ 可缺能力不可用时隐藏,主流程继续 (0.093875ms)
✔ 可弱能力不可用时降级到备用通道 (0.06125ms)
✔ 阻断型能力不可用时阻塞流程并给出用户文案 (0.063208ms)
✔ 阻断型能力只是变弱时仍可服务,不提前阻塞 (0.064583ms)
✔ 策略矛盾在声明期被拒绝 (0.3085ms)
✔ 批量编排按能力聚合,缺失信号按不可用处理 (0.547541ms)
✔ 重复能力声明被拒绝 (0.120041ms)
ℹ tests 8
ℹ pass 8
ℹ fail 0

注意「可缺能力不可用时隐藏」这条:计划里的 continueFlow 仍然是 true。隐藏的是能力入口,不是主流程。这一点如果写成 false,私信页会因为智能回复故障而无法发送普通消息,故障范围被放大了一个数量级。

另一条值得单独看的是「阻断型能力只是变弱时仍可服务」。测试断言动作是 degrade 而不是 block,用户看到的是「审核结果可能延迟」。这是有意的取舍:审核模型变慢时,继续服务并提示延迟,比直接停止发布更符合业务预期;只有当它完全不可用,才触发阻塞。

一次把审核降级成「稍后重试」的复盘

开头提到的审核事故,直接原因是一位同学在值班时把审核能力从 blocking 改成了 optional,理由是「先让发布恢复,审核结果稍后补」。

这个改动在代码里只花了一行,但它同时改变了三件事:

  • planFor 在不可用时返回 hide,而不是 block,发布流程继续。
  • 前端不再展示阻塞文案,只显示一个不显眼的「审核延迟」标记。
  • 监控里的「发布成功率」恢复了,值班同学以为故障已经缓解。

真正的问题不是这位同学判断错了,而是策略允许他这样改,而且改完没有人和机制能发现。修复分两步。第一步,把 criticality 与 blocksFlow 的约束写成发布期校验,optional 加 blocksFlow 会直接抛错,堵住「既要可选又要阻断」的矛盾配置。第二步,把审核能力的所有者写进配置,任何分级变更都要经过所有者评审。

复盘结论可以压缩成一句话:降级策略的默认值比个人判断更可靠。故障发生时人容易盯着一个指标,默认值不会。

与 10.2、10.3 的关系

本节回答「每个能力在什么信号下做什么动作」。10.2 回答「降级时具体用哪条备用通道」:小模型、规则、模板、排队如何排序,预算怎么分,怎样避免所有请求同时涌向备用模型。10.3 回答「怎么证明这套策略真的有效」:用确定性故障注入演练,检查动作、文案和恢复。

三节顺序不能反。没有分级,备用通道就不知道该服务谁;没有通道,分级只是一张承诺表;没有演练,前两节都只是纸面配置。

小结

模型不可用时,产品要先看能力分级,而不是先找另一个模型。可缺能力隐藏入口,可弱能力切到备用通道,必须阻塞的能力停住主流程。动作决定用户看到什么,文案必须跟动作一起由策略提供。

「回声」的重试事故说明,把三种能力当成同一种,会让一次提供方抖动扩散成重复请求、草稿丢失和未审核内容三件事。把分级、动作、文案写进可校验的策略,并在发布期拒绝矛盾配置,能让故障时刻的决策从个人判断变成系统默认值。

下一节处理「切到哪条备用通道」:小模型、规则、模板和排队各自适合什么,选择顺序为什么是产品决策,以及怎样用预算和风暴保护让降级不变成第二次故障。

阅读导航:上一节:9.3 命中率的度量与陷阱 · 下一节:10.2 降级策略:小模型、规则、模板、排队 。

常见错误与边界

  • 把「模型不可用」和「模型变弱」当成同一件事。前者要阻塞或隐藏,后者只是降级,混在一起会让抖动引发停摆。
  • 按模型而不是按能力分级。同一个模型支撑多个能力时,承诺不同,动作也必须不同。
  • 降级不告诉用户。界面看起来正常,用户会把低质量结果当成产品能力,信任一旦透支很难恢复。
  • 让每个调用方自己拼文案。同一故障下会出现多套说法,用户无法判断到底发生了什么。
  • 把可缺能力的 continueFlow 写成 false。隐藏入口不等于阻断主流程,故障范围会被放大。
  • 缺少健康信号时默认放行。对阻断型能力,未知必须按不可用处理,失败关闭比乐观假设安全。
  • 允许「可选 + 阻断」的矛盾配置。发布期不校验,故障时就会有人用它绕过阻塞。
  • 把分级当成一次性决定。能力、合规要求和业务阶段都会变,分级表需要所有者和定期复核。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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