《互联网产品的 AI 架构·多能力编排与任务闭环》3.2 拓扑执行与并行汇合

拓扑序只给约束,不给调度。本节把回声的三条链路按层执行:层内并行、层间交接,用 all、any、quorum 三种汇合语义处理分支失败,并用确定性样本量化关键路径、重试成本与 13.02% 单步失败预算的关系,附可运行的 TypeScript 调度器。

拓扑序不是执行顺序

3.1 的图只说明了依赖。把图变成执行,第一反应是「按拓扑序一个个跑」。这没有错,但它把并行机会全部丢掉了:A 链路里 summarize 与 classify 在拓扑序里一前一后,实际却没有依赖,本可以同时开始。

拓扑序回答的是「任何节点出现时,它的前驱都已经出现」。它给的是约束,不是时间表。真正的调度要在约束之上再回答两个问题:哪些节点可以同时跑,以及一个节点有多个前驱时,要等几个前驱成功才继续。

按层执行:层内并行,层间交接

层号取「到入口的最长路径」。A 图分成四层:input;summarize、classify;review;publish。同一层内没有边,所以可以并行;跨层边意味着一次交接。

按层执行时,两个聚合口径必须分开:

量层内口径原因
延迟取该层节点的最大值并行分支同时开始,最慢的那个决定何时进入下一层
成本取该层节点的总和每个节点都真实调用、真实计费
交接每个层间边界计一次 35ms同一边界上的多次交接并行发生,关键路径只承担一次

A 图的关键路径是 0 + 620 + 240 + 60 + 3 × 35 = 1,025ms,成本是 1,070 + 255 = 1,325 微元。串行基线是 0 + 620 + 180 + 240 + 60 + 5 × 35 = 1,275ms。并行省下 250ms,其中 180ms 是把 classify 移出关键路径,70ms 是少了两条串行交接。

这里的分母要说清楚:1,025ms 是 A 图五个节点在示例参数下的关键路径,不是「所有 AI 请求」的平均延迟;1,325 微元是这两个模型节点的示例成本,不含人工与发布的业务成本。

汇合:all、any、quorum

一个节点有多个前驱时,调度器不能简单地「等全部完成」。回声里三种语义都用得上:

策略继续条件回声中的用法分支失败后的行为
all所有前驱都成功A 链路的人工复核需要摘要与审核两份结果任一分支失败,汇合节点跳过
any至少一个前驱成功B 链路同时查主索引、缓存与备用索引一个检索源失败,仍可用另一个答复
quorum成功票数达到阈值A 链路三个审核信号中至少两个通过票数不足才跳过,单点误判被吸收

quorum 必须同时写票数与分支数。只写「至少两个通过」而不写「一共三个信号」,在新增第四个信号时语义会悄悄变化。实现里把 quorum 约束在 1..前驱数,越界在启动时失败。

三种语义不是「宽松程度」的排序。any 适合可替代的数据源,quorum 适合互相独立的判断,all 适合缺一不可的输入。把人工复核改成 any,等于只要摘要或审核任一完成就放行,这不是容错,是绕过。

失败传播:同层不连坐,下游看策略

一个节点失败,不会打断同层兄弟节点。A 图里 classify 失败时,summarize 仍会跑完;它已经付出的成本不会因为兄弟失败而退回。真正决定下游命运的是汇合策略:

  • review 是 all,classify 失败后它被跳过,publish 再因 review 跳过而跳过。
  • 如果 review 是 quorum 2/3,单个审核信号失败仍可进入人工复核,但人工看到的输入会少一份。
  • 如果 review 是 any,一份结果就能放行,失败被静默吞掉。

卷二给出的单步失败预算是 13.02%:三步、每步 1 次重试、目标成功率 95% 时,单步失败率不能超过这个值。并行不会改变这个预算,只会改变失败的影响面:串行链路里一次失败阻断整条链;并行加 quorum 可以把单点失败吸收在汇合点,但被吸收的失败仍然是成本,只是没有变成用户可见的错误。

重试:成本翻倍,延迟也翻倍

