文章目录
- 一、共同前提:两种操作都"只重跑后半段"
- 二、Replay:沿原血缘重跑
- 机制
- 最小示例
- 适用场景
- ⚠️ 两个关键注意点
- 三、Fork:拉出一条新分支
- 机制
- 最小示例
- 搭配 MessagesState 的关键细节
- 适用场景
- 四、as_node 参数:精确控制"从哪个节点之后"继续
- 五、完整工作流模板
- 六、Replay vs Fork 决策树
- 🎯 核心要点回顾
LangGraph 的"time travel"本质上是对检查点历史的导航与重执行:图每往前走一步,Checkpointer
都会存一个含checkpoint_id的全量状态快照,并通过parent_config
串成链表;get_state_history(config)就是遍历这张表(倒序)拿到所有StateSnapshot。基于这张表,LangGraph 提供两种时间旅行操作——Replay(重放)和
Fork(分叉),二者的根本差异在于是否修改状态、以及在哪条检查点血缘上继续。
一、共同前提:两种操作都"只重跑后半段"
无论是 Replay 还是 Fork,机制上都是从某个历史 checkpoint 恢复执行:
checkpoint 之前的节点不会重跑——那些节点的结果已经固化在检查点里
checkpoint 之后的节点会重新执行,包括 LLM 调用、API 请求、interrupt 中断,且可能因为温度、网络、时间等因素返回不同结果
如果从一个没有后续节点的最终 checkpoint重放,那它就是个 no-op(什么都不做)
二者的差别只在"怎么用那个 checkpoint":
| 维度 | Replay(重放) | Fork(分叉) |
|---|---|---|
| 机制 | 用历史 checkpoint 的 config 直接invoke | 先update_state改状态,再用返回的 configinvoke |
| 是否改状态 | 不改,原样重跑 | 改,注入新状态 |
| 检查点血缘 | 沿原 thread 的同一条线继续 | 从原 checkpoint拉出一条新分支 |
| 原历史 | 被新执行覆盖 | 保持不动,新分支独立存在 |
| 典型用途 | 复现 bug、调试某一步 | 探索替代路径、“如果当初那样选会怎样” |
💡 一句话记忆:Replay 是"回到过去重走原路",Fork 是"回到过去开一条新路",原路都还在。
二、Replay:沿原血缘重跑
机制
Replay的核心是——拿到某个历史 checkpoint 的config,直接传给invoke(None, config)。LangGraph 看到config里带了checkpoint_id,就知道"要从这个检查点继续",于是:
加载该 checkpoint 的
values作为起点从该 checkpoint 记录的
next节点开始往下执行之前的节点不再执行——它们的产出已经在 checkpoint 里了
后续节点真的会重新执行(不是读缓存),LLM/API/interrupt 都会再次触发
最小示例
fromlanggraph.graphimportStateGraph,STARTfromlanggraph.checkpoint.memoryimportInMemorySaverfromtyping_extensionsimportTypedDict,NotRequiredclassState(TypedDict):topic:NotRequired[str]joke:NotRequired[str]defgenerate_topic(state:State):return{"topic":"socks in the dryer"}defwrite_joke(state:State):return{"joke":f"Why do{state['topic']}disappear? They elope!"}checkpointer=InMemorySaver()graph=(StateGraph(State).add_node("generate_topic",generate_topic).add_node("write_joke",write_joke).add_edge(START,"generate_topic").add_edge("generate_topic","write_joke").compile(checkpointer=checkpointer))# 1. 首次运行config={"configurable":{"thread_id":"run-1"}}graph.invoke({},config)# 2. 查历史,找到 write_joke 之前的检查点history=list(graph.get_state_history(config))before_joke=next(sforsinhistoryifs.next==("write_joke",))# 3. Replay:从 before_joke 继续,write_joke 重跑replay_result=graph.invoke(None,before_joke.config)# generate_topic 不再执行;write_joke 重新执行(可能产出不同笑话)适用场景
复现 bug:agent 在第 7 步做了错误决策,用 replay 从第 6 步的检查点重跑,观察是否稳定复现
调试节点逻辑:修改了某个节点的代码,想用真实历史输入验证新代码
Interrupt 重触发:原执行中被
interrupt()暂停的节点,replay 时会再次暂停,等待新的Command(resume=...)
⚠️ 两个关键注意点
第一:Replay 不是"重放录像",而是"从那个时间点重新开始"。所以 LLM 调用会真的再发一次,token 会再烧一次,结果也可能不一样。
第二:从最终 checkpoint(即
next == ())重放是空操作,因为后面没节点可执行了。
三、Fork:拉出一条新分支
机制
Fork多了一步——先用update_state()在原 checkpoint 上写入修改后的状态,这会创建一个新的 checkpoint 分支(其source元数据标记为"update"或"fork"),原历史完全不动。然后拿着这个新 config 去invoke(None, ...),从新分支继续往下跑。
关键点:update_state不会回滚 thread,它只是在指定 checkpoint 处"分叉"出一条新路。
最小示例
# 接上面的 graph 和 config# 1. 找到 write_joke 之前的检查点history=list(graph.get_state_history(config))before_joke=next(sforsinhistoryifs.next==("write_joke",))# 2. Fork:修改 topic 为 "chickens"fork_config=graph.update_state(before_joke.config,values={"topic":"chickens"},)# 3. 从 fork 继续:write_joke 用新 topic 执行fork_result=graph.invoke(None,fork_config)print(fork_result["joke"])# 关于 chickens 的笑话,而不是 socks此时检查点历史变成了:
原路径: START → generate_topic(topic=socks) → write_joke → END ↑ 在这里分叉 | 新分支: update_state(topic=chickens) → write_joke' → END原 thread 里topic=socks那条历史完好无损,新分支独立存在。
搭配 MessagesState 的关键细节
如果你 fork 的是对话状态,要特别小心消息的 ID 语义(上一轮讲过add_messagesreducer 的逻辑):
# ✅ 想"替换"某条消息 → 复用原 IDfork_config=graph.update_state(checkpoint.config,{"messages":[HumanMessage(content="新输入",id=original_msg.id)]},# id 相同 → add_messages 视为更新,覆盖原消息)# ❌ 如果换了个新 ID → add_messages 视为追加,历史里会有两条 human 消息fork_config=graph.update_state(checkpoint.config,{"messages":[HumanMessage(content="新输入")]},# 新 ID → 原消息保留,新消息追加到末尾)这个细节决定了 fork 出来的是"改写历史"还是"追加分支"。
适用场景
What-if 分析:“如果当时用户问的是 X 而不是 Y,agent 会怎么答?”
多策略并行探索:不确定走 A 还是 B,fork 两条分支同时跑,比较结果
Human-in-the-loop 修正:发现某步状态错了,fork 一个修正版继续,原运行保留审计
多 interrupt 流程:一个收集多处用户输入的表单,fork 到两个 interrupt 之间,可以只改后面那次输入,而不用重新回答前面的问题
四、as_node 参数:精确控制"从哪个节点之后"继续
update_state有个as_node参数,告诉 LangGraph"把这个状态更新视为是某个节点产生的",从而决定从谁的下游继续:
fork_config=graph.update_state(before_joke.config,values={"topic":"chickens"},as_node="generate_topic",# 视为 generate_topic 的输出)# 执行会从 generate_topic 的下游节点 write_joke 继续大多数情况下 LangGraph 能从 checkpoint 的版本历史自动推断as_node,不需要手写。但两种场景必须显式指定:
并行分支:同一 superstep 有多个节点都写了状态,LangGraph 无法判断最后一个是谁,会抛
InvalidUpdateError全新 thread:在空 thread 上初始化状态(测试常用)
五、完整工作流模板
fromlanggraph.checkpoint.memoryimportInMemorySaverdeftime_travel_demo(graph,thread_id):config={"configurable":{"thread_id":thread_id}}# 0. 首次运行graph.invoke(initial_state,config)# 1. 列出所有检查点(倒序)history=list(graph.get_state_history(config))fori,snapinenumerate(history):print(f"[{i}] next={snap.next}, "f"checkpoint_id={snap.config['configurable']['checkpoint_id']}")# 2. 选一个检查点target=history[1]# 举例:倒数第二个# 3a. REPLAY:原样重跑replay_result=graph.invoke(None,target.config)# 3b. FORK:改状态后重跑fork_config=graph.update_state(target.config,values={"topic":"cats"},# 注入修改)fork_result=graph.invoke(None,fork_config)# 4. 验证原历史未被破坏history_after=list(graph.get_state_history(config))print(f"检查点数量:{len(history)}→{len(history_after)}")# fork 会增加新分支,但原路径的 checkpoint 都还在六、Replay vs Fork 决策树
你想回到过去某一点... │ ├─ 只是想原样重跑那段逻辑(复现 bug / 验证修复) │ └─ REPLAY:invoke(None, checkpoint.config) │ ├─ 想改点东西再跑(试不同输入 / 修正错误状态) │ └─ FORK:update_state(...) → invoke(None, fork_config) │ └─ 原历史必须保留审计 └─ 必须用 FORK(REPLAY 会覆盖原 thread 的后续检查点)⚠️一个反直觉点:很多人以为 Replay 是"无副作用的只读回放",其实不然。Replay 会用新执行的结果覆盖原 thread 里该 checkpoint 之后的所有检查点。如果你想保留原运行做对比,永远用 Fork。
🎯 核心要点回顾
两种操作的共同基础:都从某个历史 checkpoint 恢复,只重跑后续节点,之前节点不重跑
Replay = 续原血缘:用历史 config 直接 invoke,原样重跑,会覆盖原 thread 的后续检查点
Fork = 开新分支:先
update_state改状态生成新 checkpoint,再 invoke,原历史完全保留后续节点是真的重执行:LLM 调用、API、interrupt 都会再次触发,结果可能不同
MessagesState 场景 fork 要小心消息 ID:相同 ID 是覆盖,不同 ID 是追加
get_state_history是入口:倒序返回所有StateSnapshot,通过next字段定位"想从哪个节点之前/之后"继续Interrupt 场景:replay/fork 到含 interrupt 的节点时,会再次暂停等待
Command(resume=...)