一条链路的失败不是一个结论
「回声」的内容发布安全链由四步组成:摘要、审核分类、人工复核、发布。某天审核分类连续两次超时,编排器面临一个看起来很简单的问题:发布还要不要继续?
如果只看错误码,答案似乎很清楚:超时是可重试错误,再试一次就好。但再试一次仍然失败之后,问题就变成了四个:还要不要继续重试?下游发布能不能跳过审核?能不能用上一版的风险等级降级?还是整条链路必须停下来?
这四个问题对应四种完全不同的后果。重试会放大成本与延迟;跳过会让未审核内容进入发布队列;降级可能把旧结论当成新结论;中止则让已经完成的摘要工作白费。把「失败」当成一个统一状态,就无法回答这些问题。
卷二 13.3 留下的第三个问题正是:上游失败后,下游应该跳过、重试,还是用部分结果继续?本节先回答第一步——把失败分成可判定的四类,并给每一类写明动作、预算与用户可见性。下一节再回答第二步——部分结果到底怎样才算可用。
文中的分类结果与计数都来自 ai-lab 的示例样本运行,不是生产事故统计;七条信号是用于覆盖判定分支的构造样本。
四类失败与判定字段
分类不是给错误起一个好听的名字,而是决定下游还能不能继续。四类的定义如下:
| 类别 | 含义 | 默认动作 | 下游 | 用户可见 |
|---|---|---|---|---|
| 可重试 | 瞬时故障,重放可能成功 | retry | 成功后继续 | 无 |
| 可跳过 | 该节点输出非必需,缺失不影响主结果 | skip | 继续 | 提示 |
| 可降级 | 有可接受的替代产物 | degrade | 用替代产物继续 | 横幅 |
| 必须中止 | 缺了它结果不成立,或重放不安全 | abort | 停止 | 阻断 |
要做出这个判定,至少需要七个字段。少一个字段,分类器就只能猜:
| 字段 | 回答的问题 | 缺失时的典型误判 |
|---|---|---|
kind | 错误属于哪一类 | 把鉴权失败当超时重试 |
required | 下游是否必须拿到该输出 | 跳过审核继续发布 |
idempotent | 重复执行是否安全 | 重复下单、重复发消息 |
hasFallback | 是否有可接受的替代产物 | 把空结果当降级结果 |
attempts / maxAttempts | 还有没有本地重试额度 | 无限重试 |
retryBudgetLeft | 编排级预算还剩多少 | 单节点吃光全局预算 |
finalAction | 是否是不可回滚的最终动作 | 自动重放发布动作 |
其中 kind 不需要编排层重新定义。它直接复用网关的 AiError 错误表:限流、超时、不可用、输出非法可重试;鉴权、配额、请求非法、内容阻断不可重试。编排层只补充「这个节点在链路里的角色」,不维护第二份错误分类。
判定顺序决定动作
同样的字段,换一个判定顺序就会得到不同动作。代码采用固定优先级:
- 安全优先。
content-blocked一律不重试、不降级。必需节点直接中止;可选节点直接跳过,不拿替代内容去填补被阻断的字段。 - 幂等边界优先。非幂等且属于最终动作的失败,即使错误码可重试也中止,并标记需要人工对账。
- 再看瞬时故障。只有「可重试错误 + 还有本地额度 + 还有全局预算 + 幂等」四个条件同时成立才重试。
- 然后看替代产物。有降级产物且不是最终动作时降级;否则可选节点跳过。
- 最后才是中止。必需、无替代、不能再试,三者同时成立才停链。
这个顺序把两个常见事故挡在前面:内容被安全策略阻断却用缓存答案降级,以及发布动作超时后被自动重放。两者都不是「提高成功率」,而是把局部失败放大成业务事故。
实现
下面的分类器把上述顺序写成代码。它不做 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.summarize | timeout | 必需、幂等、有预算 | 可重试 | transient-retry |
| moderation.classify | content-blocked | 必需 | 必须中止 | safety-block |
| risk_flags | quota | 可选 | 可跳过 | optional-node |
| summary | timeout | 已试满、有替代 | 可降级 | attempts-exhausted |
| action.publish | timeout | 非幂等、最终动作 | 必须中止 | non-idempotent-side-effect |
| citations | content-blocked | 可选 | 可跳过 | safety-drop |
| chat.reply | rate-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当普通失败重试。安全阻断重试多少次都不会变成通过。 - 对非幂等的最终动作自动重放。重复发布、重复下单的代价高于一次失败。
- 跳过与降级不记录缺口。下游与用户都不知道结果少了什么。
- 重试后不重新分类。第二次失败的原因可能与第一次完全不同。
- 把「有替代产物」当成「可以降级」。最终动作即使有模板也不能用模板假装成功。
- 让可选节点先吃重试预算。可选节点失败不影响整链成功,预算应先给必需节点。
- 用自然语言当原因码。无法聚合、无法告警、无法跨版本比较。
- 把示例样本的七条信号当成生产分布。它只用于覆盖判定分支,不代表真实事故比例。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。