一个反直觉的现象
做天气 Agent 的时候,我兴冲冲地把工具从 2 个加到了 12 个: 查天气、查空气质量、查日出日落、查紫外线、查历史天气…… 想着功能越全,Agent 越强。
结果准确率掉了。问"北京今天天气",它有时去调空气质量, 有时调历史天气。而且 token 消耗翻了三倍。
我以为是模型不行,换了个更大的模型,好了一点,但依然不如只有 2 个工具的时候。 后来才想明白:问题不在模型,在我。
原因一:所有工具定义每次都要发一遍
上一篇文章讲过,工具定义是提示词的一部分。关键是: 它在每一轮请求里都会被完整发送。
算一笔账:
| 项目 | 单个工具 | 12 个工具 |
|---|---|---|
| 工具名 + 描述 | ~80 token | ~960 token |
| 参数 Schema | ~60 token | ~720 token |
| 小计(每轮固定开销) | ~140 token | ~1680 token |
而这只是"固定开销"。一次工具调用至少两轮请求, 一轮对话十次交互就是 20 次请求——1680 × 20 = 33600 token 纯粹花在描述工具上,还没算真正的对话内容。
原因二:选择题变难了
这个更本质。模型每轮都在做一道题:"现在该用哪个工具?"
2 个工具时,这是一道二选一,而且两个工具差别很大(天气 vs 计算), 闭着眼睛都能选对。
12 个工具时,其中 6 个都是"查天气相关"的。它们的描述长这样:
get_weather 查询指定城市的当前天气
get_air_quality 查询指定城市的空气质量
get_sunrise 查询指定城市的日出日落时间
get_uv_index 查询指定城市的紫外线指数
get_weather_history 查询指定城市的历史天气
get_forecast 查询指定城市未来天气
看出问题了吗?它们的描述高度相似, 都以"查询指定城市的"开头。模型很难从"当前 / 历史 / 未来" 这几个词的差别里稳定地做出正确选择。
原因三:注意力被稀释
即使不看成本,光从模型行为看:上下文里的选项越多, 每个选项获得的"注意力权重"越少。这不是玄学—— 上下文窗口是有限的,更多无关内容意味着关键信息的相对权重下降。
有实验表明,工具数量超过一定阈值(经验值大约 10-20 个, 取决于描述质量),选择准确率会明显下滑。
四个解法
解法一:拆分 Agent
不要一个 Agent 装所有工具,而是按领域拆开,由一个"路由"决定交给谁。
weather_agent = create_agent(model=model, tools=[get_weather, get_forecast])
air_agent = create_agent(model=model, tools=[get_air_quality, get_uv_index])
search_agent = create_agent(model=model, tools=[search, fetch_page])
def route(query: str) -> str:
"""先用一次轻量请求判断该走哪个 Agent。"""
...
每个子 Agent 只有 2-3 个工具,选择题变得简单,准确率自然回升。 这也是"多 Agent 协作"最常见的动机之一。
解法二:把描述写出区分度
回到上面那 6 个工具,改成这样:
@tool
def get_weather(city: str) -> str:
"""查询城市【当前此刻】的实时天气,包括气温和天气状况。
只在用户问"现在""此刻""今天"天气时使用。
不要用它查询历史数据或未来预报。
"""
@tool
def get_forecast(city: str, days: int) -> str:
"""查询城市【未来若干天】的天气预报。
只在用户问"明天""后天""这周""未来几天"时使用。
不要用它查询当前实时天气。
"""
关键是显式写出负向边界——"不要用它做什么"。 模型对这类指令很敏感,加上之后我的选择准确率明显改善。
解法三:动态加载工具
不要一次把所有工具都给模型,而是根据用户输入先做一轮粗筛, 只把相关的 3-5 个工具塞进这次请求:
def pick_tools(query: str, all_tools: list, top_k: int = 4) -> list:
"""用向量检索或关键词匹配,从全部工具里挑出最相关的几个。"""
...
这样每轮请求的固定开销从 1680 token 降到 500 左右, 而且选择题永远是 4 选 1。
解法四:压缩历史消息
工具调用结果会一直留在 messages 里。一个查天气的结果可能是 一大段 JSON,但真正有用的只有"晴,18℃"。
def summarize_tool_result(raw: str, limit: int = 200) -> str:
"""工具返回内容过长时,只保留关键部分。"""
if len(raw) <= limit:
return raw
return raw[:limit] + "…(内容过长已截断)"
更激进的做法是用模型对早期对话做摘要,或者直接丢弃 N 轮之前的工具调用细节。
生产环境还要注意什么
- 限流与重试:API 有 QPS 限制,要加退避重试
- 超时控制:工具函数必须有 timeout,否则会挂住整个循环
- 步数上限:防止模型陷入死循环烧钱
- 成本监控:记录每次请求的 token 数,设置告警阈值
- 可观测性:把每次工具调用和结果落日志,方便回溯
加工具之前先问自己:这个工具真的会被用到吗? 如果一年用一次,它给每轮对话增加的成本值不值?
小结
- 工具定义是每轮请求的固定开销,数量直接乘上 token 成本
- 描述高度相似会导致模型选择困难,边界必须写清楚
- 工具多了应该拆 Agent、动态加载,而不是硬塞
- 历史消息要压缩,工具返回结果要截断
- 生产环境需要限流、超时、步数上限和成本监控