《互联网产品的 AI 架构·多能力编排与任务闭环》7.2 失败归因:哪个节点造成的

端到端失败率 1.88%,面板只写「工作流失败」,值班同学无法修复一个叫「工作流」的东西。本节把失败拆到节点:输入、模型、解析、外部动作、超时五类,给出归因表与判据——首个失败节点、解析归产出方、输入看摘要、超时归超预算节点,并用 10 条失败样本跑出分布。

一个叫「工作流」的失败

高失败率变体把摘要节点的失败率从 3% 提到 12%,回复节点从 2% 提到 6%。整链成功率从 99.86% 掉到 98.12%:每 1,000 次请求有 18.8 次失败,每次成功交付成本从 3,872 涨到 4,139 微元。看板只给出一行「工作流失败」,值班同学接到的告警也是同一句。

问题是没有人能修一个叫「工作流」的东西。要修的是某个节点、某份提示、某个外部接口,或者某段时间预算。端到端失败率只说明「有事发生」,不说明「该改哪里」。归因的任务,是把一次失败从链路级拆到节点级,并给出可执行的下一步。

文中分布来自 ai-lab 的示例失败样本运行结果,不是生产事故统计。

失败清单不等于归因

一次链路失败通常留下这样一串记录:重排成功,解释节点失败,下单节点被跳过。三个节点里,一个成功、一个失败、一个没跑。把三个都算作「失败节点」,会得到三倍的修复工作量;把最后一条错误消息当成原因,会把「下游未执行」误判成「下游有问题」。

归因先做减法:跳过不是失败,下游报错往往只是上游失败的后果。真正的归因节点是执行顺序里第一个进入失败状态的节点;它之后被跳过的节点只记录,不背锅。

五类节点失败

节点失败按来源分五类。分类的价值不在命名,而在每类对应一套不同的判据和修复动作:

类型典型信号归因判据该改哪里
输入 input字段缺失、类型不符、引用过期输入摘要等于上游输出摘要时,归本节点输入契约;否则归上游输出契约校验、契约版本、默认值
模型 model调用报错、空输出、拒答、质量不达标失败发生在模型调用边界内档位、提示版本、提供方状态
解析 parseJSON 不合法、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 条端到端失败记录,按上面的判据归因,分布如下:

失败类型次数占比被归因节点
输入220%extract 1、fetch 1
模型330%answer 3
解析220%rerank 2
外部动作220%create-order 2
超时110%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 回放与可复现调试 。

常见错误与边界

  • 把跳过节点算作失败节点。跳过是上游失败的后果,不是原因。
  • 按最后一条错误消息归因。下游报错常常只是上游失败的传声。
  • 把解析失败记到解析器头上。坏输出由产出方负责,解析器只是检查者。
  • 输入失败一律归上游。输入摘要与上游输出一致时,问题在本节点的输入校验。
  • 超时归到下游。下游没有执行机会,应该查真正耗尽时间片的节点。
  • 只按失败次数排序。外部动作失败次数少,但人工确认与用户等待更贵。
  • 归因后直接重试。模型类失败可能换档位,外部动作失败可能缺幂等键,重试会放大问题。
  • 用示例分布当生产基线。示例只演示判据,真实分布要按自己的链路重新采集。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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