Part 4 · Agent Design Patterns 智能体设计

4.1 Tool Use function calling 的本质与 schema 设计

难度 ★★☆ 阅读 ~14 min 面试出现频率 高 前置 无
Core Concept 核心概念

模型从来没有「执行」过任何工具

这是理解 agent 的第一块基石,也是最常被误解的一点。function calling(函数调用)听起来像模型在调用函数,实际发生的是:

模型生成了一段结构化文本,内容是「我想调用 get_weather,参数是 {"city": "北京"}」。仅此而已。它没有联网,没有访问数据库,没有执行任何代码。真正去执行的是你的程序——你解析这段输出,决定要不要执行、以什么权限执行,然后把结果作为新的一轮输入送回模型。

工具调用回路 — 注意那条边界线 模型侧(只会生成文本) 你的代码(真正的执行者 · 信任边界) ① 收到问题 + 工具 schema ② 输出:想调用 get_weather(city=北京) ③ 校验参数 · 鉴权 · 真正执行 ④ 把结果作为新一轮输入送回 ⑤ 读到结果,生成最终回答 模型的全部能力止步于②。所有安全控制都必须建立在③,因为那是唯一真实发生的地方。
图 1 · 工具调用回路与信任边界。橙色虚线是整章最重要的一条线。模型的输出是一个请求,不是一个命令——它可以被拒绝、被修改、被要求二次确认。任何把模型输出直接送进执行器的设计,都等于把系统的权限交给了一段可被输入操纵的文本(见 6.2)。

这个认识有一个直接的工程推论:agent 的安全性完全由你在③处的实现决定,与模型有多「听话」无关。再好的 prompt 也只是降低模型提出危险请求的概率,而③处的权限校验才是真正的保证。

工具描述就是 prompt

模型凭什么知道该调哪个工具、参数怎么填?只凭你给的那段 schema——工具名、描述、参数名、参数描述。它不能读你的源码,不知道你的业务约定,也没有试错的机会。

所以 tool schema 不是接口文档,它是 prompt 的一部分,应该按写 prompt 的标准去写,而不是按写 API 文档的标准。这个视角转换能解决大部分工具调用失败:

要素✗ 常见写法✓ 更好的写法
工具名qry_usr_ord_v2get_user_orders——用模型能理解的自然命名,缩写和版本号只会制造歧义
描述「查询订单」「查询指定用户的历史订单列表。仅返回近 12 个月内的订单;查单笔订单详情请用 get_order_detail」——写清边界和与相邻工具的区别
参数status: stringstatus: enum["pending","shipped","done"]——能用枚举就别用自由文本,这一条改动往往能消除大半参数错误
必填项全部可选,缺失时报错明确标出必填;对可选参数说明默认行为
错误返回{"error": "invalid input"}「city 参数无效:'北平'。有效值为中国城市的现用名称,如 '北京'」——错误信息是模型下一轮的输入,要写成教学材料

最容易被忽略的一条:错误信息是给模型看的。传统 API 的错误信息写给开发者,可以简略;agent 的错误信息会直接进入模型的下一轮上下文,成为它自我纠正的唯一依据。一句「参数错误」让模型只能瞎猜;一句「日期格式应为 YYYY-MM-DD,你传的是 '下周三'——如需相对日期请先调用 get_current_date」则几乎必然让它一次改对。把错误信息当 prompt 写,是投入产出比最高的 agent 优化之一。

工具集设计的三条原则

① 为模型设计,不要镜像内部 API。内部 API 是为程序员设计的,往往粒度细、参数多、需要多步组合。直接暴露给模型,等于要求它现场学习你的系统架构。更好的做法是包一层面向任务的工具:与其给出 get_user + get_orders + get_shipping 三个工具让模型自己拼,不如提供一个 get_order_status(order_id) 直接返回它需要的全部信息。每减少一次模型的推理跳跃,就减少一处出错的机会。

② 工具数量要控制。工具越多,选错的概率越高,且每个工具的 schema 都要占用上下文(并影响 2.2 的缓存前缀长度)。当工具超过十几个时,应考虑分组按需加载——先让模型选择任务类别,再只加载该类别下的工具。

