2911 字
约 9 分钟
19
S21-A08 · Agent 失败恢复:重试、回滚与人工接管

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 步) 重跑成本低于恢复,直接重跑
副作用可逆且廉价 回滚优先,检查点可粗
需要人在场的交互任务 人作为流程节点,而非异常出口

最后一行是说:人工接管不是兜底方案,而是正常路径节点。人被设计进流程,恢复逻辑会简单一半。


自测

  1. 为什么"任务理解错"配 0 次重试,而"输出不合 schema"配 1 次?
  2. 幂等键为什么要把规范化后的参数算进哈希?不这么做会怎样?
  3. 检查点粒度的判据是什么?哪些状态明确不该落盘?
  4. 已发出的邮件撤不回,这类副作用该归到哪种回滚方式?
  5. 只有步数上限的循环检测会漏掉哪两类循环?
  6. 人工交接物缺 resume_hint 会导致什么具体后果?
S21-A08 · Agent 失败恢复:重试、回滚与人工接管
https://www.clxhxhhr.top/posts/6198/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。