Day 40 / 共 60 天 · 阶段 6 持久化与记忆(收官)

Store:跨 thread 的长期记忆与语义检索

阶段 6 的最后一讲。checkpoint 存的是"某个 thread 的状态时间线"——换个 thread 就读不到了。但真实应用需要"跨对话、跨会话共享的长期记忆":比如"记住这个用户不吃辣",无论他明天开哪个新对话都该记得。这就是 BaseStore 的领地——LangGraph 里和 checkpointer 并列的第二套持久化设施。它用分层 namespace 组织键值、所有操作统一走 batch、还支持语义检索(向量搜索)。我们读它的抽象接口 + InMemoryStore 里真实的余弦相似度搜索代码。

📍 阶段 6 · 持久化与记忆(8 天)—— 收官
D33 概念 D34 接口 D35 InMemory D36 Sqlite D37 Postgres D38 id体系 D39 serde D40 Store
💡 用一个类比先兜住今天 checkpointer 是"每个对话各自的录像带"——完整记录这盘带子从头到尾每一帧,但别的带子看不到它。Store 则是整个公司共享的一个"便笺墙 + 智能检索":
· 便笺按文件夹路径(namespace)分门别类贴,比如 ("用户", "u42", "偏好") 这个文件夹下贴着"不吃辣";
· 谁(哪个 thread/对话)都能来这面墙上贴(put)、取(get)、翻找(search);
· 更妙的是它能按意思找——你问"这人饮食有啥忌口",它靠语义相似度把"不吃辣"这张便笺翻出来,哪怕字面完全不同。
L01

为什么 checkpoint 之外还要 Store

🤔 痛点:checkpoint 不是已经能记住状态了吗?为什么还要另一套 Store? 因为 checkpoint 的记忆被 thread_id 锁死了——它是"这一条对话的历史",跨不出去。看 BaseStore 的类 docstring 一句话点破定位(store/base/__init__.py:700-706):
# store/base/__init__.py:700
class BaseStore(ABC):
    """Abstract base class for persistent key-value stores.

    Stores enable persistence and memory that can be shared across threads,
    scoped to user IDs, assistant IDs, or other arbitrary namespaces.
    Some implementations may support semantic search capabilities through
    an optional `index` configuration.
    """
    supports_ttl: bool = False        # 是否支持"到期自动删",默认关
"shared across threads"灵魂差异:checkpoint 按 thread 隔离,Store 的记忆跨 thread 共享。用户在对话 A 里说的偏好,对话 B 也能读到。这才叫"长期记忆"。
"scoped to user IDs, assistant IDs"它用任意 namespace 划分记忆归属——按用户、按 assistant、按任何你定义的维度。比 thread_id 这种单一维度灵活得多。
"semantic search capabilities"Store 独有的能力:语义检索。checkpoint 只能按坐标精确取;Store 能"按意思模糊找"(L05)。这是给 agent"回忆相关记忆"用的。
💡 本质:两套记忆,两种时间尺度记住这个对比就抓住了全局:checkpointer = 短期/会话记忆(这盘录像带的完整回放,供恢复、时间旅行、断点续跑);Store = 长期/跨会话记忆(提炼出来、值得永久记住、跨对话复用的知识)。一个 agent 应用往往两者都用:checkpointer 让单次对话能中断恢复,Store 让它记住关于用户的长期事实。它俩在 LangGraph 里是并列的两根支柱。
L02

namespace:像文件夹路径一样分层

Store 的每条记忆由 namespace(文件夹路径)+ key(文件名) 定位。看 PutOp 里对 namespace 的定义(store/base/__init__.py:431-475):

