Day 34 / 共 60 天 · 阶段 6 持久化与记忆

保存点接口:get_tuple / put / list / put_writes

昨天认识了存档卡(Checkpoint)长什么样,今天看存档器怎么用它。BaseCheckpointSaver 定义了四个必须实现的方法——这是所有后端(内存、Sqlite、Postgres)都要遵守的"合同"。看懂这份合同,后面三天的实现就是"填空题"。

📍 阶段 6 · 持久化与记忆(8 天)你在这里
D33 概念 D34 接口 D35 InMemory D36 Sqlite D37 Postgres D38 id体系 D39 serde D40 Store
💡 用一个类比先兜住今天 存档器就是一个档案管理员,只会做四件事:put=把一份新档案归档、get_tuple=按编号把档案调出来、list=按条件翻档案目录、put_writes=把"还没定稿的草稿页"夹进对应档案袋里。基类只写了"管理员该会哪几招"(抛 NotImplementedError),具体"用抽屉还是用数据库存"留给各后端。这就是抽象基类的用法——先约定契约,再谈实现
L01

四个方法一张地图

BaseCheckpointSaverbase/__init__.py:176)定义了一组方法,其中四个是核心、后端必须实现(基类里全是 raise NotImplementedError):

方法行号干啥Pregel 何时调
get_tuplebase:239按 config 读一份档(+writes+父坐标)图启动/恢复时读最新档
putbase:277写一份完整 Checkpoint每个超步结束落盘
put_writesbase:300写"任务已算完但未合并"的中间结果每个任务跑完立即写
listbase:253按条件列出多份档(时间旅行/调试)get_state_history

另有 get(base:227)是 get_tuple 的便捷包装、get_next_version(base:692)生成版本号,以及每个方法的 a 前缀异步版(aget_tuple/aput/...)。

控制流:一个超步里四个方法的调用时序 启动 get_tuple 跑任务们 每完成一个 put_writes 半成品先落盘 put 整档定稿 ↺ 下一个超步:又从 get_tuple 读回刚写的档,继续 put_writes 在前、put 在后 → 崩溃也不丢已完成任务(D46 幂等)
图注:put_writes 先于 put,是"任务级幂等"的关键顺序。
L02

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唯一入参,里面的 configurablethread_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 引擎只依赖这份契约写代码,于是任何满足契约的后端都能无缝替换。这就是为什么你换 InMemorySaverPostgresSaver,图代码一个字都不用改。
L03

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 就能用它精确定位。
💡 设计取舍①:为什么 put 要额外收 new_versions,而不是自己去 checkpoint 里算差异?因为"哪些通道变了"这个信息,Pregel 引擎在 apply_writes 时已经算过一遍(Day 21)。如果让存档器自己再和上一档 diff 一次,就得先把上一档读回来、逐通道比对——多一次 IO、多一遍计算,还可能因并发看到不一致的上一档。直接把引擎已知的 new_versions 传下来,存档器无脑照做即可。这是"信息在谁手里、就由谁传递"的朴素工程原则,避免重复计算。你会在 D35 看到 InMemorySaver 正是用它来决定"哪些通道的 blob 需要新写一份"。
📝 new_versions 的真实样子 上一档 channel_versions={"messages":"..2..", "counter":"..3.."},这一步只有 counter 又被写了一次 → new_versions={"counter":"..4.."}。后端只需为 counter 存新值,messages 沿用旧 blob 不动。
L04

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 记一笔(细粒度),整个超步都完成后再 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=)」会实际用到这几个键。
L05

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列(调试用)。
filtermetadata 字段筛,比如只看引擎产生的档 {"source":"loop"}、或某一步 {"step":3}
before只返回"比这份档更早"的——配合 limit 做历史分页。因为 id 单调递增,"更早"就是"id 更小"。
-> Iterator返回迭代器不是 list——历史可能很长,惰性产出省内存,用多少取多少。
💡 设计取舍②:为什么 list 返回 Iterator 而不是 list[CheckpointTuple]?一个跑了几百步的长对话,历史档可能成百上千份,每份还要反序列化。如果一次性 return [...],调用方哪怕只想看最近 3 份,也得把全部读出来、全部反序列化、全塞进内存。返回 迭代器后,配合数据库游标可以"读一份产一份",调用方 itertools.islice(..., 3) 取够就停,后面的根本不查。这是"惰性求值"在持久化层的标准应用——用 yield 换峰值内存和无用 IO。(你会在 D35/D36 看到实现里正是 yield CheckpointTuple(...)。)
L06

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 更进一步:"数字.随机数"——数字保证有序,随机数尾巴用来在并发写同一版本时打破平局,避免两个并发写生成完全相同的版本字符串。

L07

同步/异步"双份"接口 + 今日小结

你会发现每个方法都有个 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 卡死其它协程。
💡 本质:接口层"同步/异步"对称是为了不假设运行环境LangGraph 不知道你会在同步脚本还是 async web 服务里用它,所以两套都给全。像 InMemorySaver 因为没有真 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
明天预告 · Day 35:契约看完了,明天看第一个"填空题答案"——InMemorySaver。它用三层 defaultdict 把档案存在内存里,我们逐行拆 put 如何拆分 channel_values 存 blob、get_tuple 如何拼回来,以及它那个"数字.随机数"版本号的实现。
← Day 33 Checkpoint 概念 Day 35 · InMemorySaver →