LangChain LangGraph
Module 1 Module 2 Module 3 Module 4 Module 5 Module 6
Module 3 · Human in the Loop
1 3-1 Streaming
2 3-2 Breakpoints
3 3-3 Edit State
4 3-4 Dyn Breakpts
5 3-5 Time Travel
SUM Module Summary
HomeLangGraphModule 3 · Human in the Loop3-5 Time Travel
📓 Notebook 📖 Explained
LANGGRAPH MODULE 3 · LESSON 5

时间旅行:检查点回放与执行分支
Time Travel in LangGraph

在 LangGraph 中,每一次图的执行都会被自动保存为检查点快照。
你可以随时回溯过去的任意时刻——重放、调试、或走向一条全新的执行路线。

1 什么是"时间旅行"?——从历史回放到执行分支

在学习 Module 3 的前几节课时,我们已经了解了断点(Breakpoints)和人工介入(Human-in-the-loop)的机制。时间旅行(Time Travel) 是这些能力的自然延伸:它让你不仅能"暂停"图的执行,还能像操作时光机一样,回到任意一个已执行过的历史时刻,然后从那里重新出发。

LangGraph 在每个图执行步骤之后,都会由 Checkpointer(检查点器) 自动将当前完整状态保存为一个快照。这些快照构成了一条时间线,每个快照都有唯一的 checkpoint_id。时间旅行正是基于这条时间线实现的。

核心能力

时间旅行在 LangGraph 中包含两种操作:
· 重放(Replay):从某个过去的检查点开始,按照原本相同的路径再走一遍,结果与历史相同。
· 分叉(Fork):回到某个检查点,修改那时的状态,然后重新执行——这会产生一条新的执行分支,结果可能与历史完全不同。

时间旅行的两种操作示意

重放 (Replay)
检查点 A
原始起点
检查点 B
历史步骤 1
检查点 C
历史步骤 2
↑ 从 B 重放 → 走相同路径
检查点 C'
结果与 C 相同
分叉 (Fork)
检查点 A
原始起点
检查点 B
历史步骤 1
↑ 修改 B 的状态 → 新分支
检查点 B'
分叉后新检查点
检查点 C''
全新执行结果

这个机制与 Git 的版本控制有着高度的相似性:检查点就像 commit,重放就像 checkout 到某个历史 commit 再次查看,分叉就像从某个历史 commit 建立新分支并提交不同的更改。

时间旅行操作 触发方式 执行行为 典型用途
重放(Replay) 传入已有 checkpoint_id 运行图 从该检查点续跑,走原有路径 复现问题、验证结果一致性
分叉(Fork) update_state 修改历史状态后运行 创建新检查点分支,走新路径 实验不同输入、修复历史错误

2 构建示例 Agent——带工具调用的计算助手

notebook 使用一个简单的"算术助手" Agent 来演示时间旅行。这个 Agent 可以调用三个工具:加法、乘法、除法。它的图结构与 Module 1 的 ReAct Agent 完全相同,核心在于启用了 MemorySaver 作为检查点器,这是时间旅行的前提条件。

工具定义

from langchain_deepseek import ChatDeepSeek

def multiply(a: int, b: int) -> int:
    """Multiply a and b."""
    return a * b

def add(a: int, b: int) -> int:
    """Adds a and b."""
    return a + b

def divide(a: int, b: int) -> float:
    """Divide a by b."""
    return a / b

tools = [add, multiply, divide]
llm = ChatDeepSeek(model="deepseek-v4-pro")
llm_with_tools = llm.bind_tools(tools)

构建图(关键:传入 MemorySaver)

from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import MessagesState, StateGraph, START, END
from langgraph.prebuilt import tools_condition, ToolNode
from langchain_core.messages import SystemMessage

# 系统提示,告诉 LLM 它是一个算术助手
sys_msg = SystemMessage(content="You are a helpful assistant tasked with performing arithmetic on a set of inputs.")

def assistant(state: MessagesState):
    return {"messages": [llm_with_tools.invoke([sys_msg] + state["messages"])]}

