服务器重启后,你让一个长跑的 Temporal 或 LangGraph workflow 从 checkpoint 恢复。Agent 读到 completed_steps = 7,从第 8 步继续。但 artifacts 字典里少了第 5 步的输出——写 checkpoint 时被 OOM kill 打断在序列化中途。第 8 步去取那个缺失的产物,抛出 KeyError 崩溃。这下当前 checkpoint 已经损坏,内存里的状态也没了,要恢复就得把第 1-4 步重跑一遍。Checkpoint 损坏不常见,但一旦发生就是灾难。
最快的修复: 不要去修那个坏文件。回滚到上一个完好的 checkpoint 版本(run-42.1.json,再不行用 .2.json),并且从今往后用抗崩溃的方式写 checkpoint——临时文件 -> fsync -> 原子重命名 -> 再 fsync 目录——同时在记录里带上 checksum、schema_version 和 is_complete 标志,每次加载都校验。下面分别讲怎么定位、怎么恢复,以及一条绝不会留下半截文件的写入路径。
先判断你属于哪一类
| 加载时的症状 | 最可能的原因 | 第一步该做什么 |
|---|---|---|
json.JSONDecodeError / jq 报某个字节偏移出错 | 序列化中途被打断(OOM、SIGKILL) | 回滚到上一个版本 |
能正常加载,但某字段本该有数据却是 None/空 | 写入被截断,或「重新开始」的兜底逻辑掩盖了加载错误 | 回滚;去掉静默兜底 |
代码期望的字段上抛 KeyError/AttributeError | 代码部署后 schema 漂移了 | 显式迁移,别自动加载 |
datetime/Decimal/set 字段上抛 TypeError | 序列化把类型转掉了(如 datetime -> str) | 改用保留类型的序列化器 |
| 字段看着合法,但属于另一次运行 | 多进程并发写入 | 单写入者加锁;记录写入者 PID |
| 永远「找不到 checkpoint」,状态悄悄重置 | 解压/编解码出错被当成「文件不存在」 | 区分「加载失败」和「确实没有」 |
如果实在判断不出属于哪一类,就按第一类处理,直接回滚。回滚到一个校验过的完好版本,几乎总比原地修补一个损坏文件更安全。
常见原因
1. 序列化中途被打断
最常见。进程在写一个大 checkpoint 时收到 SIGKILL(OOM、实例关机、容器被驱逐),文件或数据库行里只有半截 blob。下次加载要么读到「看似合法但不完整」的数据,要么在截断的尾部解析失败。
怎么判断:跑 python -m json.tool checkpoint.json 或 jq '.' checkpoint.json。jq 会报出语法错误的精确字节偏移,告诉你完好的部分有多少。如果文件结束在字符串中间、数组中间,或者本该有数据的地方是 null,就是写入被打断了。把 checkpoint 的 mtime 和 dmesg/journalctl -k 里的 OOM 事件(找 Out of memory: Killed process)对一对时间。
2. 「原子」写入只做到了原子,没做到持久
一个隐蔽的坑。你写了临时文件再调 os.replace(),这在 POSIX 上确实是原子的。但 os.replace() 只保证文件名指向新旧两个 inode 中的一个;按 POSIX 语义它并不把数据刷到磁盘。一次断电或硬崩溃之后,你可能得到一个指向零长度或旧数据的文件——因为临时文件的内容和目录项都没被 fsync。下面 Step 2 的代码补上了这两次刷盘。
怎么判断:文件能通过 JSON 解析,但 state 是空的,或者退回到了旧版本,而且专门发生在主机非正常重启之后(不是干净的进程重启)。典型特征是「测试时看着很原子,只在真断电时丢数据」。
3. 多进程并发写入相互覆盖
两个进程(一个写 checkpoint 的、一个恢复监控的,或者两个共用同一 thread_id 的 Agent)同时写同一个路径。一个盖掉另一个;在 LangGraph 里往往是后写的赢,前一个 Agent 的状态被静默丢弃。
怎么判断:在每条 checkpoint 记录里记下写入者 PID 和一个单调递增的序号。如果短时间内有两个不同 PID 写了同一路径,或者序号倒退了,就发生过并发写。用单写入者机制(文件 advisory lock,或数据库行锁 / SELECT ... FOR UPDATE)来约束。
4. 代码部署后 schema 版本不匹配
新代码期望 state["artifacts"]["step_5"]["type"](嵌套),而 checkpoint 是旧版本写的,结构是 state["step_5_artifact"](扁平)。加载「成功」了,但形状不对,Agent 在结构合法、语义错误的状态上继续跑。
怎么判断:检查记录里有没有 schema_version 字段。如果没有,或者它和代码期望的版本对不上,就是漂移了。永远不要自动加载版本不匹配的 checkpoint——显式迁移它(Step 4)。
5. 序列化静默把非 JSON 类型转掉了
状态里有 datetime、Decimal、set 或 numpy 数组。一个普通的 json.dumps(..., default=str) 会把 datetime(2026, 5, 25) 变成 "2026-05-25 00:00:00"。加载回来是个 str 而非 datetime:.isoformat() 返回的字符串形状不对,.date() 直接抛 AttributeError。set 在原生 json 下根本序列化不了。
怎么判断:把每个字段在序列化前和反序列化后的 Python 类型对一对。任何变化(datetime -> str、Decimal -> float、set -> list 或报错)就是静默转换。LangGraph 自带的序列化器能避开这个问题(见 FAQ),但前提是让它来序列化——你自己节点代码里手写的 json.dumps 不会。
6. 存储后端返回了陈旧或非持久的数据
没开 AOF 持久化的 Redis 重启就丢 checkpoint;S3 在覆盖写后的 read-after-write 可能短暂返回旧版本;没开 fsync/synchronous_commit 的数据库崩溃时会丢掉最后几次写入。
怎么判断:检查后端的持久化设置。自建 Redis 要确认 appendonly yes,且 appendfsync 是 everysec 或 always。Postgres 要确认 synchronous_commit = on。验证方式:写一个 checkpoint、杀掉主机、再读回来。
7. checkpoint 是压缩的,加载时编解码库不在
checkpoint 用 lz4/zstd 写的,重建后的服务器没装对应库。加载时把压缩字节当成原始文本读(一堆乱码),或者抛异常被捕获后当成「没有 checkpoint——重新开始」,于是静默从头跑。
怎么判断:看 checkpoint 读失败是不是都汇进了「找不到 checkpoint」的分支。如果解码错误和文件不存在无法区分,你就会悄悄丢掉成果。固定编解码库的版本,并让解码错误成为一个会被记录的硬失败——绝不静默重置。
最短修复路径
Step 1:每次加载都校验完整性
import json, hashlib
class CorruptedCheckpointError(Exception): ...
class SchemaVersionMismatch(Exception): ...
def load_checkpoint_safe(path: str, schema_version: int) -> dict:
with open(path) as f:
record = json.load(f) # 尾部被截断会抛 JSONDecodeError
if not record.get("is_complete"):
raise CorruptedCheckpointError(f"Incomplete checkpoint at {path}")
saved_version = record.get("schema_version")
if saved_version != schema_version:
raise SchemaVersionMismatch(
f"Checkpoint schema v{saved_version} != code schema v{schema_version}"
)
state_blob = json.dumps(record["state"], sort_keys=True, default=str)
expected = hashlib.sha256(state_blob.encode()).hexdigest()
if record.get("checksum") != expected:
raise CorruptedCheckpointError(f"Checksum mismatch at {path}")
return record["state"]
Step 2:要抗崩溃地写,而不只是「原子」地写
os.replace() 是原子的,但不是持久的。要扛得住真断电,必须在重命名前 fsync 临时文件、在重命名后 fsync 父目录。否则重命名可能已经落地,而它指向的数据还在 page cache 里。
import os, json, hashlib, tempfile
from datetime import datetime, timezone
def save_checkpoint_atomic(path: str, state: dict, schema_version: int):
state_blob = json.dumps(state, sort_keys=True, default=str)
record = {
"state": state,
"checksum": hashlib.sha256(state_blob.encode()).hexdigest(),
"schema_version": schema_version,
"is_complete": True,
"saved_at": datetime.now(timezone.utc).isoformat(),
}
dir_name = os.path.dirname(path) or "."
# 临时文件放在同一目录 -> 同一文件系统 -> 重命名才是原子的
with tempfile.NamedTemporaryFile(
mode="w", dir=dir_name, delete=False, suffix=".tmp"
) as tmp:
json.dump(record, tmp)
tmp.flush()
os.fsync(tmp.fileno()) # 持久化 1/2:数据已落盘
tmp_path = tmp.name
os.replace(tmp_path, path) # 原子替换,绝不会是半截文件
# 持久化 2/2:把重命名这条目录项也刷盘
dir_fd = os.open(dir_name, os.O_DIRECTORY)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
datetime.utcnow() 自 Python 3.12 起已弃用,请改用上面这种带时区的 datetime.now(timezone.utc)。
Step 3:保留最近 3 代 checkpoint
def rotate_checkpoints(base_path: str, state: dict, schema_version: int):
# checkpoint.2.json -> .3.json,.1 -> .2,当前 -> .1,再写新的当前版本
for i in range(3, 0, -1):
src = f"{base_path}.{i-1}.json" if i > 1 else f"{base_path}.json"
dst = f"{base_path}.{i}.json"
if os.path.exists(src):
os.replace(src, dst)
save_checkpoint_atomic(f"{base_path}.json", state, schema_version)
当前 checkpoint 校验不过时,就沿环回退:依次试 .1.json、.2.json、.3.json,每个都用 load_checkpoint_safe 加载。第一个能通过校验的就是你的恢复点。
Step 4:版本不匹配时显式迁移
SCHEMA_VERSION = 3
def migrate_checkpoint(state: dict, from_version: int, to_version: int) -> dict:
if from_version == 1 and to_version >= 2:
# v1 -> v2:把嵌套的产物结构拍平
for k, v in state.pop("artifacts_nested", {}).items():
state[f"artifact_{k}"] = v
if from_version <= 2 and to_version >= 3:
# v2 -> v3:补上缺失的 "completed_at" 字典
state.setdefault("completed_at", {})
return state
绝不静默加载版本不匹配的 checkpoint。遇到 SchemaVersionMismatch 时,先跑迁移、再重新校验,然后用当前版本写一个新 checkpoint,最后才恢复执行。
Step 5:在 CI 里测损坏恢复
# 把当前 checkpoint 的前 512 字节清零来制造损坏
dd if=/dev/zero of=checkpoints/run-42.json count=1 bs=512 conv=notrunc
# pipeline 必须检测到损坏并回退到 .1.json
python run_pipeline.py --run-id run-42 --resume
# 期望日志:"Loaded fallback checkpoint .1.json - proceeding from step 5"
怎么确认已经修好了
- 类型往返检查:序列化一个含
datetime、Decimal、set的状态,加载回来,断言每个字段的type()没变。 - 写入途中崩溃测试:开始保存,在写入中途
kill -9进程,重启,确认加载器要么读到上一个完整 checkpoint,要么直接拒绝加载——绝不静默使用半截文件。同时检查没有残留的.tmp文件。 - 断电模拟(如果条件允许):在虚拟机里写一个 checkpoint 然后硬重启主机。重启后最新的完整 checkpoint 仍能加载;这正是 Step 2 里那次目录
fsync换来的保障。 - checksum 守门:把某个已保存 checkpoint 的
stateblob 里改动一个字节,确认load_checkpoint_safe抛出CorruptedCheckpointError,而不是返回被篡改的数据。
预防建议
- 抗崩溃地写 checkpoint:同目录临时文件 ->
fsync文件 ->os.replace->fsync父目录。绝不直接写到正式路径上。 - 每条记录都带上
checksum、is_complete标志和schema_version,每次加载都把三者都校验一遍。 - 至少保留 3 代版本(环形轮转);在两个更新的版本都校验通过前,绝不删除旧 checkpoint。
- 每次 schema 变更都写一个显式迁移函数;绝不自动加载版本不匹配的 checkpoint。
- 每个 checkpoint 路径强制单写入者(文件锁或数据库行锁),并记录写入者 PID 和序号。
- 用持久化后端:Redis 开
appendonly yes+appendfsync everysec/always,Postgres 开synchronous_commit = on,或 S3 开对象版本控制。作为权威数据源,别用内存或最终一致的存储。 - 绝不把 checkpoint 加载错误汇进「重新开始」兜底。要大声记录日志,并要求人工决策。
- 在 CI 里测损坏恢复(截断一个 checkpoint,验证回退路径有效)。
安全提示(LangGraph 自托管用户)
如果你用 LangGraph 加载 checkpoint,记得加固加载路径。Check Point Research 在 2026 年披露了一条利用链(CVE-2025-67644,SQLite checkpointer 的 SQL 注入;加上 CVE-2026-28277,不安全的 msgpack 反序列化):当攻击者能写入 checkpoint 存储、且应用对外暴露了 get_state_history() 时,可达到远程代码执行。截至 2026 年 6 月,修复版本为 langgraph >= 1.0.10、langgraph-checkpoint-sqlite >= 3.0.1、langgraph-checkpoint-redis >= 1.0.2。另外把 LANGGRAPH_STRICT_MSGPACK=true 打开(或给 JsonPlusSerializer 显式传一个 allowed_msgpack_modules 列表),让反序列化只重建一组已知安全的类型,而不是 blob 里出现的任意 Python 对象。详见 GitHub 安全公告。
常见问答 (FAQ)
Q:LangGraph 自带的序列化器是不是已经处理好 datetime、set、Decimal 了?
A:基本上是的。截至 langgraph-checkpoint 4.x(当前版本 4.1.1,2026 年 5 月),默认的 JsonPlusSerializer 用 ormsgpack 加上扩展 JSON 兜底,能正确往返 datetime、enum、set 以及 LangChain/LangGraph 的原生类型——它不是普通的 pickle。损坏通常出现在你自己节点代码手写 json.dumps 的地方,或者一次大的依赖升级之后。让 SqliteSaver/PostgresSaver 来负责序列化,在它之上加一层完整性 checksum,并在生产里把 LANGGRAPH_STRICT_MSGPACK=true 打开。
Q:Temporal 是不是自动保证 checkpoint 完整性?
A:是的,这是它的设计。Temporal 用事件溯源(event sourcing):workflow 的事件历史就是 checkpoint,存在持久数据库里(PostgreSQL、MySQL 或 Cassandra),写入是事务性的,所以半写会被回滚。风险从「损坏」转移到了「重放保真度」。改代码时用 Workflow.getVersion() / patched(),让旧历史仍能确定性重放(2025 年以前那套旧的「Worker Versioning」已在 2026 年 3 月从 Temporal Server 移除)。部署前用捕获的历史跑重放测试。
Q:能不能不加载就先检查一个损坏的 checkpoint?
A:能。python -m json.tool checkpoint.json 做基本有效性检查,jq '.' checkpoint.json 会报出第一个语法错误的精确字节偏移——这告诉你文件完好的部分有多少、值不值得人工看。对于 msgpack/二进制 blob,导出原始字节看 header,而不要让你的应用去反序列化不可信数据。
Q:20 个字段里只坏了 1 个,能只补那一个字段吗? A:不要。回滚到上一个完好版本。局部修补只修好了你看得见的损坏,往往漏掉同一事件引入的次生损坏(那次截断了第 5 个字段的 OOM,可能也丢了第 12 个字段)。回滚到一个 checksum 校验过的版本,比手动编辑安全得多。
Q:checkpoint 多大就该换存储方式? A:基于文件的 checkpoint 尽量控制在 1 MB 以内;超过就用数据库或对象存储。对于大型状态(生成的文件、很长的 LLM 历史),checkpoint 里只存引用(文件路径、S3 key),把 blob 存到外部。把小的「元数据 checkpoint」(每步都写)和大的「数据 checkpoint」(只在内容变化时写)拆开,能把每步的写入量从几十 MB 降到几 KB。