③ 消除工具之间的语义重叠。如果两个工具的描述让模型难以区分该用哪个,那么错误就是必然的,且这类错误无法靠 prompt 修好。重叠是设计问题,要在 schema 层面消除——合并、或在描述里写明「什么时候用这个而不用那个」。

Case Study 案例

把 18 个工具砍成 6 个之后(匿名化)

内部运维助手 · 工具调用成功率长期上不去

背景:一个内部运维 agent,把已有的运维 API 逐个包装成工具暴露给模型,共 18 个。上线后调用成功率始终不理想,团队反复调整 system prompt 收效甚微。

诊断:把失败轨迹按错误类型归类后,问题分布得很清楚——错误集中在三类,而没有一类是「模型不够聪明」:

选错工具
三个查询类工具功能高度重叠,描述看不出区别
参数格式错
时间参数是自由文本,模型填入「昨天」「上周」
多步拼装失败
一个常见任务需串联 4 个工具,中途任一环出错即失败

三处针对性修改:

① 合并重叠工具:三个查询工具合并为一个,用 resource_type 枚举参数区分。模型不再需要在语义相近的选项间做判断——把「选择」变成「填参数」,难度下降一个量级。

② 参数改枚举 + 结构化:时间参数从自由文本改为 {"range": enum["last_1h","last_24h","last_7d"]},覆盖 90% 的真实用法;确需精确时间时用单独的可选字段。枚举把「模型要生成正确格式」变成了「模型只需选一个」。

③ 为高频任务提供组合工具:把那个需要 4 步的常见诊断流程,包装成单个 diagnose_service(service_name),内部由代码按固定顺序编排。确定性的流程就该用代码写死,不该让模型每次现场规划——模型只在真正需要判断的地方发挥。

18 个工具最终收敛到 6 个。整个过程没有更换模型、没有微调、system prompt 基本没动。

可迁移的判断:当 agent 表现不佳时,团队的第一反应通常是「调 prompt」或「换更强的模型」,但很多失败的根因在工具集的设计里。一个有效的自检问题是:把这套工具的 schema 打印出来交给一个不了解你系统的新同事,他能只凭这些描述正确完成任务吗?如果不能,模型也不能——它掌握的信息不会比这更多。

说明:本案例为综合多个真实项目的匿名化复盘,不含任何可识别的公司、系统或个人信息。工具数量与错误分类为叙述性概括,非某次具体实验的测量数据。
Tooling Sidebar 工具层
function calling 的实现细节(会过期,正文不依赖本区块) 截至 2026-08

Schema 格式:主流 API 普遍以 JSON Schema 描述工具参数。多家提供「严格模式」(strict / structured outputs),可在解码层保证输出符合 schema——这类似 3.4 提到的约束解码,能从机制上消除结构性错误,能开就开。

并行工具调用:多数模型支持一轮返回多个工具调用请求。若这些调用彼此独立,并行执行可显著降低总延迟(见 4.2 关于轮数放大延迟的讨论)。注意你的执行层要真的并发执行,否则这个能力白给。

MCP(Model Context Protocol):把工具定义与实现从应用中解耦的开放协议,让同一批工具可被不同的 agent 应用复用。适合工具需要跨多个应用共享的场景;单一应用内的少量工具直接实现更简单。

本地/开源模型:工具调用能力差异比通用能力差异更大。小模型常见的失败是「生成了正确 JSON 但选错工具」或「参数幻觉」。若必须用小模型,严格模式 + 极少的工具数 + 全枚举参数是可行的组合。

务必记录的可观测数据:每次工具调用的名称、参数、返回、耗时、成功与否,以及失败后模型的下一步动作。这是 agent 排查的第一手材料,也是 4.5 轨迹评估的数据基础——不记录,后面所有分析都做不了。

Quiz 自测

章末自测

概念题「模型调用了删除接口,把生产数据删了」——这个说法在技术上哪里不准确?正确的表述和防护点在哪?

不准确之处:模型没有、也不可能「调用」任何接口。它只是生成了一段文本,内容是「建议调用 delete_records,参数为 ...」。真正执行删除的是应用代码——是它读取了这段输出,并选择了执行。

