Skip to content
Charles Shao
Go back

LangGraph 时光旅行:回溯状态、断点重跑与分支分叉

–views

在用 SqliteSaver 关了进程再续里,我们把 Checkpointer 从进程内的 InMemorySaver 换成了本地文件的 SqliteSaver,实现了第一次运行停在订房审批、退出进程后第二天还能带着同一个 thread_id 回来接着跑;在编排与状态契约中,图的拓扑进一步扩展成了条件边分流、Send 动态扇出与子图嵌套。然而在前面的所有实操里,Checkpointer 都只被当成了一个单纯的「断点续传器」使用:状态沿着单一的主干一路向前推,走到哪记到哪。

如果仅仅为了停住再续,手搓系列在手搓上线里每轮写一份 audit/<run_id>.json 同样能做到。LangGraph 这套 BSP / Pregel 运行时相比简单托管循环最硬核的差异化能力,在于时光旅行(Time Travel)。因为 Checkpointer 并不是把最新状态覆写在同一个格子里,而是在每一个 superstep 边界都落下一份不可变的快照。这些快照按 parent_checkpoint_id 串成一棵版本树。有了这棵树,你不仅能向后「续跑」,还能向前「回溯」:既能查阅历史每一步留下的状态真相,也能挑出过去的某个断点原地重跑,甚至能人工篡改当时的参数、指定下游节点,派生出一条全新的平行执行分支。

本篇继续以「西安酒店预订」这条业务线为锚点,把时光旅行的底层机制和三套核心操作姿势扒干净。

Table of contents

Open Table of contents

一、Checkpointer 中的版本树:不仅是一条线性链表

要理解时光旅行,首先要看清 Checkpointer 在底层究竟存下了什么。

无论使用 InMemorySaver、SqliteSaver 还是生产级的 PostgresSaver,Checkpointer 都不是维护一个随时被 UPDATE 覆写的单行记录,而是在每个 superstep 结束时追加一条快照。一个 superstep 是图运行时的一个基本调度节拍:当前批次所有就绪的节点(可能是单个节点,也可能是并发执行的多个节点)并发运行,把状态更新写回通道,由 reducer 完成合并并落盘。这个快照在代码中由 StateSnapshot 对象表示。

Checkpointer 版本树:从线性执行到时光旅行分叉

我们可以打印一个具体的 StateSnapshot,它的核心字段由六部分组成:

snapshot = graph.get_state(config)

# 1. 当前业务状态字典(经过 reducer 合并后的完整数据)
print(snapshot.values)
# {'messages': [...], 'seen_hotel_ids': ['HT-001', 'HT-002'], 'orders': {}}

# 2. 下一步 scheduled 待执行的节点元组
print(snapshot.next)
# ('tools',)  # 表示下一步该轮到 tools 节点执行

# 3. 本次快照的唯一配置坐标
print(snapshot.config)
# {'configurable': {'thread_id': 'xian-suite-2026-10-01', 'checkpoint_ns': '', 'checkpoint_id': '1ef7780a-...'}}

# 4. 指向父级快照的配置坐标(版本树的指针)
print(snapshot.parent_config)
# {'configurable': {'thread_id': 'xian-suite-2026-10-01', 'checkpoint_ns': '', 'checkpoint_id': '1ef77800-...'}}

# 5. 当前由于 interrupt() 挂起的拦截载荷
print(snapshot.interrupts)
# (Interrupt(value={'hotel_id': 'HT-002', 'room_type': 'suite', 'nights': 2, 'total': 800}, ...),)

# 6. 该步骤所执行的任务元信息
print(snapshot.tasks)

从结构可以看出两件关键事实:

第一,时光旅行的最小时间颗粒度是 superstep,而不是 Python 代码行。 你不能让执行流时光旅行到 book_hotel 函数内部的第三行与第四行之间;你只能回溯到某个节点执行完毕、新快照刚落盘的那一瞬间。因此,合理的节点切分不仅关乎代码组织,更直接决定了系统可回溯、可干预的边界。

第二,版本链条是树状追加的。 每一个 checkpoint 都通过 parent_config 记录着它的上游节点 ID。当我们沿着主干正常运行并中途分叉时,旧的快照不会被擦除或移动;新分支的起点仅仅是将它的 parent_checkpoint_id 指向了历史上的某个旧节点。

明确了版本树的底层结构后,时光旅行在工程中有三种完全不同的用法:浏览排障(Browse)、断点重跑(Replay) 与 状态篡改与分叉(Fork)。

时光旅行的三种核心姿势:浏览、重跑与分叉

二、姿势一:浏览排障(Browse)——告别盲猜日志

