欢迎光临
我们一直在努力

Agent 工具调用失败之后:重试、幂等与超时的工程做法

说明:本文讨论的是 Agent 调用外部工具失败后的处理,属于工程架构话题,不涉及具体模型版本与价格。文中代码为结构示意,未在某个具体项目里完整跑通,请按自己的技术栈调整后再上生产。

一、先接受一个事实:工具调用一定会失败

很多人做 Agent 的第一个版本,心态是"调用工具,拿到结果"。真上生产之后才发现,这是个概率游戏。

Demo 阶段之所以感觉很顺,是因为两件事被掩盖了:一是调用次数少,一次对话里可能就调一两次工具;二是你在旁边盯着,看到不对就重跑。这两件事在线上都不成立——任务变长了,人也不在。

原因也很直白:你的 Agent 调用的不是本地函数,而是网络对面的一整个服务。中间夹着 DNS 解析、TCP 握手、TLS、网关限流、API 配额、下游数据库连接池。任何一层抖一下,你拿到的就是异常。

更麻烦的是调用次数。一个稍微复杂点的任务,Agent 调 20 次工具很正常——查资料、读写文件、调接口、再校验。这时候单次成功率会被迅速放大成整体成功率:

单次调用成功率调用 20 次后的整体成功率
99.9% 约 98.0%
99% 约 81.8%
98% 约 66.8%
95% 约 35.8%

这张表值得停下来看一眼。单次 99% 已经是很健康的接口了,但 20 次串下来只剩八成。如果你的 Agent 需要调 50 次工具,99% 的单次成功率意味着一半以上的任务会中途失败。

还有个更隐蔽的问题:失败不总是以异常的形式出现。

调用超时是一种失败,返回了空列表也是一种失败,返回了三个月前的缓存数据同样是失败。如果你的失败处理只挂在 except 分支上,后面这几类你根本看不见——它们会安静地流进模型的上下文,变成一段看似正常的输入,然后模型基于它给出一个错误的结论。

所以第一件事不是写重试,而是把"失败"这件事的定义扩大:凡是让后续推理建立在错误前提上的返回,都算失败,哪怕它 HTTP 200。

结论是:**在传统程序里,异常处理是可选项;在 Agent 里,失败处理是主流程本身。**不做这件事,Agent 的可用性天花板就被这个乘法锁死了。

二、先分类,再谈处理

新手最容易犯的错,是把所有失败都塞进一个 except 里,然后统一重试三遍。这样做的后果是:该重试的没重试够,不该重试的反而被重复执行了三遍。

失败至少有四类,处置方式完全不同:

类别典型表象能否重试处置方向
瞬时故障 网络抖动、限流 429、下游 503 能 退避后重试
语义错误 参数非法 400、无权限 401/403、工具不存在 不能 修输入或修权限
业务否定 对象不存在 404、状态冲突 409、余额不足 不必 当成正常结果交给模型
不确定结果 超时、连接中断 看幂等性 先确认,再决定

第三类是很多人没意识到的:404 不是错误,是答案。

“这个用户不存在"和"接口挂了"是两件完全不同的事。前者应该原样告诉模型,让它决定下一步——换个查询条件,或者直接回复用户;后者才需要重试。把这两样混在一起处理,会得到一种很奇怪的 Agent:明明查的是不存在的记录,它却在那重试三次,然后报一个"工具调用失败"给用户。用户看到的信息是"系统故障”,真实情况是"你查的那个人真没有"。

第二类也常被误判。参数非法、权限不足这类错误,重试一万次结果都一样。它们需要的是修改调用方式:把模型生成的参数修正回来,或者去补一个权限。把这类错误丢进重试循环,只是把一次立即失败变成了一次延迟失败,顺带浪费掉几十秒。

分类这件事还有一个容易被忽略的要求:要基于协议层的信息判断,而不是解析错误文本。

很多接口在业务错误时也返回 HTTP 200,把错误码塞在响应体的某个字段里;也有的接口在限流时返回的正文是一段 HTML 而不是 JSON。靠 if "error" in text 这类字符串匹配来分类,迟早会误判。可行的做法是让每个工具适配层负责把失败规范化成统一结构,上层只认这个结构:

一个最小可用的分类函数长这样:

⚠️ 代码待验证

class ToolErrorKind:
TRANSIENT = "transient" # 可重试:限流、抖动、5xx
SEMANTIC = "semantic" # 不可重试:参数错、权限错
NEGATIVE = "negative" # 不是错:404、409
UNKNOWN = "unknown" # 结果不确定:超时

