Day 51 / 共 60 天 · 阶段 8 函数式 API 与子图

节点缓存 CachePolicy:同样的输入,别再算第二遍

一个节点调大模型要 3 秒、花几分钱。可有时同样的输入被反复喂进来——纯属浪费。CachePolicy 让你给节点/任务加一层缓存:算过一次,下次同样输入直接返回旧结果。今天从"缓存 key 怎么算"到"命中怎么跳过执行"走完整条链路。你会看到它和 Day 46 的幂等其实是同一套 writes 复用机制的近亲。

📍 阶段 8 · 函数式 API 与子图(6 天)你在这里
D47 @entrypoint/@task D48 func→pregel D49 子图基础 D50 子图隔离/stream D51 CachePolicy D52 重试容错
🤔 痛点:同一个"检索/推理"节点被反复触发同样的输入 map-reduce 扇出时(Day 16),十个分支里有三个 query 完全相同;或者一个图被同样的输入重复调用做压测。每次都真跑一遍昂贵节点,钱和时间白烧。你想要:输入一样 → 直接吃上次的结果,不重复执行。
💡 本质:缓存 = 用"输入的指纹"当钥匙,存/取节点的"写" LangGraph 的缓存不缓存"返回值"这个抽象概念,而是缓存节点产生的writes(对通道的写入)——和 checkpoint 存的东西同源(Day 34)。给输入算一个指纹(cache key),跑完把 writes 按指纹存进 cache;下次同指纹直接把存好的 writes 灌回去,跳过执行。类比:食堂打饭,同一张菜单(输入指纹)第二个人来直接给做好的(缓存 writes),不用重新炒。
L01

怎么用:给节点挂 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}
L02

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)。
💡 为什么用 dataclass 而不是一堆散参数把"缓存策略"打包成一个不可变对象,方便在多个节点间复用(p = CachePolicy(ttl=60) 到处传)、也方便存进 PregelNode 结构。frozen dataclass 还能安全共享、可哈希,符合 LangGraph 全局"策略即数据"的风格(对比 D52 的 RetryPolicy 也是 NamedTuple)。
L03

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 兼容开销。
💡 设计取舍①:为什么用 pickle 而不是 JSON 或 hash(str(input))?str(input) 不可靠(对象的 repr 可能带内存地址、不稳定);JSON 只认基本类型(自定义对象、numpy 数组序列化不了)。pickle 几乎能序列化任意 Python 对象,配合 _freeze 做"顺序无关规范化",得到既覆盖面广稳定的指纹。代价是 pickle 的字节较大且不可读——但缓存 key 后面还会被 xxh3 哈希成短摘要(L04),最终存的不是这坨大字节。这是"用通用序列化换 key 覆盖面"的取舍。
L04

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,后续所有缓存逻辑对它一律跳过。
数据结构:从输入到 CacheKey 三段 节点输入 val {"x":5,"y":0} key_func + _freeze pickle 大字节 xxh3 摘要 定长短 key CacheKey(ns, key, ttl) ns=(前缀,节点身份,节点名) · key=摘要 · ttl
图注:输入先规范化+pickle 成大字节指纹,再 xxh3 哈希成短 key,配上 ns 与 ttl 组成 CacheKey。
📝 一个真实的 CacheKey 长什么样 节点名 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 变。这就是"同节点不同输入、不同节点同输入"都不会串味的原因。
函数式 @task 的缓存 key 在另一处生成(pregel/_algo.py:858),ns 只有两段 (CACHE_NS_WRITES, identifier(call.func)),key 用 key_func(*args, **kwargs) 算——这也是 D47 clear_cache 能按 (CACHE_NS_WRITES, identifier(func)) 清缓存的原因。
L05

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 这种远端后端尤其重要)。
💡 设计取舍②:为什么用惰性删除过期项,而不是后台定时清理?朴素做法是起一个后台线程/定时器周期扫描删过期项。但那要额外的线程、锁竞争、以及"扫描频率 vs 内存占用"的调参烦恼。LangGraph 的内存缓存选惰性删除:只在 get 命中某个过期 key 时顺手删掉它。好处是零额外线程、实现极简、删除成本摊进本就要做的查询里。代价是"从没被再次访问的过期项"会一直占着内存直到被访问——但对缓存这种"本就允许丢"的数据,这点内存冗余完全可接受。这是"用一点空间换实现简单 + 零后台开销"的经典取舍;真要严格控内存,换成带主动淘汰的 Redis 后端即可。
L06

命中与写入的闭环:和幂等同源

命中:执行前,引擎拿所有"有 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,...)写缓存丢到后台异步做,不阻塞主执行——缓存是"锦上添花",不该拖慢正常流程。
控制流:缓存的取/存闭环 执行前 cache.get 批量查 cache_key 命中:填 writes → 跳过执行 未命中:真跑 产生 writes put_writes cache.set(异步) 下次同 key 命中
图注:命中即预填 writes 跳过执行;未命中跑完 set 回缓存,供下次命中。
💡 本质:缓存、幂等、replay 复用同一个"writes 复用"机制LangGraph 里三件事——① 缓存命中、② 崩溃恢复重放、③ 断点恢复不重跑 task——底层都是同一句话:如果一个任务已经有了 writes,就别再执行它,直接用这些 writes。区别只在 writes 的来源:缓存来自 cache 后端、replay 来自 checkpoint 的 pending_writes、幂等来自上次运行的存档。理解了这个统一模型,这几天的机制就串成一条线了。
L07

边界与坑 + 今日小结

⚠️ 边界①:cache 是"跨 thread 跨运行"的,别缓存带副作用/时效的节点缓存命中会完全跳过节点执行。如果节点除了返回值还有副作用(写数据库、发通知),命中时这些副作用不会再发生——你以为发了通知,其实没发。同理,缓存了"查天气"这种结果会随时间变的节点,命中时拿到的是旧天气。规避:只缓存纯函数式、结果只依赖输入的节点(如确定性的解析、计算、幂等的检索),并用 ttl 控制"能容忍多旧"。
⚠️ 边界②:默认 key_func 要求输入可 pickledefault_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
明天预告 · Day 52(阶段收官):节点会失败——网络抖动、限流、超时。RetryPolicy 怎么控制"重试几次、间隔多久、哪些异常才重试"?多个策略组合时怎么选?明天读 types.py 的 RetryPolicy 和 pregel/_retry.py 的真实重试循环,把可靠性拼图补齐。
← Day 50 子图隔离/stream Day 52 · 节点级重试与容错 →