《互联网产品的 AI 架构·多能力编排与任务闭环》3.1 把编排写成图

把回声的三条链路从三段 await 改写成可校验的执行图:区分能力、人工与动作节点,用边表达数据依赖,用唯一终点与步数、成本双闸定义终止条件,让环、孤立节点、不可达终点和非法 quorum 在运行前暴露。附可运行的 TypeScript 图模型与 14 项确定性测试。

三段 await 之后,问题才开始

「回声」的三条链路最初都写在一个 handler 里的三段 await:A 内容链路 content.summarize → moderation.classify → 人工复核 → 发布;B 客服链路 chat.reply 判意图 → support.assist 检索 → chat.reply 答复;C 下单链路 search.rerank → chat.reply 说明 → 下单动作。

三段式能跑通,但把三件事焊死在代码顺序里:执行顺序、失败处理和等待方式。A 链路里摘要与内容审核其实可以同时开始,代码却只能一前一后;人工复核要等几小时,进程只能挂着;发布失败后想只重跑发布,得从头再执行一遍。

图的作用不是把 await 画成方框。它把「依赖」从「代码顺序」里拆出来:谁需要谁的结果,谁可以和谁并行,什么情况下算结束。图是编排的结构事实,调度器只是这张图的一个消费者。

节点:三类可调度单位

节点不是「一步」,而是「一个可以被独立调度、计费、重试和恢复的单位」。回声只用到三类:

类型回声中的例子是否计费重试语义恢复时最怕什么
capabilitycontent.summarize、moderation.classify、chat.reply是可重试,重复计费重放已扣费的调用
human人工复核、人工确认下单否不能自动重试等待被当成失败
action发布文章、创建订单可能有外部费用默认不重试重复产生副作用

节点字段只描述调度需要的事实:id、capability、kind、costMicros、latencyMs、maxAttempts、failAttempts、join 与 quorum。costMicros 是示例成本,整数微元;latencyMs 是示例耗时,毫秒;failAttempts 只用于确定性测试。图不拥有提示词:提示词属于能力实现,节点只声明「调用哪个能力、需要哪些输入、产出什么结果」,否则改一句提示词就要动整张图。

边:只画数据依赖

A 链路的图不是一条线,而是一个菱形:

summarize 与 classify 都依赖 input,彼此没有边,因此可以并行;review 同时依赖两者,两条边在人工节点汇合;publish 依赖 review,是唯一的终点。

B 链路是串行图:意图决定检索词,检索结果决定答复,三条边首尾相连。C 链路也是串行,但最后一条边通向 action 而不是 capability,恢复语义完全不同。

边的三条规则:

  1. 边只表达数据依赖。summarize 完成后 classify 不需要它的结果,就不该有边。
  2. 跨层边对应一次交接。示例中每次交接 35ms,这是卷二沿用的口径,不是通用常数。
  3. 同一对节点只允许一条边。重复边不会让依赖更强,只会让交接次数虚高。

终止条件不是「跑完最后一个节点」

线性脚本的终点是最后一行。图必须显式回答「什么算结束」,否则一个漏改的分支就能让流程永远停在半路。这里用三个约束:

唯一的 terminal 节点,且所有节点都必须能到达它;maxNodeRuns 限制单次运行的节点尝试次数,防止重试与分支扩散;maxCostMicros 限制单次运行的现金成本,整数微元。A 图的示例上限是 20 次尝试与 100,000 微元;正常一次执行只用 5 次节点运行、1,325 微元,两道闸都不触发,它们防的是异常路径。

人工节点让「结束」多了一种状态:不是成功,也不是失败,而是挂起等待。图只声明这里有一个人工节点;挂起多久、超时后怎么办,属于第 6 章的异步交付,不在图模型里。

DAG 校验:把错误挡在运行前

图最容易骗人的地方,是它画出来很好看,跑起来却永远不结束。校验要先把结构错误变成可定位的问题码:

