news 2026/10/18 18:09:34

[拆解LangChain执行引擎]基于Checkpoint的持久化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
[拆解LangChain执行引擎]基于Checkpoint的持久化

Pregel基于Checkpoint的持久化机制是实现Agent应用高可用性和长期记忆的基础,它本质上是将 不断向前推进的图在“Superstep”之间将其状态固化的过程。和很多数据库持久化类似,Pregel采用基于全量数据的状态快照+基于增量更新的操作日志的持久化策略。

1. 持久化Channel状态

Pregel将状态“焊死”在Channel上,这使持久化变得很简单,它只要针对每个Superstep将每个Channel状态存下来就可以了。为了提高性能,它只需要考虑有过更新的Channel,而确Channel是否更新可以利用它的版本来决定。每个Channel都具有一个不断更新的版本,如果某个Channel在某个Superstep内有过更新,版本会往前更替。至于这个版本采用何种格式,具体如何管理,执行引擎将其下放到具体的Checkpointer实现中。

作为Checkpointer的基类,BaseCheckpointSaver将基于更新快照的存储实现在如下所示的put方法中。待持久化的数据被封装在一个Checkpoint对象中以checkpoint参数传入该方法,config和metadata参数提供描述该Checkpoint的配置和元数据,而new_versions以一个字典的形式提供了涉及的每个Channel的版本。config参数提供的RunnableConfig主要提供标识当前调用会话的Thread ID和Checkpoint命名空间。方法会返回的RunnableConfig对象一般会携带Thread ID、Checkpoint命名空间和Checkpoint ID。

/* by yours.tools - online tools website : yours.tools/zh/navtiveunicode.html */ class BaseCheckpointSaver(Generic[V]): def put( self, config: RunnableConfig, checkpoint: Checkpoint, metadata: CheckpointMetadata, new_versions: ChannelVersions, ) -> RunnableConfig async def aput( self, config: RunnableConfig, checkpoint: Checkpoint, metadata: CheckpointMetadata, new_versions: ChannelVersions, ) -> RunnableConfig … ChannelVersions = dict[str, str | int | float]

1.1 CheckpointMetadata

描述Checkpoint元数据的CheckpointMetadata类型定义如下,其三个成员构成了图执行的谱系追踪(Lineage Tracking)和控制流导航的核心。它们不参与业务逻辑计算,但决定了如何管理、回溯和审计图的状态。

/* by yours.tools - online tools website : yours.tools/zh/navtiveunicode.html */ class CheckpointMetadata(TypedDict, total=False): source: Literal["input", "loop", "update", "fork"] step: int parents: dict[str, str]

CheckpointMetadata的step字段返回Superstep编号,代表当前Checkpoint在逻辑时间轴上的位置。source字段定义了当前这个Checkpoint是由哪种类型的操作触发生成的,它是理解图生命历程的关键,具体的选项包括:

  • input: 首次调用invoke或stream方法时触发,代表图的“创世点”。这是由外部初始数据输入产生的第一个Checkpoint(Superstep序号为 -1);

  • loop:内部根据 Node 和 Channel 的订阅关系进行迭代时触发,代表图在正常执行流程中的自动化流转。大多数中间步骤的 source 都是此值。

  • update:用户手动调用了update_state方法时触发,代表一种“非自然”的状态变更。这通常用于人为干预、修正数据或在中断后注入信息。

  • fork: 当用户从历史中的某个非最新Checkpoint重新启动执行时触发,代表图产生了分支。它标记了执行流从主线脱离,开启了一个独立的时间线。

CheckpointMetadata的parents字段返回一个字典,记录了当前Checkpoint与之前Checkpoint之间的拓扑关系,其结果通常为通常形式为dict[namespace, parent_checkpoint_id]。由于 采用增量持久化,当我们需要恢复一个完整的状态视图时,引擎必须知道去哪里找那些没变动的数据,parents字典提供了回溯路径。如果当前 Checkpoint 没有 某个Channel的值,引擎就会根据parents指引,跳转到父级Checkpoint去查找,直到找到该 Channel最近一次被更新的版本。

在包含子图的复杂场景中,parents字段会记录父图命名空间对应的Checkpoint ID,确保父子图之间的状态逻辑能够跨层级对齐。当source为“fork”时,parents字段指向的是那个被分叉的历史点,而不是时间线上的物理前一个点。

