Store:跨 thread 的长期记忆与语义检索
阶段 6 的最后一讲。checkpoint 存的是"某个 thread 的状态时间线"——换个 thread 就读不到了。但真实应用需要"跨对话、跨会话共享的长期记忆":比如"记住这个用户不吃辣",无论他明天开哪个新对话都该记得。这就是 BaseStore 的领地——LangGraph 里和 checkpointer 并列的第二套持久化设施。它用分层 namespace 组织键值、所有操作统一走 batch、还支持语义检索(向量搜索)。我们读它的抽象接口 + InMemoryStore 里真实的余弦相似度搜索代码。
· 便笺按文件夹路径(namespace)分门别类贴,比如
("用户", "u42", "偏好") 这个文件夹下贴着"不吃辣";· 谁(哪个 thread/对话)都能来这面墙上贴(put)、取(get)、翻找(search);
· 更妙的是它能按意思找——你问"这人饮食有啥忌口",它靠语义相似度把"不吃辣"这张便笺翻出来,哪怕字面完全不同。
为什么 checkpoint 之外还要 Store
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"回忆相关记忆"用的。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] 指定索引哪些字段。给"存了但不想被搜到"留了口子。/ 分割),但元组有两个硬优势。其一无歧义:元组的每一段是独立字符串,段内即使含 /、| 等特殊字符也不会被误当分隔符——单字符串就得纠结转义。其二前缀匹配天然:search(("users","u42")) 直接用元组切片 namespace[:len(prefix)] == prefix 判前缀(正是 InMemoryStore store/memory/__init__.py:253-255 的实现),比字符串的 startswith + 处理边界干净得多。用结构化的元组承载层级,把"解析路径"的活从运行时省掉了——这和 Day 38 用字符串编码 checkpoint_ns 是相反的取舍,因为那里要塞进单个数据库列,这里是纯内存 API。一切操作都汇成一个 batch
BaseStore 只强制子类实现两个抽象方法:batch 和 abatch。其它 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(ops) 后,上层一次可以攒一批异构操作一个往返打包发出(对 Postgres store 意义重大,和 Day 37 的 Pipeline 同一思想)。代价是子类实现 batch 时要写个"按 op 类型分派"的分发逻辑(InMemoryStore store/memory/__init__.py:206-219 就是先 _prepare_ops 把 ops 按类型分组再分别处理)。用"一个稍复杂的批接口"换"网络效率 + 减少子类要实现的方法数",对一个可能跑在远程数据库上的设施是明显划算的。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="饮食忌口") 就能靠语义相似把它翻出来。语义检索:余弦相似度排序
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,只是有它更快。过滤 + 分页 + 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 停。在排好序、去好重的序列上切一页出来。limit=10 但只有 3 条被向量化(其余 index=False 没建索引),它会把剩下的名额用"没有分数的项"填满(score=None)。这意味着 search 的返回里可能混着 score=None 的条目——它们是"匹配了 namespace/filter 但没参与语义排序"的记忆。使用时若你 results[0].score 直接拿去比大小,碰到 None 会报错。所以处理搜索结果要判空 score:有分的是语义命中、None 的是兜底填充。这是"既想语义搜、又不丢没索引的数据"折中出来的行为,符合直觉但容易踩。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_writes | batch(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
interrupt() 的原理——它如何在节点中途"抛出"一个中断、把控制权交还给你,以及 Command(resume=...) 如何让流程从中断点精准恢复。你会看到今天和前七天学的 checkpoint,正是这一切的承重墙。