节点内部重试是调度器最容易低估的部分。示例中 summarize 配了 maxAttempts: 2,第一次失败、第二次成功时:

  • 成本从 1,070 变成 2,140 微元,因为两次调用都计费。
  • 该层延迟从 620 变成 1,240ms,因为两次尝试串行发生。
  • 关键路径从 1,025 涨到 1,645ms,整条链路的成本从 1,325 涨到 2,395 微元。

重试提高的是成功率,不是免费保险。每一次重试都要同时问三个问题:这次失败可重试吗、重试预算还剩多少、重试后的关键路径还守得住延迟目标吗。

两道闸:步数与成本

按层执行还需要硬终止。maxNodeRuns 在执行每个节点前检查,达到上限就停止本次运行,未执行节点不产生成本;maxCostMicros 同理。示例把 A 图的步数上限设为 2 时,执行完 input 与 classify 就停,summarize 一次都不会调用,成本停在 255 微元。

这两道闸不是为了正常路径,而是为了防止异常路径把预算烧穿:一个配置错误的重试、一个被错误连成扇出的分支,都可能让节点运行次数远超预期。

实现

schedule.ts 按层推进:同层节点顺序执行模拟、但延迟按最大值聚合,等价于并行;失败节点不阻断同层;汇合由 joinSatisfied 判定;检查点记录累计成本与已完成层。

// file: src/orch/schedule.ts
// tier: L2
// verify: node --test test/orch/graph.test.ts

import { AiError } from "../core/errors.ts";
import {
  deriveIdempotencyKey,
  incomingEdges,
  levelize,
  nodeMap,
  validateGraph,
  type Checkpoint,
  type ExecutionGraph,
  type GraphNode,
  type NodeRecord,
} from "./graph.ts";

export type StopReason = "complete" | "failed" | "halted" | "budget";

export interface RunOptions {
  rootIdempotencyKey: string;
  /** 覆盖节点的失败注入,用于模拟“故障已修复”后再恢复。 */
  failAttempts?: Readonly<Record<string, readonly number[]>>;
  /** 执行完该层后主动中断,模拟进程崩溃;层号从 0 起。 */
  haltAfterLevel?: number;
  /** 恢复时传入上一次的检查点。 */
  checkpoint?: Checkpoint;
}

export interface RunResult {
  graphId: string;
  status: "succeeded" | "failed" | "stopped";
  stopReason: StopReason;
  levels: string[][];
  /** 本次真正执行的节点,按层内 id 顺序。 */
  ranNodeIds: string[];
  /** 本次从检查点恢复、未重跑的节点。 */
  restoredNodeIds: string[];
  records: NodeRecord[];
  /** 本次执行的现金成本;累计成本看 checkpoint.spentCostMicros。 */
  costMicros: number;
  /** 本次执行的关键路径,不等于端到端累计延迟。 */
  latencyMs: number;
  handoffCount: number;
  checkpoint: Checkpoint;
}

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

function skippedRecord(nodeId: string, level: number, rootKey: string): NodeRecord {
  return {
    nodeId,
    status: "skipped",
    attempts: 0,
    output: "",
    costMicros: 0,
    latencyMs: 0,
    idempotencyKey: deriveIdempotencyKey(rootKey, nodeId),
    level,
  };
}

/**
 * 汇合只看直接前驱的成功数:all 要求全成功,any 允许一个,
 * quorum 要求达到票数。失败和跳过都不算成功票。
 */
export function joinSatisfied(node: GraphNode, deps: readonly string[], records: Map<string, NodeRecord>): boolean {
  if (deps.length === 0) return true;
  const successes = deps.filter((dep) => records.get(dep)?.status === "succeeded").length;
  switch (node.join ?? "all") {
    case "any":
      return successes >= 1;
    case "quorum":
      return successes >= (node.quorum ?? deps.length);
    default:
      return successes === deps.length;
  }
}

