《互联网产品的 AI 架构·多能力编排与任务闭环》5.1 失败分类与处理策略

上游失败后,下游该跳过、重试、降级还是中止?本节把失败分成可重试、可跳过、可降级、必须中止四类,给出每类的判定字段、判定顺序与稳定原因码,说明动作对下游、预算与用户提示的影响,并用七条示例信号跑出分类结果,附可运行的 TypeScript 分类器与处理策略表。

一条链路的失败不是一个结论

「回声」的内容发布安全链由四步组成:摘要、审核分类、人工复核、发布。某天审核分类连续两次超时,编排器面临一个看起来很简单的问题:发布还要不要继续?

如果只看错误码,答案似乎很清楚:超时是可重试错误,再试一次就好。但再试一次仍然失败之后,问题就变成了四个:还要不要继续重试?下游发布能不能跳过审核?能不能用上一版的风险等级降级?还是整条链路必须停下来?

这四个问题对应四种完全不同的后果。重试会放大成本与延迟;跳过会让未审核内容进入发布队列;降级可能把旧结论当成新结论;中止则让已经完成的摘要工作白费。把「失败」当成一个统一状态,就无法回答这些问题。

卷二 13.3 留下的第三个问题正是:上游失败后,下游应该跳过、重试,还是用部分结果继续?本节先回答第一步——把失败分成可判定的四类,并给每一类写明动作、预算与用户可见性。下一节再回答第二步——部分结果到底怎样才算可用。

文中的分类结果与计数都来自 ai-lab 的示例样本运行,不是生产事故统计;七条信号是用于覆盖判定分支的构造样本。

四类失败与判定字段

分类不是给错误起一个好听的名字,而是决定下游还能不能继续。四类的定义如下:

类别含义默认动作下游用户可见
可重试瞬时故障,重放可能成功retry成功后继续无
可跳过该节点输出非必需,缺失不影响主结果skip继续提示
可降级有可接受的替代产物degrade用替代产物继续横幅
必须中止缺了它结果不成立,或重放不安全abort停止阻断

要做出这个判定,至少需要七个字段。少一个字段,分类器就只能猜:

字段回答的问题缺失时的典型误判
kind错误属于哪一类把鉴权失败当超时重试
required下游是否必须拿到该输出跳过审核继续发布
idempotent重复执行是否安全重复下单、重复发消息
hasFallback是否有可接受的替代产物把空结果当降级结果
attempts / maxAttempts还有没有本地重试额度无限重试
retryBudgetLeft编排级预算还剩多少单节点吃光全局预算
finalAction是否是不可回滚的最终动作自动重放发布动作

其中 kind 不需要编排层重新定义。它直接复用网关的 AiError 错误表:限流、超时、不可用、输出非法可重试;鉴权、配额、请求非法、内容阻断不可重试。编排层只补充「这个节点在链路里的角色」,不维护第二份错误分类。

判定顺序决定动作

同样的字段,换一个判定顺序就会得到不同动作。代码采用固定优先级:

  1. 安全优先。content-blocked 一律不重试、不降级。必需节点直接中止;可选节点直接跳过,不拿替代内容去填补被阻断的字段。
  2. 幂等边界优先。非幂等且属于最终动作的失败,即使错误码可重试也中止,并标记需要人工对账。
  3. 再看瞬时故障。只有「可重试错误 + 还有本地额度 + 还有全局预算 + 幂等」四个条件同时成立才重试。
  4. 然后看替代产物。有降级产物且不是最终动作时降级;否则可选节点跳过。
  5. 最后才是中止。必需、无替代、不能再试,三者同时成立才停链。

这个顺序把两个常见事故挡在前面:内容被安全策略阻断却用缓存答案降级,以及发布动作超时后被自动重放。两者都不是「提高成功率」,而是把局部失败放大成业务事故。

实现

下面的分类器把上述顺序写成代码。它不做 I/O,只接收一组已经采集好的信号,返回动作、稳定原因码与三个布尔标记:下游是否继续、是否消耗预算、是否需要人工对账。

