专栏 Agent 工程

失败处理:错误回灌与重试边界

它连续七次调用同一个工具,参数一个字没变。界面上是七个一模一样的折叠块,每个都说 执行成功 ,每个返回的内容也完全相同。

失败处理:错误回灌与重试边界

它连续七次调用同一个工具,参数一个字没变。界面上是七个一模一样的折叠块,每个都说”执行成功”,每个返回的内容也完全相同。

这不是模型坏了,是没人告诉它”你卡住了”。

一、失败的来源比想象的多

一个动作从提议到落地,每一段都可能断:

来源 典型表现 该不该重试
网络 超时、连接重置、5xx 该,多半是瞬时的
权限 PERMISSION_DENIED 不该,重试一百次也一样
参数 schema 不匹配、字段缺失 不该,得改参数
幻觉 调一个不存在的工具 不该原样重试
空响应 模型返回空白,没有文本也没有工具 可以,但要带提醒
逻辑死循环 同一动作反复执行 必须拦

把它们混在一起处理,是所有 Agent 实现里最常见的粗糙。网络错误该退避重试,权限错误该引导用户,参数错误该让模型自己改——三种情况的正确反应完全不同。

所以第一件事不是写重试,是给错误分类。

二、错误要回灌,不要抛出

宿主执行工具失败时,有两种处理方式。

第一种:抛异常,终止整个循环,给用户报错。

第二种:把错误包装成一条工具结果,塞回上下文,让模型自己决定下一步。

正确的是第二种,但要加限定。可恢复的错误回灌,不可恢复的错误终止。

判断标准是:模型知道原因之后,能不能做出不同的选择。

参数写错了,它看到错误信息可以改一次;工具名幻觉了,它看到”没有这个工具”可以换一个;权限不足,它知道该请用户去开。这些都应该回灌。

但如果整个会话已经损坏、模型被下线、凭据失效,回灌只会让它在一个没有出口的房间里反复撞墙。这时候该停,并且明确告诉用户发生了什么。

三、重试的两种含义

中文里”重试”是一个词,实现里是两件完全不同的事。

传输层重试:请求没到达,或者到达了但响应丢了。赌的是瞬时故障,所以用短间隔、少次数。Minis 的策略是三次,退避 1 秒、2 秒、4 秒,只对可判定的瞬时错误生效。

逻辑层重试:请求成功了,但结果没意义。模型要求做一个已经做过十次的动作。这时重发请求没用,得让它知道”这条路走不通”。

前者是工程问题,后者是控制问题。混为一谈,就会出现”网络已经恢复了,程序还在重试同一个错误动作”的荒谬局面。

传输层重试还有个容易漏的细节:重试前要回滚。上一次失败的请求可能已经流出了一部分文本、一部分工具块,直接重发会让这些内容叠加两遍。所以正确顺序是:清掉本轮的临时块,保留用户已经看到的前文,再重新发起。

四、怎么判断”在犯傻”

最直觉的做法是数次数:同一个工具调超过 N 次就拦。

这个规则会误伤一类正当行为——轮询。

“任务提交了吗”“构建完了吗”“文件生成了吗”,这些调用天然要重复很多次,参数完全一样。次数限制会把正常的等待判成死循环。

所以判断依据不能只是”重复”,得看重复有没有带来新信息。每次调用记录两个哈希:参数哈希和结果哈希。参数一样、结果也一样,才说明这一轮什么都没推进。

实现里分了四条规则,优先级从高到低:

规则 判定 阈值
未知工具重复 连续调用不存在的工具 10 次直接阻断
全局熔断 任意工具,同参同结果 30 次
轮询无进展 轮询类工具,结果冻结 20 次
普通重复 同参,不管结果 10 次警告

注意最后一条只警告不阻断,因为它分不清”在等待”和”在发呆”。真正下狠手的是”同参同结果”——那才是确定没有新信息。

五、阈值为什么要分层

四个数字:10、20、30,外加一个 10 的未知工具线。

它们不是拍脑袋定的。构造检测器时有一组强制校验:警告线必须小于严重线,严重线必须小于全局熔断线,历史窗口必须装得下全局熔断。

require(warningThreshold < criticalThreshold)
require(criticalThreshold < globalCircuitBreakerThreshold)
require(historySize >= globalCircuitBreakerThreshold)

这些约束在构造时就会抛出,配置错了根本起不来。目的很明确:不允许出现”先熔断后警告”这种颠倒的配置。

分层本身也有含义。警告是给模型的提示——在结果里附一句”你已经重复了十次”,有些模型看到就会停。严重是给宿主的信号——不再等模型自觉,直接不执行。全局熔断是最后一道,跨工具统计,防止模型在几个工具之间来回横跳绕过单工具计数。

一层比一层重,因为干预的代价一层比一层大。

六、空响应也算失败

有一类失败不在错误码里:模型返回了空。

没有文本,没有工具调用,finish_reason 是正常的 stop。循环按规则判断”没有工具调用 → 结束”,于是用户看到一个空白气泡。

这种情况通常是服务端的瞬时问题,或者是模型在工具结果之后”不知道该说什么”。处理方式是重发一次请求,并附一条系统提醒,说明上一轮是空的,请基于现有信息作答。

关键是只重试一次。带提醒的重试如果还是空,就老老实实报错,不能无限循环。源码里用一个 didInjectEmptyToolReminder 标志控制,一次运行只触发一次。

一次机会,失败就认。这个策略在 Agent 里反复出现:给模型自我修正的空间,但不给它无限的空间。

七、换一个模型也算一种重试

如果当前模型持续不可用(限流、宕机、凭据过期),还有一种退路:切换到备用模型。

这件事的难点不在切换,在切换之后的状态。会话原来绑的是哪个模型、历史里的工具调用格式兼不兼容、切过去之后要不要切回来——每一个都可能把会话搞坏。

所以实现里对”因为失败而切换”和”用户主动切换”做了区分:前者是临时的,只在本次回合生效;后者是持久的,会改变会话绑定。混淆这两者,用户会发现自己的模型选择莫名其妙被改掉了。

小结

可靠的 Agent 不是不犯错,是犯了错之后,每一方都知道发生了什么:

  • 模型知道,所以能改;
  • 用户知道,所以能决定;
  • 系统知道,所以不会把同一个错误重放五百次。

失败处理的水平,基本等于这个系统的成熟度。


下一篇换个方向:一个上下文装不下的任务,怎么办。