Agent 工具调用基础:让 Agent 真正动手做事 · 第 4 / 6 节

第 4 课:工具接口设计:名字、描述、参数、返回值

学习目标:

  • 判断一份工具 description 能不能让模型选对工具、填对参数
  • 用 JSON Schema 的 enum、required 把参数误用的空间收窄,知道用 strict 模式把约束变成硬保证
  • 设计出模型能据此自我纠正的返回值和错误信息

前置要求:读完第 3 课,知道读 / 写 / 执行 / 搜索 / 调用五类工具的区别 | 上一课 第 3 课 << | 下一课 第 5 课 >>

同一个工具,两份 description,两种下场

工具箱里有一个代码搜索工具,第一版是这样注册的:

用户说:"utils.ts 这个文件放在项目哪个目录下?"

模型看到的信息只有 name 和 description 两行字。它没法知道 search_files 是按文件名找文件,还是在文件内容里找字符串——description 没说。模型选了这个工具,把 utils.ts 当作 query 传进去:

如果这个工具背后其实是全文搜索(在每个文件的内容里找 utils.ts 这个字符串),而没有哪个文件的内容里真的写着这几个字,结果就是空。模型拿到空结果,不知道是"文件不存在"还是"我搜索方式错了",只能猜——常见的猜法是换几个近义词再搜一遍,继续拿到空结果。

现在把 description 换成这样:

同一个问题,这次模型读到"文件名查找请改用 code_search_glob",直接换用了工具箱里同时注册的 code_search_glob,传对了参数:

两次调用之间没有换模型、没有换 prompt,也没有改任何实现代码——变的只是工具定义里模型能读到的那几行字:更准确的 name、说清边界并点名替代工具的 description、带说明的参数。这就是这一课要讲的东西:工具接口的每个字段,都是模型做决策时唯一能看到的依据。

description 是模型选工具时唯一能看到的东西

写代码的人习惯把工具当 API 来写注释:函数名起得达意,实现逻辑写在函数体里,谁要用就去翻源码。这套习惯搬到工具定义上会出问题——模型不会去读实现代码。它能看到的只有 name、description、input_schema 这几个字段1,选哪个工具、传什么参数,全靠这几行字。

官方对 description 的要求很直接:详细说明这个工具是做什么的、什么时候该用、行为是什么样1。这三件事缺一个,模型就得靠猜:缺"做什么",模型可能干脆不用它,绕远路硬凑;缺"什么时候用",工具箱里有几个相近工具时(比如同时有 grep 和 glob)模型分不清边界,选错概率随工具数量上升;缺"行为是什么样",模型不知道调用后会拿到什么形状的结果,写不出能正确解析这个结果的后续逻辑。

好的 description 要消除输入输出上的歧义,而不是追求辞藻2。上一节 code_search_grep 的 description 有效,是因为它做了两件事:说清楚了"搜内容而不是搜文件名",又指名了"文件名查找请用 code_search_glob"。这两句话让模型在相似工具之间做选择时不需要试错。

名字也要说清楚归属:命名空间

description 负责说清楚"这个工具做什么",name 负责另一件事:在一堆工具里,让名字本身就不容易和别的工具混淆。工具一多,尤其是接入多个外部服务之后,list_prssend_messagecreate_issue 这种名字谁都能起,光看名字分不出属于哪个服务。

官方的建议是给工具名加上服务前缀,比如 github_list_prsslack_send_message3。当模型要在几十个工具里挑一个时,带前缀的名字相当于先把范围收窄一层,不用打开 description 逐条对比就能排除掉大半选项。第 3 课的五类工具(读、写、执行、搜索、调用)如果分别接了不同后端,同样适用:fs_read_filedb_read_row 一看就不是一回事,光叫 read 就分不清。

input_schema:把参数的形状钉死

description 决定模型会不会选这个工具,input_schema 决定模型能不能把参数填对1。这里有个容易忽略的地方:JSON Schema 里不是所有字段都在"约束"参数,有的字段只是在"说明"参数。

给某个参数加一句 description,只是表达意图,不会拒绝任何不符合这句话的输入4

模型完全可能传 "typescript",也可能传 "ts",也可能传 "TypeScript 文件"——这句 description 只是建议,没人拦着它乱填。真正能拦住乱填的是 enum:

