工具设计,写给模型用的 API

把工具视作模型使用的 API,说明如何按任务拆分工具、写清描述和参数、返回可读错误,并区分读写权限。

outlier 15 分钟 5923 字 0 0
系列教程Agent从入门到放弃 第 21 / 32 章
  1. 01Agent 从入门到放弃:教程大纲
  2. 02大模型、Chatbot、Agent,一次把名字理清楚
  3. 03Agent 的最小定义,一个 while 循环
  4. 04Agent 简史,从 ReAct 到 Claude Code
  5. 05Workflow 还是 Agent?大部分场景你不需要 Agent
  6. 06Agent 能力地图,为什么编码 Agent 最先成熟
  7. 07Token,模型眼里的世界
  8. 08Next-token prediction,一个字一个字地猜
  9. 09采样,temperature、top-p 与"同题不同答"
  10. 10Transformer 与 Attention,不推公式的直觉版
  11. 11Context Window,模型的内存条
  12. 12训练三阶段,能力是怎么被"装"进去的
  13. 13推理模型,让模型先想再答
  14. 14用原理解释怪行为,原理篇结业考
  15. 15从 Prompt 工程到 Context 工程
  16. 16System Prompt 写作,角色、约束与示例
  17. 17Context Rot,为什么长对话越聊越笨
  18. 18项目记忆,CLAUDE.md 与 AGENTS.md
  19. 19检索,RAG 还是 Agentic Search
  20. 20Function Calling 协议拆解
  21. 21工具设计,写给模型用的 API
  22. 22MCP 入门,工具界的 USB-C
  23. 23用 Go 手写一个 MCP Server
  24. 24MCP 进阶与边界
  25. 25手写 Agent ②,第一个工具,read_file 与 list_files
  26. 26手写 Agent ③,让它会改代码,edit_file
  27. 27手写 Agent ④,给它一个终端,run_bash 与权限
  28. 28手写 Agent ⑤,上下文管理
  29. 29手写 Agent ⑥,流式输出、错误处理与重试
  30. 30手写 Agent ⑧,Skills 与 MCP 接入
  31. 31Claude Code 解剖,对照自己的实现
  32. 32自动化,Hooks、自定义命令与 Headless 模式
查看教程目录

开场:一个"万能"的数据库工具

我给 Agent 写的第一个"业务工具",是这样的:

{
  "name": "db",
  "description": "database",
  "input_schema": {
    "type": "object",
    "properties": { "op": {"type": "string"}, "t": {"type": "string"}, "f": {"type": "string"} }
  }
}

当时的想法很"优雅":一个工具搞定所有数据库查询,op 是操作,t 是表名,f 是过滤条件。灵活,通用,代码少。出错了就返回一个错误码,ERR 2 表示表名不对,ERR 4 表示操作不支持,反正我自己知道是什么意思。

然后我让 Agent 去查一个客户最近一笔已发货订单的快递单号。

它先试了 {"op": "query", "t": "customer"},返回 ERR 4。它猜可能是 op 不对,换成 "select",还是 ERR 4。换成 "find",ERR 4。第四次它试了 "get",终于对了,返回了所有客户的数据。然后它开始猜订单表叫什么,order?orders?order_info?……

十五步之后,它放弃了,说"无法确定数据库的结构"。

我盯着日志想了很久。工具本身没有 bug,每一次调用都按我设计的逻辑正确地执行了。问题是,这个工具是写给我自己用的,不是写给模型用的。 我知道 op 应该填 get,知道 ERR 4 是什么意思,知道订单表叫 orders。模型不知道,而它能看到的,只有那一行 "description": "database"。

这一篇讲工具设计。读完本篇,你应该能回答这几个问题:

  • 给模型设计工具,和给人设计 API,有什么不同?
  • 工具的名字、描述、参数,分别该怎么写?
  • 返回值为什么要精简,为什么最好带上"下一步可以做什么"?
  • 为什么说错误信息本身就是提示词?
  • 工具太多、功能重叠,会有什么问题?
  • 会改变外部世界的工具,要怎么单独设计?

一、工具是写给模型用的 API

1.1 你的用户只能看到定义

