开场:一个"万能"的数据库工具
我给 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"。
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 查订单。
这一句话里包含了四种信息:
- 做什么:按邮箱或姓名查找客户;
- 返回什么:客户 ID;
- 注意事项:邮箱不区分大小写(任务里的邮箱是
Zhang.San@Example.com,大小写混着,模型可能会犹豫要不要转小写); - 下一步做什么:拿到 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。下一次调用就对了。
请检查拼写"] --> G2["模型:少了一个点"] --> G3["改对了"] end style B fill:#ffcdd2,stroke:#b71c1c style G fill:#e8f5e9,stroke:#2e7d32
错误信息是模型在出错之后唯一能看到的东西。 它写得好,模型一步就能改正;写得差,模型就会在原地打转。所以写错误信息时,问自己三个问题:
- 出了什么错?
- 为什么出错?
- 接下来该怎么做?
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 篇),一个重复执行就会出问题的工具,迟早会出问题。
查、列、读
永远放行"] T --> W["会改东西
改、删、发
要权限、可演练、可重复"] style R fill:#e8f5e9,stroke:#2e7d32 style W fill:#ffcdd2,stroke:#b71c1c
八、工具太多、功能重叠
最后说一个随着工具变多而出现的问题。
每多一个工具,就有三个代价:
- 多一份定义要放进上下文:占 token,每次请求都占;
- 多一个选项要模型判断:选错的概率变高;
- 如果和别的工具功能重叠,模型会犹豫:两个工具都能读文件,该用哪个?它可能这次用这个、下次用那个,行为变得不稳定。
应对的办法:
- 合并:功能相近的工具合并成一个,用参数区分;
- 删除:长时间用不到的工具,就别给了;
- 按需加载:不是所有工具都需要一开始就给模型看。第 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:按任务切分而不是照搬底层,描述写清楚做什么、什么时候用、下一步做什么,参数少而明确、能枚举就枚举,返回值精简可读、带上下一步的提示;错误信息是模型出错后唯一能看到的东西,它本身就是提示词。
动词开头,不撞车"] --> T["好工具"] D["描述
做什么、何时用、下一步"] --> T P["参数
少、清楚、有枚举"] --> T R["返回
精简、可读、带下一步"] --> T E["错误
出了什么错、为什么、怎么办"] --> T style T fill:#fff3e0,stroke:#e65100 style E fill:#e8f5e9,stroke:#2e7d32
评论
还没有评论,来说点什么吧。