序列化 serde:checkpoint 怎么变成字节、又变回来
前四天 saver 里反复出现 self.serde.dumps_typed(...) 和 loads_typed(...)——把 checkpoint 变成字节存进数据库、读回时再变成对象。今天钻进这个"翻译官" JsonPlusSerializer。它用 ormsgpack(比 JSON 快得多的二进制格式)做主力,遇到 datetime、UUID、Pydantic 模型、numpy 数组这些"JSON 装不下"的类型时,用 Ext 扩展带类型标记编解码。你还会看到一个关键安全权衡:"能反序列化任意 Python 类型"是把双刃剑,以及 LANGGRAPH_STRICT_MSGPACK 为此设的闸。
为什么需要专门的序列化器
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 存在的全部价值,就是把这些"非原生类型"的往返做对、做快、做安全。dumps_typed:字节 + 一个"格式标记"
注意 saver 里存的从来不是"光秃秃的字节",而是 (type, bytes) 二元组。看 dumps_typed(serde/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 更慢、更不安全、跨版本更脆。"json" 格式(msgpack 迁移前)。带上 type 标记,loads_typed 就能对每份数据用当初编它的那套解码器——既支持"按数据形态选最优格式",又天然支持"多种历史格式并存的向后兼容"(L07)。代价只是每份数据多存一个短字符串。这是"自描述数据"的经典好处:数据自己带着"我该怎么被读"的说明,解码方不用猜。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——宁可明确失败,也不静默返回错误数据。Ext:给非原生类型贴"重装标签"
msgpack 本身也只认基本类型。碰到 datetime、UUID、Pydantic、numpy、以及 LangGraph 自己的 _DeltaSnapshot,靠一个 default 钩子把它们编成带类型码的 Ext(serde/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。所以嵌套的复杂对象也能层层打包。(模块, 类名, 参数) 后,解码时能 import 那个模块 → 取那个类 → 用参数调构造,得到一个类型正确、行为完整的对象(比如一个真的 AIMessage,而不是长得像它的 dict)。这也正是安全风险的来源——"照字节里写的模块名去 import 并调用",如果字节被篡改,就可能 import 恶意模块。下一讲和 L06 会看到这个双刃剑。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、只返回原始数据 tuple(return 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 里放了自定义类型,又开了严格模式没把它加进允许名单,要有"读回来可能是裸数据"的心理准备。安全和"无感恢复"在这里有张力。严格模式:能反序列化任意类型的双刃剑
回到那条 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 时精确传入你信任的模块/类列表——只放行你自己那些类型。兼容与降级:老档、坏档都要能扛 + 小结
把今天散落的兼容/降级点收拢——一个生产序列化器要扛住"格式演进"和"数据不完美":
| 场景 | serde 的应对 | 出处 |
|---|---|---|
| 老版本用 JSON 存的 checkpoint | loads_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 白名单绕过 gate | jsonplus.py:60-70 |
| checkpoint 格式 v<4 | saver 层做 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
BaseStore:它用分层 namespace 组织键值、支持 语义检索(向量搜索),是 LangGraph 里和 checkpointer 并列的第二套持久化设施。你会看到 get/search/put 如何统一走 batch、以及 InMemoryStore 里用余弦相似度做语义搜索的真实代码。