在排查复杂 Agent 偶发故障时,开发者最常做的事就是在终端里翻找长篇累牍的日志。如果日志格式不规范,或者上下文被压缩截断,排查往往沦为猜测:究竟是模型第一轮下发参数就错了,还是搜索工具回灌了异常数据?

在挂载了持久化 Checkpointer 的图上,排障完全不需要依赖散落的文本日志。通过 graph.get_state_history(config),我们可以直接拿到这条 thread_id 从诞生至今的所有历史快照切片:

import sqlite3
from pathlib import Path
from langgraph.checkpoint.sqlite import SqliteSaver

# 连接在上一篇中生成的持久化数据库
conn = sqlite3.connect("checkpoints.sqlite", check_same_thread=False)
checkpointer = SqliteSaver(conn)
# 挂载了相同 checkpointer 的图
graph = builder.compile(checkpointer=checkpointer)

config = {"configurable": {"thread_id": "xian-suite-2026-10-01"}}

# 获取全部历史快照(生成器对象,按时间倒序排列:最新的在最前,最旧的在最后)
history = list(graph.get_state_history(config))

print(f"该 Thread 共记录了 {len(history)} 个 superstep 快照:\n")

for i, state in enumerate(history):
    cp_id = state.config["configurable"]["checkpoint_id"]
    parent_id = state.parent_config["configurable"]["checkpoint_id"] if state.parent_config else "ROOT"
    step_num = state.metadata.get("step", -1)
    next_node = state.next
    msg_count = len(state.values.get("messages", []))
    hotels = state.values.get("seen_hotel_ids", [])

    print(f"[{i}] step={step_num} cp_id={cp_id[:8]}... parent={parent_id[:8]}... next={next_node}")
    print(f"    State 概况: messages={msg_count} 条, seen_hotel_ids={hotels}")

在我们的订房场景跑出的一次标准轨迹里,终端会打印出类似如下的链路账本:

该 Thread 共记录了 5 个 superstep 快照:

[0] step=4 cp_id=1ef780a1... parent=1ef78099... next=('tools',)
    State 概况: messages=4 条, seen_hotel_ids=['HT-001', 'HT-002', 'HT-003']
    [停在 interrupt():tools 节点准备下套房单,等待人确认]

[1] step=3 cp_id=1ef78099... parent=1ef7808f... next=('tools',)
    State 概况: messages=3 条, seen_hotel_ids=['HT-001', 'HT-002', 'HT-003']
    [agent 节点刚执行完,决策下发 tool_calls: book_hotel("HT-002", "suite", ...)]

[2] step=2 cp_id=1ef7808f... parent=1ef78080... next=('agent',)
    State 概况: messages=3 条, seen_hotel_ids=['HT-001', 'HT-002', 'HT-003']
    [tools 节点刚执行完 search_hotels 并回灌,白名单已刷入 State]

[3] step=1 cp_id=1ef78080... parent=1ef78072... next=('tools',)
    State 概况: messages=2 条, seen_hotel_ids=[]
    [agent 节点刚执行完,下发 tool_calls: search_hotels("西安", "2026-10-01")]

[4] step=0 cp_id=1ef78072... parent=ROOT... next=('agent',)
    State 概况: messages=1 条, seen_hotel_ids=[]
    [START 输入载入:用户提出订房目标]

这种排障体验与传统打日志有本质区别:

  1. 快照是结构化的确定性状态:不需要在海量日志里正则匹配,随时能点开任意一步的 state.values["messages"] 查看模型输入输出原文。
  2. 纯只读与零副作用:遍历 get_state_history 只是一次普通的数据库 SELECT 查询,绝不会唤醒任何节点代码,也不会触发模型 API 调用。

三、姿势二:断点重跑(Replay)——跳过前置,后置重算

有时候我们需要的不仅是「看」,而是让程序回到过去重新执行一次。

例如:大模型在 step 3 突然犯迷糊选错了酒店;或者本地网络抖动导致 step 4 挂掉了。此时你修复了网络或调整了环境,并不想从第 0 步重新查天气、搜酒店(那既耗时又浪费 API 配额),而是希望跳过已经跑通的前三步,直接从第 2 步的快照重新唤醒图,往后继续跑。

这就是 Replay(断点重跑)。它的用法极简:只要把历史上某个 checkpoint 的 config 取出来,传给 graph.invoke(None, config):

# 1. 找到 search_hotels 刚回灌完、准备交由 agent 决策订房的那个历史检查点(即 step 2)
history = list(graph.get_state_history(config))
target_checkpoint = next(
    s for s in history
    if s.next == ("agent",) and len(s.values.get("seen_hotel_ids", [])) > 0
)

print(f"选定历史回溯点: step={target_checkpoint.metadata.get('step')} id={target_checkpoint.config['configurable']['checkpoint_id']}")

