ARTICLE / 6 MIN READ

工具调用的幻觉与边界控制:Schema、校验与错误状态机

Agent 会幻觉出不存在的工具、把 user_id 传成 null、查一次天气调十次。用 Schema 强约束、调用前校验、错误处理状态机和调用预算,把不可信的模型输出关进可控的边界里。

一个查天气的 Agent,能给你整出三种事故:幻觉出一个根本没注册的 get_weather_forecast_2025 工具去调;把必填的 user_id 传成 null,下游 API 直接 400;或者你问“北京天气怎么样”,它自作主张把周边十个城市挨个查了一遍。根因是同一个:模型输出的工具调用是概率采样出来的文本,不是可信的函数指针。所以边界控制不能指望模型自觉,必须在模型外面加一圈护栏——Schema 层拦住非法调用,运行时用状态机管住错误和重试,再用预算掐住过度调用。

Schema 层:白名单 + 强约束,从源头拒绝

第一道墙是工具白名单。网关维护一份已注册工具的登记表,模型每次返回的 tool_use 都要先查这张表:名字不在表里的,直接被网关拒绝,根本不进入执行阶段get_weather_forecast_2025 这种幻觉工具在这一步就死了——它连被调用的机会都没有,返回给模型的是一条“工具不存在,可用工具为 [get_weather]“的错误,让它改。

第二道墙是参数 Schema。每个工具的参数用 JSON Schema 强约束,OpenAI 和 Anthropic 都支持 strict 模式来保证模型输出严格贴合 Schema:

{
  "name": "get_weather",
  "strict": true,
  "parameters": {
    "type": "object",
    "additionalProperties": false,
    "required": ["user_id", "city", "unit"],
    "properties": {
      "user_id": { "type": "string", "pattern": "^u_[0-9]{6,}$" },
      "city":    { "type": "string", "minLength": 1 },
      "unit":    { "type": "string", "enum": ["celsius", "fahrenheit"] }
    }
  }
}

几个约束点是刻意的:additionalProperties: false 禁止模型塞额外字段;strict 模式下所有字段进 required,可选字段要允许 null 就在 type 里显式写 ["string", "null"],而不是靠“不传”来表达——这样 user_idnull 会因为不匹配 pattern 当场被 Schema 拒掉;enum 锁死取值,杜绝模型编一个 unit: "kelvin"pattern 用正则卡住 ID 格式。Schema 越紧,模型能钻的空子越少。

调用前校验:失败要回填,不要执行

Schema 通过不等于业务合法。user_id 格式对,但用户不存在;city 是合法字符串,但不在服务覆盖范围。所以执行工具之前还有一次业务校验(validate),而校验失败的处理方式是这里的关键:不执行工具,而是把结构化的错误回填给模型,让它自己改

tool_call


[Schema 校验] ──fail──► 回填 {error: "schema", detail: "user_id 不匹配 pattern"} ──► 模型重出
   │ pass

[业务 validate] ──fail──► 回填 {error: "validation", detail: "user_id 不存在"} ──► 模型重出
   │ pass

执行工具

回填错误比直接抛异常好在:模型拿到“哪里错了”能在下一轮自我修正,而不是让整个 Agent 崩掉。但回填不能无限循环,它受下面状态机里的重试预算约束。

错误处理状态机:按错误类型决定重试还是放弃

工具真的执行了,返回 404、429、500、鉴权失败……不能一律“重试三次”了事。要按错误类型分类处理,本质是一台状态机:

              ┌─────────────┐
   工具返回 ──►│ 错误分类     │
              └──┬───┬───┬──┘
     可重试      │   │   │   不可重试
  (超时/429/503) │   │   │  (404/400/422)
                 ▼   │   ▼
          ┌──────────┐ │ ┌──────────────┐
          │退避重试   │ │ │换工具 / 报错  │
          │指数退避   │ │ │不重试         │
          │上限 N=3  │ │ └──────────────┘
          └──┬────┬──┘ │
     成功    │    │ 超上限
             ▼    ▼    │  鉴权错误 (401/403)
           完成  升级   ▼
                     ┌──────────┐
                     │ 终止      │
                     │ 不重试    │
                     └──────────┘

分三类,对应三种动作:

  • 可重试错误(超时、429 限流、503):用指数退避重试,delay = base * 2^n + jitter,但重试次数有硬上限(比如 3 次)。429 尤其要尊重响应里的 Retry-After,别急着打。超过上限不再重试,升级到“换工具或如实告诉用户暂时查不到”。这里有个隐蔽的坑:对写操作重试必须带幂等键。超时最危险的地方在于它状态未知——请求可能已经在下游成功了,只是响应丢了,此时盲目重试就会重复下单、重复扣款。所以凡是有副作用的工具,调用时要带一个业务唯一的幂等键,下游靠它去重,重试才安全。纯读工具(查天气、查物流)没有副作用,可以放心重试。
  • 不可重试错误(404 资源不存在、400/422 参数错):重试一万次结果也一样,直接不重试。404 意味着这个工具/资源本身不对,走备选工具或明确报错;400 意味着参数错,回填给模型改一次,改完还错就报错。
  • 鉴权错误(401/403):直接终止。这不是重试能解决的,是配置或权限问题,继续重试只会刷爆日志,还可能触发对方的风控封禁。

防过度调用:预算、去重、循环检测

“查一次天气调十次”是另一类失控。四道闸一起上:

  • 单轮工具预算:一次用户请求内,工具调用总次数设上限(如 8 次),超了就强制模型收敛输出。
  • 同工具去重:对 (tool_name, 规范化后的参数) 算指纹,同一指纹在本轮内重复出现直接返回缓存结果,不再真调——这拦住“同一个城市查两遍”。
  • 循环检测:把最近几轮的工具调用序列做哈希,若同一序列反复出现(查 A→查 B→查 A→查 B……),判定进入死循环,强制中断并升级。
  • 成本预算:给每次请求一个总成本/延迟预算,工具调用和模型 token 一起从预算里扣,扣光就停。这是兜底熔断,正常流程不该走到这。

要区分“过度调用”和“合理的多次调用”:用户明确问“北京、上海、广州的天气”,调三次是对的。所以预算要设得够业务正常用,但拦得住失控——上限是安全阀,不是常规限制。

边界

这套护栏治的是“调用层面”的失控,治不了“模型判断层面”的错。Schema 能保证 city 是合法字符串,但保证不了模型没把用户说的“申城”理解成别的地方;白名单能拦住幻觉工具,但拦不住模型该调工具时偷懒不调、硬凭记忆答。strict 模式和参数校验也有成本——Schema 太严会让模型在边缘 case 上反复重试撞墙,反而拖慢正常请求,需要按真实错误分布调松紧。护栏的价值是把“不可控的模型输出”收敛成“可控的错误处理”,让事故有明确的失败路径而不是静默出错;但它替代不了对工具本身的良好设计和对模型能力边界的清醒认识。

参考资料:Anthropic — Tool use with ClaudeOpenAI — Function calling

GITHUB DISCUSSION

评论与回复

评论保存在 GitHub Discussions,登录 GitHub 后即可参与,发布和回复都在本页完成。

评论区进入视口后自动加载。