// file: src/orch/failure.ts
// tier: L1
// verify: node --test test/orch/failure.test.ts

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

/**
 * 四类失败对应四种动作。分类不是给错误起名字,而是决定下游还能不能继续:
 * retryable 重试、skippable 跳过、degradable 降级、abort 中止。
 */
export type FailureClass = "retryable" | "skippable" | "abort" | "degradable";
export type FailureAction = "retry" | "skip" | "abort" | "degrade";

/** 分类器需要的全部判定字段,缺一项就可能把可恢复失败误判成中止。 */
export interface FailureSignal {
  nodeId: string;
  kind: AiErrorKind;
  /** 该节点的输出是否为下游必需。 */
  required: boolean;
  /** 重复执行是否安全;有副作用的动作必须显式声明。 */
  idempotent: boolean;
  /** 是否已有可交付的降级产物,例如模板摘要、缓存答案。 */
  hasFallback: boolean;
  /** 已尝试次数,含首次。 */
  attempts: number;
  /** 允许的最大尝试次数,首次加重试。 */
  maxAttempts: number;
  /** 编排级剩余重试预算,按次数计。 */
  retryBudgetLeft: number;
  /** 不可回滚的最终动作,例如发布、下单、发消息。 */
  finalAction: boolean;
  /** 提供方建议的等待时间,只对限流与超时有意义。 */
  retryAfterMs?: number;
}

export interface FailureDecision {
  nodeId: string;
  failureClass: FailureClass;
  action: FailureAction;
  /** 稳定原因码,便于日志与指标聚合。 */
  reason: string;
  /** 动作之后下游是否继续执行。 */
  continueDownstream: boolean;
  /** 本次动作是否消耗全局重试预算。 */
  consumesBudget: boolean;
  /** 是否需要人工复核或对账。 */
  needsReconciliation: boolean;
  /** 用户可见标记强度。 */
  userNotice: "none" | "notice" | "banner" | "block";
  retryAfterMs?: number;
}

export interface FailurePolicy {
  action: FailureAction;
  continueDownstream: boolean;
  consumesBudget: boolean;
  needsReconciliation: boolean;
  userNotice: "none" | "notice" | "banner" | "block";
}

/** 四类失败的默认处置,代码与文档引用同一张表,避免口径漂移。 */
export const FAILURE_PLAYBOOK: Record<FailureClass, FailurePolicy> = {
  retryable: {
    action: "retry",
    continueDownstream: true,
    consumesBudget: true,
    needsReconciliation: false,
    userNotice: "none",
  },
  skippable: {
    action: "skip",
    continueDownstream: true,
    consumesBudget: false,
    needsReconciliation: false,
    userNotice: "notice",
  },
  degradable: {
    action: "degrade",
    continueDownstream: true,
    consumesBudget: false,
    needsReconciliation: false,
    userNotice: "banner",
  },
  abort: {
    action: "abort",
    continueDownstream: false,
    consumesBudget: false,
    needsReconciliation: false,
    userNotice: "block",
  },
};

function fail(message: string): never {
  throw new AiError("invalid-request", `failure: ${message}`);
}

function integer(value: number, field: string, min = 0): number {
  if (!Number.isInteger(value) || value < min) fail(`${field} 必须是 >= ${min} 的整数`);
  return value;
}

/** 复用网关的错误表,避免编排层维护第二份「哪些错误可重试」。 */
function retryableKind(kind: AiErrorKind): boolean {
  return new AiError(kind, "failure: classify").retryable;
}

function validateSignal(signal: FailureSignal): void {
  if (signal.nodeId.trim() === "") fail("nodeId 不能为空");
  integer(signal.attempts, "attempts", 1);
  integer(signal.maxAttempts, "maxAttempts", 1);
  integer(signal.retryBudgetLeft, "retryBudgetLeft");
  if (signal.attempts > signal.maxAttempts) {
    fail(`${signal.nodeId}: attempts 不能超过 maxAttempts`);
  }
  if (signal.retryAfterMs !== undefined && (!Number.isFinite(signal.retryAfterMs) || signal.retryAfterMs < 0)) {
    fail("retryAfterMs 必须是 >= 0 的有限数");
  }
}

