LangGraph 的图执行模型本质上是一个超步循环(Pregel loop):每一轮超步触发一批节点并行执行,节点将写入汇聚到 channel,然后进入下一轮。这个模型给持久化提出了一个自然问题:在什么粒度上保存状态,才能在故障发生时以最低代价恢复?
答案是每超步保存一次完整快照。这个决定并不廉价——每轮超步结束都要发起一次 put() I/O——但它换来了三样东西:任意超步的完整执行历史、任意历史点的精确恢复,以及在恢复时无需重放任何节点。这是整个 checkpoint 协议设计的出发点。
在讨论数据结构之前,先理解检查点系统承载了哪些功能,因为这些功能直接决定了数据结构的每个字段存在的意义。
容错恢复是最基础的目标。进程崩溃后,框架需要能够找到最近一次完整的超步快照并从那里重启,而不是从头重跑整个图。这要求快照必须是超步级别的,而不是会话级别的。
时间旅行调试要求更高。不仅能从最新状态恢复,还要能指定任意历史超步恢复,并从那里分叉出一条新的执行路径。这意味着历史记录不能被覆盖——每次超步都必须是独立的、可寻址的记录。
多会话隔离是工程上的必然要求。同一张图可能同时服务数千个对话线程(thread_id),彼此的状态绝不能混淆。
Human-in-the-loop 是 LangGraph 的核心使用场景之一。图在某个超步产生 INTERRUPT 后可以无限期挂起——状态已落盘,进程可以关闭,服务可以重启,用户几小时后再来提交输入,图从挂起点精确恢复。没有超步级别的持久化,这个特性根本无从实现。
Checkpoint 是一个 TypedDict,以下是各字段及其背后的设计意图:
class Checkpoint(TypedDict):
v: int # schema 版本,当前为 2
id: str # UUID6,含时间戳前缀
ts: str # ISO 8601 时间戳
channel_values: dict[str, Any] # {channel_name: 反序列化后的值}
channel_versions: ChannelVersions # {channel_name: 单调递增版本号}
versions_seen: dict[str, ChannelVersions] # {node_name: {channel_name: version}}
pending_sends: list # 跨超步的 Send 消息,尚未被消费
updated_channels: list[str] | None
graph TD
subgraph Checkpoint
V["v: int\nschema 版本"]
ID["id: str\nUUID6(时间序 = 字典序)"]
TS["ts: str\nISO 8601 时间戳"]
CV["channel_values: dict\n各 channel 当前值(序列化)"]
CVer["channel_versions: dict\n各 channel 版本号"]
VS["versions_seen: dict\n各节点上次看到的版本"]
PS["pending_sends: list\n跨超步 Send 消息"]
end
subgraph CheckpointMetadata
SRC["source\ninput/loop/update/fork"]
STEP["step: int\n-1 input, 0+ loop"]
PAR["parents: dict\n子图命名空间映射"]
DELTA["counters_since_delta_snapshot\nDeltaChannel 快照计数器"]
end
Checkpoint --> CheckpointMetadata
v 字段的存在是为了向后兼容:当存储格式升级时,读取旧 checkpoint 的代码可以根据版本号选择不同的反序列化路径,而不需要迁移脚本。
id 字段使用的是 UUID6,而非更常见的 UUID4。这是一个深思熟虑的选择。UUID4 是随机的,无法从 ID 本身推断时间顺序;UUID6 将格雷戈里安时间戳编码在 ID 的高位,因此字典序等价于时间顺序。list() 接口支持 before 参数(传入一个 config,列出该 checkpoint 之前的所有历史),存储层可以直接用 WHERE checkpoint_id < :before_id 实现时间范围过滤,完全不需要额外的时间戳索引。这个小细节避免了一个常见的数据库设计陷阱——用 UUID 作主键,却要按时间排序查询。
channel_versions 记录每个 channel 当前的单调递增版本号。框架通过比较版本号判断哪些 channel 在这次超步中被写入过,从而决定下一轮需要触发哪些节点。没有版本号,框架只能重新对比值本身,这对于大型状态对象来说代价极高。
versions_seen 是一个两层嵌套的 dict:{节点名: {channel名: 版本号}},记录每个节点上次执行时看到的 channel 版本。Pregel 的触发规则是:当某个 channel 的版本高于节点上次看到的版本时,该节点被触发。这个字段使得每次超步的节点调度完全可以从快照重建,无需任何运行时状态。
pending_sends 存储跨超步的 Send 消息——那些已经被发送但还没有被目标节点消费的动态路由消息。如果进程在消费之前崩溃,这些消息会在下一次恢复时被重新处理。
元数据与 checkpoint 本体分开存储,这个分离本身就是一个设计决定——元数据服务于"理解执行历史",而 checkpoint 本体服务于"恢复执行状态",两者的访问模式完全不同。
class CheckpointMetadata(TypedDict, total=False):
source: Literal["input", "loop", "update", "fork"]
step: int # -1 表示 input checkpoint,0 起为 loop checkpoint
parents: dict[str, str] # {命名空间: checkpoint_id}
run_id: str
counters_since_delta_snapshot: dict[str, tuple[int, int]]