LangGraph 的图执行模型本质上是一个超步循环(Pregel loop):每一轮超步触发一批节点并行执行,节点将写入汇聚到 channel,然后进入下一轮。这个模型给持久化提出了一个自然问题:在什么粒度上保存状态,才能在故障发生时以最低代价恢复?

答案是每超步保存一次完整快照。这个决定并不廉价——每轮超步结束都要发起一次 put() I/O——但它换来了三样东西:任意超步的完整执行历史、任意历史点的精确恢复,以及在恢复时无需重放任何节点。这是整个 checkpoint 协议设计的出发点。

设计目标:为什么要在这个粒度上持久化

在讨论数据结构之前,先理解检查点系统承载了哪些功能,因为这些功能直接决定了数据结构的每个字段存在的意义。

容错恢复是最基础的目标。进程崩溃后,框架需要能够找到最近一次完整的超步快照并从那里重启,而不是从头重跑整个图。这要求快照必须是超步级别的,而不是会话级别的。

时间旅行调试要求更高。不仅能从最新状态恢复,还要能指定任意历史超步恢复,并从那里分叉出一条新的执行路径。这意味着历史记录不能被覆盖——每次超步都必须是独立的、可寻址的记录。

多会话隔离是工程上的必然要求。同一张图可能同时服务数千个对话线程(thread_id),彼此的状态绝不能混淆。

Human-in-the-loop 是 LangGraph 的核心使用场景之一。图在某个超步产生 INTERRUPT 后可以无限期挂起——状态已落盘,进程可以关闭,服务可以重启,用户几小时后再来提交输入,图从挂起点精确恢复。没有超步级别的持久化,这个特性根本无从实现。

Checkpoint 数据结构逐字段解析

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 消息——那些已经被发送但还没有被目标节点消费的动态路由消息。如果进程在消费之前崩溃,这些消息会在下一次恢复时被重新处理。

CheckpointMetadata 的设计哲学

元数据与 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]]