def classify(exc) –> str:
status = getattr(exc, "status", None)
if status in (429, 500, 502, 503, 504):
return ToolErrorKind.TRANSIENT
if status in (400, 401, 403, 422):
return ToolErrorKind.SEMANTIC
if status in (404, 409):
return ToolErrorKind.NEGATIVE
if isinstance(exc, TimeoutError):
return ToolErrorKind.UNKNOWN
return ToolErrorKind.UNKNOWN

关键不在于这段代码写得多好,而在于分类这一步必须先做。分类做完,后面三章才有意义。

分类结果怎么用,也是个需要提前想清楚的问题。有两条路:一是上层根据分类直接决定动作——瞬时故障就退避重试、语义错误就回炉重写参数、业务否定就原样传递;二是把分类结果当作元信息一起交给模型,让它自己判断。

生产上通常是混合的,分界线大致是:涉及资源消耗和外部副作用的动作,由代码决定;涉及语义理解和用户意图的动作,交给模型。"要不要重试"属于前者——它消耗配额、可能重复执行,不该让一个概率模型来决定。"业务否定之后该跟用户说什么"属于后者,那恰恰是模型擅长的事。

三、重试:只对"确定没产生副作用"的失败重试

重试的第一个判断标准不是"错了几次",而是这次失败有没有可能已经改动了远端状态。

原则很硬:只对确定无副作用的调用重试。

可以放心重试的:读接口、查询、列表、搜索——这些调多少次结果都一样(前提是数据没变)。

要谨慎的:写接口、发消息、扣款、创建订单、执行命令——一次超时不代表对方没执行。你重试一次,可能就扣了两次钱。

这里有个特别反直觉的地方:**越是"看起来没成功"的失败,越可能是已经执行了。**连接被重置、读超时、收到 504——这些都发生在服务端已经开始处理之后。真正安全可重试的,反而是明确的 429 和 503,因为它们通常意味着请求还没进入业务逻辑。

第二类不该重试的是语义错误,前面说过了。第三类是重试会放大问题的情况:如果下游已经过载,你的重试会在它最脆弱的时候继续加压。这就是重试风暴——一个服务变慢,所有上游开始重试,流量翻倍,服务彻底崩掉。

重试的第二个关键是退避方式。固定间隔重试在故障时会造成"惊群":你的所有任务在同一秒一起重试,把一个本来就喘不过气的下游彻底压死。

退避策略形态适用
固定间隔 1s / 1s / 1s 基本不用,故障时会叠加压力
指数退避 1s / 2s / 4s / 8s 通用做法
指数退避 + 抖动 1s±随机 / 2s±随机 / 4s±随机 推荐,避免同步重试
时间预算制 在 N 秒内尽可能重试 有明确响应时限的 Agent

抖动的作用是把"同时重试"打散成"错开重试"。它便宜到几乎没有理由不做:

⚠️ 代码待验证

import random

def backoff_delay(attempt: int, base: float = 1.0, cap: float = 30.0) –> float:
raw = min(cap, base * (2 ** attempt))
return raw * (0.5 + random.random() * 0.5) # 落在 50%~100% 之间

还有一点常被忽略:给重试设时间预算,而不是次数上限。

"最多重试 3 次"听起来安全,但如果每次超时是 30 秒、退避又是指数增长,3 次下来可能已经耗掉两分钟。用户早就不等了。更合理的是给整条工具调用链一个总预算(比如 45 秒),预算用完就放弃并进入降级,而不是机械地数次数。

次数上限还有个副作用:它让"重试"变成了一个不可预测的耗时操作。线上排查时,你很难解释为什么同一个请求这次 2 秒返回、那次 90 秒返回——因为失败路径上挂着一个次数驱动的循环。换成时间预算之后,最坏耗时是明确的。

还有一个必须提前想清楚的问题:你怎么知道某个调用到底能不能安全重试?

答案通常不在你手里,而在对方的接口文档里。如果文档没写清楚,别猜——按不可重试处理,然后换一种方式补救。补救的办法有三种:让服务端补一个幂等键(最彻底)、把操作拆成"先占位后确认"两步(中间状态可重试)、或者干脆把这个调用挪到流程末尾并要求人工确认(最保守,但一定安全)。

经验上,把"不确定能否重试"当作"不能重试",代价远小于反过来。前者最多让你多写一点补偿逻辑,后者可能让你重复扣款。

四、幂等:让重试这件事本身变得安全

上面说"只对无副作用的调用重试",但现实里你总得写数据。这时候唯一的解法是让操作本身幂等——同一个请求执行一次和执行五次,结果相同。

