一个叫「工作流」的失败
高失败率变体把摘要节点的失败率从 3% 提到 12%,回复节点从 2% 提到 6%。整链成功率从 99.86% 掉到 98.12%:每 1,000 次请求有 18.8 次失败,每次成功交付成本从 3,872 涨到 4,139 微元。看板只给出一行「工作流失败」,值班同学接到的告警也是同一句。
问题是没有人能修一个叫「工作流」的东西。要修的是某个节点、某份提示、某个外部接口,或者某段时间预算。端到端失败率只说明「有事发生」,不说明「该改哪里」。归因的任务,是把一次失败从链路级拆到节点级,并给出可执行的下一步。
文中分布来自 ai-lab 的示例失败样本运行结果,不是生产事故统计。
失败清单不等于归因
一次链路失败通常留下这样一串记录:重排成功,解释节点失败,下单节点被跳过。三个节点里,一个成功、一个失败、一个没跑。把三个都算作「失败节点」,会得到三倍的修复工作量;把最后一条错误消息当成原因,会把「下游未执行」误判成「下游有问题」。
归因先做减法:跳过不是失败,下游报错往往只是上游失败的后果。真正的归因节点是执行顺序里第一个进入失败状态的节点;它之后被跳过的节点只记录,不背锅。
五类节点失败
节点失败按来源分五类。分类的价值不在命名,而在每类对应一套不同的判据和修复动作:
| 类型 | 典型信号 | 归因判据 | 该改哪里 |
|---|---|---|---|
| 输入 input | 字段缺失、类型不符、引用过期 | 输入摘要等于上游输出摘要时,归本节点输入契约;否则归上游输出契约 | 校验、契约版本、默认值 |
| 模型 model | 调用报错、空输出、拒答、质量不达标 | 失败发生在模型调用边界内 | 档位、提示版本、提供方状态 |
| 解析 parse | JSON 不合法、schema 不符、枚举越界 | 归产出该输出的节点,不归解析器 | 输出契约、修复提示、降级模板 |
| 外部 external | 动作被拒绝、目标系统报错、幂等冲突 | 归执行动作的节点 | 授权、幂等键、目标系统 |
| 超时 timeout | 节点超过自身时间片 | 归超预算的节点;下游跳过不算 | 节点预算、并行度、降级 |
解析这一类最容易被推错。解析器只是执行契约检查,它没有生成坏 JSON;坏 JSON 是上游节点产出的。把责任记到解析器头上,修复动作就会变成「让解析器更宽容」,而正确动作是收紧产出方的输出契约。
三条判据
第一条,首个失败节点优先。按执行顺序扫描,第一个 failed、timeout 或 denied 的节点就是归因起点;之后的 skipped 只进入「受影响节点」清单。并行分支按汇合顺序记录,避免同一个汇合点出现两个「首个」。
第二,输入失败看摘要。节点收到的输入摘要是 d-fetch,上游输出摘要也是 d-fetch,说明上游把数据原样传了下来,坏数据在本节点的输入校验处没有被拦住;两个摘要不一致,说明上游输出契约没有守住,责任回到上游。摘要不必存原文,只需要能判断「是不是同一份」。
第三,超时归超预算节点。下游节点显示 skipped,是因为上游超时后它没有机会执行。把超时记到下游,会让预算调整落到错误的节点;正确动作是查那个真正耗尽时间片的节点。
实现:把判据写成可测试的函数
attribution.ts 接受一条按执行顺序排列的节点轨迹,输出归因节点、归因层与受影响节点;聚合函数再按失败类型和被归因节点生成分布。
// file: src/orch/attribution.ts
// tier: L2
// verify: node --test test/orch/eval.test.ts
import { AiError } from "../core/errors.ts";
/** 节点失败的五个来源:输入、模型、解析、外部动作、超时。 */
export type NodeFailureKind = "input" | "model" | "parse" | "external" | "timeout";
export const FAILURE_KINDS: readonly NodeFailureKind[] = [
"input",
"model",
"parse",
"external",
"timeout",
];
export type NodeStatus = "ok" | "failed" | "timeout" | "denied" | "skipped";
export interface NodeTrace {
nodeId: string;
status: NodeStatus;
/** failed 必须带 failureKind;timeout 与 denied 可省略,分别按超时与外部动作处理。 */
failureKind?: NodeFailureKind;
/** 节点实际收到的输入摘要,用于判断坏数据是否由上游原样传下来。 */
inputDigest?: string;
/** 节点产出的摘要,用于判断解析失败应由谁负责。 */
outputDigest?: string;
latencyMs: number;
}
export interface RunTrace {
runId: string;
workflow: string;
/** 按执行顺序排列;并行分支按汇合顺序记录。 */
nodes: readonly NodeTrace[];
}
/** blamedStage 指向真正要改的层,而不是“报错的那一行代码”。 */
export type BlameStage =
| "input-contract"
| "model"
| "output-contract"
| "external-action"
| "timeout-budget";
export interface Attribution {
runId: string;
workflow: string;
failed: boolean;
firstFailureNode?: string;
failureKind?: NodeFailureKind;
blamedNode?: string;
blamedStage?: BlameStage;
/** 首个失败节点之后被跳过的节点;它们不是失败原因。 */
skipped: string[];
reason: string;
}
export interface FailureBucket {
kind: NodeFailureKind;
runs: number;
share: number;
nodes: Array<{ nodeId: string; runs: number }>;
}
export interface FailureReport {
runs: number;
failedRuns: number;
buckets: FailureBucket[];
/** 按被归因节点聚合,回答“哪个节点最该改”。 */
nodes: Array<{ nodeId: string; runs: number; kinds: NodeFailureKind[] }>;
}
function fail(message: string): never {
throw new AiError("invalid-output", `attribution: ${message}`);
}
function isFailureStatus(status: NodeStatus): boolean {
return status === "failed" || status === "timeout" || status === "denied";
}
function validateRun(trace: RunTrace): void {
if (trace.runId.trim() === "") fail("runId 不能为空");
if (trace.nodes.length === 0) fail(`${trace.runId} 没有任何节点`);
const seen = new Set<string>();
for (const node of trace.nodes) {
if (node.nodeId.trim() === "") fail("nodeId 不能为空");
if (seen.has(node.nodeId)) fail(`重复的 nodeId ${node.nodeId}`);
seen.add(node.nodeId);
if (!Number.isFinite(node.latencyMs) || node.latencyMs < 0) {
fail(`${node.nodeId}.latencyMs 必须是 >= 0 的有限数`);
}
if (node.status === "failed" && !node.failureKind) {
fail(`${node.nodeId} 状态为 failed,但没有 failureKind`);
}
if (node.status !== "failed" && node.status !== "timeout" && node.status !== "denied" && node.failureKind) {
fail(`${node.nodeId} 状态为 ${node.status},不应携带 failureKind`);
}
}
}
/** 首个进入 failed / timeout / denied 的节点;skipped 不参与定位。 */
export function firstFailureIndex(nodes: readonly NodeTrace[]): number {
return nodes.findIndex((node) => isFailureStatus(node.status));
}
/**
* 把一次端到端失败归因到节点与层。
* 解析失败归产出方,输入失败看摘要是否与上游一致,超时归超预算的节点。
*/
export function attributeRun(trace: RunTrace): Attribution {
validateRun(trace);
const index = firstFailureIndex(trace.nodes);
const skipped = trace.nodes
.filter((node) => node.status === "skipped")
.map((node) => node.nodeId);
if (index === -1) {
return {
runId: trace.runId,
workflow: trace.workflow,
failed: false,
skipped,
reason: "没有失败节点",
};
}
const node = trace.nodes[index];
const previous = index > 0 ? trace.nodes[index - 1] : undefined;
const kind: NodeFailureKind =
node.status === "timeout"
? "timeout"
: node.status === "denied"
? "external"
: (node.failureKind as NodeFailureKind);
let blamedNode = node.nodeId;
let blamedStage: BlameStage;
let reason: string;
switch (kind) {
case "input": {
const inherited = previous !== undefined &&
previous.status === "ok" &&
node.inputDigest !== undefined &&
previous.outputDigest !== undefined &&
node.inputDigest === previous.outputDigest;
if (inherited) {
blamedStage = "input-contract";
reason = "上游输出原样进入本节点,坏数据在本节点的输入校验处没有被拦住";
} else if (previous !== undefined) {
blamedNode = previous.nodeId;
blamedStage = "output-contract";
reason = "本节点收到的输入与上游产出不一致,上游输出契约没有守住";
} else {
blamedStage = "input-contract";
reason = "首节点收到的外部输入不满足契约";
}
break;
}
case "parse": {
blamedStage = "output-contract";
reason = "输出无法解析或不符合 schema,责任在产出该输出的节点,不在解析器";
break;
}
case "model": {
blamedStage = "model";
reason = "模型调用失败或返回不可用结果,先查档位、提示版本与提供方状态";
break;
}
case "external": {
blamedStage = "external-action";
reason = "外部动作失败或被拒绝,先查授权、幂等键与目标系统状态";
break;
}
case "timeout": {
blamedStage = "timeout-budget";
reason = "节点超出自身时间片;下游 skipped 是结果,不是原因";
break;
}
}
return {
runId: trace.runId,
workflow: trace.workflow,
failed: true,
firstFailureNode: node.nodeId,
failureKind: kind,
blamedNode,
blamedStage,
skipped,
reason,
};
}
/** 按失败类型与归因节点聚合;桶顺序固定,节点排序稳定,报告可直接对比。 */
export function aggregateFailures(runs: readonly RunTrace[]): FailureReport {
const attributions = runs.map(attributeRun);
const failed = attributions.filter((item) => item.failed);
const byKind = new Map<NodeFailureKind, Map<string, number>>();
const byNode = new Map<string, { runs: number; kinds: Set<NodeFailureKind> }>();
for (const item of failed) {
const kind = item.failureKind as NodeFailureKind;
const node = item.blamedNode as string;
const kinds = byKind.get(kind) ?? new Map<string, number>();
kinds.set(node, (kinds.get(node) ?? 0) + 1);
byKind.set(kind, kinds);
const entry = byNode.get(node) ?? { runs: 0, kinds: new Set<NodeFailureKind>() };
entry.runs += 1;
entry.kinds.add(kind);
byNode.set(node, entry);
}
const buckets: FailureBucket[] = FAILURE_KINDS.map((kind) => {
const nodes = byKind.get(kind) ?? new Map<string, number>();
const count = failed.filter((item) => item.failureKind === kind).length;
return {
kind,
runs: count,
share: failed.length === 0 ? 0 : Number((count / failed.length).toFixed(4)),
nodes: [...nodes.entries()]
.map(([nodeId, runs]) => ({ nodeId, runs }))
.sort((a, b) => b.runs - a.runs || a.nodeId.localeCompare(b.nodeId)),
};
});
return {
runs: runs.length,
failedRuns: failed.length,
buckets,
nodes: [...byNode.entries()]
.map(([nodeId, entry]) => ({
nodeId,
runs: entry.runs,
kinds: [...entry.kinds].sort(),
}))
.sort((a, b) => b.runs - a.runs || a.nodeId.localeCompare(b.nodeId)),
};
}
attributeRun 不信任「最后一条错误」,只认执行顺序里的第一个失败状态。blamedStage 与 blamedNode 分开,是因为同一个节点可能因为不同原因失败:模型失败要换档位,解析失败要改输出契约,两者的修复人甚至不是同一个。
aggregateFailures 的桶顺序固定为五类,节点按失败次数降序、同次数按 id 排序。报告因此可以逐字节比较,适合放进回归测试,而不是每次生成一份顺序不同的表格。
示例样本上的运行结果
示例取 10 条端到端失败记录,按上面的判据归因,分布如下:
| 失败类型 | 次数 | 占比 | 被归因节点 |
|---|---|---|---|
| 输入 | 2 | 20% | extract 1、fetch 1 |
| 模型 | 3 | 30% | answer 3 |
| 解析 | 2 | 20% | rerank 2 |
| 外部动作 | 2 | 20% | create-order 2 |
| 超时 | 1 | 10% | summarize 1 |
按节点聚合,answer 以 3 次排第一,create-order 与 rerank 各 2 次。这个排序和「哪个节点报错最多」不一定相同:fetch 只被归因 1 次,但它的输出契约问题同时导致了下游 extract 的输入失败;如果只看本节点错误码,fetch 会被漏掉。
分布还解释了成本:模型类失败占三成,重试会放大 token 成本;外部动作失败虽然只占两成,但每次都要人工确认,用户等待远高于节点延迟。归因表因此不能只按次数排序,还要把成本和延迟列放进来。
归因之后做什么
模型类失败先对比档位与提示版本。同一个节点在 standard 档成功、在 economy 档失败,问题在选型;同一档位在提示 v3 成功、v4 失败,问题在提示。两者的修复动作不同,需要 7.3 的回放来区分。
外部动作失败先查授权与幂等键。越权样本里,动作在发出前就被拒绝,重试没有意义;目标系统报错则需要幂等键,避免重试创建两笔订单。
超时先看节点预算,而不是整链预算。单步失败预算 13.02% 是成功率约束,时间预算则是另一条线;把 35ms 的交接延迟乘以跳数,往往能解释「每个节点都没超时,整链却超时」。
输入与解析失败回到契约。输入摘要能区分「上游没给对」与「本节点没校验」,解析失败则几乎总是产出方的输出契约问题。两类都应该在 7.1 的失败样本里留下首个失败节点,否则归因函数没有输入。
小结
端到端失败必须先拆到节点,再谈修复。五类失败对应五套判据:首个失败节点优先、解析归产出方、输入看摘要、超时归超预算节点、外部动作查授权与幂等。示例 10 条失败记录里,模型 30%、输入 20%、解析 20%、外部 20%、超时 10%;answer 是最该先改的节点。
归因回答「哪个节点」,不回答「为什么同一个输入这次失败、下次成功」。后者需要可复现的回放,这是 7.3 的主题。
阅读导航:上一节:7.1 端到端评测集 · 下一节:7.3 回放与可复现调试 。
常见错误与边界
- 把跳过节点算作失败节点。跳过是上游失败的后果,不是原因。
- 按最后一条错误消息归因。下游报错常常只是上游失败的传声。
- 把解析失败记到解析器头上。坏输出由产出方负责,解析器只是检查者。
- 输入失败一律归上游。输入摘要与上游输出一致时,问题在本节点的输入校验。
- 超时归到下游。下游没有执行机会,应该查真正耗尽时间片的节点。
- 只按失败次数排序。外部动作失败次数少,但人工确认与用户等待更贵。
- 归因后直接重试。模型类失败可能换档位,外部动作失败可能缺幂等键,重试会放大问题。
- 用示例分布当生产基线。示例只演示判据,真实分布要按自己的链路重新采集。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。