《互联网产品的 AI 架构·多能力编排与任务闭环》3.3 检查点、恢复与幂等

人工复核要等几小时,发布动作又不能重放。本节定义回声可恢复执行图的检查点结构、只信任 succeeded 的重放边界,以及从根键派生、跨重试与恢复不变的幂等键;用 655ms + 370ms = 1,025ms 验证恢复后的延迟与成本口径,附可运行的 TypeScript 恢复模块。

人工节点把一条请求切成两段

A 链路的第三个节点是人工复核。它可能等十分钟,也可能等两天。进程不能一直挂着,请求也不能因为一次部署就从头再来。更麻烦的是最后一个节点:发布动作一旦执行,重复执行会产生第二篇可见文章,这比「慢一点」严重得多。

检查点要解决的不是「保存进度」这么简单,而是三个边界:

  • 哪些节点已经完成,恢复时可以直接复用结果。
  • 哪些节点需要重放,重放时会不会再次产生副作用。
  • 同一个业务对象在多次尝试、多次恢复中,如何被外部系统认成同一次操作。

前两个是重放边界,第三个是幂等键。三者必须一起设计,缺一个都会在恢复时出问题。

检查点里到底存什么

检查点不是数据库快照,也不是整段对话的备份。它只存恢复调度必需的事实:

字段含义为什么必须存
graphId图标识,建议带上版本,例如 echo.publish@v3防止旧检查点被新图静默恢复
rootIdempotencyKey整条链路的根键所有子节点键由它派生,换键等于换业务对象
records[].nodeId节点标识恢复时按节点复用结果
records[].statussucceeded、failed、skipped只有 succeeded 可信
records[].attempts实际尝试次数累计重试预算,避免恢复后重新送额度
records[].output节点结果或结果引用下游重放时需要输入
records[].idempotencyKey该节点派生出的键恢复时对比连续性
spentCostMicros跨恢复累计成本端到端成本不能只看最后一段
levelsDone已完成的层快速判断从哪一层继续

output 存结果还是存引用,取决于结果大小与敏感度。A 链路的摘要可能几千 token,检查点存引用更合适;但引用必须有生命周期,不能恢复时发现对象已经过期。共享上下文 3,200 token 不应该整段塞进检查点:检查点会被复制、备份、导出,把完整上下文放进去等于制造一个额外的数据泄漏面。

重放边界:只信任 succeeded

恢复时最容易犯的错,是把 failed 和 skipped 也当成「已经处理过」。正确口径只有一条:只有 succeeded 可以直接复用,其余全部重新参与调度。

  • failed:上次失败,恢复后要重试,但幂等键不变。
  • skipped:上次因为上游失败被跳过,上游修好后应该重新执行。
  • 从未出现的节点:正常调度。

示例把 A 图在第 1 层后中断,检查点里 input、summarize、classify 是 succeeded,review 与 publish 还没开始。恢复时前三个节点直接取结果,后两个节点重新执行;ranNodeIds 只有 review 和 publish,restoredNodeIds 是前三个。

重放边界还要回答「动作节点能不能重放」。publish 是 action,一旦成功就不能再执行;恢复时如果检查点里它已经是 succeeded,直接复用发布结果,不再调用发布接口。completedActions 就是用来在恢复前检查这一点的:动作节点只能复用,不能重放。

幂等键如何跨节点传递

幂等键不是每个节点各自生成的 UUID。正确的做法是从业务对象出发,派生一条键链:

  1. 根键由业务对象决定,例如「文章 id + 修订号」或「订单草稿 id」。同一篇文章的同一修订,根键永远一样。
  2. 子节点键由 rootKey 与 nodeId 派生,示例用 FNV-1a 哈希拼成 rootKey.xxxxxxxx。
  3. 重试不换键。summarize 第一次失败、第二次成功,两次调用使用同一个键。
  4. 恢复不换键。检查点里存着根键,恢复必须沿用它;换根键意味着换业务对象,应该显式拒绝。
  5. 下游从检查点拿到根键,而不是重新生成。publish 的键与首次执行时完全一致,外部系统才能把它认成同一次发布。

这也是为什么 deriveIdempotencyKey 放在图模型而不是执行器里:键由「根对象 + 节点」决定,和执行次数无关。执行器只负责在每次尝试时使用同一个键。

检查点里存每个节点的键,是为了做连续性校验。恢复后逐个对比,任何不一致都说明键的派生口径变了,不能继续执行。

检查点写在什么时机

检查点不是定时快照,而是节点状态转换的落盘点。三个时机必须分开:

时机写什么崩溃后的后果
节点执行前nodeId、幂等键、尝试序号(intent)恢复时知道「可能已经发生」,可以先查询外部系统
节点成功后结果、成本、耗时(result)可以被恢复直接复用
节点失败后错误类别、尝试次数、已消耗预算(failure)重试预算连续,不会因为恢复重新送额度

动作节点还要多一步:执行前先按幂等键查询外部系统是否已有结果。查询到就直接写 succeeded,查不到才执行。这样「执行成功但进程在写检查点前崩溃」的窗口,可以被下一次恢复识别,而不是靠人回忆。

