Day 35 / 共 60 天 · 阶段 6 持久化与记忆
InMemorySaver:最简实现,看透存取套路
昨天看的是"契约"(抽象方法全是 NotImplementedError)。今天看第一个真实现——InMemorySaver。它把档案存在内存的字典里,没有数据库的干扰,是理解"存/取到底怎么拆怎么拼"的最佳教材。看懂它,Sqlite/Postgres 只是把字典换成表。
📍 阶段 6 · 持久化与记忆(8 天)你在这里
D33 概念→
D34 接口→
D35 InMemory→
D36 Sqlite→
D37 Postgres→
D38 id体系→
D39 serde→
D40 Store
💡 用一个类比先兜住今天
InMemorySaver 像一个三层抽屉柜:第一层按"线程"分(每个对话一格),第二层按"命名空间"分(主图/子图),第三层按"档案 id"分(每一步一份)。存东西时,它还把"体积大的通道值"(比如一长串聊天记录)单独拆出去放进另一个 blob 柜,档案本体只留个"取货单号"(版本号)。取的时候按单号去 blob 柜把值捞回来拼上。今天就把这套"拆存-拼取"看明白。
L01
三层存储结构:storage / writes / blobs
类定义在 memory/__init__.py:33,三个属性是全部家当(memory/__init__.py:69-83):
# memory/__init__.py:33
class InMemorySaver(BaseCheckpointSaver[str], AbstractContextManager, AbstractAsyncContextManager):
# thread ID -> checkpoint NS -> checkpoint ID -> (checkpoint, metadata, parent_id)
storage: defaultdict[str, dict[str, dict[str, tuple[...]]]]
# (thread ID, checkpoint NS, checkpoint ID) -> {(task_id, write_idx): (task_id, channel, value, path)}
writes: defaultdict[tuple[str, str, str], dict[tuple[str, int], tuple[...]]]
# (thread id, checkpoint ns, channel, version) -> (type, bytes) ← 通道值单独存
blobs: dict[tuple[str, str, str, str | int | float], tuple[str, bytes]]
storage三层嵌套字典:thread → ns → id → 档。档里存的是不含通道值的 checkpoint、metadata、父 id。writes存 put_writes 写进来的半成品,键是 (thread, ns, checkpoint_id),值是"该档下所有任务的写"。blobs通道值单独放这:键 (thread, ns, 通道, 版本),值是序列化后的字节。同一通道不同版本各存一份。构造函数(memory/__init__.py:85)用 factory(lambda: defaultdict(dict)) 建了这套嵌套 defaultdict——好处是 storage[新thread][新ns] 直接可用、不用先判断 key 存不存在。
💡 设计取舍①:为什么把 channel_values 从 checkpoint 里抠出来、单独放进 blobs?因为通道值是又大又常复用的部分。想象一个 20 轮的对话,messages 通道每轮都变大,但很多通道(如某个配置项)几十步都没动。如果每份档都整包复制所有通道值,20 份档就把没变的通道复制了 20 遍,空间爆炸。抠出来按
(通道,版本) 存后:没变的通道 20 份档共享同一个 blob(版本没变→键相同→只有一份)。这就是 L03 里 put 只对 new_versions 写 blob 的原因——D34 讲的 new_versions 在这里兑现价值。代价是取档时要多一步"按版本去 blobs 捞值再拼"(L04)。用一点拼装复杂度,换巨大的存储去重。L02
put:拆出通道值存 blob
memory/__init__.py:427,只看核心几行:
# memory/__init__.py:448
c = checkpoint.copy()
thread_id = config["configurable"]["thread_id"]
checkpoint_ns = config["configurable"]["checkpoint_ns"]
values: dict[str, Any] = c.pop("channel_values") # ① 把通道值从档里抠出来
for k, v in new_versions.items(): # ② 只遍历“这次变了版本”的通道
self.blobs[(thread_id, checkpoint_ns, k, v)] = (
self.serde.dumps_typed(values[k]) if k in values else ("empty", b"")
)
self.storage[thread_id][checkpoint_ns].update({ # ③ 档本体(已无通道值)存 storage
checkpoint["id"]: (
self.serde.dumps_typed(c), # 序列化档
self.serde.dumps_typed(get_checkpoint_metadata(config, metadata)), # 序列化元数据
config["configurable"].get("checkpoint_id"), # 父 id
)
})
return {"configurable": {"thread_id": thread_id, "checkpoint_ns": checkpoint_ns,
"checkpoint_id": checkpoint["id"]}}
c.pop("channel_values")先 copy 再 pop——从档里摘掉通道值,剩下的 c 只有版本号等元信息。原 checkpoint 不受影响。for k,v in new_versions关键:只对本次变了版本的通道写 blob。没变的通道版本相同、blobs 里已有那份,直接复用(去重就发生在这)。if k in values else ("empty", b"")边界处理:某通道版本涨了、但 values 里没它的值(比如通道被清空),存一个 ("empty", b"") 哨兵,读时会跳过(L04)。parent = 当前 config 的 checkpoint_id巧妙:写新档时,config 里的 checkpoint_id 正是"上一份档",拿它当新档的父。家谱链就这么串起来。return 新 config把 checkpoint_id 换成新档 id 返回——D34 讲的 put 返回值契约。📝 走一遍
第 2 步只有 counter 变了:
new_versions={"counter":"..4.."} → 只往 blobs[(t,"","counter","..4..")] 写一份;messages 版本没变,沿用第 1 步写的 blob。storage[t][""]["档2id"] 存下不含值的档 + 父="档1id"。
L03
get_tuple:按版本把值拼回来
memory/__init__.py:236,看"没给 checkpoint_id → 取最新档"分支(:281):
# memory/__init__.py:282
if checkpoints := self.storage[thread_id][checkpoint_ns]:
checkpoint_id = max(checkpoints.keys()) # ① id 单调递增 → max 就是最新
checkpoint, metadata, parent_checkpoint_id = checkpoints[checkpoint_id]
writes = self.writes[(thread_id, checkpoint_ns, checkpoint_id)].values()
checkpoint_ = self.serde.loads_typed(checkpoint) # 反序列化档(此时无通道值)
return CheckpointTuple(
config={"configurable": {"thread_id": thread_id, "checkpoint_ns": checkpoint_ns,
"checkpoint_id": checkpoint_id}},
checkpoint={
**checkpoint_,
"channel_values": self._load_blobs( # ② 按版本把通道值捞回来拼上
thread_id, checkpoint_ns, checkpoint_["channel_versions"]),
},
metadata=self.serde.loads_typed(metadata),
pending_writes=[(id, c, self.serde.loads_typed(v)) for id, c, v, _ in writes],
parent_config=({"configurable": {...parent_checkpoint_id...}} if parent_checkpoint_id else None),
)
max(checkpoints.keys())不给 id 就取最新档——因为 checkpoint id 单调递增(D33/D38),字典键取 max 就是最后写的那份。O(n) 但 n 通常不大。loads_typed(checkpoint)先把"不含通道值的档"反序列化回来。**checkpoint_, "channel_values": _load_blobs(...)核心拼装:用档里的 channel_versions(通道→版本)去 blobs 逐个捞值,塞回 channel_values。存时拆开、取时拼回,对称。pending_writes顺便把该档的半成品反序列化带上(D34 讲的 CheckpointTuple 第 5 个字段)。parent_config有父 id 就构造父坐标,让时间旅行能往回走;没有(第一份档)则 None。图注:blobs 柜是 put/get 共同的"通道值仓库",按版本键天然去重。
L04
_load_blobs:捞值时如何处理 empty
拼装的关键辅助 memory/__init__.py:125:
# memory/__init__.py:125
def _load_blobs(self, thread_id, checkpoint_ns, versions) -> dict[str, Any]:
result: dict[str, Any] = {}
for k, ver in versions.items(): # 遍历 档 里的 channel_versions
kk = (thread_id, checkpoint_ns, k, ver)
if kk not in self.blobs: # ← 边界:这个版本没存 blob,跳过
continue
vv = self.blobs[kk]
if vv[0] == "empty": # ← 边界:哨兵值,代表“这通道当时是空的”
continue
result[k] = self.serde.loads_typed(vv) # 反序列化,塞进结果
return result
遍历 versions用档记录的每个通道版本作索引去捞——保证捞到的正是"这份档当时那个版本"的值,而非通道现在的值。这是"读旧档能读到旧值"的根本。kk not in blobs: continue防御:万一 blob 缺失(不该发生但要稳),跳过而非 KeyError 崩溃。vv[0] == "empty": continueL02 存的哨兵在这里被识别:通道当时无值,就不放进 channel_values——下游 from_checkpoint 会把它当"空通道"处理(Day 27 讲的 EmptyChannelError 语义)。⚠️ 边界:为什么"通道无值"要专门存 empty 哨兵,而不是干脆不写 blob?因为
channel_versions 里已经登记了这个通道的版本号(版本涨了)。如果不写任何 blob,L04 遍历时 kk not in blobs 也能跳过——看似等价。但存 empty 哨兵能明确区分两种情况:① 这个版本确实"存过、但值为空";② blob 因 bug 意外丢失。前者是正常业务(通道被 EphemeralValue 之类清空,Day 31),后者是数据损坏。哨兵让"空"是显式的、可校验的状态,而不是靠"查不到"来隐式推断——这在排查数据问题时是天壤之别。L05
put_writes:内存里的幂等去重
memory/__init__.py:473,核心循环:
# memory/__init__.py:494
thread_id = config["configurable"]["thread_id"]
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
checkpoint_id = config["configurable"]["checkpoint_id"]
outer_key = (thread_id, checkpoint_ns, checkpoint_id)
outer_writes_ = self.writes.get(outer_key)
for idx, (c, v) in enumerate(writes):
inner_key = (task_id, WRITES_IDX_MAP.get(c, idx)) # 普通写用 idx,特殊写用负数
if inner_key[1] >= 0 and outer_writes_ and inner_key in outer_writes_:
continue # ← 幂等:普通写已存在就跳过
self.writes[outer_key][inner_key] = (task_id, c, self.serde.dumps_typed(v), task_path)
inner_key=(task_id, idx或负数)用 D34 讲的 WRITES_IDX_MAP:普通通道用它在列表里的 idx,特殊写(ERROR/RESUME 等)用负数。if idx>=0 and 已存在: continue幂等核心:普通写如果这个 (task_id, idx) 已经写过了,就跳过不覆盖。恢复重跑时,同一任务同一位置的写不会写两次。负数不去重注意条件是 inner_key[1] >= 0:特殊写(负数 idx)允许覆盖——比如 RESUME 值会被新的恢复值更新。💡 本质:幂等 = "同样的写重复来,结果不变"崩溃恢复会重放已完成任务的写(D46)。这段代码保证"普通写认第一次、后来的忽略",于是重放不会把通道值累加两遍、不会重复触发 reducer。这是持久执行"至少一次执行、但效果等价于恰好一次"的地基。
L06
版本号:数字.随机数
D34 说基类默认版本是整数、字符串版本要子类自己实现。InMemorySaver 就重写了它 memory/__init__.py:619:
# memory/__init__.py:619
def get_next_version(self, current: str | None, channel: None) -> str:
if current is None:
current_v = 0
elif isinstance(current, int):
current_v = current
else:
current_v = int(current.split(".")[0]) # 从 "0003.xxxx" 里取出整数部分
next_v = current_v + 1
next_h = random.random() # 16 位随机小数
return f"{next_v:032}.{next_h:016}" # 例:000...0004.0.5123908471
split(".")[0]版本字符串是 "整数.随机"格式,取点号前的整数部分做递增基准。next_v = +1整数部分严格 +1,保证有序性——排序、比大小都靠它。f"{next_v:032}"补零到 32 位:这样字符串按字典序比较 = 按数值比较(不会出现 "10" < "9" 的字符串坑)。.{next_h:016}拼一个 16 位随机小数尾巴。作用见下方设计取舍。💡 设计取舍②:整数版本已经够用,为什么要拖个随机数尾巴?纯整数版本在并发写下会出问题:两个任务几乎同时基于同一个 current="..3.." 算 next,都得到 "..4..",版本撞车、无法区分谁先谁后。加一段随机尾巴后,两次并发写得到
"..4..0.51" 和 "..4..0.83"——整数部分保证跨步的全局有序,随机尾巴在同一整数级别内打破平局。比较时先比整数(决定新旧),整数相同再比尾巴(稳定排序、避免键冲突覆盖 blob)。这是"单调 + 抗碰撞"的低成本组合,比引入分布式锁或中心序列号轻得多。对比:默认基类(base:692)用纯
int + 1;Sqlite(D36)复用同款"数字.随机"字符串版本。可见"版本号策略"本身也是可替换的。L07
异步直接复用 + 今日小结
D34 说异步方法要两套。但 InMemorySaver 没有真 IO(就是操作内存字典),所以异步版直接调同步版 memory/__init__.py:529:
# memory/__init__.py:529
async def aget_tuple(self, config): return self.get_tuple(config)
# :543
async def alist(self, config, *, filter=None, before=None, limit=None):
for item in self.list(config, filter=filter, before=before, limit=limit):
yield item
# :565
async def aput(self, config, checkpoint, metadata, new_versions):
return self.put(config, checkpoint, metadata, new_versions)
💡 本质:无 IO 时"异步"只是包装,没必要真异步异步的价值在于"IO 等待时让出事件循环"。内存操作是纯 CPU、瞬间完成,没有等待可让,所以
aput 直接同步执行返回即可,还省去线程池开销。到 D37 的 Postgres 你会看到相反做法:那里有真网络 IO,异步版必须用异步驱动认真实现。是否真异步,取决于底层有没有可等待的 IO。还有个细节:
MemorySaver = InMemorySaver(memory:631)——老名字 MemorySaver 只是别名,向后兼容。以及文件末尾的 PersistentDict(memory:634)可把内存字典 pickle 落盘,是"内存 saver 也能勉强持久化"的小彩蛋,但官方明确只推荐它用于调试/测试。🧠 今天你应该能回答
- InMemorySaver 的三层存储是什么?(storage 档本体 / writes 半成品 / blobs 通道值)
- 为什么把 channel_values 抠出去按版本存 blob?(跨档去重,没变的通道多份档共享一份 blob)
- put 为什么只遍历 new_versions 写 blob?(只有变了版本的通道才需新 blob)
- _load_blobs 里 empty 哨兵解决什么?(显式表达"通道当时为空",区别于数据丢失)
- put_writes 如何做幂等?(普通写 (task_id,idx) 已存在则跳过,负数特殊写允许覆盖)
- 版本号为什么是"32位数字.16位随机"?(补零保证字典序=数值序;随机尾巴抗并发碰撞)
- 为什么异步方法直接调同步?(内存操作无 IO 可等待)
✋ 10 分钟动手
# 1. put / get_tuple / _load_blobs 三段对照读
sed -n '427,472p' libs/checkpoint/langgraph/checkpoint/memory/__init__.py
sed -n '125,141p' libs/checkpoint/langgraph/checkpoint/memory/__init__.py
# 2. 亲手看 blobs 去重:跑两步只改一个字段,数 blobs 大小
python - <<'PY'
from langgraph.graph import StateGraph, START
from langgraph.checkpoint.memory import InMemorySaver
from typing import Annotated
import operator
class S(dict): pass
g=StateGraph(dict); g.add_node("a",lambda s:{"n":s.get("n",0)+1})
g.add_edge(START,"a"); saver=InMemorySaver(); app=g.compile(checkpointer=saver)
cfg={"configurable":{"thread_id":"t"}}
app.invoke({"n":0,"fixed":"never changes"}, cfg)
print("blobs 键:", list(saver.blobs.keys()))
print("版本号样例:", saver.get_next_version(None, None))
PY
明天预告 · Day 36:把内存字典换成真数据库——
SqliteSaver。你会看到 storage/writes 变成两张 SQL 表、setup() 里的建表语句和 WAL 模式、以及"用锁保证线程安全"的取舍。套路和今天一模一样,只是介质变了。