问题码症状回声里的常见来源修复方向
cycle拓扑排序不完整发布失败后加了一条回到摘要的边把重试放回节点内,不要画回边
isolated-node节点没有任何边重构后遗留的旧审核节点删除或接入依赖
unreachable从入口走不到入口从 input 改成 session,旧分支没删修边或删节点
no-path-to-terminal到不了终点调试用的旁路节点接入终点或删除
bad-quorum票数超过分支数三条审核分支写了 quorum 4票数落在 1..分支数
bad-entry / bad-terminal入口或终点不在节点表配置与图分开维护启动时校验

校验的产出不是布尔值,而是一组 {code, nodeId, message}。这让同一个错误可以同时用于启动拒绝、日志聚合和测试断言。一个只返回 false 的校验器,在事故里只能让人重新读一遍图。

三条链路落成三张图

把三条链路按依赖分层,结构差异一眼可见:

链路第 0 层第 1 层第 2 层第 3 层天然并行
A 内容inputsummarize、classifyreviewpublish有,两个分支
B 客服判意图检索答复无无,意图决定检索
C 下单重排说明下单动作无无,动作依赖说明

只有 A 有真正的并行机会。B 看起来可以「先检索再判意图」,但检索词由意图决定,强行并行只会检索错东西;C 的动作必须等说明结果,提前下单是业务错误,不是性能优化。

A 图的串行基线是 1,275ms,按层执行是 1,025ms,成本都是 1,325 微元。这是示例样本上的运行结果,不是生产实测。省下的 250ms 来自两处:classify 的 180ms 移出关键路径,交接从 5 次降到 3 个层间边界、少 70ms。

卷二的三能力样本是成本 4,240 → 3,786 微元、延迟 1,200 → 1,269ms,交接 token 820、共享上下文 3,200。本节只改调度结构,不改单价与 token 结构,所以 A 图的成本不变;两个样本回答的是不同问题,不能把 1,025ms 和 1,269ms 直接比较。

实现

graph.ts 只做四件事:定义图、派生幂等键、校验结构、给出拓扑序与分层。它不执行节点,也不访问模型。

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

import { AiError } from "../core/errors.ts";

export type JoinPolicy = "all" | "any" | "quorum";
export type NodeKind = "capability" | "human" | "action";
export type NodeStatus = "succeeded" | "failed" | "skipped";

/** 节点是可调度的最小单位:调用什么、花多少、最多试几次,不在这里写提示词。 */
export interface GraphNode {
  id: string;
  capability: string;
  kind: NodeKind;
  /** 示例成本,整数微元;包含该节点自己的上下文与输出。 */
  costMicros: number;
  /** 示例耗时,毫秒。 */
  latencyMs: number;
  /** 最多尝试次数,默认 1;重试会重复计费。 */
  maxAttempts?: number;
  /** 确定性失败注入:第几次尝试失败(从 1 起)。 */
  failAttempts?: readonly number[];
  /** 多前驱汇合策略,默认 all。 */
  join?: JoinPolicy;
  /** join 为 quorum 时需要的成功票数。 */
  quorum?: number;
}

/** 边表示数据依赖:to 需要 from 的结果,不表示“上一步做完做下一步”。 */
export interface GraphEdge {
  from: string;
  to: string;
}

/** 终止条件除了唯一的终点,还需要步数与成本两道硬闸。 */
export interface GraphLimits {
  maxNodeRuns: number;
  maxCostMicros: number;
}

export interface ExecutionGraph {
  id: string;
  entry: string;
  terminal: string;
  nodes: readonly GraphNode[];
  edges: readonly GraphEdge[];
  limits: GraphLimits;
  /** 每次跨层交接的固定延迟,示例值 35ms。 */
  handoffLatencyMs: number;
}

export interface GraphIssue {
  code: string;
  nodeId?: string;
  message: string;
}