/**
 * 判定顺序本身就是策略:安全阻断优先于重试,幂等边界优先于降级,
 * 最后才看节点是否必需。顺序一变,同一组信号会得到不同动作。
 */
export function classifyFailure(signal: FailureSignal): FailureDecision {
  validateSignal(signal);
  const attemptsLeft = signal.maxAttempts - signal.attempts;
  const transient = retryableKind(signal.kind);
  const canRetry = transient &&
    attemptsLeft > 0 &&
    signal.retryBudgetLeft > 0 &&
    signal.idempotent;

  if (signal.kind === "content-blocked") {
    if (signal.required) {
      return decision(signal, "abort", "safety-block", false, "block");
    }
    return decision(signal, "skippable", "safety-drop", false, "notice");
  }

  if (canRetry) {
    const result = decision(signal, "retryable", "transient-retry", false, "none");
    if (signal.retryAfterMs !== undefined) result.retryAfterMs = signal.retryAfterMs;
    return result;
  }

  // 最终动作即使可重试也不能自动重放:重复发布或重复下单的代价高于失败。
  if (!signal.idempotent && signal.finalAction) {
    return decision(signal, "abort", "non-idempotent-side-effect", true, "block");
  }

  const attemptsExhausted = attemptsLeft <= 0;
  const budgetExhausted = transient && !attemptsExhausted && signal.retryBudgetLeft <= 0;
  const degradeReason = budgetExhausted
    ? "retry-budget-exhausted"
    : attemptsExhausted && transient
      ? "attempts-exhausted"
      : "terminal-with-fallback";

  if (signal.hasFallback && !signal.finalAction) {
    return decision(
      signal,
      "degradable",
      degradeReason,
      !signal.idempotent,
      "banner",
    );
  }

  if (!signal.required) {
    return decision(signal, "skippable", "optional-node", false, "notice");
  }

  return decision(
    signal,
    "abort",
    budgetExhausted ? "retry-budget-exhausted" : transient ? "required-terminal" : "required-non-retryable",
    !signal.idempotent,
    "block",
  );
}

function decision(
  signal: FailureSignal,
  failureClass: FailureClass,
  reason: string,
  needsReconciliation: boolean,
  userNotice: FailureDecision["userNotice"],
): FailureDecision {
  const policy = FAILURE_PLAYBOOK[failureClass];
  return {
    nodeId: signal.nodeId,
    failureClass,
    action: policy.action,
    reason,
    continueDownstream: policy.continueDownstream,
    consumesBudget: policy.consumesBudget,
    needsReconciliation: needsReconciliation || policy.needsReconciliation,
    userNotice,
  };
}

/** 把一轮失败按类别计数,用于判断事故是「重试能救」还是「必须停机」。 */
export function summarizeDecisions(
  decisions: readonly FailureDecision[],
): Record<FailureClass, number> {
  const counts: Record<FailureClass, number> = {
    retryable: 0,
    skippable: 0,
    abort: 0,
    degradable: 0,
  };
  for (const item of decisions) counts[item.failureClass] += 1;
  return counts;
}

FAILURE_PLAYBOOK 是四类失败的默认处置表,classifyFailure 只负责在边界情况下覆盖 needsReconciliation 与 userNotice。把默认值集中在一张表里,文档、监控与代码才能引用同一个口径。

reason 使用稳定字符串而不是自然语言。它可以直接成为指标标签:transient-retry 多了说明提供方在抖动,safety-block 多了说明内容策略在收紧,non-idempotent-side-effect 多了则必须有人去看对账队列。

示例样本上的运行结果

用七条构造信号覆盖主要分支,分类结果如下:

