工具调用的错误处理:改一句错误信息,自愈率从 0/20 到 17/20

Agent 循环与工具调用第 2 / 2 篇

本文的每个数字都是跑出来的。 环境是 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 就行。

三是 多工具互相依赖时的错误传播。这一篇只有一个工具。 上游工具失败导致下游参数错误,是另一个形状的问题,本文没碰。