第 19 篇说过:模型对一个工具的全部认知,就是它的名字、描述和参数 schema。 它看不到你的实现,看不到你的数据库表结构,看不到你团队的 wiki。

把模型想象成一个新来的同事,有几个特别的限制:

  • 他没法去翻文档:你没写在描述里的东西,他就不知道;
  • 他没法问你:遇到不明白的地方,只能猜;
  • 他每次都要重新读一遍说明:工具定义每次请求都要带上,占 token,占注意力(第 14 篇);
  • 但他很聪明,而且会从错误里学习:你告诉他哪里错了、该怎么改,他马上就能改对。

为这样一个用户设计 API,原则和给人设计 API 很不一样。

1.2 一个对比实验

配套代码 examples/ch20-tool-design/main.go 把开场的故事做成了一个实验:同一个任务、同一份假数据,分别交给"一套糟糕的工具"和"一套好工具"。

任务是:

客户 Zhang.San@Example.com 最近一笔已发货订单的快递单号是多少?

糟糕的那套,就是开场那个万能的 db 工具。好的那套,有三个工具:

var goodTools = []llm.Tool{
    {
        Name: "find_customer",
        Description: "按邮箱或姓名查找客户,返回客户 ID。邮箱不区分大小写。" +
            "拿到客户 ID 后可以用 list_orders 查订单。",
        // 参数:query,"客户邮箱或姓名,例如 zhang.san@example.com 或 张三"
    },
    {
        Name: "list_orders",
        Description: "列出某个客户的订单,按下单日期从新到旧排序。" +
            "status 可选:pending(待发货)、shipped(已发货)、delivered(已签收)。",
        // 参数:customer_id("find_customer 返回的客户 ID,形如 c_001"),status(枚举)
    },
    {
        Name:        "get_shipment",
        Description: "查询订单的物流信息(快递单号)。只有 shipped 或 delivered 的订单才有物流信息。",
        // 参数:order_id,"订单 ID,形如 o_1001"
    },
}

下面几节,我们一边拆这两套工具,一边讲每一条原则。

二、选什么工具:按任务切分

2.1 不要照搬底层 API

开场那个 db 工具,犯的第一个错误是照搬了底层的抽象:数据库有增删改查,有表,有过滤条件,于是工具就是"操作 + 表 + 条件"。

这对人来说很自然,因为人知道表结构。但对模型来说,这等于给了它一把万能钥匙,却不告诉它门在哪里。

好的那套工具,是按任务切分的:要完成"查快递单号"这件事,需要找客户、列订单、查物流三步,那就给三个工具,每个工具做一件明确的事。模型不需要知道表结构,只需要知道"我现在要找客户,那就用 find_customer"。

