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_id 传 null 会因为不匹配 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 Claude、OpenAI — Function calling。
GITHUB DISCUSSION
评论与回复
评论保存在 GitHub Discussions,登录 GitHub 后即可参与,发布和回复都在本页完成。
评论区进入视口后自动加载。