一、总体机制
时间旅行并非在图上新增功能,而是在已持久化的检查点链上,选取某一检查点重新执行后续流程。
flowchart TB
A([cid A]) --> B[cid B<br/>选中的这份] --> C[cid C] --> D([cid D<br/>原来的终点])
B -.->|时间旅行从这岔出去| E[cid E<br/>新支线起点] --> F([cid F<br/>新终点])
该图的核心结论为:下方支线自 cid B 派生、后于原链形成,而原链未发生任何改动。 时间旅行不修改原链,而是从选定的检查点派生新支线,全部新执行均记录于派生侧。
Replay 与 Fork 的区别不在「操作处于哪一步」——任意检查点均可作为操作对象,选取哪一步仅决定跳过多少已完成的执行。二者的唯一区别在于重跑之前是否修改状态。
flowchart TB
Q[选一份检查点] --> R{重跑之前改状态吗}
R -->|不改| RP[Replay]
R -->|改| FK[Fork]
RP --> P1[新落一份 source=fork]
P1 --> P2[父指针 = 被选中的检查点自己<br/>在链上接着这份往下长]
FK --> P3[新落一份 source=update]
P3 --> P4[父指针 = 被选中检查点的 parent<br/>在链上跟被选中那份并排]
该图的核心结论为:两条路径的「分叉位置」不同,这是唯一会导致链结构产生差异的因素。 具体的落点以及 step、next 的变化,将在 §3 中结合实测数据逐条说明。
后文反复出现的术语,定义如下:
| 术语 | 定义 | 详见 |
|---|---|---|
| 检查点(checkpoint) | 图每完成一个超步即持久化一份的状态快照 | §2.1 |
checkpoint_id | 检查点的唯一标识,时间旅行需指定的即为此值 | §2.1 |
parent_config | 上一检查点的 config,检查点链由此连接 | §2.1 |
source | 检查点的产生方式,取值 input / loop / update / fork | §2.3 |
next | 该检查点之后待执行的节点集合,执行完毕则为空元组 | §2.3 |
| 状态部分(state) | 检查点中存储的数据,即 values 字段,如 messages 列表 | §3.1 |
| Replay | 自某一检查点原样重跑 | §3.3 |
| Fork | 先修改状态,再自某一检查点重跑 | §3.4 |
| HEAD(当前指针) | 线程中最新一份检查点,聊天界面显示的状态即源于此 | §4.5 |
二、时间旅行的对象:检查点链与可回拨的指针
2.1 检查点链的结构
为清晰呈现机制,本文全程采用一个最小的两节点图:booking 记录用户所述预约时间(不产生内容),reply 根据最后一条消息生成回复。两个节点均不调用模型,同一段上下文重复执行任意次,结果完全一致——由此可将「变化源于重跑」与「变化源于模型随机性」区分开。
from typing import Annotated, TypedDict
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import add_messages
class Chat(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
def booking(state: Chat) -> dict:
return {} # 用户消息已经由 START 写进去了,这一步只是占位
def reply(state: Chat) -> dict:
return {"messages": [AIMessage("回复:" + state["messages"][-1].text)]}
def build():
return (
StateGraph(Chat)
.add_node("booking", booking)
.add_node("reply", reply)
.add_edge(START, "booking")
.add_edge("booking", "reply")
.add_edge("reply", END)
.compile(checkpointer=InMemorySaver())
)
图编译时配置 InMemorySaver,检查点持久化于内存。本例验证目标为:两轮对话之后,链上检查点的总数及各自所处的执行位置。
graph = build()
config = {"configurable": {"thread_id": "booking"}}
await graph.ainvoke({"messages": [HumanMessage("明天上午还有号吗")]}, config)
await graph.ainvoke({"messages": [HumanMessage("那下午呢")]}, config)
for s in graph.get_state_history(config):
cid = s.config["configurable"]["checkpoint_id"]
parent = s.parent_config["configurable"]["checkpoint_id"] if s.parent_config else None
print(f"step={s.metadata['step']:>2} source={s.metadata['source']:<6} next={str(s.next):<18} "
f"cid=…{cid[-6:]} parent={'…' + parent[-6:] if parent else '-'}")
step= 6 source=loop next=() cid=…b2c487 parent=…15e053 ← 第二轮回复之后,头指针在这
step= 5 source=loop next=('reply',) cid=…15e053 parent=…a8c965
step= 4 source=loop next=('booking',) cid=…a8c965 parent=…120c3b
step= 3 source=input next=('__start__',) cid=…120c3b parent=…b82130 ← 第二轮的输入检查点
step= 2 source=loop next=() cid=…b82130 parent=…27bc80
step= 1 source=loop next=('reply',) cid=…27bc80 parent=…af5f21 ← 第一轮回复之前
step= 0 source=loop next=('booking',) cid=…af5f21 parent=…8c9967
step=-1 source=input next=('__start__',) cid=…8c9967 parent=- ← 输入检查点,里面没消息
两轮对话共产生 8 份检查点,get_state_history 按从新到旧的顺序返回。三个字段即可完整描述整条链:
step:超步编号。整个线程从头到尾连续编号,并非每轮重新从零计。第一轮占据 -1 至 2,第二轮自 3 连续排至 6。next:该检查点之后待执行的节点集合。结合step即可判定该检查点所处的执行位置——next=('reply',)表示reply尚未执行,next=()表示本轮执行完毕。parent_config:上一检查点的 config。逐节连接即构成执行历史。其指向为「上一检查点」,而非「上一执行步骤对应的节点」——链由检查点连接而成,节点名仅出现于next中。
step=-1 的检查点需单独说明:其中不含任何消息,但它是整条链的根。 其 values["messages"] 为空列表,却是 Fork 最常用的落点——自此处修改状态,等价于更换入口、重新开始整条流程。
下图将三个字段的职责分别呈现:
flowchart TB
P[parent_config<br/>上一个检查点] -->|往新到旧 连成链| ME[cid ...af5f21<br/>step=0<br/>source=loop]
ME -->|往旧到新 接着往下跑| N[next<br/>后面还要跑 booking]
ME -->|这份检查点里存的数据| V[values<br/>messages 1 条]
style P fill:#e6f4ea,stroke:#137333
style N fill:#fce8e6,stroke:#c5221f
style V fill:#e8f0fe,stroke:#1a73e8
该图的核心结论为:parent_config 仅表示「来源」,next 仅表示「后续待执行的节点」,values 仅表示「当前状态」。 三者相互独立——同一份 values 可挂接于不同 parent 之下,即构成两条支线(§3 将给出实测)。
2.2 parent_id 的确定时机:持久化之前,由传入的 config 决定
这是全文最关键的一条规则,首先给出源码位置。
保存检查点时,保存器自传入的 config 中读取 checkpoint_id 作为父指针。InMemorySaver.put() 中的相关代码如下:
self.storage[thread_id][checkpoint_ns].update(
{
checkpoint["id"]: (
self.serde.dumps_typed(c),
self.serde.dumps_typed(get_checkpoint_metadata(config, metadata)),
config["configurable"].get("checkpoint_id"), # 父指针 ← 就是这里
)
}
)
config 中存在 checkpoint_id,新检查点的父指针即取该值;config 中不存在,则父指针为 None,该检查点成为根。
因此,唯一需要决策的是「传入哪一份检查点的 config」;父指针、分叉位置、状态继承,均为该决策的直接后果。 该结论的逆命题同样成立,且更值得强调:
新支线的父指针并不总是指向所选中的检查点。 保存时父指针取自 config 中的 checkpoint_id,而 update_state 在修改状态之前,会先将 config 替换为所选检查点的父检查点。因此 Fork 产生的新检查点与所选检查点在链上为兄弟关系——二者自同一位置分叉,区别仅在于前者额外经历了一次状态修改。
2.3 三种解读维度:next 决定去向、source 标识来路、step 标识位置
metadata["source"] 标识检查点的产生方式,共四种取值,本次实测均已覆盖:
source | 产生时机 | 实测出处 |
|---|---|---|
input | 调用时传入 input,为其落一份输入检查点 | 每条链最前一份(step=-1),第二轮对应 step=3 那份 |
loop | 正常执行完成一个超步 | 首次运行留下的其余检查点 |
update | 由状态修改方法产生 | §3.4 |
fork | 从旧检查点重跑时分叉产生 | §3.3 |
排查问题时,source 是最直接的判断依据。 历史中若出现 source=update 或 source=fork,即表明该线程经历过人为干预;仅见 loop,则说明该线程为正常顺序执行。
三、Replay 与 Fork:指针指向的差异
Replay 是将所选检查点之后的部分原样重跑;Fork 是在重跑之前先修改状态。 两条路径的最终结果一致:新执行记录于新支线,原支线不受影响。唯一区别在于——Fork 在重跑之前额外落有一份 source=update 的检查点。
3.1 两条路径的指针对照
flowchart TB
subgraph RP[Replay 不动状态]
direction TB
T1[选中的检查点<br/>step=k next=待跑的节点] --> F1[运行时先落一份<br/>source=fork step=k+1<br/>parent=被选中的那份]
F1 --> N1[继续跑 next 里的节点<br/>落 source=loop]
end
subgraph FK[Fork 先改状态]
direction TB
T2[选中的检查点] --> U1[update_state 落一份<br/>source=update step=k+1<br/>parent=被选中那份的 parent]
U1 --> N2[next 由 as_node 推出来<br/>继续跑并落 source=loop]
end
该图的核心结论为:两条路径的差别不在「从哪一步开始」,而在「新检查点挂接在哪一节点之下」。 两个分支的第一步均为「从所选检查点出发」,落点均为 step=k+1;唯一差异在于 parent= 一行——一侧挂接所选检查点,另一侧挂接所选检查点的 parent。下一节将这两个 parent 以箭头形式呈现。
| Replay | Fork | |
|---|---|---|
| 重跑前是否修改状态 | 否 | 是 |
| 中途落检查点数 | 1 份(fork)+ 后续产出 | 1 份(update)+ 后续产出 |
该中间检查点的 parent | 所选检查点自身 | 所选检查点的 parent |
该中间检查点的 step | 所选检查点 + 1 | 所选检查点 + 1 |
重跑时传 input | 要求为 None | 同样要求为 None,改动仅能经由状态修改方法 |
| 典型用途 | 更换模型或提示词,观察同一段上下文下的重新产出 | 基于人工修改后的状态继续执行 |
该表中最易被忽略的是第三行:两侧 step 均加一,父指针却不同。 Replay 为「自所选检查点向下延续」,Fork 为「与所选检查点同级并列」——因为 Fork 的状态修改步骤,在逻辑上已经取代了所选检查点所代表的位置。
首先给出最简对照图。中间一列为操作之前已存在的三份检查点,左右两侧分别为两种操作新产生的检查点:
flowchart TB
F1[Fork 新落的 update<br/>parent = P]
R1[Replay 新落的 fork<br/>parent = X]
P[cid P]
X[cid X<br/>被选中]
Y[cid Y]
P --> X --> Y
P -.->|改状态时从这新建| F1
X -.->|重跑时从这新建| R1
该图的核心结论为:Replay 的新检查点挂接于 X 之下(parent = X),Fork 的新检查点挂接于 P 之下(parent 为 X 的 parent)。 换言之,Replay 的新起点在链上位于 X 之后,Fork 的新起点与 X 同级并列——X 与 F1 互为兄弟。
3.2 实测:链条的实际结构
示意图之后,呈现实测的树形结构。§2.1 的两轮对话(8 份检查点)以 parent 箭头连接后为一条直线:
flowchart TB
IN([cid ...8c9967<br/>step=-1 input 0 条]) --> S0[cid ...af5f21<br/>step=0 loop 1 条]
S0 --> S1[cid ...27bc80<br/>step=1 loop 1 条] --> S2[cid ...b82130<br/>step=2 loop 2 条]
S2 --> S3[cid ...120c3b<br/>step=3 input 2 条] --> S4[cid ...a8c965<br/>step=4 loop 3 条]
S4 --> S5[cid ...15e053<br/>step=5 loop 3 条] --> E1([cid ...b2c487<br/>step=6 loop 4 条])
style IN fill:#e8f0fe,stroke:#1a73e8
style S3 fill:#e8f0fe,stroke:#1a73e8
style E1 fill:#e8f0fe,stroke:#1a73e8
style S0 fill:#f1f3f4,stroke:#5f6368
style S1 fill:#f1f3f4,stroke:#5f6368
style S2 fill:#f1f3f4,stroke:#5f6368
style S4 fill:#f1f3f4,stroke:#5f6368
style S5 fill:#f1f3f4,stroke:#5f6368
本节后续执行的三个操作叠加后,结构如下。原链较长,此处仅保留三个分叉位置所在的节点(…8c9967、…27bc80、…b82130),中间未作为分叉点的节点以省略形式表示:
flowchart TB
IN([cid ...8c9967<br/>step=-1 input]) --> A1[cid ...27bc80<br/>step=1 loop]
A1 --> A2[cid ...b82130<br/>step=2 loop]
A1 -.->|Replay 从这岔开| B2[fork ...00ad35<br/>step=2] --> B3[loop ...025db8<br/>step=3]
A2 -.->|从终态重跑<br/>只多一份复制品| C3[fork ...3b47d1<br/>step=3]
IN -.->|Fork 从这岔开| U0[update ...f9a07d<br/>step=0] --> U1[loop ...33628d<br/>step=1]
style IN fill:#e8f0fe,stroke:#1a73e8
style A1 fill:#f1f3f4,stroke:#5f6368
style A2 fill:#f1f3f4,stroke:#5f6368
style B3 fill:#f1f3f4,stroke:#5f6368
style U1 fill:#f1f3f4,stroke:#5f6368
style B2 fill:#e6f4ea,stroke:#137333
style C3 fill:#e6f4ea,stroke:#137333
style U0 fill:#fce8e6,stroke:#c5221f
该树形图可得出三点结论:
- 三条新支线均仅挂接于原链的某一节点,原链本身未被修改。 其余 5 份检查点(
…af5f21、…120c3b、…a8c965、…15e053、…b2c487)亦全部保留,仅未在图中绘出。 …00ad35与…b82130互为兄弟,二者均为…27bc80的子节点——此即 Replay:新支线与所选检查点的后继同级并列。…f9a07d与…af5f21互为兄弟,二者均为…8c9967的子节点——此即 Fork:新检查点与所选检查点同级并列。
三条支线共享同一份 parent 字典存储,故单次调用 get_state_history 即可返回全部 13 份(§4.4 将详述)。
3.3 Replay 实测:自 step=1 重跑
Replay 的调用形式与常规 invoke 几乎一致,仅有两处差异:input 传 None,config 替换为「目标检查点的 config」。本实验的验证目标为:新支线的挂接位置,以及旧支线是否有内容丢失。
history = list(graph.get_state_history(config))
target = history[5] # step=1,next=('reply',),即「用户消息已写入、回复尚未生成」那一刻
await graph.ainvoke(
input=None, # 重跑时不传输入
config=target.config,
)
for s in graph.get_state_history(config):
...
step= 3 source=loop next=() cid=…025db8 parent=…00ad35 ← 新支线跑完的终点
step= 2 source=fork next=('reply',) cid=…00ad35 parent=…27bc80 ← 新起点:父指针 = 被选中的…27bc80
step= 6 source=loop next=() cid=…b2c487 parent=…15e053 ← 旧支线的终点,还在
step= 5 source=loop next=('reply',) cid=…15e053 parent=…a8c965
step= 4 source=loop next=('booking',) cid=…a8c965 parent=…120c3b
step= 3 source=input next=('__start__',) cid=…120c3b parent=…b82130
step= 2 source=loop next=() cid=…b82130 parent=…27bc80 ← 旧支线里 step=2 的那份
step= 1 source=loop next=('reply',) cid=…27bc80 parent=…af5f21 ← 被选中的那份(起点在这)
step= 0 source=loop next=('booking',) cid=…af5f21 parent=…8c9967
step=-1 source=input next=('__start__',) cid=…8c9967 parent=-
三点结论:
- 链由 8 份增至 10 份:
source=fork起点一份,加新执行产生的终点一份。无任何检查点被删除。 - 新起点的
next与所选检查点一致,均为('reply',)——其承接的正是「reply尚未执行」这一位置。 - 新起点与所选检查点的后继互为兄弟。
…00ad35(fork)与…b82130(旧step=2的loop)父指针均为…27bc80。两条支线自同一位置分叉,互不影响。
因此,Replay 并非「回到过去删除后续内容」,而是「自该检查点派生一条新路径」,原有路径完整保留。
另需记录一个边界情况:自终态重跑不会产生任何重跑。 在上述实验基础上,对 step=2 那份(next=(),第一轮的终点)再次执行 Replay:
step= 3 source=fork next=() cid=…3b47d1 parent=…b82130 ← 只多了这一份,内容一字不差
step= 3 source=loop next=() cid=…025db8 parent=…00ad35
...
step= 2 source=loop next=() cid=…b82130 parent=…27bc80 ← 被选中的那份
next 为空元组,无待执行节点,故仅新增一份 source=fork、内容完全相同的检查点即返回,链由 10 份增至 11 份。该现象在聊天界面上对应:在已结束的对话末尾执行「重新生成」,不会触发任何重跑,链上仅新增一份副本。
3.4 Fork 实测:状态修改所落的检查点,决定新支线自其父节点分叉
Fork 分两步:先修改状态,再自新产生的检查点重跑。状态修改使用 update_state,其返回值即新检查点的 config,可直接作为执行入口。
first = list(graph.get_state_history(config))[-1] # 输入检查点 step=-1,next=('__start__',)
new_config = graph.update_state(
first.config, # 落在输入检查点上
values={"messages": [HumanMessage("改成明天下午两点的号")]}, # 往状态里写什么
as_node="booking", # 这段内容算 booking 写下的
)
await graph.ainvoke(input=None, config=new_config)
as_node 的作用是声明该段内容的写入节点身份,新检查点的 next 即由该身份推导:声明为 booking,next 即取 booking 的后继 ('reply',)——booking 因此不会被执行。
[改完状态、还没跑] ← 最右边一列是这份检查点里的 messages 条数
step= 0 source=update next=('reply',) cid=…f9a07d parent=…8c9967 n_msg=1 ← 新检查点,父指针 = 被选中那份的 parent
step= 3 source=fork next=() cid=…3b47d1 parent=…b82130 n_msg=2 ← 上面两次 Replay 留下的
step= 3 source=loop next=() cid=…025db8 parent=…00ad35 n_msg=2
step= 2 source=fork next=('reply',) cid=…00ad35 parent=…27bc80 n_msg=1
step= 6 source=loop next=() cid=…b2c487 parent=…15e053 n_msg=4 ← 旧支线一份没少
step= 5 source=loop next=('reply',) cid=…15e053 parent=…a8c965 n_msg=3
step= 4 source=loop next=('booking',) cid=…a8c965 parent=…120c3b n_msg=3
step= 3 source=input next=('__start__',) cid=…120c3b parent=…b82130 n_msg=2
step= 2 source=loop next=() cid=…b82130 parent=…27bc80 n_msg=2
step= 1 source=loop next=('reply',) cid=…27bc80 parent=…af5f21 n_msg=1
step= 0 source=loop next=('booking',) cid=…af5f21 parent=…8c9967 n_msg=1 ← 旧支线里 step=0 那份,成了新的兄弟
step=-1 source=input next=('__start__',) cid=…8c9967 parent=- n_msg=0 ← 被选中的那份,也是新检查点的父
[再跑一次之后] 新支线接着跑 reply,messages 从 1 条变 2 条
step= 1 source=loop next=() cid=…33628d parent=…f9a07d n_msg=2
step= 0 source=update next=('reply',) cid=…f9a07d parent=…8c9967 n_msg=1
三点结论:
update_state仅产生一份检查点,不会触发图的继续执行。 上述输出止于「状态修改完成、尚未执行」的状态,需再次调用执行方法才会真正运行。- 新状态取代了
booking的位置。 新的执行自reply开始,booking未执行,故结果中仅含「用户消息 + 回复」,无其他中间产物。 - 状态修改所落的检查点,决定新支线的分叉位置。 本次落于
step=-1,故新检查点为step=0、父指针指向step=-1;旧支线中原有的step=0成为其兄弟。
四、界面操作与底层机制的对应关系
前述内容均基于代码层。在聊天界面中,用户可执行的操作仅有三种:重新生成当前回复、编辑已发送的用户消息、切换至另一分支。本节将三个操作、界面表现、底层请求与指针变化逐一对齐。
4.1 界面操作与底层调用对照
| 界面操作 | 入口位置 | 底层第一步 | 第二步 | 落点(状态修改所落的检查点) |
|---|---|---|---|---|
| 重新生成 | AI 回复下方的刷新按钮 | 无 | 自「该回复生成之前」的检查点重跑,input=None | 不涉及状态修改;父指针 = 所选检查点(与「切分支」同一路径) |
| 编辑我的消息 | 用户消息上的编辑按钮 | update_state 写入新文本 | 自新检查点重跑,input=None | 该消息写入之前的检查点 |
| 切分支 | 消息旁的 1/2 或上下箭头 | 无 | 以目标分支检查点的 config 再次执行,input=None | 不涉及状态修改;父指针 = 所选检查点(与「重新生成」同一路径) |
仅依据该表,有一处易被忽略:「重新生成」与「切分支」在底层为同一调用,差异仅在于所选检查点不同。界面上的差异体现为交互意图:前者表示用户要求重新产出该回复,后者表示用户要求回到另一分支继续。
时序图可更清晰地呈现——三个操作中仅「编辑」包含两次请求,另两者为同一操作:
sequenceDiagram
participant UI as 页面
participant S as 服务端
UI->>S: runs/wait input=null checkpoint=回复之前
S-->>UI: 新回复
UI->>S: update_state 写新消息
S-->>UI: 新检查点 id
UI->>S: runs/wait input=null checkpoint=新检查点
S-->>UI: 新回复
UI->>S: runs/wait input=null checkpoint=目标支线
S-->>UI: 目标支线的链
该图中三件事需分别看待:「重新生成」仅包含一次请求,该请求中已携带「自哪份检查点继续执行」;「编辑」包含两次请求,第一次仅写入状态、不执行图,第二次才执行;「切分支」与「重新生成」形式相同,唯一差异在于 checkpoint 参数取另一支线上的检查点。
4.2 重新生成:自该回复之前那份检查点重跑
界面表现为一次「重新提问」。底层需选取的是该 AI 回复落盘之前的检查点:其 next 中包含回复节点,表明回复尚未执行。
以本节开头的两轮对话为例(…27bc80 为 step=1、next=('reply',))。
ck_step1 = [s for s in graph.get_state_history(config) if s.metadata["step"] == 1][0]
await graph.ainvoke(input=None, config=ck_step1.config)
step= 3 source=loop next=() cid=…025db8 parent=…00ad35 n_msg=2 ← 新生成的回复
step= 2 source=fork next=('reply',) cid=…00ad35 parent=…27bc80 n_msg=1 ← 分叉点:父指针 = 被选中的…27bc80
step= 6 source=loop next=() cid=…b2c487 parent=…15e053 n_msg=4 ← 旧回复和后面的第二轮,全在
注意新分叉点自身的状态为「第一条回复之前」的样子(n_msg=1,仅含用户消息),父指针指向所选检查点。 即用户所见的新回复位于一条新支线,旧回复并未消失,只是留存于兄弟支线。
4.3 编辑用户消息:as_node 应填「原本写入该消息的节点」
编辑操作需落于该消息写入状态之前的检查点。以「修改第 1 轮用户消息」为例,落点为 step=-1 的输入检查点。
ck_input = list(graph.get_state_history(config))[-1] # step=-1,next=('__start__',)
new_config = graph.update_state(
ck_input.config,
values={"messages": [HumanMessage("改成明天下午两点的号")]},
as_node="booking", # 这段内容算 booking 写的 → next 变成 booking 的后继 ('reply',)
)
await graph.ainvoke(input=None, config=new_config)
[改完状态、还没跑]
step= 0 source=update next=('reply',) cid=…f9a07d parent=…8c9967 ← 新检查点,父指针是 step=-1 那份
step= 6 source=loop next=() cid=…b2c487 parent=…15e053
...
step= 0 source=loop next=('booking',) cid=…af5f21 parent=…8c9967 ← 旧支线里 step=0 那份,成了新检查点的兄弟
[跑完]
step= 1 source=loop next=() cid=…33628d parent=…f9a07d ← 新支线的终点
step= 0 source=update next=('reply',) cid=…f9a07d parent=…8c9967 ↑ 新状态里只剩改过的那条用户消息
as_node 决定「哪个节点被跳过」。 填 booking,next 即为 ('reply',),booking 不执行;若填 reply,next 将为空元组,等价于声明「回复已写入」,图执行后立即返回,不产生任何内容。节点名填写错误不会报错(只要该节点存在于图中),但语义将整体偏移。
4.4 切换分支:历史只增不减,切换依赖于「再次执行」
界面上提供 1/2 或上下箭头,形式上类似于在两条已有支线之间切换。底层实现更为朴素:取目标分支检查点的 config,执行一次与 Replay 完全相同的调用。
此事已经实测验证(同一线程上,分别以旧终点、新终点的 config 查询历史):
只用 thread_id 查历史:两条支线都返回,属于同一个线程
step= 3 source=loop cid=…025db8 parent=…00ad35 ← 新支线的终点
step= 2 source=fork cid=…00ad35 parent=…27bc80 ← 新支线的起点
step= 6 source=loop cid=…b2c487 parent=…15e053 ← 旧支线的终点
step= 5 source=loop cid=…15e053 parent=…a8c965
...
step= 1 source=loop cid=…27bc80 parent=…af5f21 ← 两条支线共用的祖先,分叉点就在这
step= 0 source=loop cid=…af5f21 parent=…8c9967
step=-1 source=input cid=…8c9967 parent=-
用新支线终点的 config 查:只返回新支线自己那一条链(2 份:…025db8 → …00ad35)
用旧支线终点的 config 查:只返回旧支线自己那一条链(7 份:…b2c487 一路到 …8c9967)
同一份历史存储中同时存在两条支线,以某一 config 查询时,即沿该检查点的 parent 向上回溯。 聊天界面顶部显示的「当前状态」即 HEAD 检查点;切换分支=将 HEAD 置换为另一支线的末端。
「切换」这一动作本身,底层仍是一次重跑。此处实测到一个反直觉的现象:若目标为另一已执行完毕的支线终点(next=()),重跑不产生新内容,仅于链上新增一份完全相同的副本。
step= 3 source=fork next=() cid=…3b47d1 parent=…b82130 ← 切过去顺手复制的一份,内容一字不差
step= 3 source=loop next=() cid=…025db8 parent=…00ad35 ← 原来的终点还在
该现象解释了一个观察:聊天界面中的分支历史只会增长,反复切换不会删除另一支线。 用户所见「已切回」,实为前端将显示内容切换至该链,同时链上新增一份副本。
4.5 HEAD 变化与界面显示的对应关系
将三个动作的后果汇总,观察 HEAD(当前指针)的走向:
| 时刻 | HEAD 指向 | 界面显示的对话 | 链上新增检查点 |
|---|---|---|---|
| 正常聊完两轮 | …b2c487(step=6,8 份链) | 4 条消息 | 无 |
| 点「重新生成」第一条回复 | …025db8(step=3,10 份链) | 2 条消息(第二轮不可见) | fork 起点 …00ad35 + 新终点 |
| 改第 1 轮用户消息并执行完毕 | …33628d(step=1,11 份链) | 2 条消息,用户消息为修改后内容 | update 一份 …f9a07d + 新终点 |
| 切回旧支线 | 旧终点 …b2c487 的副本 | 恢复为 4 条消息 | 一份 fork 副本 |
「第二轮不可见」并非被删除,而是 HEAD 已变更。 旧支线的 4 条消息仍完整存储,仅不再位于当前链上。这正是「切换分支」可即时恢复的原因——恢复的是指针,而非数据。
五、底层机制:一次操作中 parent_id、状态与指针的变更步骤
本节按执行顺序拆解底层流程。标注「源码」者为阅读 langgraph 1.2.10 源码所得,标注「实测」者为实际运行验证所得。
5.1 update_state 的七个步骤
| 步骤 | 操作 | 依据 |
|---|---|---|
| 1 | 从 saver 取出所选检查点的完整快照(所有 channel 的值与版本) | 源码:bulk_update_state 开头 checkpointer.get_tuple(config) |
| 2 | 判定该更新的归属节点:显式给出 as_node 即采用;图中仅一个节点则默认;两者皆无则从 versions_seen 推断「最后写入状态的节点」,推断失败报 InvalidUpdateError: Ambiguous update, specify as_node | 源码:update_state 的 as_node 推断分支 |
| 3 | 将 values 提交至该节点的写入链(flat_writers)执行。此步骤经过 channel 的 reducer:messages 字段采用 add_messages,故为追加/按 id 替换语义,而非整体覆盖 | 源码 + 实测:写入后旧消息保留,messages 长度增加 |
| 4 | 生成新检查点:id 采用 uuid6(clock_seq=step),时间有序;parent_id 取自 config 中的 checkpoint_id | 源码:create_checkpoint、InMemorySaver.put |
| 5 | 写入元数据:source="update"、step=step+1 | 源码:create_checkpoint_plan_for_update_state_api |
| 6 | 将本次写入登记为 pending write(put_writes),保留原始 payload | 源码:bulk_update_state 中的 checkpointer.put_writes |
| 7 | 返回新检查点的 config。图不会自行继续执行 | 实测:不调用执行方法即停在「状态已修改」状态 |
第 4 步解释了 §3.1 表中最反直觉的一行。 父指针取自 config,而 update_state 在读取数据时已将 config 替换为所选检查点的 parent_config。因此新检查点的父指针指向所选检查点的上一检查点,在链上与所选检查点同级并列,而非挂接于所选检查点之下。
5.2 重跑(Replay / Fork 的第二步)的四个步骤
| 步骤 | 操作 | 依据 |
|---|---|---|
| 1 | 判定是否为时间旅行:config 中含 checkpoint_id,且其与线程 HEAD 不同,两条件同时满足方成立 | 源码:is_replaying = CONFIG_KEY_CHECKPOINT_ID in config,叠加 is_time_traveling 判断 |
| 2 | 先落一份 source="fork" 的检查点作为新支线起点。其父指针 = config 中的 checkpoint_id。此步骤仅执行一次:若取出的检查点 source 已为 update/fork,则跳过——update_state 已落过该检查点 | 源码:_loop._first 中的 if is_time_traveling and source not in ("update", "fork") |
| 3 | 基于该检查点的状态推导 next(prepare_next_tasks)。next 为空则直接返回——此即「自终态重跑不产生执行」的来源 | 源码 + 实测 §3.3 边界情况 |
| 4 | 执行并落新检查点(source="loop"),HEAD 移至最新一份 | 实测:新终点 parent 指向 fork 起点 |
第 2 步是「时间旅行不覆盖原支线」这一结论的实现位置。 其实现并非将 HEAD 回拨,而是先新建一份检查点,再将新执行接于其后。原链在此之后未被修改。
5.3 独立验证:「先复制、再改状态」需显式落一份 fork
update_state(values=..., as_node=...) 将「新建」与「状态修改」合并为一步,故历史中无法观察到「新建」单独出现。若需显式观察该步骤——例如使复制品的父指针不同于常规状态修改——可在修改状态之前先空复制一份:values=None、as_node="__copy__"。
本实验的验证目标为:复制品的挂接位置、source 取值,以及能否继续在其上修改状态。
# 第 1 步:将选中的检查点空复制一份(values 必须为 None)
copy_config = graph.update_state(target.config, values=None, as_node="__copy__")
# 第 2 步:在复制品上修改状态
new_config = graph.update_state(copy_config, {"messages": [HumanMessage("改成明天下午两点的号")]}, as_node="booking")
await graph.ainvoke(input=None, config=new_config)
[复制之后、还没跑]
step= 2 source=fork next=('reply',) cid=…cfe5d2 parent=…25f3a7 ← 复制品:父指针 = 被选中那份的 parent
step= 3 source=loop next=() cid=…ba429a parent=…3981de
step= 2 source=fork next=('reply',) cid=…3981de parent=…6f1d2f ← 之前 Replay 留下的
step= 2 source=loop next=() cid=…e0cf06 parent=…6f1d2f
step= 1 source=loop next=('reply',) cid=…6f1d2f parent=…25f3a7 ← 被选中的那份
step= 0 source=loop next=('booking',) cid=…25f3a7 parent=…257a9e
step=-1 source=input next=('__start__',) cid=…257a9e parent=-
三点结论:
- 复制品的
parent亦为所选检查点的parent(…25f3a7),而非所选检查点自身(…6f1d2f)。此现象与update_state一致,因二者同属「自所选检查点的位置分叉」。 - 其
source即为fork,step为所选检查点 + 1(1 + 1 = 2)。这正是 §3.1 图中所标注的source="fork"。故原图并无错误,只是其描绘的是这条「先空复制」的路径;日常直接使用update_state时,复制与写入合并为一份source=update,图中绿点不会单独出现。 - 复制品本身不含任何写入(
values=None),仅将状态原样复制,故可继续在其上修改状态。两次update_state将串成一条链:复制品 → 状态已修改的检查点 → 继续执行。
由此可解释原图为何较日常代码多一个绿点:图中描绘的是该路径(先 copy、再写入),而非 update_state 一步到位的路径。
5.4 指针、状态与数据的存储位置
明确存储结构后,前述全部现象均可自行推导。InMemorySaver 包含三部分:
storage[thread_id][checkpoint_ns][checkpoint_id] = (序列化的检查点, 元数据, 父检查点 id)
writes[(thread_id, checkpoint_ns, checkpoint_id)] = {(task_id, idx): 写入内容}
blobs[(thread_id, checkpoint_ns, channel, version)] = 渠道值
- 指针为
storage中元组的第三个元素父检查点 id。同一线程内所有分支共享同一字典,靠此连接成链。 - 状态为检查点中的
channel_values,取值时按版本号回溯至blobs读取。 - 多条支线在存储中平级共存:同一
thread_id下即为一批检查点,各自经parent连接成链。§4.4 中「以谁的 config 查询即沿谁的parent回溯」即源于此。 checkpoint_ns为子图的命名空间。子图落点采用父ns|节点名复合键,故在聊天界面对子图内部执行时间旅行时,需额外指定checkpoint_ns。此为实现细节,不应作为稳定接口依赖。
六、服务端视角:聊天界面的实际请求
聊天界面的前端不直接调用 Python,其面对的是 Agent Server 的 HTTP 接口。同一操作在服务端由两个请求构成:
1) 落状态:POST /threads/{thread_id}/state
body: {"values": {...}, "as_node": "...", "checkpoint": {"checkpoint_id": "..."}}
返回: {"checkpoint": {"thread_id": ..., "checkpoint_ns": "", "checkpoint_id": "1f1bedf4-…"}}
2) 继续跑:POST /threads/{thread_id}/runs/wait
body: {"assistant_id": ..., "input": null, "checkpoint": {"checkpoint_id": "<上一步返回的>"}}
重跑走同一接口,但仅包含第二步:POST /threads/{thread_id}/runs/wait,body 中含 "input": null 与对应的 checkpoint。
两个请求相互独立,这一点在界面上有直接可观察的后果: 第 1 个请求返回后,线程 HEAD 已为新检查点、状态已修改、next 为 ('reply',),但回复尚未开始生成;第 2 个请求发出后,界面才开始呈现生成过程。故「编辑后先看到新消息、稍后收到回复」并非前端动画效果,而是两次真实请求的时序表现。
本机已完整运行该流程(假模型 assistant,两轮对话后编辑第一轮),实测服务端输出:
--> POST /threads/01a0ffed-…/state
json={"values": {"messages": [{"role": "user", "content": "改成:明天上午十点的号"}]},
"as_node": "__start__", "checkpoint": {"checkpoint_id": "1f1bedf4-…"}}
<-- 200 {"checkpoint": {"checkpoint_id": "1f1bedf4-3ecf-6819-8000-6a6ee5168d71"}, ...}
[编辑之后,服务端返回的历史]
step= 1 source=loop next=[] cid=…1ec9ec parent=…168d71 ['hu(改成:明天上午十点的号)', 'ai(你好,我是…)']
step= 0 source=update next=['model'] cid=…168d71 parent=…5cda7f ['hu(改成:明天上午十点的号)']
step= 2 source=loop next=[] cid=…5cacbd parent=…9d42aa ['hu(第一轮:还有号吗)', 'ai(…)']
step= 1 source=fork next=['model'] cid=…9d42aa parent=…047ce7 ['hu(第一轮:还有号吗)']
step= 4 source=loop next=[] cid=…777392 parent=…dad283 ['hu…', 'ai…', 'hu(第二轮:那下午呢)', 'ai…']
step= 3 source=loop next=['model'] cid=…dad283 parent=…adf7ea
step= 2 source=input next=['__start__'] cid=…adf7ea parent=…cd9cff
step= 1 source=loop next=[] cid=…cd9cff parent=…047ce7
step= 0 source=loop next=['model'] cid=…047ce7 parent=…5cda7f ← 旧支线的第一轮,成了新检查点的兄弟
step=-1 source=input next=['__start__'] cid=…5cda7f parent=-
服务端与进程内执行的结构完全一致:source、parent、step 三列完全对应。更换存储、更换接口,指针规则不变——该规则在 InMemorySaver 与 Agent Server 上为同一套实现。
七、边界情况
- 子图的时间旅行需额外指定
checkpoint_ns。 子图历史存储于独立的命名空间,父图检查点无法引用。若聊天界面支持对子图内部步骤执行时间旅行,前端必须额外传递命名空间,否则查询到的是另一条链。(本条为源码阅读所得的命名空间结构,未经子图内重跑实测,不应作为实测结论引用。) - 多个更新必须逐条指定
as_node。 一次修改多个节点时,仅第一处允许省略as_node;省略将报as_node is required when applying multiple updates。 - 无检查点记录时修改状态将报错。 复制模式要求被复制的检查点真实存在,否则报
Cannot copy a non-existent checkpoint。 __copy__为源码中显式硬编码的判断值(if as_node == "__copy__"),并非文档公开的参数。功能可用,但不应视为长期稳定承诺;如需使用,应遵循 §5.3 的路径(先空复制、再修改状态)。
八、常见认知误区
- 误区:Replay 会覆盖原有后续内容。 实测并非如此——重跑仅从所选检查点派生一条新支线,原链上的检查点一份不少(8 份变 10 份:一份
source=fork起点加一份新终点)。 - 误区:重跑会将所选检查点之后的内容「接管」。 实测并非如此:所选检查点原样保留,新落的
source=fork检查点自其向下延续,与原链已有的后继检查点同级、共享父节点,二者互为兄弟。 - 误区:新支线的箭头总是指向所选检查点。 这是图示中最易误读的一处。Replay 确实如此(
fork起点的parent= 所选检查点),Fork 则不然:update那份的parent是所选检查点的parent,二者同级并列。 - 误区:重跑时不能传
input,传了会报错。 实测不报错,但语义改变:该输入被视为一次新的输入,整张图自入口重新执行,新产出接于旧内容之后,新落检查点source为input而非fork,旧支线终点亦保留。若意图为重跑,则不应传输入。 - 误区:状态修改方法是「将状态整体替换为给定值」。 带 reducer 的字段(如
messages)为追加语义,新写入的消息追加于后,旧消息不会被替换。 - 误区:状态修改完成后图会自行继续执行。
update_state仅负责落新检查点,重跑需再次调用执行方法,并将input传为None。 - 误区:
as_node填错会报错。 只要该节点名存在于图中即不报错,但next将随之偏移——填为产出内容的节点,next变为空元组,重跑不产生任何内容即返回。as_node决定的是「哪个节点被跳过」,而非「哪个节点署名」。 - 误区:切换分支是在两条已有链之间跳转。 每次切换,底层均为一次重跑;若目标支线末端
next已为空,链上仅新增一份内容完全相同的副本。历史只增不减,反复切换不会删除另一支线。 - 误区:聊天界面上的分支已被删除。 界面显示的是 HEAD 所在链;旧支线仍存于存储中,仅不位于当前链上(§4.5 表中「第二轮不可见」即源于此)。
- 误区:自链中间重跑仅多一份检查点。 实测自链中间重跑会多出两份:一份
source=fork起点加一份新终点;仅自终态重跑时才多一份。
九、总结
- 时间旅行的操作对象为检查点链:选取一份检查点,自其重新执行后续流程,而非修改图本身。
- 唯一的选择是「传入哪份 config」:父指针、分叉位置、状态继承,均为该选择的后果(
InMemorySaver.put中父指针直接取config["configurable"]["checkpoint_id"])。 - Replay 与 Fork 的唯一区别在于重跑前是否修改状态;修改则为 Fork,落一份
source=update;不修改则为 Replay,运行时落一份source=fork。 - 父指针的指向因路径而异:Replay 的
fork起点挂于所选检查点之下;Fork 的update挂于所选检查点parent之下,二者同级并列。 - 状态为增量更新,而非整体替换:状态修改时
values经目标节点的写入链与 channel reducer(messages采用add_messages,追加/按 id 替换)。 as_node决定next:next取该身份节点的后继,故一次决定了两件事——新检查点的位置、被跳过的节点。update_state不执行图:仅写入状态,需再次调用执行方法(input=None)方可继续。- 聊天界面的三个操作:重新生成 = 自回复之前那份检查点重跑;编辑 = 修改后执行;切分支 = 以目标链的 config 再次执行。三者底层均为「重跑」,仅编辑多出「状态修改」一步。
- 历史只增不减:重跑先落一份
source=fork检查点作为新支线起点,旧支线完整保留;切换分支依赖置换 HEAD,而非删除数据。 source共四种取值:input(调用时传入输入)、loop(正常执行完成一个超步)、update(状态修改产生)、fork(重跑分叉产生)。- 更换存储、更换接口,规则不变:
InMemorySaver与 Agent Server 上的结构一致,因为父指针由保存时的 config 决定,与具体实现无关。