正确表述:「应用代码在没有充分校验的情况下,执行了模型建议的删除操作。」

为什么这个区分很重要:它决定了防护点在哪。如果认为「模型删了数据」,修复方向会是「改 prompt 让模型别这么做」——这只能降低概率,永远无法保证,而且在 prompt injection 面前完全失效(6.2)。

正确的防护点全在执行侧:① 最小权限——agent 使用的凭证本身就不应具备删除生产数据的权限;② 危险操作需二次确认——不可逆操作必须由人确认,而不是由模型判断该不该做;③ 参数白名单与范围校验——限制作用范围;④ 可审计——全部工具调用留痕,可回溯可回滚。

一句话:把模型的输出当作来自不可信用户的请求来对待,你为普通用户输入做的所有校验,一个都不能少。

场景判断agent 经常在两个功能相近的工具之间选错。应该怎么修?为什么调 system prompt 通常不管用?

为什么 prompt 不管用:模型选工具时的主要依据是工具自身的 schema——名称与描述,它们与查询的语义相关性决定了选择。system prompt 里的「注意区分 A 和 B」是一句远离决策点的抽象指令,很容易被具体的语义相似度压过去。在错误的层面上修问题,只能靠概率,修不干净。

按优先级的修法:

① 合并。最彻底的解法——如果两个工具真的相近,就合并成一个,用枚举参数区分。把「选择题」变成「填空题」,问题从根上消失。

② 在描述里写明分界。不能合并时,在每个工具的描述里明确写出边界与对照:「查询订单列表用本工具;查询单笔订单详情请用 get_order_detail」。相互引用的描述比孤立的描述有效得多。

③ 重命名。让名称本身携带区分度:search_orders_by_user / get_order_by_id 一眼可辨,胜过 query_orders / fetch_order。

④ 分组按需加载。工具很多时,先判断任务类别,只加载相关工具——模型面对的选项少了,选错的机会自然少。

通用原则:能在 schema 层面消除的歧义,不要留给 prompt 去弥补。结构约束优于语言劝导——这与 3.4 中「格式问题用约束解码而非微调」是同一条思路。

架构评审某 agent 设计:暴露一个通用工具 execute_sql(query: string),让模型自己写 SQL 查数据库。评审这个设计。

优点:极其灵活,一个工具覆盖所有查询需求,无需为每种查询单独建工具。在只读、内部、低风险的数据探索场景下,这是一个合理甚至优秀的设计。

但作为面向用户的生产设计,它有四个严重问题:

① 权限边界完全消失。一个能执行任意 SQL 的工具,其权限等于该数据库连接的全部权限。模型写出 DROP TABLE 或跨租户查询时,你没有任何结构化的手段去拦截——只能做字符串匹配,而这是出了名的拦不干净。

② prompt injection 的直通车。如果查询涉及用户可控的内容,攻击者可以诱导模型生成任意 SQL。这把一个 LLM 问题直接升级成了 SQL 注入(6.2)。

③ 正确性无法保证。模型需要准确掌握表结构、字段含义、join 关系与业务约定。schema 稍复杂,它写出的 SQL 就可能语法正确但语义错误——返回一个看起来合理的错误数字,比报错危险得多。

④ 性能不可控。模型可能生成全表扫描或笛卡尔积,拖垮生产库。

改进方案:① 参数化的任务级工具——把真实需要的查询固化成 get_sales_by_region(region, month) 这样的工具,SQL 由代码写死,模型只填参数。这解决全部四个问题,是绝大多数生产场景的正确答案。② 若确需灵活性:只读副本 + 只读账号 + 查询超时 + 结果行数上限 + 表/列白名单 + 全量审计日志,并把它限制在内部可信用户使用。③ 折中:让模型从预定义的查询模板中选择并填参数——保留部分灵活性,同时保住可控性。

贯穿的判断:工具的能力上限就是攻击面的上限。设计工具时问一句「如果模型完全被攻击者控制,这个工具最坏能造成什么后果」——答案不可接受,就该收窄它。