Agent 跑一半崩了,最难处理的不是那个报错,而是已经发生的副作用:邮件发出去了、订单建了一半、工单状态改了但附件没传。这类事故的处置成本与"能不能重跑"直接相关,而能不能重跑,是设计阶段就定死的事。
失败恢复不是加 try/except 再 retry(3)。它要在开工前回答三问:哪些步骤可重放、恢复到哪一点算一致、什么条件下把人叫进来。
1. 失败四形态:先判形态,再选动作
| 形态 | 判定信号 | 可恢复性 | 第一动作 |
|---|---|---|---|
| 工具调用失败 | HTTP 5xx/超时、限流码、连接重置 | 高(瞬时)/ 低(参数错) | 分类后决定是否重试 |
| 模型输出不可解析 | 非法 JSON、schema 缺字段 | 高 | 带错误信息重问一次 |
| 任务理解错 | 动作合法但目标偏了(改了不该改的对象) | 低 | 停手 → 回滚 → 澄清 |
| 陷入循环 | 动作序列重复、步数暴涨、目标未推进 | 中 | 中断 → 换策略或缩范围 |
前两种是执行层故障,模型没错;后两种是意图层故障,重试只会把错误执行得更彻底。四种混成一个重试策略,是最常见的架构缺陷。
2. 重试能修什么,不能修什么
| 故障 | 重试有效 | 理由 |
|---|---|---|
| 429 限流 | 是(指数退避 + 抖动) | 外部条件随时间变化 |
| 5xx / 网关抖动 | 是(限次) | 瞬时性 |
| 读超时(幂等读) | 是 | 无副作用 |
| 401/403 | 否 | 凭据或权限问题,重试只会重复被拒 |
| 400 参数非法 | 否 | 输入错,重试同错 |
| 输出不合 schema | 一次即可,且要带错误信息 | 属于"可纠正的生成偏差" |
| 任务理解错 | 否 | 重试放大副作用 |
# retry-policy.yaml —— 按错误类别分流,而不是统一 3 次
transient:
match: [429, 502, 503, 504, "ECONNRESET", "ETIMEDOUT"]
max_attempts: 3
backoff: exponential # base 1s, factor 2, jitter 0.3
on_exhaust: degrade
schema_violation:
max_attempts: 1 # 只给一次纠正机会,附校验错误
on_exhaust: lax_parse
semantic:
max_attempts: 0 # 不重试
on_detect: halt_and_handoff
max_attempts 按类别给:语义类错误配 0 次重试,这一条最省事也最有效。
3. 重试的前提是幂等:工具层要先答应这件事
"再执行一次不出副作用"不能靠模型自觉,只能靠工具契约。三种实现,成本递增:
| 方式 | 做法 | 前提 |
|---|---|---|
| 天然幂等 | 读操作、PUT 全量覆盖、按自然键 upsert |
语义本身允许 |
| 幂等键 | 客户端生成 key,服务端去重表 | 服务端可改 |
| 补偿事务 | 先记"待执行",成功后置"已完成",失败走冲正 | 有对账能力 |
# 幂等键 = 会话 + 工具 + 规范化参数,确保"重试=同一次操作"
def idem_key(session_id: str, tool: str, args: dict) -> str:
canon = json.dumps(args, sort_keys=True, ensure_ascii=False)
return f"{session_id}:{tool}:{hashlib.sha256(canon.encode()).hexdigest()[:16]}"
工具不幂等又没有幂等键,正确做法不是"重试小心点",而是在恢复设计里标成不可重放,只能前进。这个标记要写进工具注册表,让编排层读得到。
4. 检查点:哪些状态要落盘
| 状态 | 是否落盘 | 粒度 | 理由 |
|---|---|---|---|
| 任务目标与约束 | 是 | 每次修订 | 恢复后要靠它判断"还该不该继续" |
| 已完成步骤清单 | 是 | 每步 | 决定重放起点 |
| 工具调用的外部 id | 是 | 每次副作用后 | 回滚与对账的唯一凭据 |
| 中间推理文本 | 否(或摘要) | — | 可从产物重建,落盘只涨成本 |
| 大对象(文件、向量) | 存引用 | 每次 | 落盘存指针,不存本体 |
| 预算消耗计数 | 是 | 每步 | 硬止损依据 |
{
"run_id": "r-20261109-0412",
"goal": "把工单 #8821 的退款申请归档并通知客户",
"constraints": {"no_second_refund": true, "channel": "email"},
"done": [
{"step": 3, "tool": "refund.create", "ext_id": "RF-77120", "at": "04:12:31"},
{"step": 4, "tool": "file.upload", "ext_id": "obj-9f2c", "at": "04:12:44"}
],
"pending": ["notify.customer"],
"budget": {"tokens": 41200, "calls": 7, "usd": 0.31},
"checkpoint_seq": 5
}
粒度判据:恢复时愿意重做的那部分,不必落盘。 重做代价高的(外部调用、人工确认)必须落盘;秒级能重建的(拼提示词、重算摘要)不要落盘。
5. 回滚的三种做法与可行性前提
| 做法 | 机制 | 可行性前提 | 局限 |
|---|---|---|---|
| 撤销副作用 | 调反向接口(取消订单、删文件、发更正) | 每个副作用有反向操作 | 已发出的邮件撤不回 |
| 重放到检查点 | 从 seq N 重跑,跳过已完成步骤 | 步骤幂等 + 外部状态可查询 | 不可重放的工具卡死整个链路 |
| 人工修正后继续 | 人改状态、补数据,Agent 从下一检查点接 | 有人工入口 + 可写状态 | 依赖人的响应时间 |
选法看副作用类型:可逆走撤销,可重放走重放,两者都不行的只能人工修正。 三种都要预先留接口;事后补意味着状态模型重做。
6. 循环检测:三种信号,缺一都会漏
| 信号 | 采集 | 阈值(按业务核对) | 误报来源 |
|---|---|---|---|
| 步数上限 | 每步计数 | 超过预估步数 3 倍 | 任务本身长 |
| 重复动作 | 工具名 + 参数指纹 | 同指纹连续 3 次 | 合法轮询 |
| 目标漂移 | 当前动作与 goal 的语义距离 | 连续 2 步无关 | 探索性任务 |
def detect_loop(trace, goal_embed) -> str | None:
if trace.steps > trace.estimated_steps * 3:
return "step_budget"
prints = [s.fingerprint for s in trace.steps[-3:]]
if len(set(prints)) == 1:
return "repeat_action"
if trace.drift_streak >= 2:
return "goal_drift"
return None
只有步数上限不够:Agent 能用 40 步做 8 步的事,而不少循环在 6 步内就稳定重复。三个信号任一中招即中断并记录。
7. 降级路径:按代价从低到高
| 档次 | 动作 | 代价 | 何时用 |
|---|---|---|---|
| 1 | 缩任务范围(少做一步) | 结果不完整 | 非关键步骤失败 |
| 2 | 换策略(换工具、换分解方式) | 多花一次推理 | 工具不可用 |
| 3 | 换模型(强换弱/弱换强) | 质量或成本变化 | 能力不足或成本超限 |
| 4 | 转人工 | 人力 + 延迟 | 语义错误、不可逆副作用 |
降级顺序不能反:先缩范围、再换策略、最后换模型。换模型代价最高,行为变化不可预测,等于换个系统重新调。
8. 人工接管:交接物决定人的效率
{
"handoff": {
"reason": "semantic_mismatch",
"done": ["refund.create -> RF-77120"],
"pending": ["notify.customer"],
"needs_confirm": [
{"q": "客户要求退 300,系统算出 380,以哪个为准?", "options": ["300", "380"]}
],
"evidence": ["order.json#L14", "ticket-8821.md"],
"resume_hint": "确认金额后从 notify.customer 继续"
}
}
四件事缺一不可:做了什么、剩什么、要人决定什么、凭据在哪。 缺中间那条,接管就变成人从头读日志;缺 resume_hint,人修完只能重跑。
9. 失败的可观测:每步一条记录
| 字段 | 用途 | 缺失后果 |
|---|---|---|
| 步骤输入(提示摘要 + 参数) | 复现 | 只能靠猜 |
| 工具返回(状态码 + 错误体) | 分类 | 无法区分瞬时与永久 |
| 耗时 | 定位慢工具 | 超时归因不了 |
| 成本(tokens / 调用数) | 止损 | 预算超支后才发现 |
| 检查点 seq | 回滚定位 | 恢复时不知道从哪接 |
| 判定结果(成功/降级/放弃) | 统计失败率 | 优化无基线 |
一步一条、追加写。不要事后聚合:失败常在第 2 步埋下、第 9 步才炸,聚合会把因果链切掉。
10. 预算作为硬止损
预算不是成本报表,是停止条件。三道上限同时设,任一触发即中断:
budget:
tokens: 60000 # 单次运行
wall_clock_s: 180 # 超过即中断,不看进度
tool_calls: 25
usd: 0.50
on_breach: checkpoint_and_handoff # 不是静默失败
只设 token 会出现"便宜但跑不完"的运行;只设时长会出现"时间都花在刷提示"的运行。三者互为漏洞补丁。
11. 错误分类决定处置
| 分类 | 例子 | 处置 | 是否通知人 |
|---|---|---|---|
| 可重试 | 429、503、读超时 | 退避重试,限次 | 否 |
| 需换路 | 工具下线、schema 变更 | 降级换策略 | 累计 N 次后 |
| 需澄清 | 目标歧义、约束冲突 | 中断 + 提问 | 是 |
| 需放弃 | 权限不足、数据不存在 | 记录并退出 | 是(附证据) |
分类表要写进编排代码。写在文档里的分类,落地时会退化成"全部 catch 后重试三次"。
12. 排错
| 现象 | 常见原因 | 处理 |
|---|---|---|
| 重试后订单重复 | 不幂等且无幂等键 | 补 key 或标不可重放 |
| 恢复后重复发通知 | 检查点缺外部 id | 落盘 ext_id |
| 回滚误删正常数据 | 撤销按范围删而非按 id | 撤销须精确到外部 id |
| 长时间空转 | 只有步数上限 | 补指纹级检测 |
| 接管后仍重跑全流程 | 缺 resume_hint |
补齐四要素 |
| 成本突然翻倍 | 无预算上限、循环未中断 | 三道预算同时设 |
13. 边界
| 场景 | 判断 |
|---|---|
| 一次性问答(无状态无副作用) | 不要检查点与回滚,重试一次即可 |
| 纯查询(只读) | 只需超时与限流 |
| 极短任务(< 3 步) | 重跑成本低于恢复,直接重跑 |
| 副作用可逆且廉价 | 回滚优先,检查点可粗 |
| 需要人在场的交互任务 | 人作为流程节点,而非异常出口 |
最后一行是说:人工接管不是兜底方案,而是正常路径节点。人被设计进流程,恢复逻辑会简单一半。
自测
- 为什么"任务理解错"配 0 次重试,而"输出不合 schema"配 1 次?
- 幂等键为什么要把规范化后的参数算进哈希?不这么做会怎样?
- 检查点粒度的判据是什么?哪些状态明确不该落盘?
- 已发出的邮件撤不回,这类副作用该归到哪种回滚方式?
- 只有步数上限的循环检测会漏掉哪两类循环?
- 人工交接物缺
resume_hint会导致什么具体后果?