Agent 工具调用失败恢复:超时、幂等与补偿

举报
yd_224582562 发表于 2026/10/30 09:04:58 2026/10/30
【摘要】 工具调用的失败恢复不能靠统一重试。本文按链路拆解失败点,给出错误三分类、框架侧幂等键、补偿登记表与 attempt 级 trace 的具体做法,解决重试风暴、重复下单与失败不可定位。

Agent 上生产后最常见的翻车不是模型答错,而是一次下游超时被重试三遍、用户收到三笔扣款;或者订单已经创建成功但响应丢了,Agent 以为自己失败又下了一单。根因几乎都不在模型,而在工具调用层缺少失败分类、幂等和可观测设计。

先把链路摊开:失败点比你写的 except 多

一次 tool call 的完整路径是:模型输出 tool_call → 框架解析与参数校验 → 鉴权与配额 → 业务网关 → 下游服务 → 结果序列化回填模型 → 模型下一轮推理。任何一环断开,表现都可能是“Agent 卡住了”,但处理方式完全不同。

失败点 典型表现 能否重试 处理策略
输出非法 JSON 或缺必填字段 解析异常 否 回填校验错误原文让模型修正,设硬上限
参数语义错误 下游 400 否 终止该分支,不重试
鉴权失败 401 / 403 否 终止并告警,重试只会增加错误日志
限流 429 是 按 Retry-After 或指数退避
连接 / 读超时 timeout 视幂等性 读可重试,写必须靠幂等键
下游 5xx 500 / 502 / 503 是 有限重试 + 熔断
已执行但响应丢失 客户端超时、连接重置 危险 唯一解法是幂等,重试解决不了
结果回填失败 上下文超限、400 否 截断成摘要后重填

判断能否重试的标准只有一个:这个操作重放一次,副作用是否相同。不是看错误码像不像临时故障。504 和连接重置都可能意味着下游已经扣了钱,400 也可能已经写了一半数据。

重试要有预算,且只能由一层决定

重试风暴几乎都来自两层重试的乘积:SDK 或网关自动重试 3 次,模型看到失败又发起 3 次,下游实际承受 9 次请求。两个原则:

  1. 传输层重试只对幂等操作开启,且对上层透明——上层只看到最终结果,不要看到 3 次中间失败。
  2. 模型层重试必须有硬预算:按 run 维度限制总尝试次数、总耗时、总 token 花费,超了就终止,并把已完成的步骤交代清楚。

错误分类建议收敛成一个函数,所有工具共用:

RETRYABLE_STATUS = {429, 500, 502, 503, 504}

def classify(err) -> str:
    if isinstance(err, (ParamError, AuthError, NotFoundError)):
        return "fatal"                 # 重试没有意义
    if getattr(err, "status", None) in RETRYABLE_STATUS:
        return "retry"
    if isinstance(err, TimeoutError) and ctx.is_idempotent:
        return "retry"
    if isinstance(err, TimeoutError):
        return "unknown"               # 结果未知,必须先查
    return "fatal"

unknown 是最容易被忽略的一类:请求发出去了,结果不知道。它不能重试,也不能当失败处理,只能去查。

回填给模型的错误文本要短且可行动,比如「库存服务超时,已重试 2 次仍失败,请改用 query_stock 确认后再决定」。塞 5KB 堆栈进去,结果是模型拿着同样的参数再试一次。

还要按 tool_name 加熔断:某工具 1 分钟内失败率超过 50% 就直接短路,返回「该工具暂不可用」。否则模型会在一个已经挂掉的下游上反复消耗 token。

幂等键不能交给模型生成

最常见的错误设计是让模型在参数里填 idempotency_key。模型输出是有抖动的:同样的意图,参数可能多一个空格、时间精度不同、金额表示不同,生成的 key 每次都不一样,幂等形同虚设。

正确做法是由执行框架生成,并把“重试”和“用户真的想再做一次”区分开:

def idem_key(run_id: str, step_index: int, tool: str, args: dict) -> str:
    payload = f"{run_id}:{step_index}:{tool}:{canonical_json(args)}"
    return hashlib.sha256(payload.encode()).hexdigest()
  • run_id:一次 Agent 循环。
  • step_index:模型第几次推理产生的调用。模型自己重试同一步骤时 step_index 不变,命中幂等;用户重新发一句话是新 run,会正常执行。
  • canonical_json:键排序、去空白、数字统一精度。

这里有个边界要提前想清楚:用户手动重发消息(比如网络卡了点了两次)会生成新的 run_id,从而重复下单。业务上需要额外的会话级去重键,或者在「创建订单」这类工具上要求人工确认。

幂等表结构建议:

