上周我把 agent 的工具列表打印出来,23 行。我盯着看了两分钟,发现有六组工具在抢同一件事:三个都能抓网页,三个都在找文件,两个都在记笔记。
这不是设计出来的,是长出来的。每遇到一个没覆盖的场景我就加一个,加了半年,工具集变成了一份没人敢动的历史记录。真正让我下决心删的是跑轨迹:模型在「用 web_search 还是 browser_open」上犹豫、选错、再换一个重试,这种来回在我抽样的几十条轨迹里占了不少。它不是在想,是在猜哪个名字更像。
重叠的入口,把区分成本转移给了每一次调用
最典型的是找文件。glob、grep、list_dir 三个工具,描述分别是「按通配符匹配路径」「在文件内容里搜正则」「列出目录内容」。我写的时候觉得界限清楚,但模型看到「找一下配置在哪」这种需求,三个都说得通。
合并成一个 search_files(pattern, kind),kind 只有 path 和 content 两个取值。内部的实现还是那套,只是入口从三个变一个,选择从「猜工具」变成「填参数」——填参数是它擅长的事。
抓网页也一样。web_search、browser_open、http_request 三个都能拿到页面内容。我删掉后两个,只留 web_search(返回摘要和链接)和 fetch_page(url)(拿正文)。browser_open 唯一不可替代的能力是执行 JS,我确认了自己的场景里没有必须渲染的页面之后,它就没有存在理由了。
这里有个我一开始没算的账:工具名加描述,每个大致 60 到 150 个 token,23 个工具常年在系统提示最前面占着两千上下的位置。这部分内容排在所有对话之前,影响的是后面每一个决策。删到 9 个之后,这块回到七百左右——省下的不是钱,是模型在长上下文里跑偏的概率。
通用工具是个陷阱,尤其是能写 SQL 的那种
sql_query 是我最后悔加的一个。描述写的是「执行只读 SQL 查询」,听起来干净利落。问题是模型会自己拼 join,拼错了不报错,返回一个空集或者一行明显偏小的数字,然后它拿着这个结果继续往下推。
静默失败的破坏力比报错大一个量级。报错它会重试,静默成功它会当成事实。
删掉之后我换成三个按业务问题命名的只读工具:count_orders_by_day(start, end)、top_products(start, end, limit)、user_orders(user_id, limit)。参数只有日期和条数,SQL 在工具内部写死。代价是每来一个新问题就要加一个工具,好处是模型再也没有机会自己发明一个 join。
http_request 是同一个毛病。一个能发任意请求的工具,等于把鉴权、分页、限流、错误码解析全推给模型去做,而它对这几件事一无所知。真需要调外部接口,就包一个语义明确的工具,比如 get_invoice(order_id),超时和重试写在里面。
写操作砍到最少,读操作可以放宽
这条是我踩过之后才认的:读错了,代价是一次重试;写错了,代价是一个已经发出去的邮件。
所以 send_email 改成 draft_email,create_calendar_event 改成 propose_event,update_ticket 改成 comment_ticket。三个都只产出「待确认的动作」,落在一个待办区里,由人点一下才真的执行。听上去退了一步,但上线之后我再没在半夜想过「它刚才是不是给客户发了什么」。
保留下来的写工具只有 write_file,它必须带幂等键,重试不重复落盘:
1 | def write_file(path: str, content: str, key: str) -> str: |
key 由模型生成,通常是「任务 id + 目标路径」。它偶尔会生成不同的 key 做同一件事,这比重复写更常见,但至少不会在超时重试里写两遍。
顺带删掉的是 memory_write 和 memory_read。我原来让 agent 一边往向量库写记忆,一边往工作区写文件,两边状态对不上,排查的时候不知道信哪个。现在只留文件:工作区下的 notes.md,可读、可 diff、能进 git。上下文确实变长了,token 上去一点,但出了事我能直接打开看它记了什么。
task_plan 也删了。它有输出格式、有字段校验,看起来很像回事,实际使用中模型只写不读——写完计划就往下做,从来不回头对一眼。留 todo_write 一个够了,它至少能被人看见。
剩下的九个,我给每个配一句「失败了怎么办」
最后的清单是:ask_user、web_search、fetch_page、search_files、read_file、write_file、run_python、三个按业务命名的查询、todo_write。
比数量更重要的是,每个工具的 description 末尾我都加了一句失败路径。比如 fetch_page 写「如果返回 403 或正文少于 200 字,不要重试同一个 URL,改用 web_search 找替代来源」;run_python 写「涉及货币换算、比例、日期差,一律走这里,不要心算」。
这些句子比工具本身有用。删掉 calculator 之后有一次它口算汇率错了一位,我一开始想加回去,后来发现加一句「涉及金额不要心算」就够了——它算错的时候通常是不知道该算什么,不是算不动。
有一个我删错的:read_file 的 offset 和 limit 两个参数,我一度觉得模型不会用就删了,结果遇到大文件读不全,它自己开始编后面的内容。工具的参数也是工具面的一部分,删参数和删工具的风险一样大。
判断一个工具该不该留,我现在只问三个问题:删了它,模型会怎么做?如果答案是「用另一个工具绕过去」,那就删。它的失败是响亮的还是静默的?静默的要么改实现,要么改形态。它是读还是写?写的必须有确认或幂等。
下次你要给 agent 加工具之前,先把现在的列表打出来,把每个 description 读一遍。如果你自己读到第二个就要翻回去确认区别,模型在调用的时候也在做同样的事。
评论