当我们查看一个CheckpointMetadata对象时,可以构建出如下逻辑:这个状态是由于source产生的,目前处于第step步。如果你想知道这个状态从何而来,或者想找回那些没变的数据,请根据parents列表向回追溯。

1.2 Channel版本

执行引擎将Channel版本的格式化权力下放给具体的Checkpointer实现,它们通过重写如下这个get_next_version方法提供某个Channel的下一个版本。如果表示当前版本的current参数为None,该方法会返回Channel的初始版本。

class BaseCheckpointSaver(Generic[V]): def get_next_version(self, current: V | None, channel: None) -> V

以InMemorySaver为例,它会将Channel版本格式化成一个由51个字符组成的字符串,具体格式为f"{sequence:032}.{random:016}"。sequence从1开始递增,代表了“物理状态的演进顺序”。在顺序执行时反映了Superstep的进度,但是在遇到人为干预、中断、分叉或重试的情况下,物理序列号仅能反映“存盘的次数”。Superstep序号反映的是“算法迭代的深度”,所以不能将两者等同起来,前者是大于或等于Superstep序号的。

“random”部分是一个0-1之间的随机数。如果说Channel版本前 32 位(第一部分)是时间轴上的大刻度,那么这第二部分就是确保状态在微观层面绝对唯一且可追溯的防伪码。由于 CPU 处理速度极快,多个并行任务可能在极短的微秒级时间内尝试触发写入。如果仅依靠前 32 位的递增序列号,在Superstep内部的多次写入可能会因为序列号来不及递增或在高并发下产生冲突。第二部分包含的随机数(源自浮点数的小数位)确保了即使第一部分相同,物理上的 Checkpoint ID 也是全球唯一的,这保证了UUID 级别的碰撞安全性。

从支持时间旅行与状态分叉的角度来看,第二部分就显得更加重要了。当我们从某个历史点重启时流程时,假设从历史上的“00...001”处分叉出两条不同的执行路径,两条路径的序列号可能都会递增到“00...002”,但通过第二部分的随机随机值,系统能以如下形式物理隔离这两条路径。所以版本的第二部分的内容不仅仅为了解决冲突而存在,它使得持久化层可以同时存储同一逻辑步下的多个平行宇宙而不会发生覆盖。

  • 路径 A: 00...002.0.8494...
  • 路径 B: 00...002.0.1234...

有的实现会严格采用类似于{ sequence}.{step_index}.{random_entropy}这样的三段式的版本格式化,第二部分通常包含了.0. 或.1.这样的前缀,它还兼具如下的功能:

  • 子图导航:当主图调用子图时,子图产生的Checkpoint会通过第二部分的特定位来标识它属于哪个父级任务的“逻辑分支”。
  • 任务索引:在同一个Superstep中,如果一个Node产生了多条Pending Write,第二部分可以用来索引这些写入的先后次序,确保在恢复合并时不会错位。

1.3 Checkpoint

如下所示的是Checkpoint类型的定义。它的v字段表示决定Checkpoint结构的版本号,用于后向兼容性。如果未来改变了存储格式,运行时会根据这个值决定如何正确地反序列化旧数据。id和ts分别表示Checkpoint的唯一标识和生成时间戳。updated_channels字段返回的本Superstep内涉及更新的Channel列表。引擎根据订阅它们的Node来创建下一步执行的任务。channel_values字段存储了“涉及更新”的每个Channel的更新值。channel_versions字段返回所有Channel的版本。

class Checkpoint(TypedDict): v : int id : str ts : str channel_values : dict[str, Any] channel_versions : ChannelVersions versions_seen : dict[str, ChannelVersions] updated_channels : list[str] | None

Node并不能实时观察到Channel的变化,versions_seen字段以{ "Node名": { "依赖Channel名": "版本ID" } }这样的结构返回每个Node执行时所能“看到”的Channel版本, 它记录了Node完成计算时的前置条件。在中断恢复时,引擎对比versions_seen,如果Node看到的输入版本没变,且它已经有了输出记录,那么就无需重复执行,所以这是实现因果一致性和幂等性的关键。如下的JSON是由Pregel生成的一个Checkpoint对象序列化后的结果。