flowchart TB subgraph B["照搬底层:一个万能工具"] B1["db(op, t, f)"] --> B2["模型要猜:
op 填什么?表叫什么?
条件怎么写?"] end subgraph G["按任务切分:每个工具一件事"] G1["find_customer(query)"] --> G2["list_orders(customer_id, status)"] G2 --> G3["get_shipment(order_id)"] end style B2 fill:#ffcdd2,stroke:#b71c1c style G fill:#e8f5e9,stroke:#2e7d32

2.2 也不要切得太碎

反过来也不对。如果底层有五十个 REST 接口,就包装成五十个工具,模型面对五十个选项,选错的概率会大大增加,工具定义本身也会吃掉大量上下文。

一个经验法则:想想一个人做这件事时,脑子里会分成哪几步,每一步一个工具。如果某几个接口总是一起调用(比如"查订单"之后几乎总要"查订单详情"),考虑把它们合并成一个工具。

2.3 名字要清楚,而且不能撞车

  • 用动词开头,说清楚做什么:find_customer、list_orders、get_shipment,比 customer、orders、shipment 清楚;
  • 相似的工具,名字要能区分:如果有两个"搜索"工具,一个搜代码、一个搜文档,就叫 search_code 和 search_docs,而不是 search 和 search2;
  • 来源不同的工具,加个前缀:mini-agent 接入 MCP server 时(第 31 篇),工具名会变成 mcp__<server>__<tool> 的形式,防止不同 server 的同名工具冲突,也让模型知道这个工具是从哪里来的。

三、描述:写给新同事的说明

3.1 不只写"做什么"

糟糕的那套,描述只有一个词:database。好的那套,find_customer 的描述是:

按邮箱或姓名查找客户,返回客户 ID。邮箱不区分大小写。拿到客户 ID 后可以用 list_orders 查订单。

这一句话里包含了四种信息:

  1. 做什么:按邮箱或姓名查找客户;
  2. 返回什么:客户 ID;
  3. 注意事项:邮箱不区分大小写(任务里的邮箱是 Zhang.San@Example.com,大小写混着,模型可能会犹豫要不要转小写);
  4. 下一步做什么:拿到 ID 后可以用 list_orders。

第 4 点特别有用。它把三个工具串成了一条路径,模型不用自己摸索工具之间的关系。

3.2 写法遵循第 15 篇的原则

工具描述本质上就是 prompt,第 15 篇的原则全部适用:

  • 具体胜过形容词:"强大的数据库查询工具"不如"按邮箱或姓名查找客户";
  • 解释约束的原因:get_shipment 说"只有 shipped 或 delivered 的订单才有物流信息",模型就不会拿一个 pending 的订单去查;
  • 给例子:参数描述里的"例如 zhang.san@example.com""形如 c_001"。

配套仓库 mini-agent 的 edit_file 工具,描述写得更长,因为它的用法更讲究:

修改文件:把 old_str 替换成 new_str。old_str 必须与文件中的原文逐字一致(包括缩进和空白),并且在文件中只出现一次;如果出现多次,请在 old_str 里多带几行上下文,或者设置 replace_all=true。old_str 为空表示新建文件,内容为 new_str。修改已有文件之前必须先用 read_file 读过。注意:read_file 输出里每行开头的行号和制表符不是文件内容,不要放进 old_str。

最后那一句"行号和制表符不是文件内容",是我看着模型犯了好几次同样的错误之后加上去的。工具描述里最有价值的句子,往往来自真实的翻车现场。 第 26 篇会细讲这个工具。

四、参数:少、清楚、有约束

糟糕的那套,参数叫 op、t、f,类型全是 string,没有描述,没有必填项。模型面对的是三个完全开放的文本框。

好的那套:

  • 名字自解释:query、customer_id、order_id、status;
  • 能用枚举就用枚举:status 只能是 pending、shipped、delivered 三个值之一。模型不用猜"已发货"该写成 shipped 还是 sent 还是 已发货;
  • 说明格式:customer_id 的描述是"find_customer 返回的客户 ID,形如 c_001"。它同时告诉了模型这个值从哪里来,以及长什么样;
  • 标明必填:required 里只放真正必须的参数;
  • 可选参数给合理的默认值:mini-agent 的 read_file 有 offset 和 limit 两个可选参数,不填就从第一行读、最多读 2000 行。大部分时候模型只需要填一个 path。

还有一条容易被忽略:避免"自由格式"的参数。开场那个 f(过滤条件),模型可以写 id=c_001,也可以写 id = 'c_001',也可以写 customer_id:c_001,你的代码要解析所有这些可能,模型也要猜你能解析哪种。拆成几个明确的参数,双方都轻松。

五、返回值:精简、可读、带上下一步

5.1 不要一股脑全吐出来

糟糕的那套,查询结果是这样返回的:

b, _ := json.Marshal(rows) // 不管多少行,一股脑儿全吐出来
return string(b), false

不管查出来多少行,全部序列化成 JSON 返回。三个客户还好,三万个客户呢?第 14 篇说过,工具输出是上下文里最大、最难控制的一块,而且之后每一圈都要带着它。

好的那套,list_orders 返回的是这样的文本:

订单(从新到旧):
o_1004  2026-10-12  pending  ¥59
o_1003  2026-10-03  shipped  ¥349
o_1002  2026-09-15  shipped  ¥89
o_1001  2026-08-02  delivered  ¥199
要查快递单号,用 get_shipment(order_id)。

几个特点:

  • 只返回需要的字段:订单 ID、日期、状态、金额。没有创建人、更新时间、内部备注这些模型用不上的东西;
  • 人能读懂的格式:一行一个订单,不需要模型去解析嵌套的 JSON;
  • 排好了序:"从新到旧",任务问的就是"最近一笔",模型直接看第一个 shipped 的就行;
  • 带上了下一步:"要查快递单号,用 get_shipment(order_id)"。

5.2 大结果要分页或截断

结果可能很大的工具,要有办法控制大小。mini-agent 的 read_file 读大文件时,最多返回 2000 行,结尾会附上一句:

……(共 5000 行,已显示 1-2000 行。用 offset=2001 继续读)

告诉模型还有多少、怎么继续。而所有工具的输出,在注册表里还有一道统一的截断(第 28 篇)。

5.3 给有意义的标识

如果结果里有 ID,最好同时给出人能看懂的名字。find_customer 返回的是:

找到客户:张三(zhang.san@example.com),客户 ID:c_001。下一步可以用 list_orders 查他的订单。

既有 ID(给下一个工具用),也有名字和邮箱(让模型能确认找对了人)。如果只返回一串 UUID,模型没法判断它是不是找对了,甚至可能在后面的步骤里把两个 UUID 搞混。

六、错误信息就是提示词

6.1 ERR 4 和一句人话

这是本篇我最想强调的一条。

糟糕的那套,参数不对时返回 ERR 4。模型看到 ERR 4,能得到什么信息?什么都没有。它只能瞎猜。

好的那套,find_customer 找不到客户时,返回的是:

没有找到客户 "zhangsan@example"。现有客户:张三 <zhang.san@example.com>,张珊 <zhangshan@example.com>,李四 <li.si@example.com>。请检查拼写后重试。

模型看到这条错误,马上就知道:哦,我少写了一个点,邮箱应该是 zhang.san@example.com。下一次调用就对了。

flowchart LR subgraph B["ERR 4"] B1["模型:?"] --> B2["瞎猜,再试"] --> B3["ERR 4"] --> B1 end subgraph G["一句人话"] G1["没找到。现有客户:……
请检查拼写"] --> G2["模型:少了一个点"] --> G3["改对了"] end style B fill:#ffcdd2,stroke:#b71c1c style G fill:#e8f5e9,stroke:#2e7d32

错误信息是模型在出错之后唯一能看到的东西。 它写得好,模型一步就能改正;写得差,模型就会在原地打转。所以写错误信息时,问自己三个问题:

  1. 出了什么错?
  2. 为什么出错?
  3. 接下来该怎么做?

6.2 mini-agent 里的错误信息

配套仓库的 mini-agent,每个工具的错误信息都是按这三个问题写的。几个例子:

  • 读一个不存在的文件:文件不存在:xxx。可以先用 list_files 看看目录里有什么;
  • 对一个文件调用 list_files:xxx 是文件不是目录,用 read_file 读取它;
  • 调用了一个不存在的工具:没有名为 "xxx" 的工具。可用的工具:edit_file, list_files, read_file, run_bash, ……;
  • 参数格式不对:参数不是合法的 JSON 或类型不对:……。

还有一个很重要的设计,在 agent/tools/registry.go 里:

// Run 执行一次工具调用,永远返回一个 tool_result 块。
//
// 工具出错不是程序出错:把错误信息原样交给模型,它通常能自己换个参数再试。
func (r *Registry) Run(ctx context.Context, call llm.ContentBlock) llm.ContentBlock {

工具出错,不是程序出错。 文件不存在、命令执行失败,这些都是正常的"结果",要包成 tool_result(标上 is_error)交回给模型,而不是让程序崩溃或者中断循环。

6.3 错误信息也有分寸

"错误信息要详细"不等于"把能给的全给出去"。有两种"太详细"反而有害:

  • 太长:一个工具出错时把完整的调用栈、几百行的内部日志全部返回。它们会进入上下文,之后每一圈都要带着,而模型真正需要的,只是第一行那句"连接被拒绝"。把关键的一两行放在最前面,其余的截掉或者干脆不给;
  • 泄露了不该泄露的东西:数据库连接失败时,错误里带着完整的连接串,连接串里有密码;调用第三方 API 失败时,错误里带着请求头,请求头里有 token。这些都会被发给模型服务商,还可能被模型原样转述给用户。错误信息是写给模型看的提示,不是写给你自己看的调试日志,调试日志写进你的日志文件,提示写进 tool_result。

七、读和写,要分开设计

还有一条原则,前面的例子里没有直接体现,但在真实的 Agent 里非常重要:会改变外部世界的工具,和只读的工具,要区别对待。

查客户、列订单、查物流,都是只读的:调错了,最多是浪费一次调用。但如果工具集里还有一个 cancel_order,调错了就是一个真实的客户被取消了一笔订单。

几条实用的做法:

  • 读写分成不同的工具:别把"查询"和"修改"做进同一个工具,用一个 op 参数来区分。分开之后,harness 可以对写操作单独做权限检查(第 27 篇),子 Agent 也可以只拿到只读的那几个(第 30 篇)。mini-agent 就是这么做的:权限系统里有一个只读工具的名单,read_file 和 list_files 永远放行;子 Agent 只拿到读文件、列目录和跑命令三个工具,没有 edit_file;
  • 危险的操作,名字要让人一看就知道危险:delete_customer 比 update_customer(status="deleted") 更难被"顺手"调用,审查日志时也更显眼;
  • 给写操作加一个"演练"模式:比如 dry_run=true 时只返回"将会发生什么",不真的执行。模型可以先演练一次,确认无误再执行,人也可以在演练结果上做决定;
  • 尽量让写操作可以重复执行:同一个"把订单标记为已发货"的调用,执行一次和执行两次的结果一样。Agent 的循环里,重试是常态(第 29 篇),一个重复执行就会出问题的工具,迟早会出问题。
flowchart LR T["工具"] --> R["只读
查、列、读
永远放行"] T --> W["会改东西
改、删、发
要权限、可演练、可重复"] style R fill:#e8f5e9,stroke:#2e7d32 style W fill:#ffcdd2,stroke:#b71c1c

八、工具太多、功能重叠

最后说一个随着工具变多而出现的问题。

每多一个工具,就有三个代价:

  1. 多一份定义要放进上下文:占 token,每次请求都占;
  2. 多一个选项要模型判断:选错的概率变高;
  3. 如果和别的工具功能重叠,模型会犹豫:两个工具都能读文件,该用哪个?它可能这次用这个、下次用那个,行为变得不稳定。

应对的办法:

  • 合并:功能相近的工具合并成一个,用参数区分;
  • 删除:长时间用不到的工具,就别给了;
  • 按需加载:不是所有工具都需要一开始就给模型看。第 31 篇的 skills,就是"平时只告诉模型有哪些能力,用到时再加载详细说明";
  • 用前缀分组:工具多到一定程度,按来源或用途加前缀,帮助模型快速定位。

第 23 篇讲 MCP 时会算一笔账:接了几个 MCP server 之后,光工具定义就可能吃掉上下文的一大块。

九、动手:两套工具,同一个任务

实验 1:对比两套工具

go run ./examples/ch20-tool-design

程序会依次用两套工具完成同一个任务,每一套都是一个最小的 Agent 循环(最多 15 步),最后打印结果对不对、用了几步、工具报错几次、消耗了多少 token。

不设 Key 时用的是离线演示模型(也可以在 Playground 的第 20 篇里跑)。演示模型不会推理,它只会按固定的规则依次试工具、按 schema 填参数,所以两套都不会成功。但有一个细节很有意思:在好的那套工具上,演示模型凭着参数描述里的"形如 c_001"和返回值里的客户 ID,居然顺利走过了 find_customer 和 list_orders 两步;而在糟糕的那套上,它第一步就撞上了 ERR 4。好的工具设计,连一个不会推理的程序都能多走几步。

要看真实的差距,在线 Playground 演示不了:先 export DEEPSEEK_API_KEY=sk-... 再跑。
export DEEPSEEK_API_KEY=sk-...
go run ./examples/ch20-tool-design

接上 DeepSeek 之后,重点看两套的"步数""工具报错次数"和"输入 token"。糟糕的那套,模型要在 ERR 4 和 ERR 2 之间反复猜,还要面对一股脑全吐出来的数据;好的那套,通常几步就能走完。具体差多少,取决于模型,自己跑一次看看。

实验 2:看每一步发生了什么

go run ./examples/ch20-tool-design -set bad
go run ./examples/ch20-tool-design -set good

-set 只跑一套,并且打印每一步的工具调用和返回。看看模型在糟糕的那套上是怎么猜参数的:它先试了什么 op?猜了几次表名?

实验 3:只改一处,看效果

在 runBad 里,只改错误信息,其余不动:把 "ERR 4" 改成 "不支持的操作。op 只能是 get",把 "ERR 2" 改成 "没有这张表。可用的表:customers、orders、shipments"。再跑一次 -set bad,看看步数和报错次数降了多少。

只改了两行错误信息,没有动工具的结构,也没有改描述。这就是"错误信息就是提示词"最直接的证明。

实验 4:给好工具加一个写操作

在 goodTools 里加一个 cancel_order(order_id),实现只打印一行"已取消"。然后把任务改成一个模棱两可的说法,比如"处理一下张三那笔还没发货的订单",看看模型会不会直接去调用它。再给它的描述加上一句"会立即取消订单且无法恢复,只有用户明确要求取消时才调用",对比一下。这就是第七节说的:危险的工具,描述和名字都要让人一眼看出危险。

实验 5:制造一个重叠的工具

在 goodTools 里加一个功能和 find_customer 重叠的工具,比如 search_user,描述写成"搜索用户",实现直接复用 find_customer 的逻辑。看看模型会不会在两个工具之间犹豫,或者这次用这个、下次用那个。

十、踩坑记录:工具设计的七个坑

坑 1:把底层 API 原样包装成工具。
有五十个接口就给五十个工具,或者干脆给一个万能的"执行查询"工具。按任务切分,想想一个人做这件事会分成哪几步。

坑 2:描述只写"做什么",不写"什么时候用"和"下一步"。
"查找客户"不如"按邮箱或姓名查找客户,返回客户 ID。拿到客户 ID 后可以用 list_orders 查订单"。后者把工具串成了一条路径。

坑 3:返回原始的全量数据。
数据库查出什么就返回什么,所有字段、所有行。只返回需要的字段,结果大了就分页或截断,并告诉模型怎么拿到更多。

坑 4:用错误码代替错误信息。
ERR 4、code: 1003、invalid request。模型看不懂,只能瞎猜。写出了什么错、为什么、接下来怎么做。

坑 5:只给 ID,不给名字。
返回一串 UUID,模型没法确认找对了没有,后面还容易混淆。给 ID 的同时,给出能让人(和模型)确认身份的信息。

坑 6:不测试工具本身。
工具写完,自己用代码调一调,觉得没问题就交给 Agent 了。真正的用户是模型,要让模型用一用。最有效的办法是准备几个典型任务,让 Agent 用这套工具跑几次,看日志:它在哪里犹豫了,在哪里猜错了,在哪里绕了远路。每一处都是改进描述、参数或者错误信息的线索。第 36 篇的评测,也可以用来评测工具。

坑 7:把读和写塞进同一个工具。
一个 manage_order(op, ...),op 可以是 get 也可以是 cancel。harness 没法只对"取消"做确认,因为从工具名上看不出区别;子 Agent 想只给它查询的能力,也做不到。读写分开,权限才有地方下刀。

一句话总结

工具是写给模型用的 API:按任务切分而不是照搬底层,描述写清楚做什么、什么时候用、下一步做什么,参数少而明确、能枚举就枚举,返回值精简可读、带上下一步的提示;错误信息是模型出错后唯一能看到的东西,它本身就是提示词。

flowchart LR N["名字
动词开头,不撞车"] --> T["好工具"] D["描述
做什么、何时用、下一步"] --> T P["参数
少、清楚、有枚举"] --> T R["返回
精简、可读、带下一步"] --> T E["错误
出了什么错、为什么、怎么办"] --> T style T fill:#fff3e0,stroke:#e65100 style E fill:#e8f5e9,stroke:#2e7d32

评论

还没有评论,来说点什么吧。

发表评论

Ctrl + Enter 快速提交
Enter 搜索Esc 关闭