/** 检查点里的单节点记录;恢复时只信任 succeeded。 */
export interface NodeRecord {
  nodeId: string;
  status: NodeStatus;
  attempts: number;
  output: string;
  costMicros: number;
  latencyMs: number;
  idempotencyKey: string;
  level: number;
}

export interface Checkpoint {
  graphId: string;
  rootIdempotencyKey: string;
  records: NodeRecord[];
  /** 跨多次恢复累计的成本,整数微元。 */
  spentCostMicros: number;
  /** 跨多次恢复累计的节点尝试次数。 */
  nodeRuns: number;
  levelsDone: number;
}

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

function issue(code: string, message: string, nodeId?: string): GraphIssue {
  return nodeId === undefined ? { code, message } : { code, message, nodeId };
}

/**
 * 子节点的幂等键由根键与节点 id 派生,重试和恢复都不会改变它。
 * 下游拿到的是同一个根键,而不是每个节点各自生成一个 UUID。
 */
export function deriveIdempotencyKey(rootKey: string, nodeId: string): string {
  let hash = 0x811c9dc5;
  for (const char of `${rootKey}::${nodeId}`) {
    hash ^= char.codePointAt(0) ?? 0;
    hash = Math.imul(hash, 0x01000193) >>> 0;
  }
  return `${rootKey}.${hash.toString(16).padStart(8, "0")}`;
}

export function nodeMap(graph: ExecutionGraph): Map<string, GraphNode> {
  return new Map(graph.nodes.map((node) => [node.id, node]));
}

export function incomingEdges(graph: ExecutionGraph, nodeId: string): GraphEdge[] {
  return graph.edges.filter((edge) => edge.to === nodeId);
}

function reachable(start: string, edges: readonly GraphEdge[], direction: "forward" | "backward"): Set<string> {
  const next = new Map<string, string[]>();
  for (const edge of edges) {
    const from = direction === "forward" ? edge.from : edge.to;
    const to = direction === "forward" ? edge.to : edge.from;
    next.set(from, [...(next.get(from) ?? []), to]);
  }
  const seen = new Set<string>([start]);
  const queue = [start];
  while (queue.length > 0) {
    for (const neighbor of next.get(queue.shift() as string) ?? []) {
      if (!seen.has(neighbor)) {
        seen.add(neighbor);
        queue.push(neighbor);
      }
    }
  }
  return seen;
}

/** Kahn 排序;返回的 cycle 非空说明图有环,order 不完整。 */
function kahn(ids: readonly string[], edges: readonly GraphEdge[]): { order: string[]; cycle: string[] } {
  const indegree = new Map(ids.map((id) => [id, 0]));
  const outgoing = new Map<string, string[]>(ids.map((id) => [id, []]));
  for (const edge of edges) {
    indegree.set(edge.to, (indegree.get(edge.to) ?? 0) + 1);
    outgoing.get(edge.from)?.push(edge.to);
  }
  const ready = ids.filter((id) => (indegree.get(id) ?? 0) === 0).sort();
  const order: string[] = [];
  while (ready.length > 0) {
    const id = ready.shift() as string;
    order.push(id);
    for (const neighbor of [...(outgoing.get(id) ?? [])].sort()) {
      const left = (indegree.get(neighbor) ?? 0) - 1;
      indegree.set(neighbor, left);
      if (left === 0) {
        ready.push(neighbor);
        ready.sort();
      }
    }
  }
  return { order, cycle: ids.filter((id) => (indegree.get(id) ?? 0) > 0).sort() };
}