{ "v": 4, "ts": "2026-01-18T13:42:07.542155+00:00", "id": "1f0f4737-b4b1-6bbb-8001-1e44d720a9df", "channel_versions": { "foo": "00000000000000000000000000000001.0.6943525017042773", "bar": "00000000000000000000000000000002.0.24038201058058928", "baz": "00000000000000000000000000000003.0.8444674692332181" }, "versions_seen": { "__input__": {}, "foo": { "foo": "00000000000000000000000000000001.0.6943525017042773" }, "bar": { "bar": "00000000000000000000000000000002.0.24038201058058928" } }, "updated_channels": [ "baz" ], "channel_values": { "foo": "begin", "bar": "bar", "baz": "baz" } }

1.4 存储结构

接下来,我们以InMemorySaver为例看看Checkpoint会采用怎样的存储结构,以及以此结构基础的读取方式。InMemorySave针对Checkpoint的存储涉及两个字典。一个名为blobs的字典用于存储Channel的荷载内容(值),采用的Key是由Thread ID、Checkpoint命名空间、Channel名称和版本构成的四元组。另一个名为storage的字典时一个具有四层结构的字典,具体类型为defaultdict[str, dict[str, dict[str, tuple[tuple[str, bytes], tuple[str, bytes], str | None]]]],每一层字典的Key顶如下。我们可以认为blobs用于存储数据,storage为索引表。

层级Key 类型说明
第一层Thread_id会话隔离,区分不同的用户或对话流
第二层Checkpoint_ns命名空间隔离,支持子图或不同模块的独立状态空间
第三层checkpoint_id版本隔离,用于定位特定版本的快照

最后一层字典的值是一个三元组tuple[tuple[str, bytes], tuple[str, bytes], str | None],它们将数据序列化后存储,以确保内存中的数据是不可变且易于复制的,三个部分包括:

  • tuple[str, bytes]:前一部分表示的序列化类型(通常是json或msgpack),第二部分表示经过序列化后的Checkpoint对象(由于具体的值已经存储在blobs中了,所以此时的Checkpoint的channel_values字段已经被移除)。这里不再存储 Python 字典对象,而是存储字节流。这模拟了数据库存取过程,并防止了Node在内存中意外修改已存盘的状态,实现对象的深度隔离。
  • tuple[str, bytes]:Checkpoint元数据,前一部分同样表示序列化类型,第二部分为经过序列化后的CheckpointMetadata对象。
  • str | None:父级checkpoint_id(这里的Parent与是否以子图形式执行没有关系,这里代表作为调用者的Node),InMemorySaver可以通过这个 ID在内存中顺着链条向上追溯,从而在恢复时合并增量状态。

2. 持久化Pending Write

Checkpoint是在Superstep成功结束时针对Channel状态创建的,它并不能反映一个尚未结束Superstep内的真实状态。Pregel在执行过程中可以能出现不可预期的错误,或者需要人为介入导致可预期的中断,并行执行的任务就会出现部分部分成功、部分失败和中断的情况。对于成功执行的操作,它们针对目标Channel的写入并没有通过一个Checkpoint固定下来,仅仅属于一个Pending Write。如果这种中间状态没有被持久化,等下次恢复执行的时候,本来已经成功执行的任务还会重复执行,这是无法接受的。

如果某个任务涉及到多次人为中断,每次恢复执行都需要提供Resume Value。如果这些Resume Value没有持久化,那么每次恢复调用提供的Resume Value永远都会提供给第一个中断,多次中断根本就没法实现,所以提供的Resume Value也需要以Pending Write的形式存储下来。

持久化不仅仅需要将Superstep完成时将Channel的状态以Checkpoint固定下来,还需要将涉及到的所有Pending Write按照先后顺序记录下来。Pending Write不仅仅限于描述成功任务针对目标Channel的写入和依序提供Resume Value,任务在执行中抛出的异常和中断也会以Pending Write的形式被记录下来。

实际上这种基于全量基础数据和增量操作日志相结合的持久化形式,在很多内存数据库中得到了广泛的应用。以Redis为例,它会采用相应的策略每隔一段时间将当前时间点的内存快照以RDB形式固化下来,同时针对数据库所作的每个操作都会按照时间顺序以AOL的形式存储下来。对于Pregel来说,Checkpoint就是RDB,Pending Wrtes就是AOL。当Pregel以恢复形式执行的时候,它会先提取并应用指定的Checkpoint快照,然后对状态为成功执行的Pending Write进行重放就能恢复中断时的状态。

