在 LangGraph 中,每一次图的执行都会被自动保存为检查点快照。
你可以随时回溯过去的任意时刻——重放、调试、或走向一条全新的执行路线。
在学习 Module 3 的前几节课时,我们已经了解了断点(Breakpoints)和人工介入(Human-in-the-loop)的机制。时间旅行(Time Travel) 是这些能力的自然延伸:它让你不仅能"暂停"图的执行,还能像操作时光机一样,回到任意一个已执行过的历史时刻,然后从那里重新出发。
LangGraph 在每个图执行步骤之后,都会由 Checkpointer(检查点器) 自动将当前完整状态保存为一个快照。这些快照构成了一条时间线,每个快照都有唯一的 checkpoint_id。时间旅行正是基于这条时间线实现的。
时间旅行在 LangGraph 中包含两种操作:
· 重放(Replay):从某个过去的检查点开始,按照原本相同的路径再走一遍,结果与历史相同。
· 分叉(Fork):回到某个检查点,修改那时的状态,然后重新执行——这会产生一条新的执行分支,结果可能与历史完全不同。
这个机制与 Git 的版本控制有着高度的相似性:检查点就像 commit,重放就像 checkout 到某个历史 commit 再次查看,分叉就像从某个历史 commit 建立新分支并提交不同的更改。
| 时间旅行操作 | 触发方式 | 执行行为 | 典型用途 |
|---|---|---|---|
| 重放(Replay) | 传入已有 checkpoint_id 运行图 |
从该检查点续跑,走原有路径 | 复现问题、验证结果一致性 |
| 分叉(Fork) | update_state 修改历史状态后运行 |
创建新检查点分支,走新路径 | 实验不同输入、修复历史错误 |
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)
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)
时间旅行完全依赖于检查点(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"。整个过程的每一步都被自动保存为一个检查点快照。
图执行完毕后,你可以通过两个 API 来查看历史状态:
graph.get_state(config):获取当前(最新)状态快照graph.get_state_history(config):获取该线程的所有历史检查点,按时间倒序排列# 获取当前状态
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 个检查点:
每个检查点都是一个 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 节点的那一刻。
重放是时间旅行中最简单的操作:找到一个历史检查点,把它的 config(包含 checkpoint_id)传给 graph.stream(),图就会从那个时间点续跑,执行路径与原来完全相同。
从 all_states 中选取目标时刻。例如选 all_states[-2],即图启动后刚收到用户消息、即将执行 assistant 节点的那一刻。
查看 to_replay.values 确认当时的消息内容,查看 to_replay.next 确认接下来会执行哪个节点。
第一个参数传 None(不传新输入),第二个参数传 to_replay.config,LangGraph 会识别该检查点已执行过,自动从那里续跑。
# 步骤 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.
当你传入一个 checkpoint_id 时,LangGraph 查看该检查点在时间线中的位置:
· 如果该检查点之后已有后续检查点(即原来已执行过),图会把后续步骤照原样重放,不会再调用 LLM。
· 如果该检查点之后没有后续检查点(分叉产生的新检查点),图认为这是一个全新的执行起点,会真实运行后续节点。
重放最直接的用途是调试复现:当你的 Agent 在某次运行中产生了奇怪的输出,你可以通过重放精确还原当时的执行环境,逐步检查每个节点的输入输出,无需重新运行整个工作流。
分叉比重放更强大:你不仅回到过去,还可以修改那时的状态,然后从修改后的状态出发,让图走一条全新的路径。修改操作通过 graph.update_state() 完成,它会在原有检查点的基础上创建一个新的检查点(新分支),而不影响原来的时间线。
在修改状态时,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(追加):{"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.
原始时间线完好保留,分叉时间线是从分叉点产生的独立链条,两者在 thread_id 相同但 checkpoint_id 链路不同。当前状态(get_state)会指向最新写入的分叉末端。
除了本地 Python 代码,LangGraph 还提供了 HTTP API(通过 LangGraph SDK),让你在 LangSmith Studio / 生产服务中同样使用时间旅行。这个 API 的工作方式与 Python 代码高度对称,概念完全相同。
在 module-3/studio 目录下运行 langgraph dev,Studio UI 将在 https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024 打开。通过 SDK 连接到本地服务器,即可调用 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)
# 获取要分叉的检查点
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(本地):graph.stream(None, to_replay.config) — config 直接包含 checkpoint_id
API(远程):client.runs.stream(..., checkpoint_id=...) — checkpoint_id 作为独立参数传入
两者语义完全相同,只是接口形式略有不同。Studio UI 中可以通过鼠标点击检查点来触发相同操作,无需写代码。
时间旅行不仅是一个学术概念,它在构建生产级 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 |
分叉 + 注入结果 | 绕过失败步骤继续执行 |
compile(checkpointer=...) 时传入 Checkpointer,且每次 stream/invoke 时必须提供 thread_id。没有这两点,就没有历史记录,也就无法时间旅行。直接再次 invoke:图从 START 开始重新执行,LLM 和工具都会被重新调用,由于 LLM 的非确定性,结果可能不同。
重放:图从历史检查点续跑,LangGraph 知道哪些步骤"已执行过"并照原样重现,不会重新调用 LLM,结果完全确定,与历史相同。
存在。update_state 创建的是一个新的检查点节点,挂在原有时间线的某个节点之后,形成新分支。原来的检查点链完全不受影响,仍然可以通过 get_state_history 查看,也可以继续从原有检查点进行重放或再次分叉。
MessagesState 使用 add_messages Reducer,它的规则是:如果新消息的 ID 与已有消息的 ID 相同,则替换;否则追加为新消息。因此修改已有消息时,必须从原始检查点的 values["messages"][i].id 获取 ID 并赋给新消息。
不在。MemorySaver 只保存在 Python 进程的内存中,进程终止后数据全部丢失。生产环境中应使用 SqliteSaver(保存到本地 SQLite 文件)或 PostgresSaver(保存到 PostgreSQL 数据库),以实现持久化存储。LangGraph Platform(商业版)提供托管的持久化服务。
时间旅行是 LangGraph 调试体验的顶层能力。当你有了 Checkpointer(Module 2 引入)、断点(Module 3 前四课),再加上时间旅行,你就拥有了完整的 "运行 → 暂停 → 检查 → 回溯 → 修改 → 继续" 的完整调试闭环。这让构建复杂、可靠的生产级 LLM 应用成为可能——不再是黑盒,而是可观测、可控制、可复现的系统。