Day 39 / 共 60 天 · 阶段 6 持久化与记忆

序列化 serde:checkpoint 怎么变成字节、又变回来

前四天 saver 里反复出现 self.serde.dumps_typed(...)loads_typed(...)——把 checkpoint 变成字节存进数据库、读回时再变成对象。今天钻进这个"翻译官" JsonPlusSerializer。它用 ormsgpack(比 JSON 快得多的二进制格式)做主力,遇到 datetimeUUID、Pydantic 模型、numpy 数组这些"JSON 装不下"的类型时,用 Ext 扩展带类型标记编解码。你还会看到一个关键安全权衡:"能反序列化任意 Python 类型"是把双刃剑,以及 LANGGRAPH_STRICT_MSGPACK 为此设的闸。

📍 阶段 6 · 持久化与记忆(8 天)你在这里
D33 概念 D34 接口 D35 InMemory D36 Sqlite D37 Postgres D38 id体系 D39 serde D40 Store
💡 用一个类比先兜住今天 数据库只认"字节",不认 Python 对象。serde 就是档案馆的打包/拆包工:存档前把一箱杂物(含日期、UUID、AI 消息对象、numpy 数组……)打包成一个标准快递箱(字节流),取档时再照着箱子上的标签一件件还原。难点在"杂物里有不少非标准件"——普通快递(JSON)只收字符串/数字/列表/字典,装不下 datetime 这类东西。serde 的办法是给每个非标准件贴一张"这是什么、怎么重装"的标签(Ext 类型码),拆包时照标签复原。
L01

为什么需要专门的序列化器

🤔 痛点:直接 json.dumps(checkpoint) 不行吗?为什么要造 JsonPlusSerializer? 因为 checkpoint 里塞满了 JSON 装不下的东西。看类定义和它选的底层格式(serde/jsonplus.py:82-95):
# serde/jsonplus.py:82
class JsonPlusSerializer(SerializerProtocol):
    """Serializer that uses ormsgpack, with optional fallbacks.

    !!! warning
        Security note: ... It should not be used on untrusted python objects.
        If an attacker can write directly to your checkpoint database,
        they may be able to trigger code execution when data is deserialized.
        Set LANGGRAPH_STRICT_MSGPACK=true to restrict deserialization
        to a built-in allowlist of safe types.
    """
uses ormsgpack底层不是 JSON 而是 ormsgpack——MessagePack 的高性能实现。它是二进制格式:比 JSON 文本更小、编解码更快,还原生支持 bytes、非字符串 key 等 JSON 做不到的东西。
"JsonPlus" 这个名字"JSON 加强版"——语义上像 JSON(结构化数据往返),但加了对 datetime/UUID/Pydantic/自定义类等一大堆类型的支持。checkpoint 里 AI 消息、工具调用结果全是这类对象。
warning: 安全提示docstring 头就挂了红色警告:能反序列化任意 Python 类型 → 若攻击者能写你的 checkpoint 库,反序列化时可能触发代码执行。这是 L06 要展开的核心安全权衡。
💡 本质:序列化是"对象 ↔ 字节"的可逆翻译,难点全在"非原生类型"纯字符串/数字/列表/字典的往返谁都会。真正的工程难题是:一个 AIMessage 对象、一个 datetime、一个用户自定义的 Pydantic 模型,怎么变成字节存下、又原样活过来。JsonPlusSerializer 存在的全部价值,就是把这些"非原生类型"的往返做对、做快、做安全。
L02

dumps_typed:字节 + 一个"格式标记"

注意 saver 里存的从来不是"光秃秃的字节",而是 (type, bytes) 二元组。看 dumps_typedserde/jsonplus.py:258-271):

# serde/jsonplus.py:258
def dumps_typed(self, obj: Any) -> tuple[str, bytes]:
    if obj is None:
        return "null", EMPTY_BYTES          # ① None → 标记 "null" + 空字节
    elif isinstance(obj, bytes):
        return "bytes", obj                  # ② 本来就是字节 → 标记 "bytes",原样
    elif isinstance(obj, bytearray):
        return "bytearray", obj
    else:
        try:
            return "msgpack", _msgpack_enc(obj)   # ③ 其他一律走 msgpack 编码
        except ormsgpack.MsgpackEncodeError as exc:
            if self.pickle_fallback:
                return "pickle", pickle.dumps(obj)  # ④ 编不了 & 开了兜底 → pickle
            raise exc
