节点缓存 CachePolicy:同样的输入,别再算第二遍
一个节点调大模型要 3 秒、花几分钱。可有时同样的输入被反复喂进来——纯属浪费。CachePolicy 让你给节点/任务加一层缓存:算过一次,下次同样输入直接返回旧结果。今天从"缓存 key 怎么算"到"命中怎么跳过执行"走完整条链路。你会看到它和 Day 46 的幂等其实是同一套 writes 复用机制的近亲。
query 完全相同;或者一个图被同样的输入重复调用做压测。每次都真跑一遍昂贵节点,钱和时间白烧。你想要:输入一样 → 直接吃上次的结果,不重复执行。怎么用:给节点挂 CachePolicy + 给图配 cache
两步:编译时给图一个 cache(缓存后端),add_node 时给节点一个 cache_policy:
from langgraph.graph import StateGraph, START, END
from langgraph.types import CachePolicy
from langgraph.cache.memory import InMemoryCache
from typing import TypedDict
import time
class S(TypedDict):
x: int
y: int
def slow(state: S) -> dict:
time.sleep(2) # 假装很慢
return {"y": state["x"] * 2}
g = StateGraph(S)
g.add_node("slow", slow, cache_policy=CachePolicy(ttl=120)) # ← 缓存 2 分钟
g.add_edge(START, "slow"); g.add_edge("slow", END)
app = g.compile(cache=InMemoryCache()) # ← 挂缓存后端
app.invoke({"x": 5, "y": 0}) # 慢:真跑 2 秒
app.invoke({"x": 5, "y": 0}) # 快:命中缓存,瞬间返回
cache_policy=CachePolicy(ttl=120)声明这个节点可缓存、结果活 120 秒。不给 cache_policy 的节点不缓存。compile(cache=InMemoryCache())缓存要有地方存。InMemoryCache 存内存字典;生产可换 Redis/Sqlite 后端(都实现 BaseCache)。没配 cache 则 policy 形同虚设。第二次瞬间返回输入 {"x":5,"y":0} 指纹相同 → 命中 → 跳过 slow 的执行,直接返回上次的 {"y":10}。CachePolicy:就俩字段
结构极简 types.py:518:
# types.py:518
@dataclass(**_DC_KWARGS)
class CachePolicy(Generic[KeyFuncT]):
"""Configuration for caching nodes."""
key_func: KeyFuncT = default_cache_key # 怎么把输入变成指纹
ttl: int | None = None # 活多久(秒);None = 永不过期
key_func把节点输入 → 一个字符串/字节指纹的函数。默认 default_cache_key(L03)。你可以自定义,比如只按某个字段算 key、忽略无关字段。ttl生存时间(秒)。设 120 = 缓存两分钟后失效再重算;None = 永久(除非手动 clear)。p = CachePolicy(ttl=60) 到处传)、也方便存进 PregelNode 结构。frozen dataclass 还能安全共享、可哈希,符合 LangGraph 全局"策略即数据"的风格(对比 D52 的 RetryPolicy 也是 NamedTuple)。default_cache_key:把任意输入变成稳定指纹
默认 key 函数用 pickle 把输入序列化成字节 _internal/_cache.py:26:
# _internal/_cache.py:26
def default_cache_key(*args, **kwargs) -> str | bytes:
import pickle
# protocol 5 strikes a good balance between speed and size
return pickle.dumps((_freeze(args), _freeze(kwargs)), protocol=5, fix_imports=False)
关键在 _freeze——它把输入"规范化",让"内容相同、顺序不同"的输入产生同一个指纹 _internal/_cache.py:7:
# _internal/_cache.py:7
def _freeze(obj, depth=10):
if isinstance(obj, Hashable) or depth <= 0:
return obj # 已可哈希,直接用
elif isinstance(obj, Mapping):
# sort keys so {"a":1,"b":2} == {"b":2,"a":1}
return tuple(sorted((k, _freeze(v, depth-1)) for k, v in obj.items()))
elif isinstance(obj, Sequence):
return tuple(_freeze(x, depth-1) for x in obj)
elif hasattr(obj, "tobytes"): # numpy/pandas 等
return (type(obj).__name__, obj.tobytes(),
obj.shape if hasattr(obj, "shape") else None)
return obj
Mapping → sorted字典按 key 排序再哈希——{"a":1,"b":2} 和 {"b":2,"a":1} 内容相同、指纹相同。避免"字段顺序不同就缓存不命中"的坑。Sequence → tuple 递归列表转元组(可哈希),并对每个元素递归 freeze。嵌套结构也能稳定指纹。tobytes() 分支numpy 数组这种自身不可哈希但能转字节的,用 (类型名, 字节, 形状) 表示。让科学计算的输入也能当缓存 key。depth<=0 保护递归最多 10 层,防止超深/循环结构把 freeze 卡死。够深就退化成"用对象本身"。protocol=5pickle 协议 5:速度与体积平衡好。fix_imports=False 关掉 Py2 兼容开销。str(input) 不可靠(对象的 repr 可能带内存地址、不稳定);JSON 只认基本类型(自定义对象、numpy 数组序列化不了)。pickle 几乎能序列化任意 Python 对象,配合 _freeze 做"顺序无关规范化",得到既覆盖面广又稳定的指纹。代价是 pickle 的字节较大且不可读——但缓存 key 后面还会被 xxh3 哈希成短摘要(L04),最终存的不是这坨大字节。这是"用通用序列化换 key 覆盖面"的取舍。CacheKey:命名空间 + 摘要 + TTL
L03 的指纹只是原料。真正存进缓存用的 key 是 CacheKey 三元组 types.py:615:
# types.py:615
class CacheKey(NamedTuple):
ns: tuple[str, ...] # 命名空间:区分是哪个节点的缓存
key: str # 摘要:输入指纹的哈希
ttl: int | None # 存活秒数
它在准备任务时生成 pregel/_algo.py:669:
# pregel/_algo.py:669
if cache_policy:
args_key = cache_policy.key_func(val) # ① 算输入指纹(L03)
cache_key = CacheKey(
(
CACHE_NS_WRITES, # ② 固定前缀 "__pregel_ns_writes"
(identifier(proc) or "__dynamic__"), # 这个节点的身份(模块.函数名)
name, # 节点名
),
xxh3_128_hexdigest( # ③ 把大指纹哈希成短摘要
args_key.encode() if isinstance(args_key, str) else args_key,
),
cache_policy.ttl, # ④ TTL
)
else:
cache_key = None
ns = (CACHE_NS_WRITES, 节点身份, 节点名)命名空间隔离:不同节点即使输入指纹相同,也存在不同 ns 下,绝不串味。identifier(proc) 是节点函数的 模块.限定名。xxh3_128_hexdigest(args_key)把 L03 那坨可能很大的 pickle 字节,哈希成一个定长短摘要当 key。xxh3 极快、碰撞率低。存的是摘要,不是原始大字节。ttl 透传CachePolicy 的 ttl 原样带进 CacheKey,后端存的时候据此算过期时间(L05)。没 policy → None没设 cache_policy 的节点 cache_key 为 None,后续所有缓存逻辑对它一律跳过。slow、输入 {"x":5,"y":0},生成的 CacheKey 形如:ns=("__pregel_ns_writes", "__main__.slow", "slow")、key="a3f0…"(xxh3 128 位十六进制摘要)、ttl=120。换个输入
{"x":6,"y":0} → 只有 key 变(摘要不同),ns 不变;换个节点 → ns 变。这就是"同节点不同输入、不同节点同输入"都不会串味的原因。
(CACHE_NS_WRITES, identifier(call.func)),key 用 key_func(*args, **kwargs) 算——这也是 D47 clear_cache 能按 (CACHE_NS_WRITES, identifier(func)) 清缓存的原因。BaseCache 接口 + 内存实现的 TTL
缓存后端的契约 cache/base/__init__.py:15——四对方法(同步+异步):
# cache/base/__init__.py:15
class BaseCache(ABC, Generic[ValueT]):
serde: SerializerProtocol = JsonPlusSerializer(pickle_fallback=False)
@abstractmethod
def get(self, keys: Sequence[FullKey]) -> dict[FullKey, ValueT]: ... # 批量取
@abstractmethod
def set(self, pairs: Mapping[FullKey, tuple[ValueT, int | None]]) -> None: ... # 批量存(值,TTL)
@abstractmethod
def clear(self, namespaces: Sequence[Namespace] | None = None) -> None: ... # 按 ns 清
内存实现里 TTL 就是"存的时候算出过期时间戳,取的时候比一下现在" cache/memory/__init__.py:38:
# cache/memory/__init__.py:38 (set)
now = datetime.datetime.now(datetime.timezone.utc)
for (ns, key), (value, ttl) in keys.items():
if ttl is not None:
expiry = (now + datetime.timedelta(seconds=ttl)).timestamp() # 现在+TTL
else:
expiry = None
self._cache[ns][key] = (*self.serde.dumps_typed(value), expiry) # 存: 序列化值 + 过期戳
# cache/memory/__init__.py:17 (get)
now = datetime.datetime.now(datetime.timezone.utc).timestamp()
...
enc, val, expiry = self._cache[ns][key]
if expiry is None or now < expiry: # 没过期
values[(ns, key)] = self.serde.loads_typed((enc, val))
else:
del self._cache[ns][key] # 过期 → 顺手删掉(惰性清理)
set 时算 expiry存进去的不只是值,还有一个"过期时间戳"。ttl=120 → expiry = 现在+120秒。get 时比 now < expiry取的时候看现在有没有超过过期戳。没超 → 返回;超了 → 当作未命中,并惰性删除这条(不需要后台定时清理线程)。批量 get/set接口收的是一批 key,一次调用取/存多个——一个超步可能有多个可缓存任务,批量减少往返(对 Redis 这种远端后端尤其重要)。get 命中某个过期 key 时顺手删掉它。好处是零额外线程、实现极简、删除成本摊进本就要做的查询里。代价是"从没被再次访问的过期项"会一直占着内存直到被访问——但对缓存这种"本就允许丢"的数据,这点内存冗余完全可接受。这是"用一点空间换实现简单 + 零后台开销"的经典取舍;真要严格控内存,换成带主动淘汰的 Redis 后端即可。命中与写入的闭环:和幂等同源
命中:执行前,引擎拿所有"有 cache_key 且还没写"的任务去 cache 批量查,命中的直接把 writes 灌回去 pregel/_loop.py:1549:
# pregel/_loop.py:1549
def match_cached_writes(self) -> Sequence[PregelExecutableTask]:
if self.cache is None:
return ()
matched = []
if cached := {(t.cache_key.ns, t.cache_key.key): t
for t in self.tasks.values()
if t.cache_key and not t.writes}: # 有 key 且尚未有写
for key, values in self.cache.get(tuple(cached)).items():
task = cached[key]
task.writes.extend(values) # 命中 → 把缓存的 writes 填进任务
matched.append(task)
return matched
写入:任务跑完 put_writes 时,若它可缓存,就把 writes 存进 cache pregel/_loop.py:1609:
# pregel/_loop.py:1609
def put_writes(self, task_id, writes):
super().put_writes(task_id, writes)
if not writes or self.cache is None or not hasattr(self, "tasks"):
return
task = self.tasks.get(task_id)
if task is None or task.cache_key is None:
return
self.submit(self.cache.set, {
(task.cache_key.ns, task.cache_key.key): (task.writes, task.cache_key.ttl)
})
命中: task.writes.extend(values)命中的任务被"预填"了 writes。引擎看到任务已有 writes,就不再执行它——和 D48 task 幂等复用、D46 replay 是同一招:有 writes 就跳过跑。写入: cache.set({(ns,key):(writes,ttl)})跑完把这次的 writes 连同 ttl 存进缓存。下次同 key 的任务就能被 match 到。submit(cache.set,...)写缓存丢到后台异步做,不阻塞主执行——缓存是"锦上添花",不该拖慢正常流程。边界与坑 + 今日小结
default_cache_key 用 pickle 序列化输入。输入里有 lambda、文件句柄、数据库连接等不可 pickle 的东西会报错。规避:自定义 key_func 只取能序列化的关键字段算 key,比如 CachePolicy(key_func=lambda s: str(s["query"]))——既避开不可 pickle 的字段,也让缓存 key 更精准(只按真正影响结果的输入)。👶 缓存后端和 checkpointer 是一回事吗?都在存东西。
👨🏫 不是。checkpointer(Day 33-40)存的是"某个 thread 某一步的完整状态",服务于断点续跑、时间旅行,按 thread 组织。cache 存的是"某种输入指纹对应的 writes",服务于避免重复计算,按输入指纹组织、跨 thread 共享。一个是"记录这次运行走到哪了",一个是"记住这类输入算出过啥"。二者独立,可同时用、也可只用其一。
🧠 今天你应该能回答
- CachePolicy 有哪两个字段?(key_func 算指纹、ttl 存活秒数)
- default_cache_key 为什么先 _freeze 再 pickle?(规范化:字典排序、序列转元组,让内容相同顺序不同的输入指纹一致)
- CacheKey 三段是什么?(ns 命名空间隔离节点 / key 输入摘要(xxh3 哈希)/ ttl)
- 内存后端怎么实现 TTL?(set 时算过期时间戳,get 时比 now,过期则惰性删除)
- 缓存命中后节点为什么不执行?(writes 被预填,引擎见"已有 writes"即跳过——和幂等/replay 同机制)
- 什么节点不该缓存?(有副作用的、结果随时间变的;输入不可 pickle 的要自定义 key_func)
✋ 10 分钟动手
# 1. 读四段核心
sed -n '7,31p' libs/langgraph/langgraph/_internal/_cache.py # _freeze + default_cache_key
sed -n '668,687p' libs/langgraph/langgraph/pregel/_algo.py # CacheKey 生成
sed -n '17,57p' libs/checkpoint/langgraph/cache/memory/__init__.py # 内存 TTL
sed -n '1549,1625p' libs/langgraph/langgraph/pregel/_loop.py # 命中 + 写入
# 2. 亲手看命中:数节点执行次数 + 计时
python - <<'PY'
import time
from langgraph.graph import StateGraph, START, END
from langgraph.types import CachePolicy
from langgraph.cache.memory import InMemoryCache
from typing import TypedDict
class S(TypedDict):
x: int; y: int
runs = {"n": 0}
def slow(s):
runs["n"] += 1; time.sleep(1); return {"y": s["x"] * 2}
g = StateGraph(S); g.add_node("slow", slow, cache_policy=CachePolicy(ttl=60))
g.add_edge(START,"slow"); g.add_edge("slow",END)
app = g.compile(cache=InMemoryCache())
t=time.time(); print(app.invoke({"x":5,"y":0}), round(time.time()-t,2)) # ~1s
t=time.time(); print(app.invoke({"x":5,"y":0}), round(time.time()-t,2)) # ~0s 命中
print("slow 执行次数:", runs["n"]) # 1
PY
RetryPolicy 怎么控制"重试几次、间隔多久、哪些异常才重试"?多个策略组合时怎么选?明天读 types.py 的 RetryPolicy 和 pregel/_retry.py 的真实重试循环,把可靠性拼图补齐。