工具调用的错误处理:改一句错误信息,自愈率从 0/20 到 17/20
本文的每个数字都是跑出来的。 环境是 Ollama
0.33.2+qwen2.5:7b-instruct-q4_K_M(本地,离线,不需要 key), 验证代码在仓库experiments/agent-loop/(run-errors.mjs的 D/E/F 段)。 ⚠️ 用本地 7B 是为了测机制。凡是形如「N/20」的数只对这个模型这个任务成立, 但「哪种错误该用哪种处理」是控制流的性质,与模型多聪明无关。
工具会失败。于是要决定:失败了重试几次、退避多久、要不要告诉模型。
这看起来是个策略选择问题。实测下来,在三类错误里有一类, 三种策略的成功率全是 0/20 —— 而只把错误信息重写一遍,同一个策略就变成 17/20。
一、先把「三类错误」变成三种可注入的东西
三类错误的说法很常见:可重试的、需要模型改写的、不可恢复的。 要测它们,得让每一类的触发条件和它的性质一致:
| 错误类 | 注入方式 | 因此 |
|---|---|---|
transient |
按调用次数:前 2 次返回 ETIMEDOUT |
重试确实有用 |
bad-args |
由参数本身决定:指标名对不上就查不到 | 重试同样的参数永远没用 |
fatal |
恒定 403 Forbidden |
重试和回填都没用 |
被测的三种策略,都是包在工具外面的一层,循环本身一行没改:
// 一律盲重试 3 次,不告诉模型
'blind-retry': (bench) => async (call) => {
const name = argOf(call);
let last;
for (let i = 0; i < 3; i++) {
last = bench.call(name);
if (last.ok) return last.ok;
}
return `工具失败(已重试 3 次):${last.err}`;
},
// 一律把原始错误回填给模型,让它自己决定
'tell-model': (bench) => async (call) => {
const r = bench.call(argOf(call));
return r.ok ?? r.err;
},
// 分类处理:能重试的重试,参数错的回填,不可恢复的直接终止
classify: (bench, ctl) => async (call) => {
const name = argOf(call);
let last;
for (let i = 0; i < 3; i++) {
last = bench.call(name);
if (last.ok) return last.ok;
if (/^403/.test(last.err)) { ctl.fatal = true; return `不可恢复:${last.err}`; }
if (!/ETIMEDOUT/.test(last.err)) return last.err; // 参数类 → 原样交给模型
}
return `工具失败(已重试 3 次):${last.err}`;
},
⭐ 重试属于工具层的职责,不是循环的。 这三个函数换来换去,
agentLoop(见上一篇)一个字都不用动。
把重试写进循环里的话,「换一个策略」就变成了改控制流。
二、九个格子,其中一个很反直觉
三类错误 × 三种策略,各跑 20 次:
| 错误类型 | 策略 | 成功率 | 后端调用 | 模型调用 |
|---|---|---|---|---|
| transient | blind-retry | 20/20 | 3 | 2 |
| transient | tell-model | 0/20 | 1 | 2 |
| transient | classify | 20/20 | 3 | 2 |
| bad-args | blind-retry | 0/20 | 3 | 2 |
| bad-args | tell-model | 0/20 | 1 | 2 |
| bad-args | classify | 0/20 | 1 | 2 |
| fatal | blind-retry | 0/20 | 3 | 2 |
| fatal | tell-model | 0/20 | 1 | 2 |
| fatal | classify | 0/20 | 1 | 1 |
先看那个反直觉的格子:
🚨 transient + tell-model = 0/20,而后端只被打了 1 次。
也就是说:模型拿到 ETIMEDOUT:上游 8 秒未响应,一次都没有重试,
直接回答“查不到”。而这类错误重试一下就好了 —— blind-retry 在同一档是 20/20。
⇒ 不要指望模型自己重试瞬时错误。 「把错误告诉模型让它决定」这个做法, 听起来是最灵活的,在这一类错误上是最差的。瞬时错误的重试必须由工具层做掉, 它压根不该进入模型的上下文。
再看 fatal 那三行:全是 0/20,符合定义(不可恢复就是不可恢复)。
差别在成本:classify 只用了 1 次模型调用,另两个是 2 次 ——
因为它识别出 403 之后直接终止了循环,没有再让模型说一遍“我失败了”。
这是分类处理唯一在 fatal 上的收益,而它是省下来的,不是赚回来的。
最后是 bad-args 那三行 —— 三种策略全是 0/20。
到这里,「选哪个策略」在这一类错误上完全不影响结果。原因不在策略里。
三、把错误信息改写一遍:0/20 → 17/20
bad-args 那一档的任务是「查一下 2024 年的营收」,而真实指标名叫
fin.rev.2024。模型第一次传的是什么?脚本把它打出来了:
第一次传的指标名:"2024年营收"×20
20 次全都是 "2024年营收" —— 它把任务描述里的中文原话当成了指标名。
(顺带验了这一档的前提:「第一次就猜对」是 0/20,
指标名确实猜不出来。这个数由脚本打印,不是我事后声称的 —— 不是 0 它会打警告。)
于是同一次失败,两种写法:
原始: Error: ENOENT: no such metric, open '2024年营收'
改写之后: 找不到指标「2024年营收」。可用的指标有:fin.rev.2024、fin.cost.2024、usr.count.2024
策略都是 tell-model,各跑 20 次:
| 错误写法 | 自愈率 | 换过参数 | 成功率 |
|---|---|---|---|
| 原始(ENOENT,无清单) | 0/20 · 0/20 | 0/20 | 0/20 |
| 改写成人话(附可用清单) | 17/20 · 15/20 | 17 · 15 | 17 · 15 |
(两轮都列出来 —— temperature 0.7,这是估计值不是常数。 原始那一档两轮都是干净的 0/20。)
⭐ 同一个策略、同一类错误,只改错误信息:0/20 → 17/20。
把它和上一节对起来看:三种策略在这一类错误上的差别是 0, 改一句错误信息的差别是 17/20。 ⇒ 错误信息的写法比策略的选择更决定成败,而前者通常没有人管 —— 它是从下游库里原样冒出来的字符串。
那句原始错误里其实什么都不缺:它准确地说明了“没有这个指标”。
缺的是下一步能做什么。ENOENT 告诉模型“你错了”,
清单告诉模型“该传什么”。对人也一样,只是人会自己去翻文档。
📌 第二轮还暴露出一个额外的失败形态:有 2/20 次的工具调用
根本没带 name 参数。这不是“参数错了”,是“参数没了” ——
而两者在错误信息里长得一模一样。真实系统里这两种要分开报,
否则“改写成人话”那套提示对后者是无效的(它没得改)。
四、成本放大有两个数,不是一个
让工具以 30% 的概率间歇失败,各跑 20 次。基线是同一套东西在 0% 失败率下的开销 (后端调用 1.00 次、模型调用 2.00 次):
| 策略 | 后端调用 | 放大 | 模型调用 | 放大 | 成功率 |
|---|---|---|---|---|---|
| blind-retry | 1.50 | 1.50× | 2.00 | 1.00× | 19/20 |
| tell-model | 1.05 | 1.05× | 2.05 | 1.02× | 15/20 |
| classify | 1.20 | 1.20× | 2.00 | 1.00× | 20/20 |
⭐ 这两个放大倍数是两种钱:后端调用是配额和账单(你打了几次那个 API), 模型调用是token(模型推理了几次)。只报一个数会选错策略 —— 盲重试放大前者而完全不动后者,把错误回填给模型则相反。
看结论:classify 在两个轴上同时赢过 blind-retry
(成功率 20/20 vs 19/20,后端成本 1.20× vs 1.50×)。
它省下来的正是“明知没用还要再打两次”的那部分。
而 tell-model 是最便宜的(1.05×)—— 因为它根本没在重试。
1.05× 和 15/20 是同一件事的两面:便宜是因为放弃得早。
⚠️ 这里的「后端」是内存里的假工具,没有网络、没有配额、没有计费。 放大倍数可以看,绝对成本不行。
五、回到判据
stub 给这一篇定的判据是:
重试策略要给出成本放大倍数。自愈率提升 10% 而成本翻三倍,是不划算的。
判据成立,而且这一轮的数据恰好落在它划的线的好的那一侧:
classify 相对 tell-model 把成功率从 15/20 提到 20/20(+25 个百分点),
后端成本从 1.05× 到 1.20×。不是“翻三倍换 10%”,是“贵 14% 换 25 个百分点”。
但跑完之后要给判据补一条,因为光靠它挑不出这一轮最大的那个收益:
⭐ 在比较重试策略之前,先看错误信息里有没有“下一步能做什么”。 三种策略的差是 0/20,改一句错误信息的差是 17/20。 先调策略的话,你会在一个天花板是 0 的空间里做优化。
顺带一条给「把错误告诉模型」的使用边界:
| 错误类 | 该谁处理 | 理由 |
|---|---|---|
| 瞬时(超时、5xx、限流) | 工具层重试,不进上下文 | 模型不会自己重试(0/20) |
| 参数类 | 回填给模型,但必须附可选项 | 只有它能改参数;没清单则 0/20 |
| 不可恢复(403、404 语义) | 立刻终止循环 | 省下的是“再让模型说一遍失败” |
本文没有回答的三个问题:
一是 退避策略。这里的 blind-retry 是无退避的连续三次。 真实系统要指数退避加抖动,而那会改变「成本放大」的时间分布 —— 本文只量了次数,没量时间。
二是 前沿模型会不会自己重试瞬时错误。第二节那个 0/20 是本地 7B 的行为。 更强的模型可能会重试,那会让
tell-model在transient一档的成绩完全不同。 这一条最值得在你自己的模型上重跑 —— 脚本换个LAB_MODEL就行。三是 多工具互相依赖时的错误传播。这一篇只有一个工具。 上游工具失败导致下游参数错误,是另一个形状的问题,本文没碰。