返回 (type, bytes) 二元组关键设计:第一个元素是"用什么格式编的"字符串标记("null"/"bytes"/"msgpack"/"pickle"),第二个才是字节。存数据库时 type 单独存一列(Day 36/37 表里的 type 列)——读回时靠它决定怎么解。
None / bytes 短路特判:None 存个空标记省事;已经是字节的(比如某些通道值)直接原样存,不多此一举编一遍。
主力走 _msgpack_enc绝大多数对象走 "msgpack" 分支——用 ormsgpack 编码(下一讲看它怎么处理非原生类型)。
pickle 是最后兜底连 msgpack 都编不了(极罕见的怪类型)、且用户显式开了 pickle_fallback,才退回 pickle。默认不开——因为 pickle 更慢、更不安全、跨版本更脆。
🅰 设计取舍①:为什么要带一个"格式标记",而不是固定用一种格式? 因为不同数据用不同格式最优,而且格式会演进。已经是字节的东西再包一层 msgpack 是浪费;None 编码毫无必要;而老版本存的可能是 "json" 格式(msgpack 迁移前)。带上 type 标记,loads_typed 就能对每份数据用当初编它的那套解码器——既支持"按数据形态选最优格式",又天然支持"多种历史格式并存的向后兼容"(L07)。代价只是每份数据多存一个短字符串。这是"自描述数据"的经典好处:数据自己带着"我该怎么被读"的说明,解码方不用猜。
L03

loads_typed:照标记分派解码器

loads_typed 就是 dumps 的镜像——按 type 标记选对应解码路径(serde/jsonplus.py:273-290):

# serde/jsonplus.py:273
def loads_typed(self, data: tuple[str, bytes]) -> Any:
    type_, data_ = data
    if type_ == "null":
        return None
    elif type_ == "bytes":
        return data_
    elif type_ == "bytearray":
        return bytearray(data_)
    elif type_ == "json":                     # ← 老格式:msgpack 迁移前的 JSON
        return json.loads(data_, object_hook=self._reviver)
    elif type_ == "msgpack":
        return ormsgpack.unpackb(
            data_, ext_hook=self._unpack_ext_hook,     # ★ 遇到 Ext 扩展类型时的回调
            option=ormsgpack.OPT_NON_STR_KEYS)          # 允许非字符串 key
    elif self.pickle_fallback and type_ == "pickle":
        return pickle.loads(data_)
    else:
        raise NotImplementedError(f"Unknown serialization type: {type_}")
type_ 分派核心结构:一个大 if 按 type 标记选解码方式。dumps 存了什么标记,这里就走对应分支——完美镜像。
"json" 分支(兼容老档)还留着 "json" 分支:老版本 LangGraph 用 JSON 存的 checkpoint,升级后仍能读——用 _reviver 把 JSON 里的类型标记还原成对象。向后兼容的活证据
ext_hook=self._unpack_ext_hookmsgpack 解码时传入一个回调:碰到 Ext 扩展类型码(datetime/UUID/自定义类等)就调它去重建对象(L05)。这是"非原生类型能活过来"的钩子。
OPT_NON_STR_KEYS允许字典用非字符串 key(如整数 key)——JSON 做不到,msgpack 可以。checkpoint 里确实有这种字典。
未知 type → 报错兜底:碰到不认识的 type 标记直接 NotImplementedError——宁可明确失败,也不静默返回错误数据。
控制流:dumps_typed / loads_typed 对称往返 Python 对象checkpoint / 通道值 (type, bytes)带格式标记的字节 数据库type 列 + blob 列 dumps_typed loads_typed(照 type 分派) 非原生类型(datetime/UUID/AIMessage…)在 msgpack 分支里靠 Ext 标记往返(L04/L05)
type 标记贯穿存取两端,让"按格式解码"和"多格式兼容"都成为可能
L04

Ext:给非原生类型贴"重装标签"