builder = StateGraph(MessagesState)
builder.add_node("assistant", assistant)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "assistant")
builder.add_conditional_edges("assistant", tools_condition)
builder.add_edge("tools", "assistant")

# ★ 关键:编译时传入 checkpointer,这样每步执行都会被持久化为快照
memory = MemorySaver()
graph = builder.compile(checkpointer=memory)
为什么 MemorySaver 是必须的?

时间旅行完全依赖于检查点(checkpoint)的存在。没有 Checkpointer,图执行完就结束,没有任何历史记录可以回溯。MemorySaver 将所有检查点保存在内存中,适合开发调试。生产环境中通常换用 SqliteSaver 或 PostgresSaver 持久化到数据库。

运行图,产生检查点历史

from langchain_core.messages import HumanMessage

# 指定线程 ID,同一 thread_id 下的所有执行步骤都会被关联到同一时间线
initial_input = {"messages": HumanMessage(content="Multiply 2 and 3")}
thread = {"configurable": {"thread_id": "1"}}

# 流式执行,打印每步最后一条消息
for event in graph.stream(initial_input, thread, stream_mode="values"):
    event['messages'][-1].pretty_print()

执行后,依次打印:Human 消息 → AI 决定调用 multiply → 工具返回 6 → AI 最终回答"2 乘以 3 等于 6"。整个过程的每一步都被自动保存为一个检查点快照。

算术助手的图结构

START
assistant (LLM)
调用 DeepSeek 模型
tools_condition 路由判断
有工具调用
tools
执行 add/multiply/divide
↑ 回到 assistant
无工具调用
END

3 浏览执行历史:get_state_history 与检查点结构

图执行完毕后,你可以通过两个 API 来查看历史状态:

# 获取当前状态
current = graph.get_state({'configurable': {'thread_id': '1'}})

# 获取所有历史检查点(按时间倒序,索引 0 = 最新)
all_states = [s for s in graph.get_state_history(thread)]
print(len(all_states))  # 输出: 5  ← 5 个步骤产生 5 个检查点

"Multiply 2 and 3"这次执行共产生 5 个检查点

检查点 #0(最新)—— 图执行结束
messages 包含完整对话:Human + AI(工具请求) + Tool结果 + AI(最终回答)
next = () ← 没有下一步,图已完成
检查点 #1 —— assistant 节点第二次执行后
LLM 看到工具返回结果,给出最终文字回答
next = () ← tools_condition 判断无工具调用,走向 END
检查点 #2 —— tools 节点执行后
multiply(2, 3) 工具被调用,返回结果 6
next = ('assistant',) ← 执行完工具后回到 assistant
检查点 #3 —— assistant 节点第一次执行后
LLM 分析问题,决定调用 multiply 工具
next = ('tools',) ← tools_condition 判断有工具调用
检查点 #4(最早)—— 图刚启动时
仅包含用户输入:HumanMessage("Multiply 2 and 3")
next = ('assistant',) ← 从 START 走向 assistant

StateSnapshot 的结构解析

每个检查点都是一个 StateSnapshot 对象,包含以下关键字段:

# 查看倒数第二个检查点(最早的实际执行步骤)
snapshot = all_states[-2]

# ① values:该时刻图的完整状态(所有消息)
snapshot.values
# → {'messages': [HumanMessage(content='Multiply 2 and 3', id='4ee8c440...')]}

# ② next:从该快照继续执行时,下一步要去哪个节点
snapshot.next
# → ('assistant',)

# ③ config:该检查点的标识信息,包含 thread_id 和 checkpoint_id
snapshot.config
# → {'configurable': {'thread_id': '1',
#                     'checkpoint_ns': '',
#                     'checkpoint_id': '1ef6a440-a003-6c74-8000-8a2d82b0d126'}}

# ④ metadata:额外信息,如步骤编号、来源
snapshot.metadata
# → {'source': 'loop', 'writes': None, 'step': 0, 'parents': {}}

# ⑤ created_at:该检查点的创建时间
snapshot.created_at
# → '2024-09-03T22:29:52.988265+00:00'

