对于LangGraph的时间旅行底层机制的理解

一、总体机制

时间旅行并非在图上新增功能,而是在已持久化的检查点链上,选取某一检查点重新执行后续流程。

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 根据最后一条消息生成回复。两个节点均不调用模型,同一段上下文重复执行任意次,结果完全一致------由此可将「变化源于重跑」与「变化源于模型随机性」区分开。

python 复制代码
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,检查点持久化于内存。本例验证目标为:两轮对话之后,链上检查点的总数及各自所处的执行位置。

python 复制代码
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 '-'}")
text 复制代码
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() 中的相关代码如下:

python 复制代码
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」。本实验的验证目标为:新支线的挂接位置,以及旧支线是否有内容丢失。

python 复制代码
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):
    ...
text 复制代码
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:

text 复制代码
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,可直接作为执行入口。

python 复制代码
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 因此不会被执行。

text 复制代码
[改完状态、还没跑]   ← 最右边一列是这份检查点里的 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',))。

python 复制代码
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)
text 复制代码
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 的输入检查点。

python 复制代码
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)
text 复制代码
[改完状态、还没跑]
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 查询历史):

text 复制代码
只用 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=()),重跑不产生新内容,仅于链上新增一份完全相同的副本。

text 复制代码
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 取值,以及能否继续在其上修改状态。

python 复制代码
# 第 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)
text 复制代码
[复制之后、还没跑]
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 包含三部分:

python 复制代码
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 接口。同一操作在服务端由两个请求构成:

text 复制代码
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,两轮对话后编辑第一轮),实测服务端输出:

text 复制代码
--> 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"}, ...}
text 复制代码
[编辑之后,服务端返回的历史]
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 上为同一套实现。


七、边界情况

  1. 子图的时间旅行需额外指定 checkpoint_ns。 子图历史存储于独立的命名空间,父图检查点无法引用。若聊天界面支持对子图内部步骤执行时间旅行,前端必须额外传递命名空间,否则查询到的是另一条链。(本条为源码阅读所得的命名空间结构,未经子图内重跑实测,不应作为实测结论引用。)
  2. 多个更新必须逐条指定 as_node。 一次修改多个节点时,仅第一处允许省略 as_node;省略将报 as_node is required when applying multiple updates。
  3. 无检查点记录时修改状态将报错。 复制模式要求被复制的检查点真实存在,否则报 Cannot copy a non-existent checkpoint。
  4. __copy__ 为源码中显式硬编码的判断值 (if as_node == "__copy__"),并非文档公开的参数。功能可用,但不应视为长期稳定承诺;如需使用,应遵循 §5.3 的路径(先空复制、再修改状态)。

八、常见认知误区

  1. 误区:Replay 会覆盖原有后续内容。 实测并非如此------重跑仅从所选检查点派生一条新支线,原链上的检查点一份不少(8 份变 10 份:一份 source=fork 起点加一份新终点)。
  2. 误区:重跑会将所选检查点之后的内容「接管」。 实测并非如此:所选检查点原样保留,新落的 source=fork 检查点自其向下延续,与原链已有的后继检查点同级、共享父节点,二者互为兄弟。
  3. 误区:新支线的箭头总是指向所选检查点。 这是图示中最易误读的一处。Replay 确实如此(fork 起点的 parent = 所选检查点),Fork 则不然 :update 那份的 parent 是所选检查点的 parent,二者同级并列。
  4. 误区:重跑时不能传 input,传了会报错。 实测不报错,但语义改变:该输入被视为一次新的输入 ,整张图自入口重新执行,新产出接于旧内容之后,新落检查点 source 为 input 而非 fork,旧支线终点亦保留。若意图为重跑,则不应传输入。
  5. 误区:状态修改方法是「将状态整体替换为给定值」。 带 reducer 的字段(如 messages)为追加语义,新写入的消息追加于后,旧消息不会被替换。
  6. 误区:状态修改完成后图会自行继续执行。 update_state 仅负责落新检查点,重跑需再次调用执行方法,并将 input 传为 None。
  7. 误区:as_node 填错会报错。 只要该节点名存在于图中即不报错,但 next 将随之偏移------填为产出内容的节点,next 变为空元组,重跑不产生任何内容即返回。as_node 决定的是「哪个节点被跳过」,而非「哪个节点署名」。
  8. 误区:切换分支是在两条已有链之间跳转。 每次切换,底层均为一次重跑;若目标支线末端 next 已为空,链上仅新增一份内容完全相同的副本。历史只增不减,反复切换不会删除另一支线。
  9. 误区:聊天界面上的分支已被删除。 界面显示的是 HEAD 所在链;旧支线仍存于存储中,仅不位于当前链上(§4.5 表中「第二轮不可见」即源于此)。
  10. 误区:自链中间重跑仅多一份检查点。 实测自链中间重跑会多出两份 :一份 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 决定,与具体实现无关。
相关推荐
网络毒刘2 小时前
Ask 模式做设计评审:提示词模板 + 检查清单,让 Agent 先读后改
agent·cursor·ask·工具实践·设计评审
EatFan5 小时前
2026 后端架构进入 AI 原生阶段:事件驱动 + 虚拟线程 + Agent 内嵌三驾马车怎么落地
人工智能·架构·agent·虚拟线程·事件驱动·ai原生·后端架构
用户3134672143545 小时前
Agent实践5-无 Function Call 的结构化通用 Agent
langchain·agent
minji...5 小时前
LangGraph-AI智能体开发框架 - LangGraph 入门案例1 : 智能快递配送系统
人工智能·python·ai·langchain·大语言模型·agent·langgraph
slacker-kian6 小时前
BeeAI 实战:从简单对话到 Agent 的驯服之路
ai·llm·agent·qwen·beeai
打不了嗝 ᥬ᭄6 小时前
AI-Agent入门
人工智能·agent
张忠琳6 小时前
【hermes-agent】Hermes Agent 自我进化原理之二
ai·agent·hermes
吃饱了得干活14 小时前
Agent 的决策与规划:ReAct、Plan-and-Execute、Reflexion 与 Tree of Thoughts
人工智能·llm·agent
飞哥数智坊17 小时前
Personal Agent 火了,新酿还是旧酒?
人工智能·agent