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)。三件事值得记:
- 按
thread_id分桶——一个会话一条线,天然隔离。 - 每个检查点记
parent——用checkpoint_id串成一条链,get_state_history就是沿这条链回溯。 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 循环与重试调用链 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。