# ⑥ parent_config:上一个检查点的标识(形成链式结构)
snapshot.parent_config
字段 含义 时间旅行中的用途
values 该时刻图的完整状态字典 查看当时消息列表、变量值等
next 下一步待执行的节点名称元组 判断图是否已结束,或确认接下来走哪个节点
config.checkpoint_id 该检查点的唯一 ID(UUID 格式) Replay 或 Fork 时需要传入此 ID
metadata.step 该步骤在执行序列中的序号 快速定位到执行序列中的位置
parent_config 上一个检查点的 config 沿检查点链向前追溯
顺序说明

get_state_history 返回的列表按时间倒序排列——索引 0 是最新检查点,索引 -1(最后一个)是最早的检查点(图刚启动时)。因此 all_states[-2] 是图启动后第一次真正执行的步骤,即接收到用户输入后,即将进入 assistant 节点的那一刻。

4 重放(Replay):从过去某个检查点重新执行

重放是时间旅行中最简单的操作:找到一个历史检查点,把它的 config(包含 checkpoint_id)传给 graph.stream(),图就会从那个时间点续跑,执行路径与原来完全相同。

实际操作步骤

# 步骤 1:选定要重放的历史检查点
to_replay = all_states[-2]

# 确认该时刻的状态(只有用户的输入消息)
to_replay.values
# → {'messages': [HumanMessage(content='Multiply 2 and 3')]}

# 确认下一步节点
to_replay.next
# → ('assistant',)

# 确认 checkpoint_id
to_replay.config
# → {'configurable': {'thread_id': '1',
#                     'checkpoint_id': '1ef6a440-a003-6c74-8000-8a2d82b0d126'}}

# 步骤 2:从该检查点重放
# input=None 表示不注入新输入,让图从检查点状态继续
for event in graph.stream(None, to_replay.config, stream_mode="values"):
    event['messages'][-1].pretty_print()

重放后的输出:

================================ Human Message =================================
Multiply 2 and 3
================================== Ai Message ==================================
Tool Calls: multiply(a=2, b=3)
================================= Tool Message =================================
6
================================== Ai Message ==================================
The result of multiplying 2 and 3 is 6.
LangGraph 如何识别"已执行"与"未执行"

当你传入一个 checkpoint_id 时,LangGraph 查看该检查点在时间线中的位置:
· 如果该检查点之后已有后续检查点(即原来已执行过),图会把后续步骤照原样重放,不会再调用 LLM。
· 如果该检查点之后没有后续检查点(分叉产生的新检查点),图认为这是一个全新的执行起点,会真实运行后续节点。

重放的意义

重放最直接的用途是调试复现:当你的 Agent 在某次运行中产生了奇怪的输出,你可以通过重放精确还原当时的执行环境,逐步检查每个节点的输入输出,无需重新运行整个工作流。

5 分叉(Fork):修改过去状态并开启新的执行路径

分叉比重放更强大:你不仅回到过去,还可以修改那时的状态,然后从修改后的状态出发,让图走一条全新的路径。修改操作通过 graph.update_state() 完成,它会在原有检查点的基础上创建一个新的检查点(新分支),而不影响原来的时间线。

消息 Reducer 的关键细节:覆盖 vs 追加

在修改状态时,messages 字段有特殊的行为:MessagesState 使用 add_messages Reducer,默认是追加新消息而非替换。要想覆盖(修改)一条已有消息,必须提供相同的消息 ID。这样 Reducer 才知道"这是对已有消息的修改",而不是"这是一条新消息"。

# 步骤 1:选定要分叉的历史检查点(与重放相同)
to_fork = all_states[-2]

# 查看当时的消息和其 ID
to_fork.values["messages"]
# → [HumanMessage(content='Multiply 2 and 3', id='4ee8c440-0e4a-47d7-852f-06e2a6c4f84d')]

to_fork.config["configurable"]["checkpoint_id"]
# → '1ef6a440-a003-6c74-8000-8a2d82b0d126'