function executeNode(
  node: GraphNode,
  level: number,
  rootKey: string,
  override: readonly number[] | undefined,
): NodeRecord {
  const failedAttempts = new Set(override ?? node.failAttempts ?? []);
  const maxAttempts = node.maxAttempts ?? 1;
  let attempts = 0;
  let costMicros = 0;
  let latencyMs = 0;
  let ok = false;
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    attempts = attempt;
    costMicros += node.costMicros;
    latencyMs += node.latencyMs;
    if (!failedAttempts.has(attempt)) {
      ok = true;
      break;
    }
  }
  return {
    nodeId: node.id,
    status: ok ? "succeeded" : "failed",
    attempts,
    output: `${node.capability}#${node.id}:${ok ? "ok" : "failed"}`,
    costMicros,
    latencyMs,
    idempotencyKey: deriveIdempotencyKey(rootKey, node.id),
    level,
  };
}

/**
 * 按层执行:同层节点并行,层内延迟取最大值、成本取总和。
 * 失败不打断同层兄弟节点,只影响下游的汇合判定。
 */
export function runGraph(graph: ExecutionGraph, options: RunOptions): RunResult {
  const issues = validateGraph(graph);
  if (issues.length > 0) {
    fail(issues.map((issue) => `${issue.code}: ${issue.message}`).join("; "));
  }
  if (options.rootIdempotencyKey.trim() === "") fail("rootIdempotencyKey 不能为空");

  const levels = levelize(graph);
  const nodes = nodeMap(graph);
  const records = new Map<string, NodeRecord>();
  const restoredNodeIds: string[] = [];

  if (options.checkpoint) {
    if (options.checkpoint.graphId !== graph.id) {
      fail(`检查点属于图 ${options.checkpoint.graphId},不能恢复到 ${graph.id}`);
    }
    if (options.checkpoint.rootIdempotencyKey !== options.rootIdempotencyKey) {
      fail("恢复必须沿用同一个 rootIdempotencyKey");
    }
    for (const record of options.checkpoint.records) {
      if (record.status === "succeeded" && nodes.has(record.nodeId)) {
        records.set(record.nodeId, record);
        restoredNodeIds.push(record.nodeId);
      }
    }
    restoredNodeIds.sort();
  }

  const ranNodeIds: string[] = [];
  let costMicros = 0;
  let nodeRuns = 0;
  let latencyMs = 0;
  let handoffCount = 0;
  let levelsDone = 0;
  let stopReason: StopReason = "complete";
  let stopped = false;

  for (let level = 0; level < levels.length && !stopped; level += 1) {
    let levelLatency = 0;
    let ranInLevel = false;
    for (const nodeId of levels[level]) {
      if (records.get(nodeId)?.status === "succeeded") continue;
      const node = nodes.get(nodeId);
      if (!node) continue;
      const deps = incomingEdges(graph, nodeId).map((edge) => edge.from);
      if (!joinSatisfied(node, deps, records)) {
        records.set(nodeId, skippedRecord(nodeId, level, options.rootIdempotencyKey));
        continue;
      }
      if (nodeRuns >= graph.limits.maxNodeRuns || costMicros >= graph.limits.maxCostMicros) {
        stopReason = "budget";
        stopped = true;
        break;
      }
      const override = options.failAttempts?.[nodeId];
      const record = executeNode(node, level, options.rootIdempotencyKey, override);
      records.set(nodeId, record);
      ranNodeIds.push(nodeId);
      ranInLevel = true;
      nodeRuns += record.attempts;
      costMicros += record.costMicros;
      levelLatency = Math.max(levelLatency, record.latencyMs);
    }

    if (level > 0 && ranInLevel) {
      handoffCount += 1;
      latencyMs += graph.handoffLatencyMs;
    }
    latencyMs += levelLatency;

    if (stopped) break;
    levelsDone = level + 1;
    if (options.haltAfterLevel === level) {
      stopReason = "halted";
      stopped = true;
      break;
    }
  }

  if (stopReason === "complete") {
    stopReason = records.get(graph.terminal)?.status === "succeeded" ? "complete" : "failed";
  }
  const status = stopReason === "complete" ? "succeeded" : stopReason === "failed" ? "failed" : "stopped";
  const orderedRecords = [...records.values()].sort(
    (left, right) => left.level - right.level || left.nodeId.localeCompare(right.nodeId),
  );
  const checkpoint: Checkpoint = {
    graphId: graph.id,
    rootIdempotencyKey: options.rootIdempotencyKey,
    records: orderedRecords,
    spentCostMicros: (options.checkpoint?.spentCostMicros ?? 0) + costMicros,
    nodeRuns: (options.checkpoint?.nodeRuns ?? 0) + nodeRuns,
    levelsDone,
  };

  return {
    graphId: graph.id,
    status,
    stopReason,
    levels,
    ranNodeIds,
    restoredNodeIds,
    records: orderedRecords,
    costMicros,
    latencyMs,
    handoffCount,
    checkpoint,
  };
}