# store/base/__init__.py:438
class PutOp(NamedTuple):
    namespace: tuple[str, ...]
    """Hierarchical path that identifies the location of the item.
       The namespace acts as a folder-like structure to organize items.
       Examples:
           ("documents",)                    # 根级
           ("documents", "user123")          # 用户专属
           ("cache", "embeddings", "v1")     # 多层嵌套
    """
    key: str
    """Unique identifier within the namespace."""
    value: dict[str, Any] | None
    """The data to store, or None to mark the item for deletion."""   # ← value=None 表示删除
    index: Literal[False] | list[str] | None = None
    """要不要为语义搜索建索引、索引哪些字段(L05)"""
namespace 是字符串元组像文件夹路径:("users", "u42", "prefs")层级结构让你能"取某个用户的所有偏好"(按前缀搜),也能精确定位单条。
key 在 namespace 内唯一namespace + key 一起构成完整地址——类比"文件夹路径 + 文件名"。同一 namespace 下 key 不能重(重了就是更新)。
value=None 表示删除巧妙复用:put 一个 value=None 就是"删除这条"。把增/改/删统一成一个 PutOp,不用单独的 delete 数据结构。
index 控制建不建索引可选:None 用 store 默认、False 这条不建索引(存了但不参与语义搜索)、list[str] 指定索引哪些字段。给"存了但不想被搜到"留了口子。
数据结构:namespace 元组 → key → Item 的两层字典 _data[ ("users","u42","prefs") ] key="diet"Item(value={"text":"不吃辣"}) key="lang"Item(value={"text":"中文"}) 前缀 ("users","u42") 用元组切片即可匹配该用户所有偏好
底层就是 namespace元组 → key → Item 两层字典,前缀搜靠元组切片比较
🅰 设计取舍①:为什么用"元组路径"当 namespace,而不是单一字符串 key(如 "users/u42/prefs")? 单字符串也能表达层级(拿 / 分割),但元组有两个硬优势。其一无歧义:元组的每一段是独立字符串,段内即使含 /| 等特殊字符也不会被误当分隔符——单字符串就得纠结转义。其二前缀匹配天然search(("users","u42")) 直接用元组切片 namespace[:len(prefix)] == prefix 判前缀(正是 InMemoryStore store/memory/__init__.py:253-255 的实现),比字符串的 startswith + 处理边界干净得多。用结构化的元组承载层级,把"解析路径"的活从运行时省掉了——这和 Day 38 用字符串编码 checkpoint_ns 是相反的取舍,因为那里要塞进单个数据库列,这里是纯内存 API。
L03

一切操作都汇成一个 batch

BaseStore 只强制子类实现两个抽象方法:batchabatch。其它 get/search/put 全是"包装成 op 丢给 batch"(store/base/__init__.py:724-769):

# store/base/__init__.py:724
@abstractmethod
def batch(self, ops: Iterable[Op]) -> list[Result]:
    """Execute multiple operations synchronously in a single batch."""

@abstractmethod
async def abatch(self, ops: Iterable[Op]) -> list[Result]: ...

# store/base/__init__.py:748  —— get 只是包一个 GetOp 丢给 batch
def get(self, namespace, key, *, refresh_ttl=None) -> Item | None:
    return self.batch(
        [GetOp(namespace, str(key), _ensure_refresh(self.ttl_config, refresh_ttl))]
    )[0]                                      # 取结果列表第 0 个
