我的 Agent 传错参数不是玄学:给工具调用加了层自愈,8 个畸形样本救回 6 个

我给工具写 schema 的时候一直觉得自己写得挺清楚的:类型、枚举、必填、上下界,全标上了。然后模型照样给我传 max_results: "20"、把 path 写成 folder、给日期字段塞一个"明天"。

我一开始的处理很粗暴:校验失败,把错误原文塞回对话,让模型重传。能跑通,但每错一次就是一整轮模型往返,而且它经常在同一个地方再错一次。我受不了这个,就在工具调用前面垫了一层——先把能修的修掉,修不了的再回模型。

下面是我写完、跑完、把真实输出贴出来的记录。

schema 过了,不代表参数能用

我最初以为是 schema 没写严。后来去翻了两家的官方文档(核对日期 2026-10-02),发现是我误会了 strict 模式。

OpenAI 那边,strict: true 的说法是让函数调用 "reliably adhere to the function schema, instead of being best effort"。但它有硬前提:每个 object 都得写 additionalProperties: false,所有字段都得进 required,可选字段只能靠 "type": ["string","null"] 表达。而且文档明确写了,Chat Completions 默认就是非 strict 的 best-effort;Responses API 在 schema 没法规范化时会静默回退成 best-effort,只在响应里给一个 strict: false 让你自己发现。

Anthropic 这边也加了 strict: true,文档原话是 "ensure Claude's tool calls always match your schema exactly"。

两家都在往"保证符合 schema"上走,方向没错。但我看清了一件事:符合 schema 和能用,是两件事。

at: "明天" 在 {"type":"string","format":"date"} 眼里是一等良民——它就是个字符串,没违反任何约束。模型没犯错,是我的工具收到之后才知道完蛋了。strict 管的是形状,管不了语义。这类错误我只能自己接。

我把会犯的错分成六类,每类一条规则

归了个类,一共六条修复规则,按我自己的信任程度从高到低排:

  1. coerce:"20" → 20,"是" → true。纯类型转换,零风险。
  2. alias:folder → path、kw → pattern。字段名同义,也基本零风险。
  3. array-wrap:单个收件人 "a@example.com" → ["a@example.com"]。
  4. rel-date:"明天"、"10/8"、"下周一" → 2026-10-03 这种。这条必须你传一个基准日期进来,不然"明天"没法算。
  5. clamp:max_results: 500 但上限是 100 → 砍到 100。这条我有顾虑,下面说。
  6. enum-fuzzy:"按时间" → mtime,"time" → mtime。三级匹配,先归一(去大小写和分隔符),再查同义词表,最后算编辑距离。

第 6 条是我犹豫最久的。编辑距离兜底有可能把两个意思完全不同的词拉到一起。我的处理是只接受距离 ≥1 且 ≤2 的匹配,并且所有模糊匹配都记进 changes 里——日志能查到,出问题能回溯,不能悄悄改完就完事。

schema 我只扩了两个字段:x-alias 存别名映射,default 存兜底值。核心入口长这样:

// argfix-core.js 节选
function heal(schema, args, ctx = {}) {
  const before = validate(schema, args);
  if (before.length === 0) {
    return { ok: true, round: 0, args, changes: [], errors: [], receipt: null };
  }
  const { value, changes } = fix(schema, args, ctx);
  let candidate = value;
  if (ctx.dropUnknown === true && typeOf(candidate) === 'object') {
    const keep = {};
    for (const [k, v] of Object.entries(candidate)) {
      if (k in (schema.properties || {})) keep[k] = v;
      else changes.push({ path: `$.${k}`, rule: 'drop-unknown', from: k, to: '<删除>' });
    }
    candidate = keep;
  }
  const after = validate(schema, candidate);
  return {
    ok: after.length === 0,
    round: 1,
    args: candidate,
    changes,
    errors: after,
    receipt: after.length ? buildReceipt(ctx.toolName || 'tool', after, changes) : null,
  };
}

相对日期这块我写死了几种说法,剩下的交给调用方传基准日期:

// argfix-core.js 节选
function parseLooseDate(raw, base) {
  const s = String(raw).trim();
  if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
  const b = new Date(base + 'T00:00:00');
  const shift = (n) => iso(new Date(b.getTime() + n * 86400000));
  if (/今天|today/i.test(s)) return iso(b);
  if (/明天|tomorrow/i.test(s)) return shift(1);
  if (/后天/i.test(s)) return shift(2);
  if (/昨天|yesterday/i.test(s)) return shift(-1);
  const m = s.match(/(下{0,1}?)周([一二三四五六日天])/);
  if (m) { /* 算出周几,过了就顺延到下周 */ }
  const slash = s.match(/^(\d{1,2})[/.](\d{1,2})$/);
  if (slash) {
    const [, mo, d] = slash;
    return iso(new Date(b.getFullYear(), Number(mo) - 1, Number(d)));
  }
  return null;
}

完整文件 309 行,零依赖,Node 22 跑通。

