第 09 章

时间旅行

重放与分叉——回到过去,改写历史

从任意检查点重新执行,或修改状态后创建平行时间线

走错路了

智能体三步前做了一个错误决定,现在想回到那个岔路口试试另一条路。

想重来一次

智能体把"笑话"分类成了"诗歌",导致走了错误的分支。你看到了结果才发现。但代码已经跑完了,回不去了。

想换个参数

让模型生成关于"猫"的笑话。跑完之后想看看换成"鸡"会怎样。得从头改输入、重新跑整个图。

想调试某一步

第三步的输出明显不对,想看看那个时候的状态是什么样的。但只留下了最终结果,中间状态全丢了。

时间旅行就是为这个设计的:基于检查点,你可以回到任意历史节点——重新执行(重放),或修改状态后执行(分叉)。不需要从头跑。

开始之前

前置知识

  • 持久化 — 检查点和线程的概念
  • 中断 — interrupt() 的用法
  • 理解 StateSnapshot 和 getStateHistory

学完后你应该能

  • 区分重放(Replay)和分叉(Fork)
  • 从任意检查点重新执行图
  • 修改历史状态并创建新分支
  • 理解中断在时间旅行中的行为
  • 在多个中断点之间进行分叉
  • 配置子图的独立检查点并时间旅行到子图内部

两种模式

重放和分叉——回到过去的不同方式

图:重放 vs 分叉——两种时间旅行模式

重放(Replay)

  • 从历史检查点重新执行
  • 不修改状态,原样重来
  • 适合调试、观察中间行为

分叉(Fork)

  • 修改历史检查点的状态
  • 从修改后的状态继续执行
  • 适合探索"如果……会怎样"

重放

找到目标检查点,原样重新执行

思考题:你的笑话生成图已经跑完了,但你发现第二个节点(写笑话)的输出不太理想。你想看看如果从那一步开始重新执行,结果会不会不同。怎么"时光倒流"重跑?
// 先运行一次图
const config = { configurable: { thread_id: "thread-1" } };
const result = await graph.invoke({}, config);

// 收集所有检查点
const states = [];
for await (const state of graph.getStateHistory(config)) {
  states.push(state);
}

// 找到 writeJoke 执行前的检查点
const beforeJoke = states.find((s) => s.next.includes("writeJoke"));

// 从那个检查点重新执行
const replayResult = await graph.invoke(null, beforeJoke.config);

📄 完整代码:09_replay.ts

关键 API:graph.getStateHistory(config) 返回所有历史检查点。用 invoke(null, checkpointConfig) 从指定检查点重新执行。null 表示不提供新输入。
最终检查点的重放是无操作:如果检查点的 next 为空(即图已执行完毕),重放不会有任何效果——因为没有后续节点需要执行。只有 next 中包含待执行节点的检查点才有重放意义。

重放注意点

重放不是"读缓存"——是真的重新执行

重要:重放会重新执行节点。LLM 调用、API 请求、中断都会重新触发,可能产生不同结果。
操作重放时会怎样
LLM 调用重新调用,可能返回不同内容(除非 temperature=0)
API 请求重新发送请求
interrupt()重新触发中断,等待新的 resume
随机数生成新的随机值
为什么这样设计:检查点保存的是状态快照,不是执行录像。重放是从快照点开始重新走一遍流程,确保所有副作用都被正确执行。

分叉

修改历史状态,创建一条平行时间线

思考题:你的笑话图跑完了,主题是"程序员"。现在你想看看如果主题换成"数学老师",笑话会怎样——但原始结果不能丢。怎么在不影响原时间线的情况下,分叉出一条新路径?
图:分叉模式——从检查点创建平行时间线
// 找到目标检查点
const beforeJoke = states.find((s) => s.next.includes("writeJoke"));

// 分叉:在那个检查点修改 topic
const forkConfig = await graph.updateState(
  beforeJoke.config,
  { topic: "chickens" },  // 修改状态
);

