时间旅行
重放与分叉——回到过去,改写历史
从任意检查点重新执行,或修改状态后创建平行时间线走错路了
智能体三步前做了一个错误决定,现在想回到那个岔路口试试另一条路。
想重来一次
智能体把"笑话"分类成了"诗歌",导致走了错误的分支。你看到了结果才发现。但代码已经跑完了,回不去了。
想换个参数
让模型生成关于"猫"的笑话。跑完之后想看看换成"鸡"会怎样。得从头改输入、重新跑整个图。
想调试某一步
第三步的输出明显不对,想看看那个时候的状态是什么样的。但只留下了最终结果,中间状态全丢了。
两种模式
重放和分叉——回到过去的不同方式

图:重放 vs 分叉——两种时间旅行模式
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一条从左到右的时间线,中间有 4 个检查点节点 - 节点从左到右:CP1(蓝色)→ CP2(蓝色)→ CP3(蓝色)→ CP4(蓝色,当前状态) - 上方分叉:从 CP2 向右上延伸一条新路径,经过 CP2'(绿色)→ CP3'(绿色),标注"分叉:修改状态后继续" - 下方循环:从 CP4 画一条回退虚线到 CP2,标注"重放:原样重新执行" - 时间线底部标注箭头"时间" - 右侧一个方框对比:"重放 = 回放录像 | 分叉 = 改写剧本" - 各元素关系:原时间线 CP1→CP2→CP3→CP4 是线性串联;分叉从 CP2 向上分出新路径(修改状态后继续);重放从 CP4 回退到 CP2(原样重新执行),两种操作不改变原时间线 - 关键标注:原时间线标注"已完成的执行",分叉路径标注"updateState 修改后继续",重放回退标注"原样重新执行" - 配色建议:浅灰背景,原路径用蓝色,分叉路径用绿色,回退用红色虚线 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
重放(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);
graph.getStateHistory(config) 返回所有历史检查点。用 invoke(null, checkpointConfig) 从指定检查点重新执行。null 表示不提供新输入。
next 为空(即图已执行完毕),重放不会有任何效果——因为没有后续节点需要执行。只有 next 中包含待执行节点的检查点才有重放意义。
重放注意点
重放不是"读缓存"——是真的重新执行
| 操作 | 重放时会怎样 |
|---|---|
| LLM 调用 | 重新调用,可能返回不同内容(除非 temperature=0) |
| API 请求 | 重新发送请求 |
| interrupt() | 重新触发中断,等待新的 resume |
| 随机数 | 生成新的随机值 |
分叉
修改历史状态,创建一条平行时间线

图:分叉模式——从检查点创建平行时间线
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一条主时间线 + 一条分叉时间线 - 主时间线(蓝色):CP1 → CP2(topic="cats") → CP3 → CP4(joke about cats) - 分叉时间线(绿色):从 CP2 分出 → CP2'(topic="chickens") → CP3'(joke about chickens) - 分叉点标注:"updateState 修改 topic" - 右侧对比框:"原时间线:猫的笑话 | 分叉时间线:鸡的笑话" - 各元素关系:主时间线与分叉时间线共享 CP1、CP2 的历史,从 CP2 开始分叉——主线继续原 topic,分叉线修改 topic 后产生新的检查点序列,两条时间线互不影响 - 关键标注:分叉点标注"updateState({ topic: 'chickens' })",分叉时间线的每个检查点标注"'号区分",对比框突出两条线的最终结果差异 - 配色建议:浅灰背景,主线蓝色,分叉绿色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
// 找到目标检查点
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 的笑话
updateState 在目标检查点修改状态,这会创建一个新的检查点。然后用 invoke(null, forkConfig) 从新检查点继续执行。
指定节点
用 asNode 控制分叉后的执行起点
// 将更新视为 generateTopic 产生的
// 执行从 writeJoke(generateTopic 的后继)恢复
const forkConfig = await graph.updateState(
beforeJoke.config,
{ topic: "chickens" },
{ asNode: "generateTopic" },
);
不指定 asNode
LangGraph 自动推断——通常是目标检查点的 next 中列出的节点。绝大多数情况下推断是正确的。
指定 asNode
明确告诉 LangGraph "这次修改是哪个节点产生的"。执行从该节点的后继开始。
需要显式指定的场景
- 并行分支:同一超步中多个节点都更新了状态,LangGraph 无法确定谁是最后一个(抛出
InvalidUpdateError) - 无执行历史:在全新线程上设置初始状态(常见于测试)
- 跳过节点:设为后续节点,让图认为该节点已执行过
中断与回溯
中断在时间旅行中总是重新触发
// 首次运行:遇到中断
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"] }
多重中断之间的分叉
图中有多个中断点时,可以从它们之间分叉
// 图结构: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 的后继。
动手练习
重放笑话生成器,分叉换主题
- 运行一次笑话链(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);