针对Pending Write的持久化通过调用BaseCheckpointSaver如下所示的put_writes/aput_writes方法完成。Pending Write的持久化是基于任务进行的,所以我们需要指定任务的ID和路径。config参数提供RunnableConfig对象携带了所需的Thread ID, Checkpoint命名空间和Checkpoint ID。具体针对Channel的Pending Write由writes参数提供的, 这是一个由Channel名称和值的二元组组成的序列。

class BaseCheckpointSaver(Generic[V]): def put_writes( self, config: RunnableConfig, writes: Sequence[tuple[str, Any]], task_id: str, task_path: str = "", ) -> None async def aput_writes( self, config: RunnableConfig, writes: Sequence[tuple[str, Any]], task_id: str, task_path: str = "", ) -> None

对于InMemorySaver来说,它将PendingWrite存储于一个结构为defaultdict[tuple[str, str, str], dict[tuple[str, int], tuple[str, str, tuple[str, bytes], str]]]的两层字典中。第一层字典的Key为Thread ID,Checkpoint命名空间和Checkpoint ID三元组。第二层元组的第一个部分为Task ID,第二部分是当前Pending Write在writes序列中的索引。真正存储的内容是由如下四部分组成的元组:

  • task_id:冗余存储任务 ID,便于快速检索。
  • channel: Channel的名称。
  • tuple[str, bytes]:前部分表示序列化格式(如"json"或"pickle")。后一部分为序列化后的字节。
  • task_path:任务在图结构中的完整路径。
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 18:18:31

看完就会:AI论文平台,千笔·专业学术智能体 VS 灵感ai,专科生专属!

随着人工智能技术的迅猛发展,AI辅助写作工具已经逐渐成为高校学生完成毕业论文的重要助手。尤其是在专科生群体中,面对繁重的论文写作任务,越来越多的学生开始尝试借助AI工具提升效率、优化内容质量。然而,市场上AI工具种类繁多&a…

作者头像 李华
网站建设 2026/10/15 5:10:12

SeqGPT-560M在企业知识管理中的应用:智能文档检索系统

SeqGPT-560M在企业知识管理中的应用:智能文档检索系统 1. 引言 企业每天都在产生大量的文档资料:产品手册、技术文档、会议记录、客户资料、研究报告...这些知识资产散落在各个角落,就像一座座信息孤岛。当员工需要查找某个特定信息时&…

作者头像 李华
网站建设 2026/10/15 22:15:44

为什么92%的早期用户在v1.8迁移至2.0后首周崩溃率下降?Seedance语义理解底层重构的4个反直觉设计决策(内部技术备忘录节选)

第一章:Seedance 2.0语义理解与视频生成映射对比评测报告Seedance 2.0 是面向多模态内容创作的端到端语义驱动视频生成系统,其核心升级在于将自然语言指令更精准地映射为时空一致的视频帧序列。本报告基于统一测试集(包含127条跨领域中文指令…

作者头像 李华
网站建设 2026/10/15 18:29:19

Phi-4-mini-reasoning多语言支持实战:跨语言数学问题求解

Phi-4-mini-reasoning多语言支持实战:跨语言数学问题求解 1. 多语言数学推理的挑战与机遇 数学问题求解一直是人工智能领域的核心挑战之一,而当我们把多语言因素加入其中时,复杂度更是呈指数级增长。不同语言有着截然不同的数学表达习惯、术…

作者头像 李华
网站建设 2026/10/16 0:00:49

MySQL语音数据库:Qwen3-TTS合成结果存储与检索方案

MySQL语音数据库:Qwen3-TTS合成结果存储与检索方案 1. 引言 想象一下这样的场景:一家在线教育平台每天需要为数千个课程章节生成语音讲解,一个内容创作团队每周要制作上百个多语言播客节目,或者一个智能客服系统需要为不同客户定…

作者头像 李华
网站建设 2026/10/9 13:39:42

ChatTTS能耗评估:长时间运行的电力消耗估算

ChatTTS能耗评估:长时间运行的电力消耗估算 1. 引言:为什么需要关注语音合成的能耗? 当我们谈论语音合成技术时,大多数人首先关注的是声音质量、自然度和易用性。但随着AI应用越来越普及,一个经常被忽视但至关重要的…

作者头像 李华