AI Agent 乱调工具,靠改 prompt 治不好。实测数据摆在这里:模型单工具调用准确率约 95%,工具数涨到 20 个以上就掉到 65%–78%,而规模化后 60%–75% 的失败根本不是选错工具,是参数填错。这篇按「根因 → Schema 约束 → 分类隔离 → 权限白名单 → 运行时风控 → 高危二次确认」拆成四层工程防线,每层都给可落地的判断依据,也说清每层在什么情况下会失效。
目录
- 工具为什么会乱调
- 症状对照表
- 第一层:Schema 约束
- 第二层:工具分类与暴露收敛
- 第三层:权限白名单
- 第四层:运行时风控
- 第四层的最后一环:高危工具二次确认
1. 工具为什么会乱调(Why Agents Mis-call Tools)
先把责任分清楚:模型不是「偶尔手滑」,它是 结构性地 不可靠。
它更像一个记性极好、语感极强、但从不承认自己会错的外包同事。你让他「把报告表整理一下」,他能在零点几秒内产出一份格式漂亮的执行计划;问题在于,他手上同时握着「读报表」和「清空日志」两把钥匙,而他真心认为后者也算「整理」。
这个比喻不是修辞。Salesforce AI Research 的 ToolScan(工具调用错误诊断基准)把工具调用错误拆成七类,覆盖 10 类环境、30 多个任务、150 条人工标注查询,结论是 连最强的模型也会稳定复现这些错误模式(Kokane 等,ICLR 2025 Workshop,DOI:10.48550/arXiv.2411.13547):
- Insufficient API Calls(IAC,调用次数不足):该拆成多次调用的任务,只调了一次,任务没做完。
- Incorrect Argument Value(IAV,参数值错误):参数值填错,也包括漏掉必填参数。
- Incorrect Argument Name(IAN,参数名错误):幻觉出不存在的参数名,比如要
device_id却写equipment_id。 - Incorrect Argument Type(IAT,参数类型错误):布尔值写成字符串
"True",列表写成逗号串。 - Repeated API Calls(RAC,重复调用):用完全相同的参数反复调用,纯属烧钱。
- Incorrect Function Name(IFN,函数名错误):调了一个根本没注册的工具,比如系统里只有
cancel_order,它生成refund_order。 - Invalid Format Error(IFE,格式非法):输出结构本身就不合法,解析器直接报错。
七类里,IAV、IAN、IAT、IFE 四类都发生在 参数层面。这跟规模化之后的比例对得上:第三方对 BFCL(Berkeley Function Calling Leaderboard,伯克利函数调用排行榜)v3 的 2026 年 5 月快照做过一次汇总,规模化部署下 参数不匹配占工具调用失败的 60%–75%,比选错工具更高发(Presenc AI 汇总;原始榜单见 BFCL)。
同一份汇总里还有两组数字值得记住:
| 条件 | 前沿模型准确率 |
|---|---|
| 单工具调用 | 95%–96% |
| 5 个工具候选 | 85%–91% |
| 20 个以上工具候选 | 65%–78% |
以及链式调用的复合衰减:单次 90% 的成功率,连调 3 次是 ,5 次是 ,10 次只剩 。三个数字都能自己按幂次复算,这也是「生产环境偏爱 3–5 步短链」的直接原因。
工具变多为什么会更差?2026 年 6 月的 ToolChoiceConfusion 论文给了个干净对照实验:102 个任务、100 个工具、4 个模型后端、2448 次运行。把 100 个工具全暴露时,任务成功率 0.83、平均每次任务 1.25 次错误调用;只暴露当前步因果必需的那一个工具时,成功率 0.99、错误调用 0.01,token 消耗还降了约 90%(arXiv:2606.06284)。
结论很清楚:模型永远会犯错,工程必须兜底。 这跟会议里那句「90% 的开发者只会写几句描述,靠模型自觉」是同一件事的两种说法——把可靠性寄托在 prompt 上,等于把生产环境的稳定性赌在一次采样上。
2. 症状对照表(Symptom, Cause, Fix)
下面这张表是本文的索引。左边是你线上看到的现象,中间是它真正的成因,右边是对应的防线层。建议先按现象定位,再跳到对应章节。
| 症状 | 原因 | 解法 |
|---|---|---|
| 用户说「整理报表」,模型调了删除工具,日志被清空 | 语义相近的高危工具与查询工具混在同一候选集,模型按字面相似度选 | 第二层:按副作用分档隔离,高危工具不进默认候选集 |
报错 unexpected keyword argument 'equipment_id' | 模型幻觉参数名(ToolScan 的 IAN) | 第一层:Schema 白名单字段 + 别名归一化 |
报错 'str' object has no attribute 'append' | 参数类型错,模型把列表写成字符串(IAT) | 第一层:类型强校验,校验失败即拦截不重试业务逻辑 |
| 同一工具同参数被连续调用十几次 | 模型陷入重试循环,无轮次与重复检测(RAC) | 第四层:重复调用指纹 + 最大轮次熔断 |
| 日志量暴涨、token 成本翻几倍 | 错误重试无退避、无上限,错误信息回灌上下文 | 第四层:错误分类 + 有限重试 + 熔断后降级 |
| 普通用户会话触发了管理员级操作 | 工具集合按「全局」下发,未按用户/会话收敛 | 第三层:按主体绑定工具白名单 |
| 高危操作弹出确认框,用户看都没看就点了同意 | 确认框只写「是否执行」,没给影响范围 | 第七层不可省:确认必须带操作预览与默认拒绝 |
表里最后一行的「第七层」笔误不必纠正——它想说的是第四层的最后一环,也就是第 7 章。这也顺带说明一件事:防线是可以分的,章节编号是会错的,所以兜底必须写在代码里,不能写在文档里。
3. 第一层:Schema 约束(Schema Constraints)
第一层解决的是「参数根本不该进来」。它的定位是闸机,不是保安:不判断意图,只判断格式。
3.1 工具描述要能回答四个问题
很多团队的工具定义长这样:
{"name": "query_data", "description": "查询数据"}
这句话对模型的信息量约等于零。模型不知道它查什么、什么时候该调、有没有副作用,于是只能按字面猜。可用的描述至少要回答四件事:
- 这个工具能做什么;
- 它 不能 做什么(这条最常被漏掉,也最能减少误选);
- 什么场景下该调;
- 有没有副作用、是否可撤销。
对应的实例:
{
"name": "query_report",
"description": "按报表 ID 查询已生成的报表内容。仅用于读取,不修改、不删除任何数据,也不触发重新生成。当用户询问报表内容、下载报表时调用。",
"parameters": {
"type": "object",
"properties": {
"report_id": {"type": "string", "description": "报表 ID,格式为 RPT- 加 6 位数字,例如 RPT-000123"},
"max_rows": {"type": "integer", "minimum": 1, "maximum": 5000, "description": "本次返回的最大行数"}
},
"required": ["report_id"],
"additionalProperties": false
}
}
additionalProperties: false 这一行是关键。它把「模型幻觉出一个新字段」从「静默通过」变成「显式失败」。
3.2 Schema 只管概率,代码才管确定性
必须说清楚一个边界:Schema 是给模型看的 说明书,不是执行时的 强制约束。模型完全可以在 JSON 里写出 "max_rows": "十",而 JSON Schema 校验器只有在服务端真的跑一遍校验时才会拒绝它。
所以参数进入业务函数之前,必须再走一次程序化校验。Python 生态里最省事的是 Pydantic(数据校验库):
from datetime import date
from pydantic import BaseModel, Field, model_validator
class ReportQueryInput(BaseModel):
"""报表查询参数模型,负责类型、范围与跨字段约束校验。"""
report_id: str = Field(pattern=r"^RPT-\d{6}$") # 报表 ID 强格式约束
start_date: date # 起始日期,自动解析 ISO 格式
end_date: date # 结束日期,自动解析 ISO 格式
@model_validator(mode="after")
def check_range(self):
"""校验日期区间合法性,避免把空结果当成「无数据」上报。"""
if self.start_date > self.end_date: # 起始晚于结束属于业务非法
raise ValueError("start_date 不能晚于 end_date") # 抛出后由上层转成结构化错误
return self # 校验通过则原样返回
3.3 别名归一化:只修可以安全修的那部分
实测中最常见的坑不是类型错,是 同义不同名:工具要 entity,模型给 entities、products、product_names。
这类差异语义等价,可以在校验前做一层映射:
PARAM_ALIASES = { # 仅收录语义完全等价的同义字段
"entities": "entity", # 复数形式归一
"products": "entity", # 业务别名归一
"product_names": "entity", # 更长的业务别名归一
}
def normalize_arguments(raw: dict) -> dict:
"""把同义参数名映射为工具定义的规范名,返回新字典不修改入参。"""
return {PARAM_ALIASES.get(key, key): value for key, value in raw.items()} # 命中别名则替换,否则保留原名
但归一化必须有纪律:只处理含义明确、可以安全转换的字段。如果把所有「看起来像」的字段都自动纠正,真正的意图错误会被悄悄抹平——用户要的是 A 对象,模型填了 B 对象,你把它归一成 A 的字段名,错误就从「参数名不匹配」升级成了「静默处理了错误对象」,后者更难查。
这一层的边界到此为止。它保证「进来的参数是合法的」,但完全不保证「这个工具该不该被调用」。后者交给第二层。
4. 第二层:工具分类与暴露收敛(Tool Isolation)
第一层管「参数对不对」,第二层管「这个工具凭什么出现在候选集里」。
4.1 按副作用分三档
工具不是生而平等的。读一个报表和执行一条 shell 命令,风险差好几个数量级,但在很多实现里它们被塞进同一个 tools 数组平铺下发,模型看到的是一份没有权重的清单。
按副作用分档是成本最低的改进:
| 等级 | 定义 | 典型工具 | 默认处理 |
|---|---|---|---|
| L0 只读 | 无副作用,不改任何状态 | 查报表、检索知识库、只读 SQL | 直接放行 |
| L1 写入 | 改状态但可回滚 | 写入文件、INSERT/UPDATE、发消息 | 需授权 + 审计日志 |
| L2 高危 | 不可逆或影响面大 | DELETE、清空、批量操作、权限变更、代码执行 | 默认不暴露,需二次确认 |
这个三档划分和网络安全行业实践一致(参考 FreeBuf 的 Agent 权限模型实践),也与国家标准的口径吻合:全国网络安全标准化技术委员会《网络安全标准实践指南——智能体交互安全要求(征求意见稿 v0.23)》在 8.2 条明确要求「工具提供者应实施最小接口暴露原则,仅向智能体暴露最小必要的功能接口」,并要求「涉及高危系统权限的工具调用时,充分告知用户安全风险,获取用户明示同意」(原文下载)。
4.2 高危工具不进默认候选集
回到会议里那个真实事故:用户说「报告表整理一下」,模型调用了删除工具,清空日志数据。
这不是模型「理解错了」,而是工程「给错了」。当一个会话的候选集里同时有 query_report 和 delete_all_logs,你等于在问模型一道二选一的题,而它答错的概率在 20 个工具规模下是 20%–35%。
正确的做法是 默认只暴露 L0。L1、L2 通过显式授权或独立通道引入,而不是一开始就摆在桌面上。CMTF 的对照实验已经证明这条路有效:100 个工具全暴露是 0.83 成功率、1.25 次错误调用;只暴露当前步必需的工具是 0.99 成功率、0.01 次错误调用(arXiv:2606.06284;同一实验的完整参数见第 1 章,此处为同一组数字的二次引用)。
4.3 大规模工具集用「两段式路由」
工具超过 20 个时,单一模型直接选工具的准确率会掉到 65%–78%(BFCL v3 快照数据)。这时应改成两段式:
用户请求
→ 路由模型(只在 L0 中挑 5–10 个候选,不含高危工具)
→ 执行模型(只在候选集中选具体工具并生成参数)
→ 第一层 Schema 校验
→ 第三层权限校验
路由模型的职责是 收敛候选集,不是执行。这样一个职责拆分,把「100 选 1」降级成「10 选 1」,同时也把高危工具从执行模型的视野里彻底摘掉了。
这套思路和 Skill 路由是同一个问题:候选集越大,命中率越低。在 AI03 那篇《Agent Skill 路由设计》里我们讨论过大规模 Skill 的命中率保障,结论可以平移过来——收敛暴露面,比优化提示词有效得多。
5. 第三层:权限白名单(Permission Whitelist)
第二层解决「候选集里有什么」,第三层解决「这个人能调什么」。
5.1 默认拒绝,白名单优先
权限判断的顺序必须是:先查硬禁名单(Denylist,如生产环境禁用 shell 执行),再查白名单(Allowlist),两者都未命中一律拒绝。不是黑名单没命中就放行——那是默认允许,等于没有防线。
TOOL_PERMISSION_LEVELS = { # 工具名 → 风险等级映射,未登记的视为不可用
"query_report": 0, # L0 只读,直接放行
"write_report": 1, # L1 写入,需授权
"delete_all_logs": 2, # L2 高危,默认不暴露
}
DENIED_TOOLS = {"delete_all_logs"} # 生产环境硬禁名单,优先级最高
def check_tool_permission(tool_name: str) -> dict:
"""按默认拒绝策略判断工具是否可被当前执行器调用。"""
if tool_name in DENIED_TOOLS: # 硬禁名单优先,直接拒绝
return {"allowed": False, "reason": "denied_by_denylist"} # 返回结构化拒绝原因便于审计
if tool_name not in TOOL_PERMISSION_LEVELS: # 未登记工具一律不放行
return {"allowed": False, "reason": "not_in_allowlist"} # 避免模型幻觉工具名直接穿透
level = TOOL_PERMISSION_LEVELS[tool_name] # 取出风险等级
if level == 0: # L0 只读
return {"allowed": True, "mode": "direct"} # 直接执行
if level == 1: # L1 写入
return {"allowed": True, "mode": "authorized"} # 需主体已获授权
return {"allowed": True, "mode": "confirm_required"} # L2 高危,必须二次确认
if __name__ == "__main__": # 直接运行即可验证三条分支
for name in ("query_report", "write_report", "delete_all_logs", "refund_order"): # 四个典型工具名
print(name, "->", check_tool_permission(name)) # 逐条打印判定结果
运行输出:
query_report -> {'allowed': True, 'mode': 'direct'}
write_report -> {'allowed': True, 'mode': 'authorized'}
delete_all_logs -> {'allowed': False, 'reason': 'denied_by_denylist'}
refund_order -> {'allowed': False, 'reason': 'not_in_allowlist'}
注意最后一行:refund_order 从未登记,按「未登记即拒绝」被挡下。这就是默认拒绝与默认允许的分界——如果当时写的是「不在黑名单就放行」,模型幻觉出来的这个工具名会直接穿透到执行层。
5.2 权限要绑到「主体 + 会话」
只在全局配一份工具清单是不够的。同一个系统里,普通用户和运维管理员看到的候选集应该不同,临时会话和长期会话的授权也应该不同。
判据有三条:
- 主体维度:普通用户无 L2 权限,管理员全开——做不到这一点,等于所有用户共享管理员的能力。
- 会话维度:会话开始时授予一次权限,会话内不动态提升,防止中途被诱导提权。
- 凭证维度:每个工具使用独立、短时效的凭证,而不是一个全局万能 token。
这套逻辑不是 AI 时代的新发明。NIST SP 800-53 的 AC-6(Least Privilege,最小权限)早就把「只授予完成任务所必需的权限」写成了控制项;OWASP 在 LLM06:2025「Excessive Agency(过度代理)」里把同一个原则翻译到了大模型场景,核心建议就是限制工具与权限范围(AI RiskAtlas 的工具管控条目 对这两条做了逐条映射)。
值得单独提一句 2026 年 1 月的 AgenTRIM:它做的是运行时逐步最小权限——离线从代码与执行轨迹重建工具接口,运行时按每步动态过滤可用工具并做状态感知校验,在 AgentDojo 基准上显著压低了攻击成功率同时保持任务完成度(arXiv:2601.12449)。这条路线说明「权限」不必是静态配置,可以是随任务状态流动的。
6. 第四层:运行时风控(Runtime Guardrails)
前三层都是 调用前 的静态约束。第四层管的是调用 过程中 的失控。
6.1 前置意图预判:先判断「该不该调」,再判断「调哪个」
最容易被忽略的一类错误是:用户根本没要求执行,模型却执行了。
「取消订单会产生费用吗?」——这是在问规则,不是在要求取消。但模型看到一个 cancel_order 工具,很容易顺手调下去。ToolScan 把这类归入「不应该调用工具时仍然调用」,它不在七类参数错误里,危害却往往更大。
工程上可以用确定性规则做前置拦截:
import re
INTERROGATIVE_PATTERN = re.compile(r"(会不会|是否|能不能|要多少钱|会产生|规则|政策)") # 询问规则的表达模式
def looks_like_inquiry(user_input: str) -> bool:
"""判断输入是否只是在询问规则而非下达执行指令。"""
return bool(INTERROGATIVE_PATTERN.search(user_input)) # 命中询问模式返回 True
命中询问模式时,把 L1、L2 工具从候选集里临时摘掉,只留 L0 检索类工具。这是一条「宁可少做,不可做错」的规则——取证式提问和命令式请求,风险不对称。
6.2 熔断:三道保险丝
只做意图预判不够,模型还会在失败后反复重试。需要三道保险丝:
第一道:重复调用指纹。 对 (工具名, 规范化参数) 做哈希,同一指纹在一次任务内出现超过 2 次即熔断。这一道直接针对 ToolScan 的 RAC 类别。
第二道:最大轮次上限。 给任务设一个硬上限,比如 MAX_ITERATIONS = 2 次纠错重试。超过就停止,返回明确失败原因。理由是收益递减:参数错误重试一次可能修好,权限不足重试一百次也不会成功,只会把延迟和 token 成本一起推高。
第三道:错误分类驱动的差异化处理。 把错误分成四类,分别走不同路径:
| 错误类型 | 例子 | 处理策略 |
|---|---|---|
| 参数错误 | 日期格式错、缺必填字段、字段名错 | 返回结构化校验错误,允许模型修正一次 |
| 结果为空 | 查不到设备、时间窗内无记录 | 调整查询条件,仍为空则明确告知用户 |
| 暂时性错误 | 网络超时、限流、服务繁忙 | 按指数退避有限重试 |
| 永久性错误 | 无权限、资源不存在、违反业务规则 | 停止重试,直接返回原因 |
这里有个容易做错的细节:错误信息 不要 统一压成「工具调用失败,请重试」。这个字符串对模型来说零信息量,它会原地重试。错误信息越具体,模型自我修复的成功率越高;反之,模糊错误 + 无上限重试 = 日志爆炸 + 成本飙升,正好是会议里提到的那条事故链。
这一层的最后一道闸,是任何静态规则都替代不了的部分:高危操作的二次确认。它单独成章。
7. 第四层的最后一环:高危工具二次确认(Human-in-the-loop)
7.1 为什么必须做
删除、修改、批量操作、数据清空——这些工具一旦误调,损失不可逆。国家标准对此的要求是硬性的:《智能体交互安全要求》8.2 g 明确要求高危系统权限的工具调用必须「充分告知用户安全风险,获取用户明示同意」。
原则很清楚:AI 无权自己执行高危操作。Agent 只负责生成操作预览——做什么、为什么、影响范围多大——由用户确认后才真正执行。
7.2 但二次确认不是万能药(反直觉数据)
这里必须泼一盆冷水,否则第 7 章会变成一篇安全安慰剂。
2026 年多项研究指向同一个结论:人类在审批环节的表现远比我们想象的差。
- 一项约 4 万场次、40.9 万次批准/拒绝决策的公开实验显示,人类审批者整体准确率只有 66.3%,约每 3 个威胁漏掉 1 个,且 32.9% 的场次「净收益为负」——放过了威胁、又误拦了安全命令(explainx.ai 数据整理)。
- 一项 1053 名测试者参与的 Claude Code 现场实验里,面对注入的危险命令,人工拦截率仅 13.6%,而同一场景下后台自动分类器的拦截率是 89%(分析)。
- 更糟的是「Invisible Ink Threats」那类攻击:攻击者把恶意目标藏在合法任务背后,此时 HITL(Human-in-the-loop,人在回路)不但没降低攻击成功率,反而平均升高 7.8%,个别配置下从 21.2% 翻到 42%(arXiv:2608.02018)。
上述前两条均为二手转述,原始实验报告未完整公开,引用时请按「方向性证据」使用,不要当精确基准。但三条方向一致,足以支撑一个判断:确认框的质量,决定了二次确认是防线还是橡皮章。
7.3 有效确认框的四个要素
所以第 7 章的正确写法不是「加个确认弹窗就完事」,而是给出确认框的设计约束:
- 给操作预览,不给抽象提问。 不要问「是否允许执行 delete_all_logs」,要写「将删除 /var/log/app 下 3 个文件、共 1.2 GB,操作不可撤销,最近一次备份在 3 天前」。
- 给影响范围,且可核对。 影响的对象数量、涉及的表/目录、是否会触发下游任务——这些必须来自真实的预检,不是模型口述。
- 默认拒绝,而不是默认同意。 弹窗的安全动线应该让「不同意」成为默认选项,避免用户习惯性回车。
- 合并同类项。 连续弹出 20 个确认框,用户必然无脑点同意;批量操作应聚合成一次确认,并明示总量。
第 4 条尤其重要:审批疲劳是 66.3% 和 13.6% 这两个数字最直接的解释。确认次数越多,单次确认的信息价值越低。
7.4 正确的优先级:最小权限 > 二次确认
最后回到一个工程判断上:二次确认应该是 最后一道 防线,不是第一道。
顺序反了会出问题。如果高危工具本来就在候选集里、权限也没收敛,那你只能靠人工审批去兜;而人工审批的准确率已经被上面三组数据否掉了。反过来,如果第二层就把高危工具移出默认候选集、第三层就把权限绑到主体,那么需要人工确认的场景会被压缩到极少数——这时候确认框稀少、信息完整,人工判断才真正有效。
这也解释了第 5 章为什么要强调默认拒绝:每减少一次不必要的确认,剩下的确认就更有分量。
这套四层防线适合工具数超过 5 个、且存在至少一个不可逆操作的生产系统;如果你的 Agent 只接两三个只读工具,加这么多层反而拖慢响应,先把 Schema 校验做扎实就够了。
本专栏《AI Agent 工具》聚焦 tool calling 的可靠性治理,从工具描述、候选集收敛、权限管控一路讲到运行时风控与人工确认,每篇只拆一个能落地的问题,每周更新一篇。