只有 batch/abatch 是抽象方法子类只需实现这两个,get/search/put/delete/list_namespaces 全是基类里"包 op → 调 batch → 取结果"的便捷封装。实现一个新 store 后端,工作量集中在一个方法里
Op 是几种操作的联合GetOp / SearchOp / PutOp / ListNamespacesOp 各是一个 NamedTuple。batch 收到一列混合的 op,逐个执行、结果按序返回。
get = batch([GetOp])[0]单个 get 就是"造一个 GetOp、丢给 batch、取返回列表第一项"。单操作是批操作的特例
结果顺序对齐输入docstring 明确:返回列表的顺序和输入 ops 顺序一一对应。调用方靠下标取回自己那条的结果。
🅰 设计取舍②:为什么把所有操作收敛成一个 batch 接口,而不是每种操作一个抽象方法? 因为批处理是远程 store 的性能命脉,而单接口把"批"变成一等公民。如果每种操作各一个抽象方法,"一次调用里混合做 3 个 put + 2 个 search"就无从表达,只能挨个发、挨个等网络往返。收敛成 batch(ops) 后,上层一次可以攒一批异构操作一个往返打包发出(对 Postgres store 意义重大,和 Day 37 的 Pipeline 同一思想)。代价是子类实现 batch 时要写个"按 op 类型分派"的分发逻辑(InMemoryStore store/memory/__init__.py:206-219 就是先 _prepare_ops 把 ops 按类型分组再分别处理)。用"一个稍复杂的批接口"换"网络效率 + 减少子类要实现的方法数",对一个可能跑在远程数据库上的设施是明显划算的。
L04

InMemoryStore:batch 里怎么处理三类 op

看最简实现 InMemoryStore 的 batch 主干,理解 put/search 怎么落地(store/memory/__init__.py:206-219):

# store/memory/__init__.py:206
def batch(self, ops: Iterable[Op]) -> list[Result]:
    results, put_ops, search_ops = self._prepare_ops(ops)   # ① 按类型分组 + 先处理 get
    if search_ops:
        queryinmem_store = self._embed_search_queries(search_ops)  # ② 把查询词转成向量
        self._batch_search(search_ops, queryinmem_store, results)  #    执行语义搜索
    to_embed = self._extract_texts(put_ops)                        # ③ 从待写项里抽要索引的文本
    if to_embed and self.index_config and self.embeddings:
        embeddings = self.embeddings.embed_documents(list(to_embed))  # 批量算 embedding
        self._insertinmem_store(to_embed, embeddings)                 # 存进向量表
    self._apply_put_ops(put_ops)                                   # ④ 真正写/删数据
    return results

# store/memory/__init__.py:404  —— put 的落地:value=None 就删,否则存 Item
def _apply_put_ops(self, put_ops) -> None:
    for (namespace, key), op in put_ops.items():
        if op.value is None:
            self._data[namespace].pop(key, None)     # value=None → 删除
        else:
            self._data[namespace][key] = Item(...)   # 否则存/覆盖一个 Item
_prepare_ops 分组先把混合 ops 拆成 put/search 两组(get 在这一步就地取数据填进 results)。不同类型走不同处理路径。
put 前先算 embedding关键顺序:写入前,若配了索引,先把要索引的文本转成向量存起来——这样之后的 search 才能按语义找到它。存数据和存向量是两条并行的账。
_data 是 namespace → key → Item底层就是两层字典store/memory/__init__.py:186):defaultdict(dict),第一层按 namespace、第二层按 key。和 Day 35 InMemorySaver 的嵌套字典异曲同工。
value=None → pop 删除L02 说的"删除语义"在这里兑现:op.value is None 就从字典 pop 掉。增改删统一在一个循环里。
📝 真实值例子 store.put(("users","u42","prefs"), "diet", {"text": "不吃辣"}, index=["text"])_data[("users","u42","prefs")]["diet"] = Item(value={"text":"不吃辣"}),同时把"不吃辣"这句话算成一个向量存进向量表。之后 store.search(("users","u42"), query="饮食忌口") 就能靠语义相似把它翻出来。
L05

语义检索:余弦相似度排序

Store 最有魔力的是"按意思找"。核心就是把查询和候选都变成向量,算余弦相似度store/memory/__init__.py:493-519):