加上 enum 之后,合法取值被显式列出来,模型几乎总会照着填,乱填的概率大幅下降。但注意:这是对模型的强引导,不是平台的硬保证——默认模式下 API 并不替你校验参数是否符合 schema,模型偶尔仍会产出类型不符或漏掉必填字段的输入5,工具实现那一侧对非法值的检查还是要保留。required 同理:一个"写文件"工具如果 path 不是必填,模型偶尔会漏填,工具实现要么报错要么猜一个默认路径,两种结果都不理想;把 path 标成 required,能让这类误用的概率降到很低,而"必填"二字被平台真正强制执行,要靠下一节的 strict 模式。

记住这个区别:type、enum、required 是校验语义上的真约束,title、description 只是给模型看的说明,写得再细也不构成校验规则4。设计 input_schema 时先问:这个参数上"不该出现的输入"能不能用 enum 或 required 直接堵死,而不是只在 description 里写"请传 xxx"。至于这些约束怎么从"写在 schema 里"升级成"平台强制执行",下一节讲。

把软约束变成硬保证:additionalProperties: false 和 strict 模式

上一节反复强调"约束"和"说明"的区别,但还有一层要分清:schema 里写了约束,和模型产出的参数一定通过校验,仍是两回事。默认模式下,API 不会替你拦截不符合 schema 的调用——模型偶尔会把数字写成字符串 "2",或者干脆漏掉一个必填字段5

还有一个更隐蔽的方向:模型可能凭空多给字段。假设一个建工单工具的 schema 只声明了 titlepriority 两个参数,模型某次调用却传来了:

skip_review 这个键,schema 的 properties 里从来没写过,是模型自己联想出来的。标准 JSON Schema 的默认行为恰恰允许对象携带这种未声明的额外键——如果你的工具实现顺手把整个 input 透传给下游,而下游代码里真有一段逻辑在检查这个字段名,一次模型的臆造就静默绕过了本该有的审核步骤。在 input_schema 顶层加上 "additionalProperties": false,把"只允许出现声明过的键"也写进校验规则。

要让平台真正强制执行这一切,给工具定义加上顶层字段 "strict": true。strict 模式的原理是约束模型的采样过程本身,让它只能生成符合 schema 的 token 序列——类型、enum、required、additionalProperties 全部兑现,不合法的参数根本不会被生成出来5。官方文档里 strict 模式的示例 schema 全都同时带着 additionalProperties: false,两者是配套使用的。到这一步,"不合法的值在请求发出前就被排除"才真正成立;没开 strict 模式的工具,实现侧的参数校验一行都不能省。

返回值:给模型下一步能用的信息,不是给人看的日志

工具执行完,结果会被包进一个 tool_result 块传回模型,核心字段是 tool_use_id(对应哪次调用)、content(结果内容)、is_error(有没有出错)6。这三个字段里,最容易写坏的是失败时的 content。

假设一个"写文件"工具因为目录不存在而失败,两种写法:

这是把系统日志原样甩回去。模型能看出来"失败了",但看不出来"接下来该做什么"——常见的后果是模型原样重试同一次调用,第二次还是同样的错误,陷入循环。

同样是失败,这个版本告诉模型三件事:失败原因是什么、可以调用哪个工具来解决、或者还有别的什么路径可选。MCP 规范说得很明确:客户端应该把工具执行时的错误反馈给模型,让它能自己纠正7——前提是这段信息本身携带了纠正所需要的线索,不是一段只有排查代码的人才看得懂的堆栈信息。

工具数量和粒度:不是越多越好

工具箱不是越大越好用。每个工具定义(name、description、input_schema 加起来)在对话开始前就要塞进上下文,工具一多,这部分开销涨得很快。Anthropic 工程团队给过一个数字:58 个工具的定义占用了大约 55K token,对话真正开始之前就先烧掉这么多上下文;他们内部还见过更极端的情况——优化之前,工具定义本身就吃掉了 134K token8。上下文越挤,模型能留给实际任务推理的空间就越少。

工具数量多带来的第二个问题和 token 无关:选择本身变难了。功能相近的工具堆在一起,模型光是"该用哪一个"就要多做一步判断,判断错的概率跟着工具数量一起涨,更多工具不代表智能体表现更好2。这也是为什么前面反复强调 description 要写清楚边界。