# 2. 传入该 checkpoint 的完整 config 恢复执行,输入 payload 给 None
replay_result = graph.invoke(None, target_checkpoint.config)

# 观察后续流转
print("—— 重跑执行完毕 ——")
for m in replay_result["messages"]:
    print(f"[{m.type}] {getattr(m, 'name', '')} {m.content or getattr(m, 'tool_calls', '')}")

执行这段代码时,底层的运行逻辑至关重要,必须严格牢记三条规则:

第一,前置节点绝对不会重新执行。 START、第一次天气查询和 search_hotels 都属于该 checkpoint 之前的产物。这些数据在 target_checkpoint.values 中已经完整就绪,运行时直接装载使用,零网络开销,零 Token 消耗。

第二,后置节点是真实的重新计算,而不是读取本地回放缓存! 这是最容易引起新手误解的地方。invoke(None, target_checkpoint.config) 不是简单的视频倒带播放,而是将当时的状态喂给下游节点重新跑一次真正的 Python 代码。这意味着:

第三,对已经终结的快照(next=())调用 Replay 是无效的空操作(No-op)。 如果你把流程已经跑完到达 END 的最后一个 checkpoint 拿去 invoke(None, ...),框架一检测到 next=(),会立刻原样返回当前 values,什么也不会发生。

四、姿势三:状态篡改与分支分叉(Fork)——What-If 推演

Replay 虽然能重跑,但它喂给下游的依旧是当时那一模一样的上下文。在真实的人工协同或离线测试中,最强烈的诉求往往是:“回到那一刻,把某个参数改掉,看看接下来会发生什么”。

这就是 Fork(状态篡改与分叉)。典型场景如:

1. update_state 核心机制

在 LangGraph 中,修改历史状态的唯一入口是 graph.update_state()。

我们以「把套房改成标准间」为例,演示如何从 step 2(搜索结果刚出来、准备调 agent 订房)分叉出一条新线:

# 1. 抓取历史回溯点(搜索完成、白名单刚就位的那一步)
history = list(graph.get_state_history(config))
before_book = next(
    s for s in history
    if s.next == ("agent",) and len(s.values.get("seen_hotel_ids", [])) > 0
)

# 2. 构造我们要篡改或追加的状态数据
# 此时我们要模拟用户突然改变主意,追加一条人类指令:“改订标准间”
new_human_msg = HumanMessage(content="算了,套房太贵了,帮我改成标准间(standard)吧,入住日期不变。")

# 3. 在历史 checkpoint 之上派生新状态
fork_config = graph.update_state(
    before_book.config,
    values={"messages": [new_human_msg]},
    as_node="agent",  # 关键参数:指定该状态更新视为哪个节点产出
)

print(f"新分支检查点创建成功!新 checkpoint_id={fork_config['configurable']['checkpoint_id']}")
print(f"此时图的 next 走向: {graph.get_state(fork_config).next}")

执行 update_state 后,数据库里并不会修改 before_book 那一行,而是新插入了一行 checkpoint,它的 parent_checkpoint_id 稳稳地指向 before_book。

2. 必须重视的 as_node 参数

在调用 update_state 时,第三个参数 as_node 往往决定了分叉的成败。

为什么需要 as_node?因为图运行时必须知道:这份新状态注入之后,下一步该触发哪条边、由哪个下游节点来消费?

