保存点接口:get_tuple / put / list / put_writes
昨天认识了存档卡(Checkpoint)长什么样,今天看存档器怎么用它。BaseCheckpointSaver 定义了四个必须实现的方法——这是所有后端(内存、Sqlite、Postgres)都要遵守的"合同"。看懂这份合同,后面三天的实现就是"填空题"。
NotImplementedError),具体"用抽屉还是用数据库存"留给各后端。这就是抽象基类的用法——先约定契约,再谈实现。四个方法一张地图
BaseCheckpointSaver(base/__init__.py:176)定义了一组方法,其中四个是核心、后端必须实现(基类里全是 raise NotImplementedError):
| 方法 | 行号 | 干啥 | Pregel 何时调 |
|---|---|---|---|
get_tuple | base:239 | 按 config 读一份档(+writes+父坐标) | 图启动/恢复时读最新档 |
put | base:277 | 写一份完整 Checkpoint | 每个超步结束落盘 |
put_writes | base:300 | 写"任务已算完但未合并"的中间结果 | 每个任务跑完立即写 |
list | base:253 | 按条件列出多份档(时间旅行/调试) | get_state_history |
另有 get(base:227)是 get_tuple 的便捷包装、get_next_version(base:692)生成版本号,以及每个方法的 a 前缀异步版(aget_tuple/aput/...)。
get_tuple:读档契约
基类里它只是一句"你得自己实现",但签名和文档就是契约 base/__init__.py:239:
# base/__init__.py:239
def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
"""Fetch a checkpoint tuple using the given configuration.
Returns: The requested checkpoint tuple, or `None` if not found.
Raises: NotImplementedError: Implement this method in your custom checkpoint saver.
"""
raise NotImplementedError
# base/__init__.py:227 —— get 是它的便捷包装
def get(self, config: RunnableConfig) -> Checkpoint | None:
if value := self.get_tuple(config):
return value.checkpoint
config: RunnableConfig唯一入参,里面的 configurable 带 thread_id(必填)、checkpoint_ns、可选的 checkpoint_id。契约:给了 checkpoint_id 就读那一份,没给就读该 thread 最新的一份。-> CheckpointTuple | None找到返回完整合订本(含 pending_writes、parent_config),找不到返回 None(不是抛异常)——让调用方用 if 判断即可。get() 包装基类顺手提供:只想要数据本体、不要 writes/父坐标时用 get。:= 海象运算符:拿到就取 checkpoint,None 就返回 None。raise NotImplementedError,看着"啥也没干",但它规定了输入类型、输出类型、None 的语义。Pregel 引擎只依赖这份契约写代码,于是任何满足契约的后端都能无缝替换。这就是为什么你换 InMemorySaver → PostgresSaver,图代码一个字都不用改。put:写档,为什么要多一个 new_versions?
base/__init__.py:277:
# base/__init__.py:277
def put(
self,
config: RunnableConfig,
checkpoint: Checkpoint,
metadata: CheckpointMetadata,
new_versions: ChannelVersions, # ← 本次写入相比上一档,哪些通道涨了版本
) -> RunnableConfig:
"""Store a checkpoint with its configuration and metadata.
Returns: RunnableConfig: Updated configuration after storing the checkpoint.
"""
raise NotImplementedError
checkpoint要存的整份档(D33 七字段)。metadata标签栏(source/step/parents)。new_versions本次写入新增/变化的通道版本。为什么单列?因为后端可以据此只存变化的通道值(增量存储),而不是每次全量复制所有通道——省空间省 IO。-> RunnableConfig返回指向刚写这份档的新 config(里面 checkpoint_id 填成新档 id)。下一步 get_tuple 就能用它精确定位。new_versions 传下来,存档器无脑照做即可。这是"信息在谁手里、就由谁传递"的朴素工程原则,避免重复计算。你会在 D35 看到 InMemorySaver 正是用它来决定"哪些通道的 blob 需要新写一份"。channel_versions={"messages":"..2..", "counter":"..3.."},这一步只有 counter 又被写了一次 → new_versions={"counter":"..4.."}。后端只需为 counter 存新值,messages 沿用旧 blob 不动。
put_writes:把"半成品"夹进档案袋
base/__init__.py:300:
# base/__init__.py:300
def put_writes(
self,
config: RunnableConfig,
writes: Sequence[tuple[str, Any]], # [(channel, value), ...] 这个任务产生的写
task_id: str, # 哪个任务写的
task_path: str = "", # 任务在图中的路径(子图/Send 时区分)
) -> None:
"""Store intermediate writes linked to a checkpoint."""
raise NotImplementedError
writes一批 (通道, 值)。这是某个任务刚跑完的产出,还没被合并进"正式档",先记为"挂在这份档上的待写入"。task_id标识是哪个任务写的。恢复时靠它判断"这个任务是不是已经跑过了"→ 跑过就跳过,实现幂等。task_path任务路径,子图/Send 动态扇出(Day 16)时用来区分同名任务的不同实例。-> None不返回坐标——因为它是挂在已存在的档上,坐标就是入参 config,不产生新档。put_writes 记一笔(细粒度),整个超步都完成后再 put 出一份新档(粗粒度)。这两级粒度让"崩溃恢复"能精确到任务:已 put_writes 的任务不重跑。相关的还有个常量 base/__init__.py:795,它定义了几种"特殊写"的负数索引:
# base/__init__.py:795
WRITES_IDX_MAP = {ERROR: -1, SCHEDULED: -2, INTERRUPT: -3, RESUME: -4}
# 普通写按它在 writes 列表里的下标(0,1,2...);
# 特殊写(错误、已调度、中断、恢复)映射到负数下标,避免和普通写撞键。
0,1,2... 当 idx(主键的一部分)。像"中断信号 INTERRUPT""恢复值 RESUME"这类不是业务数据、但也要存进 writes 表的特殊记录,如果也从 0 开始编号,就会和普通写的主键冲突、互相覆盖。用 -1,-2,-3,-4 这些普通写永远不会用到的负数,让它们"和平共处"在同一张表里。D41「interrupt」、D42「Command(resume=)」会实际用到这几个键。list:翻档案目录(filter / before / limit)
base/__init__.py:253:
# base/__init__.py:253
def list(
self,
config: RunnableConfig | None,
*,
filter: dict[str, Any] | None = None, # 按 metadata 过滤(如 {"source":"loop"})
before: RunnableConfig | None = None, # 只列早于某档的(分页/翻历史)
limit: int | None = None, # 最多返回几份
) -> Iterator[CheckpointTuple]:
"""List checkpoints that match the given criteria."""
raise NotImplementedError
config可为 None。给了就限定某 thread;None 表示跨所有 thread列(调试用)。filter按 metadata 字段筛,比如只看引擎产生的档 {"source":"loop"}、或某一步 {"step":3}。before只返回"比这份档更早"的——配合 limit 做历史分页。因为 id 单调递增,"更早"就是"id 更小"。-> Iterator返回迭代器不是 list——历史可能很长,惰性产出省内存,用多少取多少。return [...],调用方哪怕只想看最近 3 份,也得把全部读出来、全部反序列化、全塞进内存。返回 迭代器后,配合数据库游标可以"读一份产一份",调用方 itertools.islice(..., 3) 取够就停,后面的根本不查。这是"惰性求值"在持久化层的标准应用——用 yield 换峰值内存和无用 IO。(你会在 D35/D36 看到实现里正是 yield CheckpointTuple(...)。)get_next_version:版本号怎么涨
这是基类里唯一有默认实现的核心方法 base/__init__.py:692——因为版本递增有个通用逻辑:
# base/__init__.py:692
def get_next_version(self, current: V | None, channel: None) -> V:
"""Generate the next version ID for a channel.
Default is to use integer versions, incrementing by `1`."""
if isinstance(current, str):
raise NotImplementedError # 字符串版本得子类自己实现
elif current is None:
return 1 # 第一次:从 1 开始
else:
return current + 1 # 之后:+1
current: V | None当前版本,None 表示这个通道还没有任何版本(第一次写)。channel: None已废弃参数,为向后兼容保留,永远传 None。默认整数 +1基类默认版本就是 1,2,3... 的整数序列,够单机用。字符串抛 NotImplementedError如果想用字符串版本(如 InMemory 的 "032位数字.16位随机"),必须子类重写——D35 会看到 InMemorySaver 的实现。👶 小白:版本号为什么不直接用时间戳,非要搞个递增数字?
👨🏫 老师:时间戳精度有限,同一毫秒内两次写会撞成一样、分不出先后;而且时钟可能回拨。递增版本号严格保证"后写的一定大",是"数据新旧"的可靠依据。InMemorySaver 更进一步:"数字.随机数"——数字保证有序,随机数尾巴用来在并发写同一版本时打破平局,避免两个并发写生成完全相同的版本字符串。
同步/异步"双份"接口 + 今日小结
你会发现每个方法都有个 a 开头的孪生兄弟:aget_tuple(base:429)、aput(base:468)、aput_writes(base:491)、alist(base:443)。基类文档里专门提醒:
# base/__init__.py:204 (类文档节选)
# Note: When creating a custom checkpoint saver, consider implementing async
# versions to avoid blocking the main thread.
# base/__init__.py:443 —— 注意 alist 这个空 yield 小技巧
async def alist(self, config, *, filter=None, before=None, limit=None):
raise NotImplementedError
yield # ← 让函数在语法上成为 async generator
raise 后面还有 yield妙处:函数体里有 yield,Python 才把它认成异步生成器(返回类型 AsyncIterator)。这行 yield 永远执行不到(前面已 raise),纯粹是为了让类型正确——是 Python 生成器语义的一个经典 trick。为什么要双份同步图用同步方法、异步图(ainvoke)用异步方法,避免在事件循环里跑阻塞 IO 卡死其它协程。return self.get_tuple(...) 复用同步实现(D35 会看到);Postgres 则两套各自用同步/异步驱动,认真实现。🧠 今天你应该能回答
- 后端必须实现的四个核心方法是哪几个?(get_tuple / put / put_writes / list)
- 为什么 put 要额外收 new_versions?(引擎已算过差异,直接传下来避免重复计算/增量存储)
- put_writes 和 put 的粒度区别?(任务级半成品 vs 超步级定稿;put_writes 在前保证幂等)
- WRITES_IDX_MAP 为什么用负数?(特殊写和普通写共表不撞主键)
- list 为什么返回 Iterator?(历史可能很长,惰性产出省内存和无用 IO)
- get_next_version 默认怎么涨?字符串版本怎么办?(整数 +1;字符串需子类重写)
✋ 10 分钟动手
# 1. 通读四个核心方法的签名与文档
sed -n '227,318p' libs/checkpoint/langgraph/checkpoint/base/__init__.py
# 2. 看 get_next_version 默认实现 + WRITES_IDX_MAP
sed -n '692,712p' libs/checkpoint/langgraph/checkpoint/base/__init__.py
sed -n '788,808p' libs/checkpoint/langgraph/checkpoint/base/__init__.py
# 3. 观察 list/before 分页效果
python - <<'PY'
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
g=StateGraph(dict); g.add_node("a",lambda s:{"n":s.get("n",0)+1})
g.add_edge(START,"a"); g.add_edge("a",END)
app=g.compile(checkpointer=InMemorySaver()); cfg={"configurable":{"thread_id":"t"}}
for _ in range(3): app.invoke({"n":0}, cfg)
for t in app.checkpointer.list(cfg): print(t.metadata.get("step"), t.config["configurable"]["checkpoint_id"][:8])
PY
InMemorySaver。它用三层 defaultdict 把档案存在内存里,我们逐行拆 put 如何拆分 channel_values 存 blob、get_tuple 如何拼回来,以及它那个"数字.随机数"版本号的实现。