// 从分叉点继续执行
const forkResult = await graph.invoke(null, forkConfig);
console.log(forkResult.joke); // 关于 chickens 的笑话

📄 完整代码:09_fork.ts

分叉 = updateState + invoke:先用 updateState 在目标检查点修改状态,这会创建一个新的检查点。然后用 invoke(null, forkConfig) 从新检查点继续执行。
updateState 不会回滚线程:它只是在指定检查点处创建一个分支——原始执行历史保持完整,不受影响。两条时间线独立存在,互不干扰。

指定节点

用 asNode 控制分叉后的执行起点

思考题:你的图有 step1→step2→step3 三步。你想模拟"如果 step2 的输出是 X 而不是 Y,后续会怎样"。updateState 修改了状态,但图怎么知道从哪一步继续?
// 将更新视为 generateTopic 产生的
// 执行从 writeJoke(generateTopic 的后继)恢复
const forkConfig = await graph.updateState(
  beforeJoke.config,
  { topic: "chickens" },
  { asNode: "generateTopic" },
);

📄 完整代码:09_as_node.ts

不指定 asNode

LangGraph 自动推断——通常是目标检查点的 next 中列出的节点。绝大多数情况下推断是正确的。

指定 asNode

明确告诉 LangGraph "这次修改是哪个节点产生的"。执行从该节点的后继开始。

需要显式指定的场景

  • 并行分支:同一超步中多个节点都更新了状态,LangGraph 无法确定谁是最后一个(抛出 InvalidUpdateError)
  • 无执行历史:在全新线程上设置初始状态(常见于测试)
  • 跳过节点:设为后续节点,让图认为该节点已执行过

中断与回溯

中断在时间旅行中总是重新触发

思考题:你的审批图有个 interrupt 节点。你回放到那个检查点想重试,但 interrupt 又弹出来了。能不能:(a) 重新回答中断问题?(b) 直接跳过中断?
// 首次运行:遇到中断
await graph.invoke({ value: [] }, config);
await graph.invoke(new Command({ resume: "Alice" }), config);

// 从中断前重放 → 中断再次触发
const beforeAsk = states.filter((s) => s.next.includes("askHuman")).pop();
const replayResult = await graph.invoke(null, beforeAsk.config);
// 图再次暂停在中断处,等待新的 resume

// 从中断前分叉
const forkConfig = await graph.updateState(beforeAsk.config, { value: ["forked"] });
const forkResult = await graph.invoke(null, forkConfig);
await graph.invoke(new Command({ resume: "Bob" }), forkConfig);
// 结果: { value: ["forked", "Hello, Bob!", "Done"] }
注意:重放/分叉时遇到 interrupt 会暂停——你需要提供新的 resume 值。旧的中断值不会自动回放。

📄 完整代码:09_interrupt_time_travel.ts

多重中断之间的分叉

图中有多个中断点时,可以从它们之间分叉

思考题:多步表单中用户想修改第二步的答案而不影响第一步,怎么精确定位两个中断之间的检查点来分叉?
// 图结构:askName → askAge → final
// askName 和 askAge 各有一个 interrupt()

// 运行完成两个中断
await graph.invoke({ value: [] }, config);
await graph.invoke(new Command({ resume: "Alice" }), config);
await graph.invoke(new Command({ resume: "30" }), config);

// 收集检查点
const states = [];
for await (const state of graph.getStateHistory(config)) {
  states.push(state);
}

// 找到 askName 之后、askAge 之前的检查点
const between = states.filter((s) => s.next.includes("askAge")).pop();

// 在两个中断之间分叉
const forkConfig = await graph.updateState(between.config, { value: ["modified"] });
const result = await graph.invoke(null, forkConfig);
// askName 的结果保留("name:Alice")
// askAge 重新触发中断,等待新的回答

📄 完整代码:09_interrupt_time_travel.ts