踩坑警示(Issue #6433):在早期部分版本中,如果在回溯分叉时漏传或错传了 as_node,由于缺乏运行时推导依据,新快照的 next 属性会被直接置空为 ()。这会导致后续无论怎么调用 invoke(None, fork_config),图都会判定为已经到达终点而直接结束。在 1.0.3 及后续版本中官方修补了该推导逻辑,但在工程实践中,永远显式指定 as_node 是最健壮的习惯。

3. Reducer 契约依然生效

我们在状态契约与 Reducer中反复强调:状态更新绝不是盲目的字典覆盖,必须遵循通道上声明的 Reducer 规则。

在 update_state 中同样如此:

4. 驱动分叉线跑向终点

拿到 update_state 返回的 fork_config 后,再次调用 invoke(None, fork_config),即可让图沿着这条平行宇宙一路执行下去:

# 沿着分叉后的新配置继续推进
print("—— 启动分叉执行 ——")
fork_res = graph.invoke(None, fork_config)

# 观察是否成功停在订标准间的 interrupt 点
fork_snapshot = graph.get_state(fork_config)
print(f"分叉线当前停滞状态 next={fork_snapshot.next}")
for intr in fork_snapshot.interrupts:
    print(f"审批信息已变更: {intr.value}")
    # 输出: {'hotel_id': 'HT-002', 'room_type': 'standard', 'nights': 2, 'total': 300}

# 人工批准分叉线上的订单
final_fork_res = graph.invoke(Command(resume="确认"), fork_config)
print(f"分叉线最终订单落盘: {final_fork_res.get('orders')}")

整个过程行云流水:原来的套房订单历史安然躺在数据库中,新的标准间订单则在分叉线上顺利完成,两套状态互不干扰。

5. 真正的多线程隔离:派生独立 thread_id

上面的代码虽然分叉出了新分支,但它们依然共享同一个 thread_id = "xian-suite-2026-10-01"。这意味着该 thread_id 的最新游标(Head)已经被移动到了这条分叉线上。

如果你希望原主干保留、同时在不污染原主干游标的前提下开启一场探索,标准做法是换一个全新的 thread_id,将历史状态作为初值灌入:

# 提取历史切片 values
historical_values = before_book.values.copy()
# 剔除需要重置的临时字段,修改房型目标
historical_values["messages"].append(HumanMessage(content="改订标准间"))

# 赋予一个全新的平行空间线程 ID
new_thread_config = {"configurable": {"thread_id": "xian-standard-fork-002"}}

# 在全新独立线程中从容启动
graph.invoke(historical_values, new_thread_config)

这种模式在评测系统(Evals)和人工审核流中最为常见:线上用户遇到 Bad Case,测试系统把当时的快照 Dump 下来并赋给测试用的 thread_id,在沙盒中反复重放与分叉,线上真实的会话线程不会产生任何抖动。

五、更深一层:子图与持久化开销

掌握了三大姿势后,进入大型系统时还有两个横切细节需要心中有数。

1. 子图内部能否时光旅行?

在编排与状态契约中,我们引入了子图。子图内部能否进行时光旅行,取决于子图在编译时是否挂载了独立的 Checkpointer:

2. Checkpoint 膨胀与存储治理

时光旅行虽然美妙,但它的代价是存储换空间。

每一轮 superstep 都要对全部活跃通道做一次序列化落盘。如果你的 Agent 会话长达数十轮,messages 列表越来越庞大,Checkpoint 表的体积会呈现线性甚至超线性膨胀:

六、常见误区与排障手册

常见误区 / 异常现象底层原因正确应对姿势
误以为 update_state 会把历史旧数据“抹掉”误把图的 Checkpointer 当作传统数据库的单行更新Checkpointer 是只读追加的版本树;历史节点依旧安在,分叉产生的是带有新 ID 的独立分支
Replay 时发现没有读缓存,外部接口被真实调用了一遍误以为 Replay 是静态视频回放历史节点跳过,但当前节点及后续节点是真实重新执行代码;外部写操作必须挂载业务幂等键防重
拿终态 checkpoint(next=())调用 invoke(None, ...) 毫无反应终态节点已经执行至 END,图引擎判定该快照无就绪任务检查 snapshot.next;时光旅行必须选在有后置未决任务的检查点上发起
update_state 之后 next 变成空元组 (),续跑直接退出遗漏了关键参数 as_node,框架无法推演由谁的出边决定路由(或使用了低于 1.0.3 的版本)显式指定 as_node="对应节点名",明确告诉引擎这次状态更新由谁产生
在未挂载 Checkpointer 的图上调用 get_state_history图在内存中裸跑,状态跑完即抛时光旅行强依赖 Checkpointer;初始化 compile(checkpointer=...) 是先决条件
跨子图时光旅行时找不到子图中间步骤快照子图默认未开启独立持久化,在父图眼中仅是一次大步骤为子图显式声明 compile(checkpointer=True),并使用 subgraphs=True 提取上下文

接下来

到这一篇,我们彻底解锁了持久化 Checkpointer 的全部潜能:从中断停住、关了进程持久化续跑,再到沿着时间线倒序巡检、断点重跑与篡改分叉。

然而,无论 Checkpointer 把单个线程的版本树管理得多么精致,它的视野边界依然被死死锁在当前这个 thread_id 内部。当用户关掉当前页面、开启了一场全新的对话,或者需要多个协作 Agent 共享全局的长期事实(例如:“这位旅客对花粉过敏、永远不要推荐带花园的房型”、“该用户是白金会员,全局免收押金”)时,按 Thread 隔离的 Checkpointer 就无能为力了。

下一篇我们将引入 LangGraph 持久化体系的另一块核心拼图——Store(跨 Thread 长期记忆存储)。它将突破 Thread 的封印,拆清它与 Checkpointer 之间的职责分工、命名空间设计以及跨会话状态沉淀。


–views
Share this post on:

Next Post
什么时候不必上 Agent 框架:三条边界,四笔成本