SA-G-02 checkpoint五种静默失效
Checkpoint 恢复了但状态不对:五种静默失效与它们的检测断言
本文定位:「编排与状态治理(SA-G)」簇第 2 篇。上一篇 【01《Agent 跑多少步就该管起来》】 给了三个能从日志算出来的迁移信号——那是动工前的闸门;本篇处理闸门之后的第一个坑:状态存下来了、恢复也成功了,但恢复出来的那个状态是错的。
上一篇:【01《Agent 跑多少步就该管起来》】 · 下一篇:【03《用有向图编排 Agent(上)》】
小提一句: 由于平台每日发布笔记数量限制,目前系列笔记还没全部上传。后续内容会持续更新,大家可以先点个关注、收藏,以免错过。 谢谢!
引子:一次「恢复成功」的排查,产出了错误的结论
设一条带时间窗的参照任务:verify 判定证据不足,回 gather 补一轮;中途撞上审批门,任务挂起 40 分钟(演示占位值)。恢复时日志只有一行 resume ok,报告照常产出,结论是「未发现异常变更,建议观察」。
问题是靠事后对账才看得出来的:list_events 那次调用带着的时间窗,是挂起之前算出来的绝对区间,恢复后没有重算;query_snapshot 查的是「最近 15 分钟」,而恢复那一刻距离快照已经过去 40 分钟。两个工具都成功返回,字段格式都对,只有内容属于另一个时间世界。
一、恢复成功和状态正确是一回事吗?
不是。恢复这条路径有四种出口,只有第二种才算真的对了,第四种虽然麻烦但至少是诚实的:
| 出口 | 系统是否报错 | 谁会发现 | 典型代价 | 检出手段 |
|---|---|---|---|---|
| 恢复失败 | 报错,异常栈可见 | 立刻,值守 | 任务中断,可重来 | 异常日志 |
| 恢复成功且状态正确 | 无 | 无人 | 无 | 无 |
| 恢复成功但状态错误 | 无 | 事后对账或调用方 | 产出看似正常的错误结论 | 恢复期断言 |
| 状态判定不可用,降级重跑 | 告警 | 值守 | 重复消耗,结果正确 | 断言 + 降级策略 |
第三行是本篇的全部内容。它和第一行的差别不在严重程度,而在可见性:恢复失败会叫,静默失效不会叫。一个不叫的故障,平均存活时间取决于对账频率,而不是观测灵敏度。
[算] 一条错误结论的期望存活时长 ≈ 对账周期 ÷ 2 + 从对账发现到人工介入的时长。对账周期是一周时,这个数是天级的;恢复失败是分钟级的。量级差就在这里——不是破坏力更大,是它活得久,久到结论已经被下游消费掉了。
快不快是分钟级的事,对不对是天级的事。这也是本篇不讨论「怎么让恢复更快」的原因。
把恢复流程画成固定链路,就能看出断言该挂在哪:写快照打版本标记,中断后读出,先迁移再过断言,通过才放行。
断言挂在哪条路径上,结果完全不同。挂在恢复路径上才查得到东西:写快照那一刻状态还新鲜,而工具改名、句柄过期、时间窗漂移这三类失效全部发生在挂起期间,写完立刻断言一条也查不到。
二、Schema 三原则怎么落到字段上?
三条原则说起来简单,落到字段上才有约束力:
- 可序列化:快照必须能进 JSON 或 msgpack 这类跨进程结构,不能含文件句柄、连接对象、协程对象。
- 版本化:任何字段增删都前进
schema_version,并配一个迁移函数。 - 不含不可重放的东西:句柄、租约 id、工具 endpoint 这类外部引用不能直接当值存,要存 id + 提交时指纹,恢复时重新探活。
下面这份定义是后文所有断言的操作对象。数据结构约定:快照是一个 dataclass,序列化成扁平 JSON 落盘,不做嵌套对象树;参数含义:as_of 表示这个时间窗是「截至哪个时刻」算出来的,submit_fp 是提交参数指纹;建模取舍:宁可多存一个哨兵字段,也不让默认值替你编一个看起来合理的值。
#!/usr/bin/env python3
# 数据结构约定:快照只存可重放的值,引用 / 句柄 / 相对时间一律换成
# id + as_of + 指纹三元组;as_of 是截取时刻的绝对时间戳,不是「最近 N 分钟」。
# 参数含义:version 用 major.minor.patch,加字段只前进 minor 并登记迁移函数;
# 迁移函数要声明能不能回滚,回滚栏写「否」的字段只能从增量日志重建。
# 建模取舍:缺字段不给默认值,用哨兵暴露出来——默认值会把「缺失」打扮成「有值」。
"""checkpoint schema —— 状态快照的结构定义。用法: 被 recovery 与断言库共同 import"""
from __future__ import annotations
import json, time, hashlib
from dataclasses import dataclass, field, asdict
from typing import Any
SCHEMA_VERSION = "1.4.0" # 语义化版本: 加必填字段前进 minor, 改字段语义前进 major
def _fp(obj: Any) -> str:
"""任意对象的稳定指纹: 先排序再哈希, 避免 dict 顺序造成假差异"""
return hashlib.sha256(json.dumps(obj, sort_keys=True, ensure_ascii=False).encode()).hexdigest()[:12]
@dataclass
class ToolRef:
"""工具引用: 只存 id 与参数 schema 指纹, 不存 endpoint(会漂移)"""
tool_id: str
args_schema_fp: str
@dataclass
class Window:
"""绝对时间窗: 相对窗口必须在写快照之前换算成绝对区间"""
start_ts: int
end_ts: int
as_of: int = 0 # 这个窗口是"截至哪个时刻"算出来的
@dataclass
class Sampling:
"""采样参数: 回放要能复现当时的输出, 就必须把这几个数存下来"""
model: str = "unknown"
temperature: float = -1.0
top_p: float = -1.0
seed: int = -1 # -1 是哨兵, 表示"当时没记", 不是"seed 是 -1"
@dataclass
class Handle:
"""外部句柄: 只存 id + 提交指纹 + 租约到期时间, 恢复时先探活"""
handle_id: str
submit_fp: str
lease_expires_ts: int = 0
@dataclass
class Checkpoint:
schema_version: str = SCHEMA_VERSION # 版本化原则
run_id: str = ""
step: int = -1
node: str = ""
retry_budget: int = -1 # 哨兵: 区分"缺失"与"真的是 3"
tool_refs: list = field(default_factory=list)
windows: dict = field(default_factory=dict) # node 名 -> Window 的 dict
sampling: dict = field(default_factory=dict)
handles: list = field(default_factory=list)
evidence: list = field(default_factory=list)
created_ts: int = field(default_factory=lambda: int(time.time()))
def to_json(self) -> str:
return json.dumps(asdict(self), ensure_ascii=False, sort_keys=True)
@classmethod
def from_json(cls, raw: str) -> "Checkpoint":
d = json.loads(raw)
return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
2.1 四个名词在本篇的位置
这几个词后文反复出现,先把含义钉死:
| 名词 | 一句话原理 | 在知识结构里的位置 | 本篇用到它的哪一面 |
|---|---|---|---|
| 快照 vs 增量 | 快照存"当前值",增量存"发生了什么",两者可互相还原 | 同族:状态外部化的两个落点 | checkpoint 是快照;迁移函数读增量日志补齐字段 |
| 事件溯源 | 状态不存结果,存一串不可变事件,状态由重放得出 | 上位:状态外部化;出处 Fowler | 解释"快照缺字段时为什么能靠日志补真值" |
| 幂等键 | 同一次逻辑操作携带同一个键,重复提交只生效一次 | 上位:幂等性;同族:去重表 | 句柄失效后重提任务时用它防双写 |
| 租约 | 持有者在租约期内独占资源,到期自动释放 | 上位:分布式锁;同族:心跳续约 | 只取过期语义:恢复时先确认租约还在不在自己手里 |
2.2 字段表
每个字段都得有必须存在的理由,说不出理由的字段就不该进快照——它只会增加迁移成本:
| 字段 | 类型 | 可否为空 | 引入版本 | 为什么必须存 |
|---|---|---|---|---|
schema_version | str | 否 | 1.0.0 | 没有它,迁移函数不知道该跑哪一段 |
run_id / step | str / int | 否 | 1.0.0 | 定位恢复到哪一步,也是时间旅行的入口参数 |
node | str | 否 | 1.0.0 | 断言报告要能指名是哪个节点的状态坏了 |
retry_budget | int | 否(缺失用 -1) | 1.3.0 | 恢复后凭空多出重试次数,预算守卫就失效了 |
tool_refs | list of ToolRef | 可空列表 | 1.1.0 | 工具改名与换 schema 是失效的第二大来源 |
windows | dict of Window | 否 | 1.2.0 | 相对时间窗是"时间敏感状态过期"的根源 |
sampling | dict | 否 | 1.4.0 | 回放对不上,多半是它没存 |
handles | list of Handle | 可空列表 | 1.2.0 | 句柄有生命周期,恢复时必须探活 |
evidence | list | 否 | 1.0.0 | 已取得的证据丢了会导致重复取证 |
created_ts | int | 否 | 1.0.0 | 时间新鲜度断言的分母 |
2.3 加字段时的两种写法
新增字段最省事的写法是给个默认值——那正是静默失效开始的地方:
# ❌ 错误: 新字段带默认值, 旧快照读出来是 3, 而不是它当时真实的剩余预算
@dataclass
class Checkpoint:
schema_version: str = "1.4.0"
retry_budget: int = 3
# ✅ 正确: 缺失用哨兵值暴露出来, 再用迁移函数从增量日志回填真实值
def mig_13_to_14(cp: dict, events: list) -> dict:
"""1.3.0 -> 1.4.0: 补 retry_budget 与 sampling, 真值只从增量日志取"""
budget = next((e["value"] for e in reversed(events) if e["k"] == "retry_budget"), None)
cp["retry_budget"] = budget if budget is not None else -1 # 取不到就标缺失
cp.setdefault("sampling", {"model": "unknown", "temperature": -1.0,
"top_p": -1.0, "seed": -1})
cp["schema_version"] = "1.4.0"
return cp
哨兵值 -1 的作用只有一个:把「缺失」和「真的是 3」区分开。断言库认这个哨兵,并在报告里把它翻译成人话。
三、五种静默失效各自长什么样?
五种失效按成因分层排:前两种是结构问题,中间两种是语义问题,最后一种是外部资源问题。
| 失效 | 触发条件 | 现象 | 成因 | 检测 | 规避 |
|---|---|---|---|---|---|
| 字段新增后旧快照缺值 | 新版本加字段后读旧快照 | 字段被默认值填充,看起来有值 | 默认值把"缺失"伪装成"有值" | 断言1:必填字段显式存在且非哨兵 | 哨兵值 + 迁移函数回填 |
| 工具引用失效 | 挂起期间工具改名或参数 schema 变更 | 调用报错,或调中同名但语义不同的工具 | 把引用当值存(存了 endpoint 或裸名字) | 断言2:tool_id 与 schema 指纹仍在注册表 | 只存 id + 指纹,不存地址 |
| 时间敏感状态过期 | 挂起时长超过窗口长度 | 查询成功,但查的是另一个时间窗 | 相对时间固化成绝对区间后未重算 | 断言3:now - as_of 超阈值即判过期 | 存绝对区间 + as_of,恢复时判新鲜度 |
| 采样参数与随机种子丢失 | 快照不含 sampling 四字段 | 回放结果与当时不一致,调试无法复现 | 采样参数在运行时配置里,不在状态里 | 断言4:四字段齐全且非哨兵 | sampling 作为状态字段随快照走 |
| 外部句柄失效 | 挂起期间句柄过期或被他人接管 | 404,或拿到别人任务的结果 | 句柄是有生命周期的外部资源 | 断言5:探活 + owner 比对 + 租约未到期 | 只存 id 与提交指纹,恢复时探活 |
版本兼容按下面这张矩阵排。关键约束是迁移函数必须声明能不能回滚,否则迁错一次就丢一批快照:
| 旧版本 | 新版本 | 迁移函数 | 是否可回滚 |
|---|---|---|---|
| 1.0.0 | 1.1.0 | mig_10_to_11(补 tool_refs) | 是,仅新增可空字段 |
| 1.1.0 | 1.2.0 | mig_11_to_12(补 windows、handles) | 是 |
| 1.2.0 | 1.3.0 | mig_12_to_13(补 retry_budget,填 -1) | 是 |
| 1.3.0 | 1.4.0 | mig_13_to_14(补 sampling,读增量日志) | 否,依赖增量日志是否还在 |
最后一行那个「否」是设计决定:1.4.0 的 sampling 真值只能从增量日志取,日志被清就回不去。所以增量日志的保留期要 ≥ 快照的保留期——这是事件溯源那条原则的直接推论,不是拍脑袋定的。
迁移函数本身有三条硬要求:链式执行(1.0 → 1.4 要能走 10→11→12→13→14,而不是只留 10→14)、幂等(同一个快照迁两次结果一致)、可重入(迁到一半崩了,重跑不吃掉已迁的部分)。缺了第二条,你会遇到「恢复一次字段对、恢复两次字段翻倍」这类怪事。
恢复期四个组件的分工如下,断言库是唯一被允许说「不」的地方:
四、每种失效的一条可执行断言怎么写?
断言库的形式是五条独立函数,每条返回一个结果对象,失败时带一个动作(拒绝 / 重算 / 降级 / 转人工),而不是只打一行日志。
| 断言 | 检查什么 | 失败时的动作 |
|---|---|---|
assert_required_fields_present | 必填字段在快照里显式存在,不是靠默认值补齐 | 拒绝恢复,转迁移流程 |
assert_tool_refs_valid | tool_id 与 args_schema_fp 都在当前注册表里 | 拒绝恢复,标记需人工确认 |
assert_time_window_fresh | now - as_of 在阈值内,且相对窗口已重算 | 重算窗口并记 warn,严重超期则拒绝 |
assert_sampling_present | sampling 四字段齐全且非哨兵值 | 用当前默认补齐并记 warn |
assert_handles_alive | 句柄探活存活,且 owner 是自己、租约未到期 | 丢弃句柄,按幂等键重提任务 |
4.1 五条断言的实现
跑之前先约定三件事。输入是 Checkpoint 的 dict 形态,外加一个「当前世界」上下文(当前时间、工具注册表、句柄探活函数);输出是结果列表,每项含 name / ok / reason / action;建模取舍是断言只做判定与建议动作,不做修复——修复逻辑放在调用方,这样断言可以单独测。
#!/usr/bin/env python3
# 数据结构约定:断言输入是恢复后的状态对象加恢复时刻的环境时间戳,
# 输出是 (断言名, 动作) 列表,动作只有 reject / recompute / degrade 三种。
# 参数含义:FRESH_TTL 管时间敏感状态,超过即判过期;工具引用与句柄各有一条存活检查。
# 建模取舍:只打日志的断言等于没写,所以每条断言必须绑定一个可执行的恢复动作。
# 已知偏差:五种失效的占比分布缺实测;时间窗用绝对区间,跨时区要统一成 UTC。
"""assertions.py —— checkpoint 恢复期静默失效断言库。用法: python assertions.py ckpt.json"""
import json, sys, time
from dataclasses import dataclass
FRESH_TTL = 600 # 时间窗新鲜度阈值(秒)。标定法: 取三路只读工具
# 结果保持可用的最小时长下界; 缺跨班次样本与跨任务集验证
REQUIRED = ["run_id", "step", "node", "retry_budget", "windows", "sampling", "created_ts"]
SENTINEL = -1 # 与 schema 里的哨兵值保持一致, 用来区分"缺失"与"真值"
@dataclass
class Result:
name: str
ok: bool
reason: str
action: str # none / reject / recompute / degrade / manual
def assert_required_fields_present(cp: dict) -> Result:
"""失效1: 必填字段缺失, 或被默认值/哨兵伪装成有值。逐字段看是否显式出现"""
miss = [f for f in REQUIRED if f not in cp or cp.get(f) in (None, "", SENTINEL, {})]
if miss:
return Result("required_fields", False, "缺失或被哨兵占位: " + ",".join(miss), "reject")
return Result("required_fields", True, "%d/%d 必填字段显式存在" % (len(REQUIRED), len(REQUIRED)), "none")
def assert_tool_refs_valid(cp: dict, registry: dict) -> Result:
"""失效2: 工具引用失效。比对 tool_id 与参数 schema 指纹, 只认指纹不认名字"""
refs = cp.get("tool_refs", [])
bad = [r.get("tool_id", "?") for r in refs
if r.get("tool_id") not in registry
or registry.get(r.get("tool_id")) != r.get("args_schema_fp")]
if bad:
return Result("tool_refs", False, "工具已变更: " + ",".join(bad), "manual")
return Result("tool_refs", True, "%d/%d 工具引用命中" % (len(refs), len(refs)), "none")
def assert_time_window_fresh(cp: dict, now: int) -> Result:
"""失效3: 时间敏感状态过期。逐节点看 as_of 与当前时间的差, 只报超阈值的"""
stale = []
for node, w in cp.get("windows", {}).items():
gap = now - int(w.get("as_of", 0))
if gap > FRESH_TTL:
stale.append("%s(+%ds)" % (node, gap))
if stale:
return Result("time_window", False, "窗口已过期: " + " ".join(stale), "recompute")
return Result("time_window", True, "全部窗口在 %ds 内" % FRESH_TTL, "none")
def assert_sampling_present(cp: dict) -> Result:
"""失效4: 采样参数丢失。四个字段缺一个, 回放就复现不了当时的输出"""
s = cp.get("sampling", {}) or {}
miss = [k for k in ("model", "temperature", "top_p", "seed")
if k not in s or s.get(k) in (None, "", SENTINEL)]
if miss:
return Result("sampling", False, "采样参数缺失: " + ",".join(miss), "degrade")
return Result("sampling", True, "model=%s seed=%s" % (s.get("model"), s.get("seed")), "none")
def assert_handles_alive(cp: dict, probe) -> Result:
"""失效5: 外部句柄失效。状态不是 RUNNING / owner 不是自己 / 租约到期, 任一即失效"""
dead = []
for h in cp.get("handles", []):
st = probe(h.get("handle_id")) # probe: id -> {"status":..., "owner":...}
expired = int(h.get("lease_expires_ts", 0)) < int(time.time())
if st.get("status") != "RUNNING" or st.get("owner") != cp.get("run_id") or expired:
dead.append(h.get("handle_id", "?"))
if dead:
return Result("handles", False, "句柄不可用: " + ",".join(dead), "degrade")
return Result("handles", True, "%d/%d 句柄存活" % (len(cp.get("handles", [])), len(cp.get("handles", []))), "none")
def run_all(cp: dict, ctx: dict) -> list:
"""按序跑五条断言。不短路: 一次报全, 便于事后统计各类失效的占比"""
return [assert_required_fields_present(cp),
assert_tool_refs_valid(cp, ctx["registry"]),
assert_time_window_fresh(cp, ctx["now"]),
assert_sampling_present(cp),
assert_handles_alive(cp, ctx["probe"])]
if __name__ == "__main__":
cp = json.load(open(sys.argv[1], encoding="utf-8"))
ctx = {"now": int(time.time()),
"registry": json.load(open("registry.json", encoding="utf-8")),
"probe": lambda hid: {"status": "RUNNING", "owner": cp.get("run_id")}}
bad = 0
for r in run_all(cp, ctx):
print("%-4s %-22s %-44s %s" % ("PASS" if r.ok else "FAIL", r.name, r.reason, r.action))
bad += 0 if r.ok else 1
print("结论:", "拒绝恢复,转人工确认" if bad >= 2 else ("放行并记 warn" if bad == 1 else "放行"))
预期输出(跑 python assertions.py ckpt-0731.json,数字为注入后的样例):
FAIL required_fields 缺失或被哨兵占位: retry_budget reject
FAIL time_window 窗口已过期: query_snapshot(+2413s) recompute
FAIL handles 句柄不可用: t-8821 degrade
PASS tool_refs 3/3 工具引用命中 none
PASS sampling model=ops-mini seed=20261019 none
结论: 拒绝恢复,转人工确认
脚本说明
Result:四个字段里action是关键——它把「断言失败」翻译成「下一步做什么」,调用方不需要自己解释失败原因。assert_required_fields_present:把SENTINEL、None、空串一起当成缺失,这样 2.3 节那个-1不会被当成真值放过。assert_tool_refs_valid:比的是args_schema_fp而不是工具名,工具改名但参数 schema 没变时仍可放行。assert_time_window_fresh:逐节点看as_of,只报超阈值的节点,方便定位是哪一步的证据过期。assert_handles_alive:三个条件取或——状态不是 RUNNING、owner 不是自己、租约已到期;探活本身超时时应记为「未知」,不要直接判失效。run_all:不短路,一次把五条结果报全,统计各类失效占比时才有分母。
五、时间旅行调试怎么操作?
断言报告告诉你「哪一类失效」,时间旅行调试告诉你「从哪一步开始错的」。操作只有四步,不涉及原理:
- 定位:从断言报告或运行记录里拿到
run_id+step,取出对应的快照文件与增量日志区间。 - 回放:以该快照为起点重跑,固定
sampling里的 seed,工具按当时的返回值 mock。 - 改状态:要验证假设时改的是恢复前的状态对象(比如把
retry_budget从 -1 改成 3),不是改代码。 - 复跑:从改后的状态继续跑,把两条轨迹按 step 对齐做 diff,看差异从哪一步开始出现。
第 2 步的 mock 必须分三层(成功 / 边界 / 失败),只有「成功」这一层的话,回放永远跑不进边界分支,你会得出「改对了」的错觉。
数据在这条链路上流转的形状如下——断言结果会写回失效标记,成为调试的入口:
5.1 两个待补数字怎么测
- 静默失效率:故障注入器按五类各注入一次(删字段 / 改工具 id / 把时钟前推 40 分钟 / 清空 sampling / 吊销句柄),每类跑 N 次恢复,统计「恢复无异常但最终产物与基准产物不一致」的比例。
- 加断言前后的差:同一批注入样本跑两遍(断言关闭 / 断言开启),比两个量——检出率(被拦下的比例)与误报率(断言报错但状态其实正确的比例)。
- 误报率高于检出率时:说明阈值(如
FRESH_TTL)标定错了,先重标阈值再谈覆盖率。
六、什么时候不该自建存储?
| 情况 | 判据 | 替代做法 |
|---|---|---|
| 单机单进程、任务几分钟内结束 | 无跨进程恢复需求 | 用框架自带 checkpointer,别自己写落盘 |
| 需要跨天或跨实例恢复 | 挂起时长 > 单进程寿命 | 用持久化执行引擎,不是扩展自己的表 |
| 需要审计与合规留痕 | 要求每次状态变更可追溯 | 事件溯源 + 只追加日志,别自造快照格式 |
| 状态里含不可序列化的对象 | 有连接 / 文件句柄 / 协程对象 | 先改状态设计,不是换存储 |
四种情况的共同点是:问题不在存储层。第二种最常被误判成「我要一个更强的数据库」,实际需要的是重放语义与确定性,那是另一类系统的活。
真要自己写时,要先注意:写放大(每步一落 vs 节点边界落,差一个数量级)、迁移成本(每次加字段要改多少个历史快照)、排查成本(快照不可读时,你能不能靠增量日志重建)。[算] 写放大倍数 = 单任务步数 ÷ 落盘点数,参照任务在 max_steps=12 下按步落是 12 倍,按节点边界落只有 7 个节点。这笔账算不过来就别自己造。
回退与降级路径要提前写死:动作是 reject 就回放并转人工,recompute 就重算窗口后继续,degrade 就丢弃该部分状态(句柄、采样参数)并按默认继续,同时落一条 warn。不要让断言失败直接落到「照常继续」——那等于没写断言。
七、小结
答案落在一条区分上:resume ok 只证明读盘成功,不证明状态正确;能把五种静默失效挡住的,是恢复期五条带动作的断言。
resume ok 这行返回值只说明读盘成功,不说明读出来的状态对。代价能算:一条错误结论的期望存活时长是对账周期的一半加上人工介入的时长,对账周期一周时是天级,恢复失败是分钟级。贵的不是破坏力,是它活得久——久到结论已经被下游消费完了。
所以这条路径上能替你说话的只有恢复期的断言,而且每条断言必须带动作:reject 就回放并转人工,recompute 就重算窗口,degrade 就丢弃那部分状态再落一条 warn。只打日志的断言等于没写。
状态里只存能重放的值,引用、句柄、相对时间一律转成 id + as_of + 指纹,endpoint 与裸工具名不进快照。加字段时前进 minor 并配迁移函数,迁移函数要声明能不能回滚——1.4.0 那次回滚栏写的是「否」,因为 sampling 的真值只能从增量日志取。哨兵值也别省,默认值会把「缺失」打扮成「有值」,这是五种失效里唯一能在写代码那一刻掐掉的一类。
先做这一步:把五条断言接到你的恢复入口,用 5.1 节的注入器跑一遍,把 [实测] 填成真数字。填完会撞上一个不太好答的问题——你的断言覆盖了几种失效? 只覆盖「字段缺失」的话,剩下四类里至少有三类正在你的系统里安静地发生。
下一篇:【03《用有向图编排 Agent(上)》】,它给出条件分支、显式循环与并行三种控制流模板、节点契约的六个字段,以及并行汇聚的三种策略。
参考
- LangGraph Persistence(支撑第一节 checkpoint 能存什么、恢复到哪一步)
- LangGraph Time travel(支撑第五节时间旅行调试的四步操作)
- LangGraph 官方文档(支撑第六节「用框架自带 checkpointer」的对照物)
更多推荐
所有评论(0)