# store/memory/__init__.py:493
def _cosine_similarity(X: list[float], Y: list[list[float]]) -> list[float]:
    """Compute cosine similarity between a vector X and a matrix Y."""
    try:
        import numpy as np
        X_arr = np.array(X); Y_arr = np.array(Y)
        X_norm = np.linalg.norm(X_arr)
        Y_norm = np.linalg.norm(Y_arr, axis=1)
        mask = Y_norm != 0                                  # 防止除以零
        similarities = np.zeros_like(Y_norm)
        similarities[mask] = np.dot(Y_arr[mask], X_arr) / (Y_norm[mask] * X_norm)  # ★ 余弦
        return similarities.tolist()
    except ImportError:                                     # 没装 numpy → 纯 Python 兜底
        # norm1 = sum(a*a for a in X)**0.5 ; 逐个算 dot/(norm1*norm2)
        ...
余弦相似度公式两个向量夹角的余弦 = 点积 / (各自模长之积)。值域 -1~1,越接近 1 越"方向一致"、语义越像。这是文本语义检索的标准度量。
X 是查询、Y 是所有候选一次算"查询向量 vs 一批候选向量"的相似度——用 numpy 矩阵运算一次性算完所有候选,比逐个循环快得多。
mask = Y_norm != 0边界:零向量(模长 0)会导致除零,先用掩码把它们排除、相似度记 0。防御性细节。
except ImportError 纯 Python 兜底没装 numpy 也能跑——退回纯 Python 逐个算(store/memory/__init__.py:517-519)。不硬依赖 numpy,只是有它更快。
控制流:一次语义 search 的流水线 查询词"饮食忌口" embed → 向量[0.1, ...] 余弦相似度vs 候选向量矩阵 降序取 topK带 score 返回 候选先经 namespace 前缀 + filter 过滤,再按相似度排序、分页(L06) "不吃辣" 和 "饮食忌口" 字面不同但向量相近 → 被找到
语义检索 = 查询与候选都向量化 → 余弦相似度排序 → 取最相关的几条
L06

过滤 + 分页 + max pooling

真实搜索不止"算相似度",还要先过滤、再排序、去重、分页。看 _batch_search 的排序段(store/memory/__init__.py:324-350):

# store/memory/__init__.py:324
scores = _cosine_similarity(query_embedding, flat_vectors)
sorted_results = sorted(zip(scores, flat_items), key=lambda x: x[0], reverse=True)  # 按分降序
# max pooling:同一条记忆可能有多个向量(多字段/多段),只保留它最高的那次命中
seen: set[tuple[tuple[str, ...], str]] = set()
kept = []
for score, item in sorted_results:
    key = (item.namespace, item.key)
    if key in seen:
        continue                          # ← 这条已收过(用它更高分的那次)→ 跳过
    ix = len(seen)
    seen.add(key)
    if ix >= op.offset + op.limit:
        break                             # 够了(分页上界)
    if ix < op.offset:
        continue                          # 还没到分页起点,跳过
    kept.append((score, item))
先过滤后打分搜索前会先按 namespace 前缀 + filter 条件筛出候选(store/memory/__init__.py:238-265_filter_items),再只对候选算相似度——不对全库打分,省算力。
sorted(..., reverse=True)按余弦分从高到低排——最相关的排最前。
max pooling 去重妙点:一条记忆若索引了多个字段/多段文本,会有多个向量、可能多次命中。seen 集合保证每条记忆只保留它得分最高的那次命中,避免同一条挤满结果。
offset / limit 分页ix(去重后的序号)实现分页:小于 offset 跳过、到达 offset+limit 停。在排好序、去好重的序列上切一页出来。
⚠ 边界:请求数量超过已索引数时,用"无分数项"补齐——别把 score=None 当异常store/memory/__init__.py:345-350:如果你要 limit=10 但只有 3 条被向量化(其余 index=False 没建索引),它会把剩下的名额用"没有分数的项"填满score=None)。这意味着 search 的返回里可能混着 score=None 的条目——它们是"匹配了 namespace/filter 但没参与语义排序"的记忆。使用时若你 results[0].score 直接拿去比大小,碰到 None 会报错。所以处理搜索结果要判空 score:有分的是语义命中、None 的是兜底填充。这是"既想语义搜、又不丢没索引的数据"折中出来的行为,符合直觉但容易踩。
L07

