Checkpoint 恢复出来的状态是损坏的

Agent 从 checkpoint 恢复后状态字段缺失、类型错乱或值异常。本文教你定位损坏、回滚到完好的历史版本,并写出真正抗崩溃的 checkpoint。

服务器重启后,你让一个长跑的 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_versionis_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.jsonjq '.' checkpoint.jsonjq 会报出语法错误的精确字节偏移,告诉你完好的部分有多少。如果文件结束在字符串中间、数组中间,或者本该有数据的地方是 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 类型转掉了

状态里有 datetimeDecimalsetnumpy 数组。一个普通的 json.dumps(..., default=str) 会把 datetime(2026, 5, 25) 变成 "2026-05-25 00:00:00"。加载回来是个 str 而非 datetime.isoformat() 返回的字符串形状不对,.date() 直接抛 AttributeErrorset 在原生 json 下根本序列化不了。

怎么判断:把每个字段在序列化前和反序列化后的 Python 类型对一对。任何变化(datetime -> strDecimal -> floatset -> list 或报错)就是静默转换。LangGraph 自带的序列化器能避开这个问题(见 FAQ),但前提是让它来序列化——你自己节点代码里手写的 json.dumps 不会。

6. 存储后端返回了陈旧或非持久的数据

没开 AOF 持久化的 Redis 重启就丢 checkpoint;S3 在覆盖写后的 read-after-write 可能短暂返回旧版本;没开 fsync/synchronous_commit 的数据库崩溃时会丢掉最后几次写入。

怎么判断:检查后端的持久化设置。自建 Redis 要确认 appendonly yes,且 appendfsynceverysecalways。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"

怎么确认已经修好了

  1. 类型往返检查:序列化一个含 datetimeDecimalset 的状态,加载回来,断言每个字段的 type() 没变。
  2. 写入途中崩溃测试:开始保存,在写入中途 kill -9 进程,重启,确认加载器要么读到上一个完整 checkpoint,要么直接拒绝加载——绝不静默使用半截文件。同时检查没有残留的 .tmp 文件。
  3. 断电模拟(如果条件允许):在虚拟机里写一个 checkpoint 然后硬重启主机。重启后最新的完整 checkpoint 仍能加载;这正是 Step 2 里那次目录 fsync 换来的保障。
  4. checksum 守门:把某个已保存 checkpoint 的 state blob 里改动一个字节,确认 load_checkpoint_safe 抛出 CorruptedCheckpointError,而不是返回被篡改的数据。

预防建议

  • 抗崩溃地写 checkpoint:同目录临时文件 -> fsync 文件 -> os.replace -> fsync 父目录。绝不直接写到正式路径上。
  • 每条记录都带上 checksumis_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.10langgraph-checkpoint-sqlite >= 3.0.1langgraph-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 月),默认的 JsonPlusSerializerormsgpack 加上扩展 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。

相关阅读

标签: #AI 编程 #Agents #排查