注意这里说的"结果相同"是指业务状态相同,而不是返回值完全一致。第一次创建返回"已创建",第二次返回"已存在",业务上都对。

四种常见做法,代价不同:

做法思路代价适用
客户端幂等键 请求带唯一 ID,服务端去重 需要服务端配合 支持幂等键的 API
先查后写 写之前先查存在性 有并发竞态 低并发场景
唯一约束兜底 靠数据库唯一索引拦住重复 需要能拿到冲突错误 有数据库控制权
改成状态机转移 只允许合法状态跃迁 需要改造业务模型 订单、工单类流程

第一种最省心,但前提是对方接口支持。幂等键的生成要有讲究——它必须在重试之间保持不变,否则重试时带一个新键,服务端会当成两次不同的请求:

⚠️ 代码待验证

import uuid

def call_with_idempotency(client, payload, idem_key=None):
# 关键:重试时必须复用同一个 key,不能在函数内部每次重新生成
idem_key = idem_key or str(uuid.uuid4())
headers = {"Idempotency-Key": idem_key}
try:
return client.post("/orders", json=payload, headers=headers)
except TransientError:
return retry(lambda: client.post("/orders", json=payload, headers=headers))

这段代码最容易写错的地方就是注释那句:把 uuid4() 写在重试循环里面。那样每次重试都是一个新请求,幂等键形同虚设。

第二种"先查后写"看起来简单,实际有竞态:两个请求同时查、同时发现"不存在"、然后都去写。所以它必须配合第三种,用唯一索引兜底——让第二个请求撞上约束错误,然后把约束错误识别为"已存在",当成成功处理。

第四种是业务层面的解法:把"设置状态为已发货"改造成"从待发货转移到已发货",重复执行会因为状态不满足前置条件而失败。这种做法的好处是把幂等性收拢到了状态机里,不需要额外的键。

当工具完全无法幂等,且又是关键操作时,工程上只有一条路:把它放到流程的最后一步,并且在调用前要求一次明确确认。 先做完所有可以安全重试的准备工作,最后一步只发一次。失败了不是重试,而是走人工介入或补偿流程。

这个取舍不好看,但它诚实。假装一个不可幂等的操作能被安全重试,比承认它需要人工确认危险得多。

五、超时:不要用一个数管所有工具

超时经常被当成"性能问题"来调,其实是一致性问题。

请求超时的那一刻,你唯一确定的事情是"我没等到响应"。对方到底执行了没有?不知道。这才是不确定结果的来源。一个被调慢了的读接口只是慢;一个被调慢了的写接口是危险的。

所以超时值不该是一个全局常量,而应该由工具的性质决定:

工具类型合理量级理由
本地缓存 / 内存检索 百毫秒级 无网络,慢了就是代码问题
数据库查询 秒级 有索引就该快,慢查询要单独治
外部 API 调用 数秒到十几秒 看对方 SLA,不宜长于用户耐心
生成类任务(长文、图像) 数十秒 本身耗时,但要能用异步任务替代

超时值怎么定?很多人凭感觉填。更靠谱的做法是拿观测数据反推——取该工具近一周的 P99 耗时,乘一个余量系数(1.5 到 2 倍)作为超时值。这样既不会把正常的长尾请求误杀,也不会让一个已经卡死的调用拖到天荒地老。反过来说,如果你没有这个工具的耗时数据,说明你还没有做第七章的可观测性,那个才是先要补的。

同时要分清三层超时,很多框架默认只给你最后一层:

⚠️ 代码待验证

timeout = Timeout(
connect=3.0, # 连不上就别耗着
read=15.0, # 单次读取
total=45.0, # 整条链路的上限
)

三层里最容易被漏掉的是 connect。只设总超时的话,遇到一个黑洞 IP,请求会一直挂在握手阶段,占着连接池不放。连接超时设短一点,能让这类故障快速暴露。

超时之后的第一动作不是重试,是确认。

对读操作,直接重试没问题;对写操作,应该先查一次"刚才那次到底成没成"——用一个业务上唯一的键去查(比如订单号或你发的幂等键),查到就说明成功,没查到再决定重试。这一步看起来多余,但它把"不确定"变成了"确定",后面的决策才有依据。

完整版资料清单:本文用到的工程检查清单和术语对照都整理在里面了,扫码即可获取:

配套资料获取

六、熔断与降级:一个工具坏掉,不该拖垮整个 Agent

重试解决的是"偶发失败",熔断解决的是"持续失败"。

