工具是 Agent 的手脚,设计不好手脚就打架

绝大多数"Agent 不听话"的问题,根源不在模型,在工具设计。

我在 Anthropic 和 OpenAI 的工程博客里反复看到同一个结论:换更强的模型,不如把工具设计好。一个能用的 Agent 通常只需要 3~10 个设计精良的工具,而不是 30 个凑数的。这篇把工具设计的几个关键点讲透,包括粒度、命名、参数、错误反馈,以及工具数量膨胀时怎么办。

粒度:太粗不会用,太细走岔路

工具粒度是第一个要拍板的事。做一个"打开文件并读取前 100 行"的工具,还是拆成"打开文件"+"读取"两个?

经验法则是:一个工具应该对应一个用户在自然语言里会单独提到的操作

判断标准很简单——想象用户说这句话:"帮我打开 config.json 然后读一下"。用户把这两件事明确分开了,所以应该是两个工具。再换一句:"帮我查一下北京天气"。用户没说"先连网络再发 HTTP 再解析 JSON",所以 get_weather 应该是一个工具,把里面的细节藏起来。

太粗的工具,模型会抱怨"工具能力不够用,我想做的做不了"。太细的工具,模型会在工具之间反复横跳,上下文膨胀、token 爆炸。大部分人犯的错是太细——因为写代码的人的本能就是拆函数。写 Agent 工具时要刻意反过来,能合就合

好的例子: search_code(query: str, language: str | None = None) -> list[Match] 差的例子: list_files() + read_file() + grep_in_file() 三个工具让模型自己组合

命名:用动词开头,让语义 self-evident

模型看到工具名时,它没看到你的注释和文档——至少第一眼没看到。好的命名能让模型在看到名字的瞬间就知道用来干什么。

几条可以照抄的规则:

用动词开头search_docsdocs 好,send_emailemail_handler 好。动词让模型在决策时的语感和自然语言指令对齐。

名字里带对象search_docssearch 好。当工具库有 10 个工具时,模型靠名字就能快速定位。

避免缩写和内部术语evict_cache 看起来酷,但对模型是混淆信号。clear_cacheinvalidate_cache 更好。公司内部系统名字比如 call_spanner 对模型没有任何意义。

同类工具用一致前缀file_read / file_write / file_list 一看就是一组,模型在需要操作文件时会自然地从这组里选。

参数:Schema 是第二份说明书

参数的 JSON Schema 不只是"告诉运行时怎么校验",它本质上是模型决策时的另一份说明书。每个参数的 description 写得好不好,直接决定模型调用时填得对不对。

{
    "type": "function",
    "function": {
        "name": "search_code",
        "description": "在代码库里搜索匹配查询的代码片段。支持自然语言语义搜索和精确关键词搜索。",
        "parameters": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "搜索内容。可以是自然语言描述(例如 '处理用户登录的函数')或精确关键词(例如 'handleLogin')",
                },
                "language": {
                    "type": "string",
                    "enum": ["python", "typescript", "go", "rust"],
                    "description": "限定编程语言。不填则搜所有语言",
                },
                "max_results": {
                    "type": "integer",
                    "default": 10,
                    "description": "最多返回多少个匹配。默认 10,性能允许可以到 50",
                },
            },
            "required": ["query"],
        },
    },
}

几个细节:

description 要写"为什么"不只是"是什么"。上面 query 那段告诉模型两种用法,模型看到后会根据任务选择合适的查询风格。

用 enum 限制可枚举参数。不仅能防止模型乱填,还能让模型知道"哦原来只有这几种选择"。

default 写清楚。模型看到 default 会默认不填,避免每次都要编一个值。

required 精简。非必填的就别列进 required,给模型留灵活度。

错误反馈:Agent 的"老师"

Agent 最大的学习机会来自调错了之后收到的错误信息。这一点被很多人低估。

返回 {"error": "failed"} 是最糟的——模型不知道怎么错的,只能重试。好的错误反馈长这样:

def search_code(query, language=None, max_results=10):
    if max_results > 100:
        return {
            "error": "参数错误",
            "message": f"max_results 最大 100,你传了 {max_results}",
            "hint": "如果需要更多结果,请分批查询",
        }
    if not query.strip():
        return {
            "error": "参数错误",
            "message": "query 不能为空",
            "hint": "如果你想列出所有代码,用 list_files 工具",
        }
    ...