只有 result 没有 intent,恢复时无法判断动作是否已经发生;只有 intent 没有 result,恢复时又无法区分「没执行」和「执行了但没来得及记录」。两者一起写,重放边界才是可判定的。本节模块只计算恢复边界与复用结果,不实现外部存储;intent 的落盘属于执行器与存储层,但恢复逻辑必须假定它存在。

人工等待不是失败

人工节点挂起时,检查点里不应出现 succeeded 或 failed 记录:节点保持未完成,恢复时自然进入 replay 集合。等待状态由人工任务单管理,而不是塞进检查点。这三种情况对应完全不同的处理:

  • 超时未处理:提醒或升级,不是自动重试。
  • 复核人换班:任务转交,根键与节点键都不变。
  • 复核拒绝:图按终止条件结束,不是把整条链路重跑一遍。

A 链路的人工节点如果被当成 failed 自动重试,会产生重复工单;如果被当成 succeeded,发布会在没有复核结论的情况下继续。人工节点的状态必须单独建模,不能塞进「成功/失败」二值里。

版本边界:旧检查点不能静默进新图

图会改:节点增删、边调整、人工节点位置变化。旧检查点恢复到新图上,轻则跳过该跑的节点,重则把旧结果喂给新契约。

最低要求是 graphId 带版本,恢复时严格比对。示例的 graphId 是 echo.publish,检查点属于别的图就直接失败。更严格的做法是再存一个契约哈希,节点输入输出 schema 一变就拒绝恢复;这一层放在第 11 章的平台化里展开。

版本不匹配时的正确动作不是「尽力恢复」,而是「显式失败并让人决定」:是从头重跑,还是写一个迁移器把旧记录映射到新图。静默恢复会把一个版本问题变成一堆业务脏数据。

恢复不是重试

恢复与重试经常被混为一谈,但它们的输入不同:重试只带着上一次的错误与同一个节点的输入;恢复带着整张图的状态、已完成节点的结果与累计预算。把恢复实现成「整条链路重跑」,等于让已经成功的节点再付一次钱;把重试实现成「只重跑最后一个节点」,又会丢掉上游可能已经变化的输入。

三种入口要分开处理:

情况动作原因
节点失败,图未变从检查点恢复,只重放未完成节点已完成结果仍然有效,重跑是浪费
图已变拒绝恢复,迁移或从头重跑旧结果可能不满足新契约
动作已成功但记录缺失先按幂等键查询外部系统,再决定是否执行副作用可能已经发生,盲重放会翻倍

第三种情况最危险,也最容易被忽略。它要求动作节点从设计之初就支持「按键查询」,而不是等事故发生后再补一个对账脚本。

示例样本上的运行结果

A 图在第 1 层后主动中断,再用检查点恢复。所有数字来自 node --test test/orch/graph.test.ts 的确定性断言。

指标首次执行(中断)恢复执行合计
执行节点input、classify、summarizereview、publish5 个
复用节点无3 个3 个
本次关键路径655ms370ms1,025ms
本次成本1,325 微元0 微元1,325 微元
层间交接1 次2 次3 次
幂等键不一致无0 个0 个

首次执行的 655ms 是 input 的 0ms、summarize 的 620ms 与一次交接 35ms。恢复执行的 370ms 是 review 的 240ms、publish 的 60ms 与两次交接 70ms。两段相加正好 1,025ms,与不中断执行的关键路径一致;如果恢复时给整层复用的边界也计 35ms,合计会多出 35ms,这个误差会随着恢复次数累积。

成本口径同样要分开看:首次执行 1,325 微元,恢复执行 0 微元,因为恢复的两个节点都是人工与动作节点,不调用模型。若 summarize 上次失败、恢复后重跑,累计成本是 2,395 + 1,070 = 3,465 微元;重试的 1,070 不会被「恢复」抹掉。

交接 token 也是同理。已完成节点不再重新组装上下文,820 token 的交接不会为它们再付一次;但被重放的节点仍要重新交接。这与前缀缓存 28.47% 的回本线是两件事:检查点复用节点结果,前缀缓存复用 token 前缀,不能互相替代。

实现

checkpoint.ts 只做四件事:计算重放边界、恢复执行、校验幂等键连续性、列出已完成的动作节点。

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

import { AiError } from "../core/errors.ts";
import { type Checkpoint, type ExecutionGraph, type NodeRecord } from "./graph.ts";
import { runGraph, type RunResult } from "./schedule.ts";

export interface ResumeOptions {
  /** 默认沿用检查点里的根键;只有换业务对象时才允许显式换键。 */
  rootIdempotencyKey?: string;
  failAttempts?: Readonly<Record<string, readonly number[]>>;
  haltAfterLevel?: number;
}

export interface ReplayBoundary {
  /** 从检查点直接复用、不重跑的节点。 */
  restored: string[];
  /** 需要重新执行的节点,含 failed、skipped 与从未开始的节点。 */
  replay: string[];
}

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

