《AI Agent 高级编程》5.1 读 LangGraph:图执行器与检查点机制

打开本机 langgraph 1.2.14 的源码读图执行器:StateGraph.compile 产出 CompiledStateGraph,PregelLoop 的 tick/after_tick 一轮就是一次超步,超步之间只做通道合并;每个超步结束调用 _put_checkpoint,检查点按 thread_id 落进 Checkpointer。

5.1 读 LangGraph:图执行器与检查点机制

第 4 章把三个框架放在同一道题上比了分数与成本,那是黑盒对照。从本节起我们换成白盒:把框架装进本机、打开它的 .py 文件,逐段读它的执行器与状态落盘。第一个读 LangGraph——因为它是这套书里唯一把「循环」显式建模成图 + 超步的框架,也是第 6 章 durable execution 的参照物。

本节把「LangGraph 到底怎么跑一个 agent」这个问题推进到源码级:定位真实文件,读出 StateGraph.compile、PregelLoop.tick/after_tick、_put_checkpoint 三段代码,讲清超步如何推进、检查点存在哪,并用一次真跑的运行把超步序列与存储结构对出来。

5.1.1 先定位源码,不凭记忆

按 BRIEF 的纪律,先在本机定位真实文件,再贴片段。本节所有引用都来自 /tmp/agentbook/venv3 里实际安装的包:

/tmp/agentbook/venv3/bin/python -c "import importlib.metadata as m; print(m.version('langgraph'), m.version('langgraph-checkpoint'), m.version('langchain-core'))"
/tmp/agentbook/venv3/bin/python -c "import langgraph.graph as g; print(g.__file__)"
1.2.14 4.2.0 1.6.9
/tmp/agentbook/venv3/lib/python3.14/site-packages/langgraph/graph/__init__.py

图这一层在 langgraph/graph/state.py(StateGraph 与 CompiledStateGraph),执行器在 langgraph/pregel/,检查点接口在 langgraph/checkpoint/base/,内存实现是 langgraph/checkpoint/memory/。记住这条路径分工,后面每一段都能落回具体文件。

5.1.2 compile:图怎么变成可执行体

StateGraph 只是声明——你 add_node / add_edge,它攒的是一张静态拓扑。真正把它变成「能 invoke 的东西」的是 compile(),位于 langgraph/graph/state.py:1177:

# langgraph/graph/state.py:1177
def compile(
    self,
    checkpointer: Checkpointer = None,
    *,
    cache: BaseCache | None = None,
    store: BaseStore | None = None,
    interrupt_before: All | list[str] | None = None,
    interrupt_after: All | list[str] | None = None,
    ...
) -> CompiledStateGraph[StateT, ContextT, InputT, OutputT]:

关键在参数 checkpointer:检查点能力不是图自带的,而是编译时注入的。不传就没有持久化;传了 InMemorySaver / SqliteSaver,运行状态才会在每个超步后落盘。文档字符串把语义写得很直白:

# langgraph/graph/state.py:1197
If provided, this `Checkpointer` serves as a fully versioned
"short-term memory" for the graph, allowing it to be paused, resumed,
and replayed from any point.

而一旦启用 checkpointer,调用就必须带 thread_id——它是存取检查点的键:

# langgraph/graph/state.py:1204
config = {"configurable": {"thread_id": "my-thread"}}
graph.invoke(inputs, config)

compile() 内部还做两件事:self.validate(...) 校验拓扑,以及把节点规格(StateNodeSpec)连同默认值装配进 CompiledStateGraph。返回的对象实现 Runnable 接口,所以能 invoke / stream / batch。

5.1.3 超步模型:tick 与 after_tick

LangGraph 的执行器叫 Pregel,模型来自 Bulk Synchronous Parallel。真正的循环在 langgraph/pregel/main.py:3031,短得不像一个执行器:

# langgraph/pregel/main.py:3026
# Similarly to Bulk Synchronous Parallel / Pregel model
# computation proceeds in steps, while there are channel updates.
# Channel updates from step N are only visible in step N+1
while loop.tick():
    for task in loop.match_cached_writes():
        loop.output_writes(task.id, task.writes, cached=True)
    for _ in runner.tick(
        [t for t in loop.tasks.values() if not t.writes],
        timeout=self.step_timeout,
        get_waiter=get_waiter,
        schedule_task=loop.accept_push,
    ):
        yield from _output(...)
    loop.after_tick()