# 步骤 2:使用 update_state 修改该检查点的状态
# 关键:传入相同的消息 ID → 触发"覆盖"而非"追加"
fork_config = graph.update_state(
    to_fork.config,          # 指定在哪个检查点上做修改
    {"messages": [HumanMessage(
        content='Multiply 5 and 3',          # ← 把"2 and 3"改成"5 and 3"
        id=to_fork.values["messages"][0].id  # ← 同一个消息 ID!
    )]},
)

# update_state 返回新创建的分叉检查点的 config
fork_config
# → {'configurable': {'thread_id': '1',
#                     'checkpoint_id': '1ef6a442-3661-62f6-8001-d3c01b96f98b'}}  ← 全新 ID!
为什么必须提供消息 ID?

不提供 ID(追加){"messages": [HumanMessage(content='Multiply 5 and 3')]} ← 生成新 ID,会在原来的消息后面新增一条消息,状态变为 2 条消息。

提供相同 ID(覆盖){"messages": [HumanMessage(content='Multiply 5 and 3', id='4ee8c440...')]} ← 找到并替换那条消息,状态仍为 1 条消息,内容变了。

# 步骤 3:确认分叉后的状态
graph.get_state({'configurable': {'thread_id': '1'}}).values["messages"]
# → [HumanMessage(content='Multiply 5 and 3', id='4ee8c440...')]  ← 内容已更新

# 步骤 4:从分叉检查点运行图(这次 LangGraph 会真实执行,而非重放)
for event in graph.stream(None, fork_config, stream_mode="values"):
    event['messages'][-1].pretty_print()

分叉后的执行输出——注意问题已经变成了"5 和 3 相乘":

================================ Human Message =================================
Multiply 5 and 3
================================== Ai Message ==================================
Tool Calls: multiply(a=5, b=3)
================================= Tool Message =================================
15
================================== Ai Message ==================================
The result of multiplying 5 and 3 is 15.

分叉后的检查点链结构

原始时间线
  • 检查点 A:图启动
  • 检查点 B:收到"Multiply 2 and 3"
  • 检查点 C:assistant 决定调工具
  • 检查点 D:工具返回 6
  • 检查点 E:最终回答"2×3=6"
分叉后新时间线
  • (共享原始时间线到检查点 B)
  • 检查点 B':修改为"Multiply 5 and 3"
  • 检查点 C':assistant 决定调工具
  • 检查点 D':工具返回 15
  • 检查点 E':最终回答"5×3=15"

原始时间线完好保留,分叉时间线是从分叉点产生的独立链条,两者在 thread_id 相同但 checkpoint_id 链路不同。当前状态(get_state)会指向最新写入的分叉末端。

6 通过 LangGraph API 实现时间旅行

除了本地 Python 代码,LangGraph 还提供了 HTTP API(通过 LangGraph SDK),让你在 LangSmith Studio / 生产服务中同样使用时间旅行。这个 API 的工作方式与 Python 代码高度对称,概念完全相同。

启动本地 LangSmith Studio

在 module-3/studio 目录下运行 langgraph dev,Studio UI 将在 https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024 打开。通过 SDK 连接到本地服务器,即可调用 API 实现时间旅行。

通过 API 重放

from langgraph_sdk import get_client

# 连接本地 LangGraph 服务
client = get_client(url="http://127.0.0.1:2024")

# 1. 先运行一次,产生历史
initial_input = {"messages": HumanMessage(content="Multiply 2 and 3")}
thread = await client.threads.create()
async for chunk in client.runs.stream(
    thread["thread_id"],
    assistant_id="agent",
    input=initial_input,
    stream_mode="updates",
):
    # 处理流式输出...
    pass

# 2. 获取历史检查点
states = await client.threads.get_history(thread['thread_id'])
to_replay = states[-2]  # 取最早的实际执行检查点
to_replay['checkpoint_id']
# → '1ef6a449-817f-6b55-8000-07c18fbdf7c8'

# 3. 从该检查点重放(传入 checkpoint_id 参数)
async for chunk in client.runs.stream(
    thread["thread_id"],
    assistant_id="agent",
    input=None,                              # 不传新输入
    stream_mode="values",
    checkpoint_id=to_replay['checkpoint_id']  # ← 关键:传入检查点 ID
):
    print(f"Receiving event type: {chunk.event}")
    print(chunk.data)