Store vs checkpoint + 阶段 6 收官

阶段 6 八天走完,用一张表把两套持久化设施钉在一起,这是你脑中该常驻的对照:

维度Checkpointer(D33-39)Store(D40)
记什么某 thread 每个超步的完整状态快照提炼出的、值得长期留存的知识
隔离维度thread_id(一对话一时间线)任意 namespace(用户/assistant/…)
能否跨 thread 共享❌ 不能✅ 能(这是它的存在理由)
取用方式按 (thread,ns,id) 坐标精确取get 精确取 + search 语义/条件找
典型用途中断恢复、时间旅行、断点续跑记住用户偏好/事实、RAG 式回忆
核心接口get_tuple/put/list/put_writesbatch(get/search/put 皆封装于此)

👶 小白:一个 agent 到底该用哪个?

👨‍🏫 老师:通常两个都用。给图 compile(checkpointer=..., store=...),节点函数里能同时拿到两者。checkpointer 保证"这次对话中断了能续上";store 保证"跨对话记住关于用户的长期事实"。举例:客服 agent 用 checkpointer 记住"当前这通对话聊到哪了",用 store 记住"这个客户是 VIP、偏好邮件联系"——下次他开新对话,对话历史是新的(新 thread),但 store 里的 VIP 标记还在。

🧠 今日 & 阶段 6 小结自测

  • Store 和 checkpointer 的根本区别?(Store 跨 thread 共享长期记忆,checkpointer 按 thread 隔离状态时间线)
  • namespace 为什么用元组而非字符串?(无分隔符歧义 + 前缀匹配用切片天然干净)
  • BaseStore 为什么只有 batch/abatch 是抽象方法?(get/search/put 都封装成 op 走 batch,子类只实现一处,且批处理是远程性能命脉)
  • put 里 value=None 表示什么?(删除该条,增改删统一成一个 PutOp)
  • 语义检索靠什么排序?(查询和候选都向量化,算余弦相似度降序取 topK)
  • max pooling 和分页里的坑?(同一条记忆多向量只留最高分那次;limit 超过已索引数会用 score=None 项补齐,用时要判空)
  • (回顾)checkpoint 三元组坐标 + serde 类型标记 + saver 幂等,串起来是怎样一条存取链?

✋ 10 分钟动手

# 1. BaseStore 抽象 + get/search/put 签名
sed -n '700,769p' libs/checkpoint/langgraph/store/base/__init__.py

# 2. InMemoryStore 的 batch 分派 + 余弦相似度
sed -n '206,219p' libs/checkpoint/langgraph/store/memory/__init__.py
sed -n '493,519p' libs/checkpoint/langgraph/store/memory/__init__.py

# 3. 亲手用 Store 存取(不带语义搜索,无需 embedding)
python - <<'PY'
from langgraph.store.memory import InMemoryStore
store = InMemoryStore()
store.put(("users","u42","prefs"), "diet", {"text":"不吃辣"})
print("精确取:", store.get(("users","u42","prefs"), "diet").value)
print("按前缀搜:", [i.key for i in store.search(("users","u42"))])
PY
🔮 阶段 6 收官 & Day 41 预告 · 进入阶段 7 中断与人在环持久化八天学完,你已经掌握了 LangGraph "把状态存下来、恢复回去"的完整链路。而持久化正是"人在环(human-in-the-loop)"的地基——因为要让流程"停下来等人",必须能把当前状态安全存档、等人回复后再从存档恢复。明天 Day 41 进入阶段 7:看 interrupt() 的原理——它如何在节点中途"抛出"一个中断、把控制权交还给你,以及 Command(resume=...) 如何让流程从中断点精准恢复。你会看到今天和前七天学的 checkpoint,正是这一切的承重墙。
← Day 39 序列化 serde Day 41 · interrupt 原理 →