8 个样本,我跑了一遍

我攒了 8 个畸形参数,覆盖三个工具(search_files / set_reminder / send_email),基准日期设成 2026-10-02。真实输出:

[search_files] {"path":"/var/log","pattern":"*.log","max_results":"20","sort":"mtime"}
  首轮校验:1 个错 -> type
  修复 $.max_results: "20" -> 20  [coerce->integer]
  结果:✅ 直接通过

[search_files] {"folder":"/var/log","kw":"*.log","max_results":500,"sort":"按时间"}
  首轮校验:6 个错 -> missing,missing,unknown-field,unknown-field,above-max,not-in-enum
  修复 $.folder: "folder" -> "path"  [alias]
  修复 $.kw: "kw" -> "pattern"  [alias]
  修复 $.max_results: 500 -> 100  [clamp]
  修复 $.sort: "按时间" -> "mtime"  [enum-fuzzy]
  结果:✅ 直接通过

[set_reminder] {"at":"明天","repeat":"工作日","note":"交周报"}
  首轮校验:2 个错 -> bad-format,not-in-enum
  修复 $.at: "明天" -> "2026-10-03"  [rel-date]
  修复 $.repeat: "工作日" -> "weekdays"  [enum-fuzzy]
  结果:✅ 直接通过

[send_email] {"to":"someone@example.com","subject":"hi","body":"test","cc_me":"是"}
  首轮校验:2 个错 -> type,type
  修复 $.to: "someone@example.com" -> ["someone@example.com"]  [array-wrap]
  修复 $.cc_me: "是" -> true  [coerce->boolean]
  结果:✅ 直接通过

=== 汇总 ===
样本 8 个:自动修好 6 个,必须回模型 2 个
自动修复覆盖率 75.0%
[对照组] 只校验不自愈:8/8 个直接失败,全都要回模型重试一轮

第二个样本我最满意:四个不同类型的错(字段别名、别名、越界、枚举)一次性全修完,一次模型往返都没花。

剩下 2 个没救回来的:repeat: "every week"——同义词表和编辑距离都没捞到 weekly,"every week" 和 "weekly" 归一之后差太远;还有一个是多出来的 priority: "high" 字段。

修不了的,我让它带着回执回去

自愈必须有出口。我的做法是生成一段结构化回执塞回对话,而不是只丢一句"参数错了":

工具 set_reminder 的参数没通过校验,我已经自动改了 1 处:
  - $.at:"下周一" → "2026-10-12"(规则 rel-date)
下面这些我改不了,必须你重传:
  - $.repeat:值 "every week" 不在允许列表 ["none","daily","weekdays","weekly"] 里
请只重传参数 JSON,不要改工具名。

关键在最后那句"请只重传参数 JSON"。我试过不加这句,模型有很大概率把整个工具调用连同推理过程重讲一遍,白烧一轮 token。

重试我设了硬上限 2 轮。超了就把这个工具标记为本轮不可用、直接跳过,绝不让它在循环里空转——这是我从自己之前那个批处理任务里学到的教训。

两个我改过主意的地方

不静默丢字段。 第 8 个样本里多了个 priority: "high"。我第一版是直接删掉继续跑,跑通了,但后来觉得不对:priority 是模型真的想表达的东西,我悄悄删了,用户最后拿到一封没加急的邮件,还以为是模型没听懂。所以我改成默认报错回模型,只有显式传 dropUnknown: true 才删。实测开了之后第 8 例也能过,但我只在副作用可逆的工具上开。

clamp 我只在读操作上开。 max_results: 500 砍到 100 无所谓,多翻一页的事。但如果是 delete_files 的 count,模型想要删 500 个,我替它砍成 100 个,那是在替它做决定。写操作我只报错,不裁剪。

代价

跑 16000 次 heal() 实测:

样本 8 个 × 2000 轮 = 16000 次 heal()
总耗时 636.1 ms,单次平均 0.0398 ms(39.8 µs)
纯校验(不自愈)单次平均 0.0038 ms

一次自愈 40 微秒。一次模型往返按 800 ms 算,省下一轮重试相当于两万倍的时间差。三个 schema 加起来 897 个字符,这点上下文开销我完全不在意。

我的判断

这层自愈值不值得写,取决于你的工具是只读还是写入。只读工具闭眼上——40 微秒换一轮模型往返,我想不出不做的理由。写入工具我只敢开 coerce、alias、array-wrap 这三条最保守的,clamp 和 enum-fuzzy 一律关掉,宁可多回模型一次,也不要替它把话说死。

还有一点是我写完才想明白的:自愈层修得越多,你越不容易发现自己的 schema 写得烂。如果某个字段天天靠 enum-fuzzy 兜底,说明这个枚举的名字起得有问题,该改的是 schema,不是修复规则。我现在会定期把 changes 按规则聚合看一眼,enum-fuzzy 命中特别多的字段,就是该重命名的字段。

自愈是止痛片,不是治疗方案。

添加新评论

文章目录