error + message + hint 三段式。error 给模型一个可匹配的错误类型,message 给模型具体情况,hint 告诉模型下一步怎么做。

特别注意那个 hint——它相当于工具开发者在告诉模型"你找错工具了,去试 X"。这种反馈是 Agent 能"学"的最短路径。

类似的模式在系统级错误(网络、权限、超时)里同样重要:

return {
    "error": "permission_denied",
    "message": f"无法读取 {path},需要管理员权限",
    "hint": "此路径不应该被 Agent 访问。请尝试 /tmp 或 ./output 下的路径",
}

对比只返回 "Permission denied" 这种 Linux 风格的错误——后者模型完全不知道该换路径还是该申请权限,只能瞎试。

工具数量膨胀:20+ 工具之后的灾难

当你的 Agent 有 30 个工具时,它会开始出现一些奇怪的行为。模型调用错误工具的概率随工具数量非线性增长。原因是每一个工具的 schema 都要塞进 prompt,到 30 个时 system prompt 已经好几千 token,模型注意力稀释。

有几种常见的解法:

工具分组 + 两级路由。把工具按领域分组:file_* / db_* / web_* / notification_*。第一轮让模型只看"分组列表"和对应的简短描述,选一个组;第二轮再把这个组的全部工具展开给它。这个模式叫 hierarchical tool calling,在 Cursor、Claude Code 这些生产 Agent 里都用。

工具过滤。根据当前任务类型动态筛选工具。例如用户说"帮我部署",就只暴露 deploy_*git_* 相关工具,不暴露 email_*。过滤逻辑可以用 embedding 相似度:把用户输入向量化,和每个工具的 description 向量化后做相似度排序,选 top-K。

合并工具。重新审视是不是真需要这么多工具。很多时候 get_user_by_id / get_user_by_email / get_user_by_phone 可以合成一个 find_user(query, by="id|email|phone")

Anthropic 公开的工程经验是一个 Agent 超过 20 个工具就要警惕,超过 40 几乎一定会出问题。

并行工具调用

现代 API(OpenAI / Anthropic / 大部分开源模型)都支持一次返回多个 tool_calls。用户问"北京和上海天气分别怎样"时,模型可以一次发两个 get_weather 请求,你应该并行执行:

import asyncio

async def run_tools_parallel(tool_calls):
    async def run_one(tc):
        fn = TOOLS[tc.function.name]
        args = json.loads(tc.function.arguments)
        return tc.id, await asyncio.to_thread(fn, **args)
    results = await asyncio.gather(*[run_one(tc) for tc in tool_calls])
    return results

不做并行,一个需要查 10 个地点天气的任务会老老实实跑 10 次 API。做了并行,一次就返回。对长任务的延迟优化是决定性的。

需要注意的是并行调用需要工具之间没有依赖。如果模型错误地并行发起了"先建文件夹再在文件夹里写文件"这两个调用,就会出错。解法是让模型在 prompt 里意识到"有依赖的工具要分两轮调用,无依赖的才能并行"——推理模型基本都能自己处理好。

工具即 API,API 即产品

最后一句值得记的原则:工具就是给模型用的 API,API 的所有设计原则都适用

一个好的人类 API——命名清晰、参数合理、错误信息友好、文档完善——同样是好的模型 API。反过来也成立。所以如果你已经在写人用的 SDK,写 Agent 工具就是把 SDK 再精心设计一遍,因为模型是个"读说明书非常字面、稍不留神就理解偏"的用户。

下一篇讲 Plan-and-Execute——当 ReAct 遇到需要 20 步的任务,为什么光靠"思考一步做一步"会漂移,以及怎么用"先规划再执行"来锁定全局目标。

相关阅读

版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。

(采用 CC BY-NC-SA 4.0 许可协议进行授权)

本文标题:03. 工具设计的艺术:粒度、命名、错误反馈与工具爆炸

本文链接:https://www.sshipanoo.com/blog/ai/ai-agent/03-工具设计/

本文最后一次更新为 天前,文章中的某些内容可能已过时!