拓扑序不是执行顺序
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是quorum2/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,025ms | 1,325 微元 | review 成功 |
三分支 all,一个失败 | 失败 | 45ms | 300 微元 | merge 跳过 |
三分支 any,两个失败 | 成功 | 100ms | 350 微元 | merge 成功 |
quorum 2/3,一个失败 | 成功 | 100ms | 350 微元 | merge 成功 |
quorum 3/3,一个失败 | 失败 | 45ms | 300 微元 | merge 跳过 |
summarize 首次失败后重试 | 成功 | 1,645ms | 2,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,分段延迟相加会超过原始关键路径。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。