/** 先收结构问题,再谈调度;有环或不可达的图不应该进入执行器。 */
export function validateGraph(graph: ExecutionGraph): GraphIssue[] {
  if (graph.nodes.length === 0) return [issue("empty-graph", "图至少需要一个节点")];
  const issues: GraphIssue[] = [];
  const byId = new Map<string, GraphNode>();
  for (const node of graph.nodes) {
    if (node.id.trim() === "") {
      issues.push(issue("empty-node-id", "节点 id 不能为空"));
      continue;
    }
    if (byId.has(node.id)) issues.push(issue("duplicate-node", `重复的节点 ${node.id}`, node.id));
    else byId.set(node.id, node);
    if (!Number.isInteger(node.costMicros) || node.costMicros < 0) {
      issues.push(issue("bad-cost", "costMicros 必须是非负整数", node.id));
    }
    if (!Number.isFinite(node.latencyMs) || node.latencyMs < 0) {
      issues.push(issue("bad-latency", "latencyMs 必须是非负数", node.id));
    }
    if (node.maxAttempts !== undefined && (!Number.isInteger(node.maxAttempts) || node.maxAttempts < 1)) {
      issues.push(issue("bad-attempts", "maxAttempts 必须是 >= 1 的整数", node.id));
    }
    if (node.join !== undefined && !["all", "any", "quorum"].includes(node.join)) {
      issues.push(issue("bad-join", `未知的汇合策略 ${String(node.join)}`, node.id));
    }
  }

  if (!byId.has(graph.entry)) issues.push(issue("bad-entry", `入口 ${graph.entry} 不在节点表中`));
  if (!byId.has(graph.terminal)) issues.push(issue("bad-terminal", `终点 ${graph.terminal} 不在节点表中`));
  if (!Number.isInteger(graph.limits.maxNodeRuns) || graph.limits.maxNodeRuns < 1) {
    issues.push(issue("bad-run-limit", "maxNodeRuns 必须是 >= 1 的整数"));
  }
  if (!Number.isInteger(graph.limits.maxCostMicros) || graph.limits.maxCostMicros < 0) {
    issues.push(issue("bad-cost-limit", "maxCostMicros 必须是非负整数"));
  }
  if (!Number.isFinite(graph.handoffLatencyMs) || graph.handoffLatencyMs < 0) {
    issues.push(issue("bad-handoff", "handoffLatencyMs 必须是非负数"));
  }

  const edges: GraphEdge[] = [];
  const seen = new Set<string>();
  for (const edge of graph.edges) {
    if (!byId.has(edge.from) || !byId.has(edge.to)) {
      issues.push(issue("unknown-node", `边 ${edge.from}->${edge.to} 引用了不存在的节点`));
    } else if (edge.from === edge.to) {
      issues.push(issue("self-loop", `节点 ${edge.from} 指向自己`, edge.from));
    } else if (seen.has(`${edge.from}->${edge.to}`)) {
      issues.push(issue("duplicate-edge", `重复的边 ${edge.from}->${edge.to}`));
    } else {
      seen.add(`${edge.from}->${edge.to}`);
      edges.push(edge);
    }
  }

  const ids = [...byId.keys()];
  const { cycle } = kahn(ids, edges);
  if (cycle.length > 0) issues.push(issue("cycle", `图存在环,涉及 ${cycle.join(", ")}`));

  if (byId.has(graph.entry) && byId.has(graph.terminal)) {
    const forward = reachable(graph.entry, edges, "forward");
    const backward = reachable(graph.terminal, edges, "backward");
    for (const id of [...ids].sort()) {
      if (!forward.has(id)) issues.push(issue("unreachable", `节点 ${id} 从入口不可达`, id));
      if (!backward.has(id)) issues.push(issue("no-path-to-terminal", `节点 ${id} 到不了终点 ${graph.terminal}`, id));
    }
  }

  const touched = new Set(edges.flatMap((edge) => [edge.from, edge.to]));
  for (const id of [...ids].sort()) {
    if (!touched.has(id) && id !== graph.entry && id !== graph.terminal) {
      issues.push(issue("isolated-node", `节点 ${id} 没有任何边`, id));
    }
  }

  for (const node of byId.values()) {
    if (node.join !== "quorum") continue;
    const deps = edges.filter((edge) => edge.to === node.id).length;
    const quorum = node.quorum ?? 0;
    if (!Number.isInteger(quorum) || quorum < 1 || quorum > deps) {
      issues.push(issue("bad-quorum", `quorum 必须落在 1..${deps}`, node.id));
    }
  }
  return issues;
}