这两件事必须分开。如果某个下游已经挂了,重试只会让你把资源持续投进一个黑洞,同时拖慢所有正常请求。

熔断器是三个状态:

  • 关闭:正常放行,同时统计失败率
  • 打开:失败率超阈值,直接拒绝,不再发起真实调用
  • 半开:等待一段时间后,放少量请求试探,成功就恢复,失败就继续打开

⚠️ 代码待验证

class CircuitBreaker:
def __init__(self, fail_threshold=5, open_seconds=30):
self.fail_threshold = fail_threshold
self.open_seconds = open_seconds
self.failures = 0
self.opened_at = None

def allow(self) –> bool:
if self.opened_at is None:
return True
if time.time() – self.opened_at >= self.open_seconds:
return True # 半开:放一个试探请求
return False

def on_result(self, ok: bool):
if ok:
self.failures = 0
self.opened_at = None
else:
self.failures += 1
if self.failures >= self.fail_threshold:
self.opened_at = time.time()

熔断阈值要按工具单独配置。一个用来查天气的边缘工具挂了,不该触发整个 Agent 熔断;一个用来读用户核心数据的工具挂了,可能整个任务就没法继续了。把这两者的阈值设成一样,等于让边缘故障拥有和核心故障相同的破坏力。

Agent 场景下,熔断还有一个特殊价值:模型会反复调用同一个坏工具。 它不知道这个工具挂了,只知道"上次没成功",于是换个参数再试。人遇到连续失败会停下来问一句,模型不会——它会一直在那儿换姿势。有个熔断器挡在前面,能省下大量无意义的调用和 token。

熔断也会误判,这点要有预期。**正常的业务否决不应该是熔断的输入。**如果你把 404 也算进失败率,那么一个专门查"不存在记录"的工具会被自己的正常返回熔断掉。所以熔断统计只应该吃第二章里的"瞬时故障"和"不确定结果"两类。