反过来,粒度太粗也不行。一个"文件操作"工具把读、写、删、改都塞进一个 input_schema,靠 action 参数区分行为,模型得先猜对 action 取值,再猜对该填哪些参数——比拆成 fs_read_filefs_write_file 几个职责单一的工具更容易出错。实践中的取舍:先按第 3 课的五类划分把工具拆开,数量涨上来之后再用命名空间和精确的 description 控制选错的概率,而不是靠堆一个万能工具去压数量。

小结

  • description 是模型选工具、填参数时唯一能看到的说明,写清楚"做什么、什么时候用、什么时候别用"比写得优雅更重要
  • name 里加上服务前缀(命名空间)能在工具一多时帮模型先排除掉一大批不相关的选项
  • input_schema 里 type、enum、required 是校验语义上的真约束,title、description 只是说明;enum 加 required 能把模型乱填的概率压到很低,把"不合法输入根本不会被生成"变成硬保证要靠 additionalProperties: false 加 strict 模式5
  • 返回值尤其是失败时的返回值,要把"为什么失败"和"下一步该做什么"写清楚,模型才能自我纠正,而不是原样重试
  • 工具数量不是越多越好:定义要吃掉上下文 token,工具越相似模型越容易选错;粒度也不是越细越好,先按功能拆分,再靠清楚的命名和 description 控制选错的概率

>> 第 5 课:权限与安全:给 Agent 的动手边界

Footnotes

  1. Define tools — Claude API — https://platform.claude.com/docs/en/agents-and-tools/tool-use/define-tools 2 3

  2. Writing effective tools for AI agents—using AI agents | Anthropic Engineering — https://www.anthropic.com/engineering/writing-tools-for-agents 2

  3. How to implement tool use - Claude Platform Docs — https://platform.claude.com/docs/en/agents-and-tools/tool-use/implement-tool-use

  4. Creating your first schema - JSON Schema — https://json-schema.org/learn/getting-started-step-by-step 2

  5. Strict tool use — Claude API — https://platform.claude.com/docs/en/agents-and-tools/tool-use/strict-tool-use 2 3 4

  6. Handle tool calls — Claude API — https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls

  7. Tools - Model Context Protocol — https://modelcontextprotocol.io/docs/concepts/tools

  8. Introducing advanced tool use on the Claude Developer Platform | Anthropic Engineering — https://www.anthropic.com/engineering/advanced-tool-use

练习

01

项目里有个"写文件"工具,现在的定义是:

Level 1:重写一份含糊的工具定义

工具箱里同时还有一个 edit_file 工具,专门做"在已有文件里做局部替换"。模型经常该调 edit_file 做小修改时却调了 write_file,把整个文件覆盖掉。

请重写 write_file 的 description 和 input_schema,要求:

  1. description 里说清楚这个工具会整体覆盖文件内容,并指出局部修改应该用 edit_file
  2. 给 input_schema 加一个用 enum 约束的参数,区分"文件不存在时创建"和"文件已存在时覆盖"两种情况,避免模型不小心覆盖了不该动的文件
  3. 检查:如果模型收到"帮我把 config.json 里的端口号改成 8080"这句话,还会不会去调 write_file
完成标准 · 本地勾选
02

下面是一段简化过的真实往返记录。一个"运行测试"工具连续被调用了 3 次,每次入参完全一样:

Level 2:诊断一次因返回值写坏而失败的调用
第 1 次调用: { "name": "run_tests", "input": { "suite": "unit" } }返回: { "content": "Error: connect ECONNREFUSED 127.0.0.1:5432", "is_error": true }
第 2 次调用: { "name": "run_tests", "input": { "suite": "unit" } }返回: { "content": "Error: connect ECONNREFUSED 127.0.0.1:5432", "is_error": true }
第 3 次调用: { "name": "run_tests", "input": { "suite": "unit" } }返回: { "content": "Error: connect ECONNREFUSED 127.0.0.1:5432", "is_error": true }

请回答:

  1. 模型为什么会用完全相同的参数重复调用 3 次,而不是换一种做法?
  2. 这个错误的真实原因(数据库连接被拒绝,端口 5432 没人监听)需要模型做什么才能解决?工具箱里假设还有一个 start_service 工具。
  3. content 字段重写成一段能让模型在第 2 次调用之前就换成正确做法的错误信息。
完成标准 · 本地勾选

我的笔记

记下想法、痛点、没懂的地方。只写进这门课的附录,正课文件不动。