export function assertValidGraph(graph: ExecutionGraph): void {
  const issues = validateGraph(graph);
  if (issues.length > 0) {
    fail(issues.map((entry) => `${entry.code}: ${entry.message}`).join("; "));
  }
}

/** 同一张图每次得到同一个拓扑序,测试与回放才能比较。 */
export function topologicalOrder(graph: ExecutionGraph): string[] {
  assertValidGraph(graph);
  return kahn(graph.nodes.map((node) => node.id), graph.edges).order;
}

/** 层号取“到入口的最长路径”,同一层的节点之间没有依赖,可以并行。 */
export function levelize(graph: ExecutionGraph): string[][] {
  const order = topologicalOrder(graph);
  const levelOf = new Map<string, number>();
  for (const id of order) {
    const parents = incomingEdges(graph, id).map((edge) => levelOf.get(edge.from) ?? 0);
    levelOf.set(id, parents.length === 0 ? 0 : Math.max(...parents) + 1);
  }
  const levels: string[][] = [];
  for (const id of order) {
    const level = levelOf.get(id) as number;
    (levels[level] ??= []).push(id);
  }
  return levels;
}

deriveIdempotencyKey 放在图模型里,而不是执行器里,是因为键必须由结构决定:同一个根请求、同一个节点,无论重试几次、恢复几次,键都一样。3.3 会展开它如何跨节点传递。

validateGraph 先收集再返回,不在第一个错误处抛出。启动时一次看到全部结构问题,比修一个跑一次快得多。kahn 同时服务于校验与拓扑排序:校验看剩余节点判断环,排序返回完整序列;ready 集合按 id 排序,保证同一张图每次得到同一个顺序。

levelize 的层号取「到入口的最长路径」。同一层的节点之间一定没有依赖,这是并行执行的充分条件;反过来,有依赖的节点一定不在同一层。

示例样本上的运行结果

对 A 图分别按「串行基线」与「按层执行」计算,结果如下。所有数字来自 node --test test/orch/graph.test.ts 的确定性断言。

指标串行基线按层执行
层间交接次数5 条边3 个边界
交接延迟175ms105ms
关键路径1,275ms1,025ms
成本1,325 微元1,325 微元
终态成功成功

并行没有让成本下降,因为两个分支都要调用模型。它只改变关键路径:原来 classify 排在 summarize 后面,现在两者同时开始;原来五次交接串行发生,现在每个层间边界只付一次 35ms。把并行当成「免费加速」是编排里最常见的误判。

小结

图模型把编排拆成三件可分别检查的事:节点是可调度、可计费、可恢复的单位;边只表达数据依赖,并行机会由缺边自然产生;终止条件由唯一终点加步数、成本双闸共同定义。A 链路的示例从 1,275ms 压到 1,025ms,成本保持 1,325 微元,说明并行只压缩关键路径,不减少总工作量。

结构校验先于执行:环、孤立节点、不可达节点、到不了终点的节点和非法 quorum,都应该在启动时暴露,而不是在凌晨的告警里暴露。

阅读导航:上一节:2.3 编排层的输入输出与版本 · 下一节:3.2 拓扑执行与并行汇合 。

常见错误与边界

  • 把图画成流程图。流程图表达「先做什么」,执行图表达「谁依赖谁」;两者的边含义不同。
  • 用边写控制顺序。给两个没有数据依赖的节点加边,会消灭本来存在的并行机会。
  • 没有唯一终点。多终点会让「整条链路成功」失去判定标准。
  • 把并行当免费加速。两个分支都调用模型时,关键路径缩短,成本不变甚至上升。
  • 用随机 UUID 当幂等键。随机键在重试和恢复时一定会变,等于没有幂等。
  • 把不同样本的成本数字混着比较。调度样本与计费样本的输入结构不同,差值不能直接相加。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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