msgpack 本身也只认基本类型。碰到 datetime、UUID、Pydantic、numpy、以及 LangGraph 自己的 _DeltaSnapshot,靠一个 default 钩子把它们编成带类型码的 Extserde/jsonplus.py:295-307,859-860):

# serde/jsonplus.py:295  —— 每种非原生类型分配一个整数"类型码"
EXT_CONSTRUCTOR_SINGLE_ARG = 0    # 单参数构造(如 UUID(str))
EXT_CONSTRUCTOR_POS_ARGS   = 1    # 位置参数构造
EXT_CONSTRUCTOR_KW_ARGS    = 2    # 关键字参数构造
EXT_METHOD_SINGLE_ARG      = 3
EXT_PYDANTIC_V1            = 4    # Pydantic v1 模型
EXT_PYDANTIC_V2            = 5    # Pydantic v2 模型
EXT_NUMPY_ARRAY           = 6    # numpy 数组
EXT_DELTA_SNAPSHOT        = 7    # LangGraph 的 DeltaChannel 快照

# serde/jsonplus.py:305  编码钩子:ormsgpack 遇到不认识的对象就调它
def _msgpack_default(obj: Any) -> str | ormsgpack.Ext:
    if isinstance(obj, _DeltaSnapshot):
        return ormsgpack.Ext(EXT_DELTA_SNAPSHOT, _msgpack_enc(obj.value))
    # ... 往下依次判断 Pydantic v2 / v1 / datetime / UUID / numpy / 各种类型 ...

# serde/jsonplus.py:859  编码入口:把 _msgpack_default 作为 default 传给 packb
def _msgpack_enc(data: Any) -> bytes:
    return ormsgpack.packb(data, default=_msgpack_default, option=_option)
每种类型一个整数码0~7 各代表一种"重建方式"。Ext = (类型码, 该类型的内容字节)——类型码就是"重装标签",告诉解码方"这块字节该怎么变回对象"。
default=_msgpack_defaultormsgpack 编码时,凡遇到它不认识的对象,就回调 _msgpack_default。这个函数一路 isinstance 判断,命中哪种类型就返回对应的 Ext。
构造方式分三档码 0/1/2 分别对应"单参/位置参/关键字参"构造——因为不同类的 __init__ 签名不同。编码时记下 (模块名, 类名, 构造参数),解码时照着重新构造(L05)。
_DeltaSnapshot 递归编码Ext 内容可以再套 Ext:_DeltaSnapshot 把它内部的 value 递归再编一次 msgpack。所以嵌套的复杂对象也能层层打包。
数据结构:一个非原生对象怎么变成 Ext UUID("abc-...")msgpack 不认识→ 回调 _msgpack_default Ext(类型码, 内容字节) 码=0 (SINGLE_ARG) | 内容 = msgpack(("uuid","UUID","abc-...")) 即 (模块名, 类名, 构造参数) 类型码 = "重装标签";解码时照码 import 模块→取类→用参数构造,还原成真 UUID
Ext 把"这是什么类型 + 怎么重建"打包进字节,是非原生类型能往返的关键
💡 为什么用"记下构造方式"而不是直接存对象的字段?因为要保证"还原出来的是同一个类的实例,而不是一个字典"。存 (模块, 类名, 参数) 后,解码时能 import 那个模块 → 取那个类 → 用参数调构造,得到一个类型正确、行为完整的对象(比如一个真的 AIMessage,而不是长得像它的 dict)。这也正是安全风险的来源——"照字节里写的模块名去 import 并调用",如果字节被篡改,就可能 import 恶意模块。下一讲和 L06 会看到这个双刃剑。
L05

ext_hook:照标签把对象重装回来

解码时,msgpack 每碰到一个 Ext 就调 ext_hook,按类型码重建对象(serde/jsonplus.py:633-666):