/** 恢复只信任 succeeded;partial、failed、skipped 都回到待执行集合。 */
export function replayBoundary(graph: ExecutionGraph, checkpoint: Checkpoint): ReplayBoundary {
  if (checkpoint.graphId !== graph.id) {
    fail(`检查点属于图 ${checkpoint.graphId},不能恢复到 ${graph.id}`);
  }
  const restored = [...new Set(
    checkpoint.records.filter((record) => record.status === "succeeded").map((record) => record.nodeId),
  )].sort();
  const restoredSet = new Set(restored);
  const replay = graph.nodes.map((node) => node.id).filter((id) => !restoredSet.has(id)).sort();
  return { restored, replay };
}

/**
 * 恢复执行。已完成节点直接取检查点结果,未完成节点按原图重新调度。
 * 根幂等键默认不变,因此同一节点在首次执行与恢复执行中派生出同一个键。
 */
export function resumeGraph(graph: ExecutionGraph, checkpoint: Checkpoint, options: ResumeOptions = {}): RunResult {
  const rootIdempotencyKey = options.rootIdempotencyKey ?? checkpoint.rootIdempotencyKey;
  return runGraph(graph, {
    rootIdempotencyKey,
    failAttempts: options.failAttempts,
    haltAfterLevel: options.haltAfterLevel,
    checkpoint,
  });
}

/** 对比恢复前后同一节点的键;返回不一致的节点,空数组表示幂等键连续。 */
export function idempotencyMismatches(checkpoint: Checkpoint, result: RunResult): string[] {
  const before = new Map(checkpoint.records.map((record) => [record.nodeId, record.idempotencyKey]));
  return result.records
    .filter((record) => before.has(record.nodeId) && before.get(record.nodeId) !== record.idempotencyKey)
    .map((record) => record.nodeId)
    .sort();
}

/** 端到端成本按累计口径取,不能把恢复后这一段的成本当成整条链路成本。 */
export function totalSpentMicros(checkpoint: Checkpoint): number {
  return checkpoint.spentCostMicros;
}

/** 已成功的动作节点不能重放:它们已经产生外部副作用,恢复时只能复用结果。 */
export function completedActions(graph: ExecutionGraph, checkpoint: Checkpoint): NodeRecord[] {
  const actions = new Set(graph.nodes.filter((node) => node.kind === "action").map((node) => node.id));
  return checkpoint.records.filter((record) => actions.has(record.nodeId) && record.status === "succeeded");
}

replayBoundary 把图与检查点放在一起看:restored 是可直接复用的节点,replay 是其余全部节点。它不关心上次为什么没完成,只关心「现在缺什么」。

resumeGraph 默认沿用检查点里的根键,只有显式传参才允许更换。runGraph 会再次校验图标识与根键,避免调用方绕过。

idempotencyMismatches 返回的是节点 id 列表,不是布尔值。恢复后如果有节点键不一致,日志里能直接定位到哪个节点的派生口径变了。

completedActions 需要图才能判断哪些节点是 action:同样是 succeeded,能力节点可以复用结果,动作节点必须确认外部副作用已经发生且只发生一次。

小结

检查点保存的是恢复调度需要的事实:图标识、根幂等键、每节点状态与结果、累计成本与已完成层。恢复时只信任 succeeded,失败与跳过都重新调度;已完成节点不重跑,动作节点只复用不重放。

幂等键从业务对象的根键派生,跨重试与恢复不变,下游沿用它而不是重新生成。A 图的示例中,655ms + 370ms 正好回到 1,025ms,成本保持 1,325 微元;两个数字都说明恢复不是「再跑一遍」,而是「接着上次的边界继续」。

阅读导航:上一节:3.2 拓扑执行与并行汇合 · 下一节:4.1 共享上下文的三种形态 。

常见错误与边界

  • 把 failed 或 skipped 当成已完成。恢复后该跑的节点不跑,链路会带着空洞继续。
  • 恢复时重新生成幂等键。同一个业务对象会被外部系统认成两次操作。
  • 每个节点各自生成 UUID。键与业务对象脱钩,重试和恢复都无法对齐。
  • 动作节点成功后仍重放。发布或下单会产生第二次副作用,这比失败更难收拾。
  • 先执行再写检查点。进程在两者之间崩溃,副作用已经发生但没有任何记录。
  • 检查点存整段上下文。3,200 token 的共享上下文会变成额外的复制、备份与泄漏面。
  • 旧检查点静默恢复进新图。节点契约变了,旧结果会被喂给新下游。
  • 只看恢复段的成本。端到端成本要取累计口径,重试花掉的钱不会因为恢复而退回。
  • 把检查点复用当成前缀缓存。前者复用节点结果,后者复用 token 前缀,回本线不是一回事。
  • 恢复时给整层复用的边界重复计交接。分段延迟相加会超过原始关键路径。
  • 把恢复实现成整条链路重跑。已完成节点会再付一次成本,动作节点还可能重复执行。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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