一次 while 迭代 = 一个超步。注释那三行是全篇的题眼:第 N 步的通道写入,只在第 N+1 步可见;通道在一个超步内不可变,写入只在超步切换时合并。这解释了为什么同一步里的两个节点读不到彼此的输出。

tick()(langgraph/pregel/_loop.py:614)负责开局:先查步数上限,再 prepare_next_tasks(...) 算出本超步该跑哪些节点,若无任务就把 status 置为 "done" 并返回 False(循环结束):

# langgraph/pregel/_loop.py:614
def tick(self) -> bool:
    """Execute a single iteration of the Pregel loop."""
    if self.step > self.stop:
        self.status = "out_of_steps"
        return False
    self.tasks = prepare_next_tasks(
        self.checkpoint, self.checkpoint_pending_writes, self.nodes,
        self.channels, self.managed, self.config, self.step, self.stop, ...
    )
    if not self.tasks:
        self.status = "done"
        return False
    ...
    return True

after_tick()(langgraph/pregel/_loop.py:698)负责收尾:把本超步所有任务的写入汇总,apply_writes(...) 合并进通道,然后存检查点,最后才 self.step += 1:

# langgraph/pregel/_loop.py:698
def after_tick(self) -> None:
    # finish superstep
    writes = [w for t in self.tasks.values() for w in t.writes]
    ...
    self.updated_channels = apply_writes(
        self.checkpoint, self.channels, self.tasks.values(),
        self.checkpointer_get_next_version, self.trigger_to_nodes,
    )
    ...
    self.checkpoint_pending_writes.clear()
    self.is_replaying = False
    # save checkpoint
    self._put_checkpoint({"source": "loop"})

注意顺序:先合并通道、再存检查点、最后步号 +1。所以检查点里存的是「本超步已合并、下一步尚未开始」的状态,恢复时自然从下一步继续。

5.1.4 真跑:一次运行的两个超步

光读代码不够,跑一遍对齐。下面这个图 START → a → b → END,两个节点各改一个计数:

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver

class S(TypedDict):
    n: int

def a(state: S) -> dict:
    return {"n": state["n"] + 1}

def b(state: S) -> dict:
    return {"n": state["n"] * 10}

g = StateGraph(S)
g.add_node("a", a); g.add_node("b", b)
g.add_edge(START, "a"); g.add_edge("a", "b"); g.add_edge("b", END)
app = g.compile(checkpointer=InMemorySaver())
cfg = {"configurable": {"thread_id": "t1"}}

for chunk in app.stream({"n": 1}, cfg, stream_mode="updates"):
    print("SUPERSTEP", chunk)

真跑输出(/tmp/agentbook/venv3/bin/python):

SUPERSTEP {'a': {'n': 2}}
SUPERSTEP {'b': {'n': 20}}

两个超步、两个节点各跑一次,与源码模型完全一致:a 在超步 1 写入 n=2,b 在超步 2 才读到这个值并算出 20。stream_mode="updates" 恰好把每个超步的通道更新吐出来,是观察超步边界最方便的口子。

5.1.5 检查点是什么结构

Checkpoint 是一个 TypedDict,定义在 langgraph/checkpoint/base/__init__.py:92:

# langgraph/checkpoint/base/__init__.py:92
class Checkpoint(TypedDict):
    v: int                     # 检查点格式版本,当前为 1
    id: str                    # 唯一且单调递增,可用来排序
    ts: str                    # ISO 8601 时间戳
    channel_values: dict[str, Any]   # 通道名 -> 快照值
    channel_versions: ChannelVersions  # 通道名 -> 单调递增版本串
    versions_seen: dict[str, ChannelVersions]  # 节点 ID -> 它见过的通道版本
    updated_channels: list[str] | None

三个字段是理解「下一步该跑谁」的钥匙:

  • channel_values 是状态本身(图的状态 schema 落在哪些通道上)。
  • channel_versions 给每个通道打版本号;节点执行会让它经手的通道版本前进。
  • versions_seen 记录每个节点上次看到的版本——prepare_next_tasks 正是靠它判断「这个通道的版本比该节点见过的更新了吗」,从而决定哪些节点在下一超步被触发。这就是「数据流驱动」而非「固定顺序执行」的实现。

empty_checkpoint()(langgraph/pregel/_checkpoint.py:41)造出第一个空检查点:

# langgraph/pregel/_checkpoint.py:41
def empty_checkpoint() -> Checkpoint:
    return Checkpoint(
        v=LATEST_VERSION,
        id=str(uuid6(clock_seq=-2)),
        ts=datetime.now(timezone.utc).isoformat(),
        channel_values={}, channel_versions={}, versions_seen={},
    )

每个新检查点的 id 由 uuid6(clock_seq=step) 生成(见 _checkpoint.py:289 的 create_checkpoint),把步号编进 UUID 的前几组,所以 ORDER BY id 天然等于按超步排序——这是「id 单调递增」的实现细节。

5.1.6 检查点存在哪:InMemorySaver.put

接口在 BaseCheckpointSaver(langgraph/checkpoint/base/__init__.py:176),内存实现 InMemorySaver.put 在 langgraph/checkpoint/memory/__init__.py:421:

# langgraph/checkpoint/memory/__init__.py:442
c = checkpoint.copy()
thread_id = config["configurable"]["thread_id"]
checkpoint_ns = config["configurable"]["checkpoint_ns"]
values: dict[str, Any] = c.pop("channel_values")
for k, v in new_versions.items():
    self.blobs[(thread_id, checkpoint_ns, k, v)] = (
        self.serde.dumps_typed(values[k]) if k in values else ("empty", b"")
    )
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"),  # parent
        )
    }
)

存储是两级嵌套字典:storage[thread_id][checkpoint_ns][checkpoint_id] = (序列化后的检查点, 元数据, 父检查点 id)。三件事值得记:

  1. 按 thread_id 分桶——一个会话一条线,天然隔离。
  2. 每个检查点记 parent——用 checkpoint_id 串成一条链,get_state_history 就是沿这条链回溯。
  3. channel_values 被拆出去单独存进 blobs,以 (thread_id, ns, 通道名, 版本) 为键;检查点本体只留其余字段。这就是第 6 章讲「按版本增量存储」时要对照的机制。

真跑一段把存储结构打印出来(同一段代码,加两行 dump):

NUM_CHECKPOINTS 4
CK step=-1 source=input  next=('__start__',)
CK step=0  source=loop   next=('a',)
CK step=1  source=loop   next=('b',)
CK step=2  source=loop   next=()

source=input 是注入输入那次,source=loop 是每个超步各一次;next 字段直接告诉你「恢复时会从哪继续」。两次 after_tick 对应 step=1 与 step=2,正好等于我们看到的两个超步。

5.1.7 存检查点的时机:_put_checkpoint

after_tick 调用的 _put_checkpoint(langgraph/pregel/_loop.py:1142)决定这个超步要不要落盘,关键两行:

# langgraph/pregel/_loop.py:1194
do_checkpoint = self._checkpointer_put_after_previous is not None and (
    exiting or self.durability != "exit"
)
self.checkpoint = create_checkpoint(
    self.checkpoint, self.channels if do_checkpoint else None,
    self.step, id=self.checkpoint["id"] if exiting else None,
    updated_channels=self.updated_channels,
    get_next_version=self.checkpointer_get_next_version if do_checkpoint else None,
    ...
)

durability 是这里的旋钮:默认每超步都写(durability != "exit"),设成 "exit" 则只在退出时写一次——用「崩溃可恢复性」换「写放大」。落盘本身走 self.submit(self._checkpointer_put_after_previous, ...)(第 1266 行)提交到线程池,不阻塞主循环;但 main.py:3054 在 durability == "sync" 时会 loop._put_checkpoint_fut.result() 等它完成。三档 durability 的差别,在第 6 章与自研检查点对照时还会再出现。

5.1.8 这套框架替你做了什么、你又让渡了什么

维度LangGraph 替你做了你让渡了
循环超步调度、通道合并、触发判定全自动「循环长什么样」被图拓扑与通道语义决定
状态通道版本、versions_seen 驱动节点触发状态必须可序列化进通道
持久化checkpointer 注入即得暂停/恢复/重放得接受 thread_id / checkpoint_ns 的键模型
落盘时机durability 三档可选想精细控制写放大得读懂 _put_checkpoint

一句话:LangGraph 把「agent loop」升维成了数据流图 + 超步,代价是你得按它的通道模型思考状态。下一节换 PydanticAI——它的 run 循环不走超步,而是一张显式的状态机图,重试是图里长出来的一个节点。

阅读导航:上一节:4.3 三框架同题对照:同评测集比分数与成本 · 下一节:5.2 读 PydanticAI:run 循环与重试调用链 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距