Agent 工具调用失败恢复:超时、幂等与补偿
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 次请求。两个原则:
- 传输层重试只对幂等操作开启,且对上层透明——上层只看到最终结果,不要看到 3 次中间失败。
- 模型层重试必须有硬预算:按 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 永久卡死。
下游不支持幂等键很常见,准备三级退化方案:
- 透传业务唯一键:把订单号、request_id 塞进下游已有的唯一约束。
- 查询前置:先按业务键查一次,存在就直接返回;查询与写入之间有竞态,必须靠下游唯一索引兜底。
- 两阶段:预留(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 查日志。
落地建议
- 统一错误分类器:把
fatal / retry / unknown三分类做成所有工具共用的中间件,禁止各工具自己写try/except + retry。 - 框架生成幂等键:key 由
run_id + step_index + tool + canonical_args计算,对模型不可见;写操作工具强制走幂等表,读操作直接放行。 - 两层重试只留一层:关掉 SDK 的自动重试,把重试决策集中到执行器,并设 run 级总次数、总耗时预算和按工具的熔断阈值。
- 补偿登记表 + 独立 worker:写操作成功即登记补偿动作,失败进死信并告警,别在 Agent 循环里同步回滚。
- span 粒度做到 attempt:重试不覆盖、异步补偿用 link。先解决“能定位到哪一步”,再补指标和看板。
- 点赞
- 收藏
- 关注作者
评论(0)