# serde/jsonplus.py:633
def ext_hook(code: int, data: bytes) -> Any:
    if code == EXT_DELTA_SNAPSHOT:
        return _DeltaSnapshot(
            ormsgpack.unpackb(data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS))
    elif code == EXT_CONSTRUCTOR_SINGLE_ARG:
        try:
            tup = ormsgpack.unpackb(data, ext_hook=ext_hook, ...)  # tup = (模块, 类名, 参数)
            if not _check_allowed(tup[0], tup[1]):     # ① 安全检查:这个类允许 import 吗?
                return tup[2]                          #    不允许 → 只还原原始数据,不构造对象
            return getattr(importlib.import_module(tup[0]), tup[1])(tup[2])   # ② import 并构造
        except Exception:
            return None                                # ③ 构造失败 → 返回 None,不崩
    elif code == EXT_CONSTRUCTOR_POS_ARGS:
        ...
        if tup[0] == "langgraph.types" and tup[1] == "Send":
            return _send_from_args(tup[2])             # 特殊类型走专门重建
        return getattr(importlib.import_module(tup[0]), tup[1])(*tup[2])
code 对应 dumps 的类型码完美镜像 L04:编码时贴的类型码,这里照码分派到对应的重建逻辑。
importlib.import_module + getattr核心重建:照字节里记的 (模块名, 类名) 去 import 那个类,再用参数调它的构造。UUID、datetime、AIMessage 就这样活过来。
_check_allowed 安全闸关键:import 前先问 _check_allowed "这个模块.类名在允许名单里吗"。不在名单 → 不 import、只返回原始数据 tuplereturn tup[2]),避免加载不受信任的代码。
try/except → None健壮性:重建过程任何异常都吞掉返回 None,不让一个坏字段崩掉整份 checkpoint 的加载
⚠ 边界:不在允许名单的类型,会"降级成原始数据"而非报错——静默行为要留心 注意 if not _check_allowed(...): return tup[2] 这一行:当某个类不被允许 import 时,它不是抛异常,而是安静地返回构造参数本身(一个 dict 或 tuple)。好处是加载不会崩、且注释说"如果用在 Pydantic state 里,构造时会被重新校验"。但坑在于:你以为读回来的是一个 MyModel 对象,实际拿到的可能是一个 dict——后续代码 obj.some_attr 就会 AttributeError。所以如果你在 State 里放了自定义类型,又开了严格模式没把它加进允许名单,要有"读回来可能是裸数据"的心理准备。安全和"无感恢复"在这里有张力。
L06

严格模式:能反序列化任意类型的双刃剑

回到那条 docstring 警告。默认行为其实是"宽松"的,安全靠一个环境变量收紧(serde/jsonplus.py:97-119):

# serde/jsonplus.py:107
if allowed_msgpack_modules is _lg_msgpack._SENTINEL:
    if _lg_msgpack.STRICT_MSGPACK_ENABLED:       # 环境变量 LANGGRAPH_STRICT_MSGPACK=true
        allowed_msgpack_modules = None           # 严格:只允许内置的 SAFE_MSGPACK_TYPES
    else:
        # 宽松(默认):所有类型都允许(带一条 warning)
        allowed_msgpack_modules = True
默认 = True(宽松)开箱即用为了不给用户添麻烦:默认允许反序列化任意类型——你放什么自定义对象进 State 都能无感往返。代价是安全性放宽。
STRICT_MSGPACK_ENABLED设环境变量 LANGGRAPH_STRICT_MSGPACK=true → 切到严格模式:只允许一个内置的"安全类型白名单"(SAFE_MSGPACK_TYPES,如 datetime/UUID/LangChain 核心消息类型)。名单外的类型走 L05 的"降级成原始数据"。
也能显式传 allowed_msgpack_modules不想用环境变量的粗粒度开关,可以在构造 JsonPlusSerializer精确传入你信任的模块/类列表——只放行你自己那些类型。
🅰 设计取舍②:为什么默认宽松而非默认安全?把安全交给用户主动开启,对吗? 这是一个真实且有争议的取舍。默认宽松让绝大多数用户(自己控制 checkpoint 数据库、不面对攻击者)零配置就能用任意类型——体验丝滑。默认严格会让很多人一上来就撞"我的自定义类型读不回来了"的墙。LangGraph 的判断是:"攻击者能直接写你的 checkpoint 数据库"这个前提,在多数部署里不成立(数据库本就该受保护),所以把默认设成宽松、用 docstring 警告 + 一个环境变量把"收紧"的开关交给真正面临风险的用户(如多租户、数据库可能被污染的场景)。这是"默认易用 vs 默认安全"的经典分歧——它选了易用,但把风险说清楚、把闸留好。判断对错取决于你的威胁模型:生产多租户环境,你应该主动开严格模式。
L07