关键思路:多步表单场景中,用户可能想修改第二步的答案而不影响第一步。getStateHistory 能精确定位两个中断之间的检查点,分叉后前面的回答被保留,只有后续步骤重新执行。

子图与回溯

子图的时间旅行取决于它的 checkpointer 配置

思考题:你的主图里嵌套了一个子图。主图的检查点能看,但子图内部哪一步出了问题,你怎么查?默认状态下子图对主图来说是个"黑盒"。能打开看看吗?
模式checkpointer行为
继承(默认)不单独指定父图将整个子图视为一个超步,只能从父级时间旅行
子图独立checkpointer: true可以时间旅行到子图内部节点之间
// 子图使用独立的 checkpointer
const subgraph = new StateGraph(StateAnnotation)
  .addNode("stepA", stepA)  // 含 interrupt()
  .addNode("stepB", stepB)  // 含 interrupt()
  .addEdge(START, "stepA")
  .addEdge("stepA", "stepB")
  .compile({ checkpointer: true }); // 独立的检查点历史

// 获取子图的内部检查点
const parentState = await graph.getState(config, { subgraphs: true });
const subConfig = parentState.tasks[0].state.config;

// 从子图内部检查点分叉
const forkConfig = await graph.updateState(subConfig, { value: ["forked"] });
const result = await graph.invoke(null, forkConfig);
// stepB 重新执行,stepA 的结果被保留

📄 完整代码:09_subgraph_time_travel.ts

推荐做法:大多数情况用继承模式就够了。只有当子图内部有复杂的多步逻辑需要独立调试时,才给它设置 checkpointer: true。访问子图内部检查点需要用 getState(config, { subgraphs: true })。

排错指南

找不到检查点

症状:states.find(...) 返回 undefined。

修复:打印所有检查点的 next 字段,确认节点名称拼写正确。注意区分大小写。

重放结果不同

症状:重放后输出和第一次不一样。

修复:正常现象。LLM 调用、随机数都会重新执行。如果需要确定性结果,设置 temperature: 0。

分叉后不执行

症状:updateState 后 invoke 没有触发新节点。

修复:检查 asNode 是否正确。分叉后的执行起点取决于 asNode 的后继。

动手练习

重放笑话生成器,分叉换主题

任务:用 Ch02 的笑话链式工作流,执行一次后做两件事。
  • 运行一次笑话链(topic = "cats")
  • 从 generateJoke 节点前重放,观察结果是否不同
  • 从同一个检查点分叉,把 topic 改成 "chickens",观察新结果

参考答案

// 1. 运行一次
const config = { configurable: { thread_id: "tt-1" } };
const result = await chain.invoke({ topic: "cats" }, config);

// 2. 收集检查点
const states = [];
for await (const s of chain.getStateHistory(config)) states.push(s);

const beforeJoke = states.find((s) => s.next.includes("generateJoke"));

// 3. 重放
const replay = await chain.invoke(null, beforeJoke.config);
console.log("重放结果:", replay.joke);

// 4. 分叉
const forkConfig = await chain.updateState(beforeJoke.config, { topic: "chickens" });
const fork = await chain.invoke(null, forkConfig);
console.log("分叉结果:", fork.joke);
核心模式:getStateHistory 找检查点 → invoke(null, config) 重放 → updateState + invoke 分叉。三个 API 就能完成所有时间旅行操作。

本章回顾

到这里,你已经能

  • 区分重放和分叉两种时间旅行模式
  • 从任意检查点重新执行图
  • 修改历史状态并创建新分支
  • 用 asNode 控制执行起点
  • 理解中断在时间旅行中的行为
  • 在多个中断点之间分叉,保留前面的回答
  • 为子图配置独立检查点并时间旅行到内部

它和前后章节的关系

核心结论:时间旅行让你不怕犯错。基于检查点,你可以回到任意历史节点重新尝试。重放用于调试,分叉用于探索不同的可能性。

延伸阅读

继续本课程

相关章节