通过 API 分叉

# 获取要分叉的检查点
states = await client.threads.get_history(thread['thread_id'])
to_fork = states[-2]
msg_id = to_fork['values']['messages'][0]['id']  # 原始消息的 ID

# 用 update_state 修改历史状态(需提供消息 ID 以触发覆盖)
forked_input = {"messages": HumanMessage(
    content="Multiply 3 and 3",
    id=msg_id  # ← 同一消息 ID
)}
forked_config = await client.threads.update_state(
    thread["thread_id"],
    forked_input,
    checkpoint_id=to_fork['checkpoint_id']  # ← 在哪个检查点上分叉
)
# forked_config 包含新创建的分叉检查点 ID

# 从分叉检查点运行(图会真实执行,计算 3×3=9)
async for chunk in client.runs.stream(
    thread["thread_id"],
    assistant_id="agent",
    input=None,
    stream_mode="updates",
    checkpoint_id=forked_config['checkpoint_id']  # ← 分叉检查点 ID
):
    # 输出:Multiply 3 and 3 → 9
    pass
Python 代码 vs API 的对比

Python(本地)graph.stream(None, to_replay.config) — config 直接包含 checkpoint_id
API(远程)client.runs.stream(..., checkpoint_id=...) — checkpoint_id 作为独立参数传入

两者语义完全相同,只是接口形式略有不同。Studio UI 中可以通过鼠标点击检查点来触发相同操作,无需写代码。

7 时间旅行的应用场景:调试、实验、错误恢复

时间旅行不仅是一个学术概念,它在构建生产级 AI Agent 时有着极其实用的价值。以下是三个核心应用场景:

场景一:调试——精确复现历史错误

问题背景

你的 Agent 在处理某个用户请求时给出了错误答案,但你无法重现问题,因为每次重新调用 LLM 结果都略有不同。

时间旅行解法:找到那次出错运行的 thread_id,用 get_state_history 列出所有检查点,定位到问题发生前的那一刻,用该检查点的 config 重放。由于重放不会重新调用 LLM(而是照原样重现),你能百分之百还原问题场景,逐步检查每个节点的输入输出。

# 找到问题线程
problem_thread = {"configurable": {"thread_id": "problem-thread-id"}}

# 列出所有检查点,找到问题节点前的那一刻
all_states = [s for s in graph.get_state_history(problem_thread)]

# 找到某个可疑的检查点
suspicious = all_states[3]
print(suspicious.next)   # 确认接下来会执行什么
print(suspicious.values) # 查看当时的完整状态

# 从该点重放,观察后续执行
for event in graph.stream(None, suspicious.config, stream_mode="values"):
    event['messages'][-1].pretty_print()

场景二:实验——用不同输入尝试多种策略

问题背景

你想测试:如果用户当初提问的方式不同(比如换了措辞),Agent 的回答会有何变化?或者你想让同一个 Agent 从相同起点出发,但尝试使用不同的工具或策略。

时间旅行解法:回到目标检查点,用 update_state 修改输入,创建分叉,然后从分叉点运行。你可以创建多个分叉,A/B 测试不同的提示词或输入,所有结果都保留在检查点链中可供对比。

场景三:错误恢复——绕过某个失败步骤

问题背景

Agent 的某个工具调用失败了(比如 API 超时、外部服务不可用),导致整个执行链中断。你不想从头重跑,因为前面已经做了很多有效的工作。

时间旅行解法:找到失败步骤之前的检查点,用 update_state 手动注入一个"假的成功结果"(模拟工具本应返回的值),然后从修改后的分叉点继续运行,跳过那个失败的工具调用。

# 假设工具节点失败了,失败前的检查点是 all_states[2]
before_failure = all_states[2]

# 手动注入工具应当返回的结果(跳过真实调用)
from langchain_core.messages import ToolMessage

recovery_config = graph.update_state(
    before_failure.config,
    {"messages": [ToolMessage(
        content="42",           # 手动提供工具结果
        tool_call_id="..."     # 对应原始工具调用的 ID
    )]},
    as_node="tools"           # 以 tools 节点的身份写入,保持元数据正确
)