兼容与降级:老档、坏档都要能扛 + 小结

把今天散落的兼容/降级点收拢——一个生产序列化器要扛住"格式演进"和"数据不完美":

场景serde 的应对出处
老版本用 JSON 存的 checkpointloads_typed 保留 "json" 分支,用 _reviver 还原jsonplus.py:281-282
msgpack 编不了的怪类型可选 pickle_fallback 兜底(默认关)jsonplus.py:268-271
某个 Ext 类型重建失败try/except 返回 None,不崩整份档jsonplus.py:652-653
类型不在安全名单降级返回原始数据(dict/tuple),交给上层校验jsonplus.py:645-649
安全类型即使无名单也放行_is_safe_json_type 白名单绕过 gatejsonplus.py:60-70
checkpoint 格式 v<4saver 层做 pending_sends 迁移(Day 37 见过)postgres/__init__.py:165-168

👶 小白:这些兼容代码看着好啰嗦,真有必要吗?

👨‍🏫 老师:太有必要了。checkpoint 是会在数据库里躺很久的——用户今天存的对话,半年后升级了三个版本的 LangGraph 还要能读回来。序列化格式一旦"写进过别人的数据库",就永远不能简单地改,只能"新格式往前兼容老格式"。这些啰嗦的 if 分支,就是"不抛弃任何一份历史存档"的承诺。你会发现整个持久化层(Day 30 Topic 的 tuple 兼容、Day 37 的 MIGRATIONS、今天的多格式解码)都在反复做同一件事:演进时带着历史一起走。

🧠 今日小结自测

  • 为什么不用 json 而用 ormsgpack?(二进制更快更小,支持 bytes/非字符串 key,且能挂 Ext 扩展类型)
  • dumps_typed 为什么返回 (type, bytes) 而非光字节?(type 标记让读回时按格式分派,并支持多历史格式兼容)
  • 非原生类型(datetime/UUID/AIMessage)怎么往返?(编码时贴 Ext 类型码 + 记(模块,类名,参数),解码时 import 重构造)
  • ext_hook 里 import 前为什么先 _check_allowed?(防止反序列化时 import 不受信任的代码,安全闸)
  • 类型不在名单会怎样?(降级返回原始 dict/tuple,不报错——静默行为,读回可能不是对象)
  • LANGGRAPH_STRICT_MSGPACK 管什么?(默认宽松允许任意类型;设 true 切严格模式只允许安全白名单)
  • 为什么保留这么多兼容分支?(checkpoint 长期躺库,格式演进必须向后兼容老存档)

✋ 10 分钟动手

# 1. dumps_typed / loads_typed 对照读
sed -n '258,290p' libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py

# 2. Ext 类型码 + 编码/解码钩子
sed -n '295,307p' libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py
sed -n '633,666p' libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py

# 3. 亲手看非原生类型的往返
python - <<'PY'
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
from datetime import datetime, timezone
from uuid import uuid4
s = JsonPlusSerializer()
obj = {"when": datetime.now(timezone.utc), "id": uuid4(), "nums": [1,2,3]}
t, b = s.dumps_typed(obj)
print("格式标记:", t, "| 字节数:", len(b))
back = s.loads_typed((t, b))
print("还原类型:", type(back["when"]).__name__, type(back["id"]).__name__)  # datetime UUID
PY
🔮 明日预告 · Day 40 Store 长期记忆(阶段 6 收官)checkpoint 是"某个 thread 的状态时间线"——换个 thread 就读不到。但真实应用需要"跨 thread、跨会话共享的长期记忆"(比如"记住这个用户偏好",无论他开哪个对话)。明天看 BaseStore:它用分层 namespace 组织键值、支持 语义检索(向量搜索),是 LangGraph 里和 checkpointer 并列的第二套持久化设施。你会看到 get/search/put 如何统一走 batch、以及 InMemoryStore 里用余弦相似度做语义搜索的真实代码。
← Day 38 id 体系与超步 Day 40 · Store 长期记忆 →