节点错误关键字段类别原因码
content.summarizetimeout必需、幂等、有预算可重试transient-retry
moderation.classifycontent-blocked必需必须中止safety-block
risk_flagsquota可选可跳过optional-node
summarytimeout已试满、有替代可降级attempts-exhausted
action.publishtimeout非幂等、最终动作必须中止non-idempotent-side-effect
citationscontent-blocked可选可跳过safety-drop
chat.replyrate-limit必需、全局预算为 0必须中止retry-budget-exhausted

按类别聚合得到:可重试 1 条、可跳过 2 条、可降级 1 条、必须中止 3 条。这个分布说明一件重要的事:七条失败里只有一条适合自动重试。如果把七条都送进重试队列,不但救不回另外六条,还会把预算烧在注定失败的路径上。

原因码还区分了两种「中止」。safety-block 不需要对账,因为内容根本没有进入下游;non-idempotent-side-effect 需要对账,因为发布动作可能已经在超时前落地。两者在用户侧都表现为阻断,但在运维侧是完全不同的工单。

处理策略落地

分类只有落到编排器行为上才有意义。四类动作对应的执行语义如下:

动作编排器做什么预算结果状态后续
retry等待建议时长后重放当前节点扣 1 次全局预算未定成功则继续,失败重新分类
skip不产出该字段,记录缺口不扣部分可用交给 5.2 的验收
degrade写入替代产物与来源标记不扣部分可用交给 5.2 的验收
abort停止后续节点,保留已完成产物不扣不可交付对账或人工复核

重试是唯一消耗全局预算的动作,因此它必须受 5.3 的预算与熔断约束。跳过与降级不消耗重试预算,但它们会改变交付物的完整性,因此必须受 5.2 的验收约束。中止不产生交付物,但要把已完成的中间产物保留下来,否则人工复核只能从头再来。

一个容易忽略的细节是:retry 之后的失败要重新分类,而不是沿用上一次的结论。第一次是超时,第二次可能变成内容阻断;如果沿用「可重试」,就会在安全策略已经拒绝的情况下继续重放。

留给 5.2 的接口

分类器只回答「接下来做什么」,不回答「做出来的东西能不能交付」。skip 与 degrade 都会让链路带着缺口继续,缺口是否可接受,取决于字段契约而不是失败类别。

因此 5.1 的输出正好是 5.2 的输入:哪些字段缺失、哪些字段用了替代产物、替代产物的来源是什么。下一节会把这些信息变成可判定的验收结果:完整、部分可用、拒绝交付,以及对应的用户提示级别。

小结

失败分类的价值不在于把错误码整理得更整齐,而在于让「下游还能不能继续」成为一次可复算的判定。四类失败对应四种动作:可重试消耗预算,可跳过留下缺口,可降级写入替代标记,必须中止停止链路。

判定顺序比字段本身更关键:安全阻断优先于重试,幂等边界优先于降级,替代产物优先于跳过,必需性最后兜底。七条示例信号中只有一条进入重试,另外六条分别进入跳过、降级与中止,说明「一律重试」既救不回结果,也会放大成本。

分类器输出的缺口与替代标记,就是 5.2 验收部分结果的原始输入。

阅读导航:上一节:4.3 长任务的会话状态与记忆边界 · 下一节:5.2 部分可用的验收标准 。

常见错误与边界

  • 只看错误码分类。超时既可重试也可能已经产生副作用,必须结合 idempotent 与 finalAction。
  • 把 content-blocked 当普通失败重试。安全阻断重试多少次都不会变成通过。
  • 对非幂等的最终动作自动重放。重复发布、重复下单的代价高于一次失败。
  • 跳过与降级不记录缺口。下游与用户都不知道结果少了什么。
  • 重试后不重新分类。第二次失败的原因可能与第一次完全不同。
  • 把「有替代产物」当成「可以降级」。最终动作即使有模板也不能用模板假装成功。
  • 让可选节点先吃重试预算。可选节点失败不影响整链成功,预算应先给必需节点。
  • 用自然语言当原因码。无法聚合、无法告警、无法跨版本比较。
  • 把示例样本的七条信号当成生产分布。它只用于覆盖判定分支,不代表真实事故比例。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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