joinSatisfied 只数直接前驱的成功票,失败与跳过都不算成功。这个口径很重要:如果把跳过当成成功,all 会在上游失败时继续放行,整条链路的失败会被伪装成部分成功。

executeNode 把重试写成串行循环,每次尝试都累加成本与延迟。failAttempts 是确定性注入,用来在测试里复现「第一次失败、第二次成功」,不参与生产判断。

交接只在「该层确实执行了节点」时计一次。恢复执行时,如果整层都从检查点复用,这一层不产生交接;否则会把已经付过的 35ms 再算一遍,恢复后的分段延迟相加就会超过原始关键路径。这个细节在 3.3 会用 655ms + 370ms = 1,025ms 验证。

示例样本上的运行结果

同一套示例参数下,四个场景的确定性结果如下,均来自 node --test test/orch/graph.test.ts:

场景终态关键路径成本汇合节点
A 图正常执行成功1,025ms1,325 微元review 成功
三分支 all,一个失败失败45ms300 微元merge 跳过
三分支 any,两个失败成功100ms350 微元merge 成功
quorum 2/3,一个失败成功100ms350 微元merge 成功
quorum 3/3,一个失败失败45ms300 微元merge 跳过
summarize 首次失败后重试成功1,645ms2,395 微元review 成功

all 场景的 300 微元只包含三个分支,汇合节点被跳过、不计费;any 与 quorum 场景的 350 微元多出的 50 是汇合节点本身。关键路径的差异来自汇合节点是否执行,而不是失败分支的耗时。

这些数字回答的是调度结构问题,不是业务收益问题。A 图的 1,325 微元与卷二三能力样本的 3,786 微元不是同一批输入,不能相减;能比较的是同一张图在串行与按层两种调度下的 1,275ms 与 1,025ms。

小结

拓扑序给约束,层给并行机会。按层执行时,延迟取层内最大值、成本取层内总和、交接只按层间边界计一次;汇合用 all、any、quorum 三种语义,分别对应缺一不可、可替代数据源和独立判断投票。失败不连坐同层,但会通过汇合策略改变下游命运;重试同时放大成本与延迟,必须放进 13.02% 的单步失败预算里一起看。

A 图的示例从 1,275ms 压到 1,025ms,成本保持 1,325 微元,说明调度优化的是关键路径,不是总工作量。

阅读导航:上一节:3.1 把编排写成图 · 下一节:3.3 检查点、恢复与幂等 。

常见错误与边界

  • 把拓扑序当执行顺序。它只保证前驱先出现,不保证并行,也不保证最短关键路径。
  • 同层节点按成本相加算延迟。并行分支的延迟应取最大值,成本才取总和。
  • 每个层间边都计一次交接。同一边界上的交接并行发生,关键路径只承担一次。
  • 用 any 处理缺一不可的输入。人工复核只要一份结果就放行,等于绕过审核。
  • quorum 不写总分支数。新增信号后阈值语义会变,票数必须和分支数一起校验。
  • 把跳过当成功。上游失败会被伪装成部分成功,整条链路的终态失真。
  • 重试不计成本。每次尝试都计费,重试后的关键路径也会变长。
  • 重试没有全局预算。单步 13.02% 是三步、一次重试、95% 目标下的上限,步骤一变就要重算。
  • 忽略恢复时的交接重复计数。整层复用后仍加 35ms,分段延迟相加会超过原始关键路径。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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