降级路径一般三条,按优先级排:

  • 换一个能达成相同目的的等价工具
  • 返回有明确时效的缓存结果,并在内容里标注这是缓存
  • 直接告诉模型"这个能力当前不可用"
  • 第三条最容易被写错。很多人在这里抛一个异常,结果模型收到一段堆栈,开始胡猜。正确做法是把失败结构化地交给模型,让它知道这是一个确定的事实,而不是一个需要它去解释的谜题:

    ⚠️ 代码待验证

    TOOL_UNAVAILABLE = {
    "status": "unavailable",
    "tool": "search_orders",
    "reason": "上游服务熔断中",
    "retry_after_seconds": 30,
    "suggestion": "可以改用 query_orders_by_id,或先向用户说明暂时查不了",
    }

    模型拿到这个结构,会做出合理决策——换工具,或者向用户说明情况。拿到一堆异常文本,只会开始编。这个差别在线上非常明显。

    七、可观测性:失败发生在哪一层,要一眼看得出来

    前面六章的做法能不能生效,取决于一件事:你事后说得清失败发生在哪里。

    Agent 的调用链比普通服务长得多——用户输入 → 模型推理 → 工具选择 → 参数生成 → 网络调用 → 结果解析 → 模型再推理。中间任何一环出问题,表面现象都是"Agent 没给出好答案"。

    所以每次工具调用至少要记下这些字段:

    字段为什么需要
    工具名 定位是哪个能力出问题
    入参哈希 排障需要,但原文可能含敏感信息,只存哈希
    耗时 区分"慢"和"错"
    重试次数 重试率高说明下游不稳,不是重试逻辑有问题
    最终状态 成功 / 语义错误 / 业务否定 / 超时未决
    错误分类 直接对应第二章的四分类

    入参存哈希而不是原文,是个需要提前想清楚的取舍。完整的入参对排障最有价值,但它往往含用户隐私、token、内部标识。折中方案是:常规日志存哈希,同时保留一个受控的"原始入参采样"通道,按需开启,并设置短保留期。

    三个最值得盯的指标:

    指标异常信号通常意味着
    工具成功率 突然下降 下游故障,或参数生成开始出错
    P95 耗时 上升但成功率不变 下游变慢,还没开始报错
    重试率 持续偏高 下游长期不稳,重试在掩盖问题

    第二个指标特别值得留意。成功率没变、耗时上升,往往是大故障前唯一能看到的早期信号。等成功率也掉了,多半已经影响到用户了。

    排障时最有用的习惯是倒着走:从"用户说没结果"出发,先看模型最后收到的工具返回长什么样,再往前翻是哪一次调用开始不对。

    举个常见的情形:用户反馈"Agent 查不到我的订单"。顺着查,你会去怀疑搜索工具、怀疑数据库;倒着走,第一件事是打印模型最后拿到的那个返回值——很可能里面是一句 {"status":"negative","reason":"user_not_found"}。到这一步问题就清楚了:Agent 用的用户标识和订单系统里的标识不是同一个体系,不是"查不到",是"查错人了"。

    这类问题在工具返回的原始内容里一眼就能看出来。前提是你把它记下来了。

    最后一个实际问题:全都记下来,日志会很大。一个活跃的 Agent 每天产生几十万次工具调用,把每次的完整上下文都存下来,成本不低。

    合理的做法是分层采样:失败和重试的调用全量记录(这是排查的刚需,绝对量也不大),成功的调用按比例采样(比如 5%,用来算成功率基线就够)。同时给每条记录加一个统一的 trace 标识,把同一次用户请求触发的所有工具调用串起来——否则你只能看到一堆散落的调用记录,还原不出完整链路,前面说的"倒着走"也就走不动了。

    完整版资料清单:本文用到的工程检查清单和术语对照都整理在里面了,扫码即可获取:

    配套资料获取

    附表 A:关键取舍一览

    本文涉及的所有工程判断集中在这里,方便按需回看。

    取舍本文结论判断依据位置
    失败处理放在哪 主流程,不是兜底分支 调用次数会放大单次失败率 第一章
    失败的判定范围 含"200 但内容不可信" 只挂 except 会漏掉静默失败 第一章
    是否统一重试 不统一,先四分类 404 与 503 处置相反 第二章
    分类依据 协议层信息,不解析错误文本 业务错误常伪装成 200 第二章
    重试前提 确认无副作用 超时不代表没执行 第三章
    重试上限 用时间预算,不用次数 次数上限会积累成分钟级等待 第三章
    幂等键生成 重试间保持不变 循环内新建键会让去重失效 第四章
    先查后写 必须配合唯一索引 存在并发竞态 第四章
    超时值 按工具性质分层设置 超时本质是不确定结果 第五章
    超时值怎么定 由 P99 反推并留余量 凭感觉填会误杀长尾 第五章
    超时后动作 读操作直接重试,写操作先确认 写操作重试可能造成重复执行 第五章
    熔断阈值 按工具单独配置 核心与边缘工具容忍度不同 第六章
    熔断统计口径 只吃瞬时故障与不确定结果 业务否定会误触发熔断 第六章
    降级怎么告诉模型 结构化对象,不抛异常 模型对异常文本会开始编 第六章
    入参怎么记录 存哈希 + 受控采样 排障需求与敏感信息冲突 第七章
    最该盯的指标 P95 耗时 + 重试率 成功率是滞后指标 第七章

    附表 B:术语速查表

    术语含义
    瞬时故障 由网络或临时负载引起的失败,重试通常可恢复
    语义错误 请求本身不合法,重试无意义,需要修改输入或权限
    业务否定 请求合法但业务上不成立(如对象不存在),应作为结果而非异常
    不确定结果 超时等场景下,调用是否生效未知
    幂等 同一操作执行多次与执行一次,业务状态相同
    幂等键 客户端生成的唯一标识,供服务端识别并去重
    指数退避 重试间隔随失败次数指数增长
    抖动 在退避间隔上叠加随机量,避免多客户端同时重试
    重试风暴 上游集中重试导致下游压力倍增,故障被放大
    熔断器 失败率超阈值时直接拒绝调用,保护下游与自身
    半开状态 熔断器冷却后放少量试探请求,用于判断是否恢复
    降级 主路径不可用时提供的替代方案
    P95 / P99 耗时 95% / 99% 的请求快于该耗时,比平均值更能反映真实体验

    写在最后:这篇用到的资料

    写这篇文章时,我把几个 Agent 项目里踩过的失败场景重新梳理了一遍,顺手整理成几份配套的东西:

    • 大模型学习路线图:从 LLM 基础到 Agent 开发,各阶段该学什么、用什么资料
    • 大模型全套教程:按主题分好的视频与文档清单
    • 大模型实战好书:24 本,附每本适合的阶段

    资料是我自己整理的,放在下面这个码上,扫码即可获取:

    配套资料获取

    大模型全套教程

    大模型实战好书

    添加时备注「大模型」,优先通过。

    拿到之后建议先看学习路线图那一份,先定位自己在哪个阶段,再决定学什么,比一上来就啃框架效率高得多。

    赞(0)
    未经允许不得转载:171主机测评 » Agent 工具调用失败之后:重试、幂等与超时的工程做法
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址