失败处理:错误回灌与重试边界
它连续七次调用同一个工具,参数一个字没变。界面上是七个一模一样的折叠块,每个都说 执行成功 ,每个返回的内容也完全相同。
失败处理:错误回灌与重试边界
它连续七次调用同一个工具,参数一个字没变。界面上是七个一模一样的折叠块,每个都说”执行成功”,每个返回的内容也完全相同。
这不是模型坏了,是没人告诉它”你卡住了”。
一、失败的来源比想象的多
一个动作从提议到落地,每一段都可能断:
| 来源 | 典型表现 | 该不该重试 |
|---|---|---|
| 网络 | 超时、连接重置、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 不是不犯错,是犯了错之后,每一方都知道发生了什么:
- 模型知道,所以能改;
- 用户知道,所以能决定;
- 系统知道,所以不会把同一个错误重放五百次。
失败处理的水平,基本等于这个系统的成熟度。
下一篇换个方向:一个上下文装不下的任务,怎么办。