create table tool_idempotency (
  idem_key     varchar(128) primary key,
  tool_name    varchar(64)  not null,
  status       varchar(16)  not null,   -- pending / succeeded / failed
  response     json,
  created_at   timestamp    not null,
  expire_at    timestamp    not null
);

执行顺序是「先抢占、再执行、后写结果」:INSERT ... ON CONFLICT DO NOTHING 抢占成功才真正调用下游,否则轮询已有结果。pending 必须有超时(比如 30 秒),进程崩溃后要能被回收,否则这条 key 永久卡死。

下游不支持幂等键很常见,准备三级退化方案:

  1. 透传业务唯一键:把订单号、request_id 塞进下游已有的唯一约束。
  2. 查询前置:先按业务键查一次,存在就直接返回;查询与写入之间有竞态,必须靠下游唯一索引兜底。
  3. 两阶段:预留(pending 记录)→ 执行 → 确认。中间失败就留一条可对账的悬挂记录,比静默重复安全。

部分成功:Agent 的多步调用就是分布式事务

工具 A 扣库存、工具 B 建订单、工具 C 发通知,B 失败时 A 已经生效。指望模型自己回滚不现实——它不知道自己做了什么,也无法保证回滚成功。

正确姿势是把补偿从主链路剥离:每次成功的写操作登记一条补偿记录,由独立 worker 消费。

COMPENSATIONS = {
    "reserve_stock": "release_stock",
    "create_order":  "cancel_order",
    "charge":        "refund",
}

补偿本身也会失败:退款接口挂了、库存服务超时。补偿失败进死信队列 + 人工工单,不要无限重试。

按操作性质分档,比一刀切地写回滚逻辑更实用:

操作类型 例子 设计
纯读 查天气、查汇率 直接重试,无需幂等
可逆写 扣库存、建草稿 幂等键 + 补偿动作
不可逆写 发短信、发券、扣款 两阶段(预留-确认-取消),或排到链路最后
极高价值 转账、注销账号 暂停 Agent,转人工确认

不可逆操作的关键是「尽量往后放」:让所有可能失败的查询和校验先跑完,把真正产生副作用的一步压到链路末尾。这不能消灭失败,但能显著压缩需要补偿的窗口。

Trace:先确定停在哪一步,再谈为什么

排障时最先要回答的不是“为什么失败”,而是“停在哪了”。ID 体系至少四层,且全部透传到下游 header:

  • trace_id:一次用户请求
  • run_id:一次 Agent 循环
  • step_id:一次模型推理
  • tool_call_id:一次工具调用(父 span)
  • attempt:每一次实际请求(子 span)

关键细节:重试必须单独记 span,不能覆盖上一条。默认只记录最后一次结果,你会看到“成功”,而前面 3 次超时——那恰恰是重试风暴的证据。

一个 tool call 的 span 至少包含:

{
  "trace_id": "t-9f2c", "run_id": "r-118", "step_id": 4,
  "tool_call_id": "tc-77", "attempt": 2,
  "tool_name": "create_order",
  "args_hash": "b3d1...", "args_digest": "sku=A12 qty=1 amount=199.00",
  "idem_key": "9a0c...", "idem_hit": false,
  "http_status": 504, "latency_ms": 5003,
  "result": "timeout", "compensation_registered": true
}

参数只记摘要和 hash,敏感字段脱敏;完整参数放单独的加密存储,用 args_hash 关联。异步补偿的 span 用 link 关联回原 trace,否则三个月后没法回答“这笔退款是哪次对话触发的”。

指标上,除了常规的成功率和 P99,额外盯两个派生指标:

  • 重试放大比 = 实际请求数 / 逻辑调用数。超过 1.5 说明下游不稳或退避策略失效。
  • 幂等命中率 = 命中已有 key 的次数 / 总调用。异常升高说明上游在重复触发。

排障顺序固定为:run 停在哪一步(最后一个成功的 tool_call_id)→ 该 step 的 attempt 序列 → 对应 idem_key 的幂等表记录 → 下游侧按 request_id 查日志。

落地建议

  1. 统一错误分类器:把 fatal / retry / unknown 三分类做成所有工具共用的中间件,禁止各工具自己写 try/except + retry。
  2. 框架生成幂等键:key 由 run_id + step_index + tool + canonical_args 计算,对模型不可见;写操作工具强制走幂等表,读操作直接放行。
  3. 两层重试只留一层:关掉 SDK 的自动重试,把重试决策集中到执行器,并设 run 级总次数、总耗时预算和按工具的熔断阈值。
  4. 补偿登记表 + 独立 worker:写操作成功即登记补偿动作,失败进死信并告警,别在 Agent 循环里同步回滚。
  5. span 粒度做到 attempt:重试不覆盖、异步补偿用 link。先解决“能定位到哪一步”,再补指标和看板。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。