# 从恢复检查点继续执行(LLM 会看到工具结果并继续推理)
for event in graph.stream(None, recovery_config, stream_mode="values"):
    event['messages'][-1].pretty_print()
应用场景 使用的 API 核心操作 价值
调试复现 get_state_history + stream(None, config) 重放 百分之百还原错误现场
A/B 实验 update_state + stream(None, fork_config) 分叉 同起点测试多种策略
错误恢复 update_state(as_node=...) + stream 分叉 + 注入结果 绕过失败步骤继续执行

8 核心概念总结与常见问题

时间旅行核心概念
Checkpointer 检查点器
  • • compile(checkpointer=...) 启用
  • • 每次节点执行后自动保存快照
  • • MemorySaver:内存,适合开发
  • • SqliteSaver / PostgresSaver:生产用
StateSnapshot 检查点结构
  • • values:该时刻的完整状态
  • • next:下一步节点名称
  • • config.checkpoint_id:唯一标识
  • • parent_config:上一检查点引用
重放(Replay)
  • • graph.stream(None, checkpoint_config)
  • • 走原有路径,不重新调用 LLM
  • • 结果与历史相同
  • • 用途:调试复现
分叉(Fork)
  • • graph.update_state(config, new_values)
  • • 创建新的 checkpoint_id 链
  • • 真实运行后续节点
  • • 用途:实验 / 错误恢复
时间旅行的前提条件
图必须在 compile(checkpointer=...) 时传入 Checkpointer,且每次 stream/invoke 时必须提供 thread_id。没有这两点,就没有历史记录,也就无法时间旅行。

常见问题解答

Q:重放和直接再次 invoke 有什么区别?

直接再次 invoke:图从 START 开始重新执行,LLM 和工具都会被重新调用,由于 LLM 的非确定性,结果可能不同。
重放:图从历史检查点续跑,LangGraph 知道哪些步骤"已执行过"并照原样重现,不会重新调用 LLM,结果完全确定,与历史相同

Q:分叉后原来的时间线还存在吗?

存在。update_state 创建的是一个新的检查点节点,挂在原有时间线的某个节点之后,形成新分支。原来的检查点链完全不受影响,仍然可以通过 get_state_history 查看,也可以继续从原有检查点进行重放或再次分叉。

Q:如何在 update_state 时覆盖而非追加消息?

MessagesState 使用 add_messages Reducer,它的规则是:如果新消息的 ID 与已有消息的 ID 相同,则替换;否则追加为新消息。因此修改已有消息时,必须从原始检查点的 values["messages"][i].id 获取 ID 并赋给新消息。

Q:MemorySaver 的数据在程序重启后还在吗?

不在。MemorySaver 只保存在 Python 进程的内存中,进程终止后数据全部丢失。生产环境中应使用 SqliteSaver(保存到本地 SQLite 文件)或 PostgresSaver(保存到 PostgreSQL 数据库),以实现持久化存储。LangGraph Platform(商业版)提供托管的持久化服务。

Module 3 知识回顾

Lesson 1 流式输出(Streaming):实时展示 Agent 执行过程中的每条消息
Lesson 2 断点(Breakpoints):在指定节点前后暂停,等待人工确认
Lesson 3 编辑状态(Edit State):暂停后修改图状态再继续,实现人工介入
Lesson 4 动态断点(Dynamic Breakpoints):由节点内部逻辑决定是否触发暂停
Lesson 5 时间旅行(Time Travel):回溯历史检查点,重放或分叉执行路径
最终结论

时间旅行是 LangGraph 调试体验的顶层能力。当你有了 Checkpointer(Module 2 引入)、断点(Module 3 前四课),再加上时间旅行,你就拥有了完整的 "运行 → 暂停 → 检查 → 回溯 → 修改 → 继续" 的完整调试闭环。这让构建复杂、可靠的生产级 LLM 应用成为可能——不再是黑盒,而是可观测、可控制、可复现的系统。