三段 await 之后,问题才开始
「回声」的三条链路最初都写在一个 handler 里的三段 await:A 内容链路 content.summarize → moderation.classify → 人工复核 → 发布;B 客服链路 chat.reply 判意图 → support.assist 检索 → chat.reply 答复;C 下单链路 search.rerank → chat.reply 说明 → 下单动作。
三段式能跑通,但把三件事焊死在代码顺序里:执行顺序、失败处理和等待方式。A 链路里摘要与内容审核其实可以同时开始,代码却只能一前一后;人工复核要等几小时,进程只能挂着;发布失败后想只重跑发布,得从头再执行一遍。
图的作用不是把 await 画成方框。它把「依赖」从「代码顺序」里拆出来:谁需要谁的结果,谁可以和谁并行,什么情况下算结束。图是编排的结构事实,调度器只是这张图的一个消费者。
节点:三类可调度单位
节点不是「一步」,而是「一个可以被独立调度、计费、重试和恢复的单位」。回声只用到三类:
| 类型 | 回声中的例子 | 是否计费 | 重试语义 | 恢复时最怕什么 |
|---|---|---|---|---|
capability | content.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,恢复语义完全不同。
边的三条规则:
- 边只表达数据依赖。
summarize完成后classify不需要它的结果,就不该有边。 - 跨层边对应一次交接。示例中每次交接 35ms,这是卷二沿用的口径,不是通用常数。
- 同一对节点只允许一条边。重复边不会让依赖更强,只会让交接次数虚高。
终止条件不是「跑完最后一个节点」
线性脚本的终点是最后一行。图必须显式回答「什么算结束」,否则一个漏改的分支就能让流程永远停在半路。这里用三个约束:
唯一的 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 内容 | input | summarize、classify | review | publish | 有,两个分支 |
| 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 个边界 |
| 交接延迟 | 175ms | 105ms |
| 关键路径 | 1,275ms | 1,025ms |
| 成本 | 1,325 微元 | 1,325 微元 |
| 终态 | 成功 | 成功 |
并行没有让成本下降,因为两个分支都要调用模型。它只改变关键路径:原来 classify 排在 summarize 后面,现在两者同时开始;原来五次交接串行发生,现在每个层间边界只付一次 35ms。把并行当成「免费加速」是编排里最常见的误判。
小结
图模型把编排拆成三件可分别检查的事:节点是可调度、可计费、可恢复的单位;边只表达数据依赖,并行机会由缺边自然产生;终止条件由唯一终点加步数、成本双闸共同定义。A 链路的示例从 1,275ms 压到 1,025ms,成本保持 1,325 微元,说明并行只压缩关键路径,不减少总工作量。
结构校验先于执行:环、孤立节点、不可达节点、到不了终点的节点和非法 quorum,都应该在启动时暴露,而不是在凌晨的告警里暴露。
阅读导航:上一节:2.3 编排层的输入输出与版本 · 下一节:3.2 拓扑执行与并行汇合 。
常见错误与边界
- 把图画成流程图。流程图表达「先做什么」,执行图表达「谁依赖谁」;两者的边含义不同。
- 用边写控制顺序。给两个没有数据依赖的节点加边,会消灭本来存在的并行机会。
- 没有唯一终点。多终点会让「整条链路成功」失去判定标准。
- 把并行当免费加速。两个分支都调用模型时,关键路径缩短,成本不变甚至上升。
- 用随机 UUID 当幂等键。随机键在重试和恢复时一定会变,等于没有幂等。
- 把不同样本的成本数字混着比较。调度样本与计费样本的输入结构不同,差值不能直接相加。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。