说明:本文讨论的是 Agent 调用外部工具失败后的处理,属于工程架构话题,不涉及具体模型版本与价格。文中代码为结构示意,未在某个具体项目里完整跑通,请按自己的技术栈调整后再上生产。
一、先接受一个事实:工具调用一定会失败
很多人做 Agent 的第一个版本,心态是"调用工具,拿到结果"。真上生产之后才发现,这是个概率游戏。
Demo 阶段之所以感觉很顺,是因为两件事被掩盖了:一是调用次数少,一次对话里可能就调一两次工具;二是你在旁边盯着,看到不对就重跑。这两件事在线上都不成立——任务变长了,人也不在。
原因也很直白:你的 Agent 调用的不是本地函数,而是网络对面的一整个服务。中间夹着 DNS 解析、TCP 握手、TLS、网关限流、API 配额、下游数据库连接池。任何一层抖一下,你拿到的就是异常。
更麻烦的是调用次数。一个稍微复杂点的任务,Agent 调 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 本,附每本适合的阶段
资料是我自己整理的,放在下面这个码上,扫码即可获取:



添加时备注「大模型」,优先通过。
拿到之后建议先看学习路线图那一份,先定位自己在哪个阶段,再决定学什么,比一上来就啃框架效率高得多。




