1. 从六十个工具说起:Agent 为什么会“挑花眼”
“Agent 工具给到六十个,它开始挑花眼”——这句话第一次看到的时候我笑了很久,因为它太真实了。做过 Agent 开发的人都知道,给模型挂三五个工具的时候,它表现得像个靠谱的助理;挂到二十个,它开始偶尔选错;挂到六十个,它基本上就变成了一个站在自助餐厅里端着盘子发呆的人,什么都想要,什么都拿不准。
这个现象背后其实是一个非常具体的工程问题:工具数量增长带来的选择空间爆炸。假设一个 Agent 有 60 个工具,每次用户提问它都要从这 60 个里挑出最合适的一个或几个,那么理论上它面对的组合空间是 2 的 60 次方这个量级。即便模型再聪明,它也不可能在这种规模下保持稳定的判断力。这不是模型能力的问题,而是信息架构的问题。
我最近在做一个基于Spring AI和LangChain4j的 Java Agent 项目,中途因为业务需求不断叠加,工具数量从最初的 8 个一路涨到了 60 多个。那段时间的日志简直没法看:用户问“帮我查一下上个月的订单”,Agent 去调了天气查询工具;用户问“生成一份销售报表”,它跑去调了用户信息接口。最离谱的一次,用户只是说了句“你好”,它居然触发了三个工具调用,其中一个是删除缓存。
所以这篇内容我想聊的不是“怎么让 Agent 更聪明”,而是怎么在工具规模膨胀之后,让 Agent 依然能保持清醒。这涉及到工具的组织方式、MCP 协议的接入策略、框架层面的路由设计,以及在 Java 生态里具体怎么落地。如果你正在用 Spring AI 或者 LangChain4j 做 Agent 开发,或者正在被 MCP 工具接入的数量问题困扰,那这些踩坑经验应该对你有用。
2. 工具膨胀的根源:为什么六十个就成了分水岭
2.1 工具数量与选择准确率的非线性关系
先看一组我在项目里实测的数据。我们用一个固定的测试集,包含 200 条真实用户请求,分别在不同工具数量下测试 Agent 的工具选择准确率:
| 工具数量 | 选择准确率 | 平均响应时间 | 误触发率 |
|---|---|---|---|
| 5 | 96.5% | 1.2s | 2.1% |
| 10 | 94.2% | 1.8s | 3.5% |
| 20 | 88.7% | 2.9s | 7.8% |
| 40 | 76.3% | 4.5s | 15.2% |
| 60 | 61.8% | 6.7s | 24.6% |
这张表很说明问题。从 5 个工具到 20 个工具,准确率下降还在可接受范围内;但从 20 到 60,准确率直接腰斩,误触发率翻了七倍。更关键的是响应时间,因为每次请求都要把所有工具的 schema 塞进上下文,token 消耗随工具数量线性增长,延迟自然跟着涨。
注意:这里的“误触发”指的是 Agent 调用了与用户意图无关的工具,哪怕最终回答是对的,这种调用也是有害的,因为它可能产生副作用。
2.2 上下文窗口不是免费的午餐
很多人觉得现在模型上下文窗口都到 128K 甚至 200K 了,塞 60 个工具的描述算什么。这个想法忽略了一个关键点:上下文窗口大不等于注意力资源无限。
工具描述在上下文里是“候选答案”,模型需要在生成时对这些候选做注意力分配。当候选数量过多时,注意力会被稀释,模型更容易被表面相似的描述误导。比如“查询订单”和“查询用户”这两个工具,在只有五个工具时模型能轻松区分;但当上下文里同时存在“查询订单”“查询订单物流”“查询订单退款”“查询历史订单”“查询订单备注”等十几个相似工具时,模型就开始犯迷糊了。
我在 Spring AI 里做过一个对比实验:同样 60 个工具,一组用完整的 description,一组把 description 压缩到 20 字以内。结果压缩组的准确率反而高了 8 个百分点。这说明工具描述的冗余信息在数量膨胀时会变成噪声。
2.3 MCP 协议带来的双刃剑
MCP(Model Context Protocol)的出现在很大程度上解决了工具接入的标准化问题。以前每接一个外部能力都要写适配层,现在只要符合 MCP 协议,Agent 就能直接调用。但这也带来一个新问题:接入太容易了,导致工具数量失控。
我见过一个团队,因为 MCP 接入成本低,两周内接了 40 多个 MCP Server,涵盖数据库查询、文件操作、浏览器控制、代码执行等各个方向。结果 Agent 的表现断崖式下跌。原因很简单:MCP 让“能接”变得容易,但没有解决“该不该接”和“怎么组织”的问题。
在 Java 生态里,LangChain4j对 MCP 的支持让这件事更顺手了,你可以用几行代码就把一个 MCP Server 的工具全部注册进来。但顺手不等于正确,后面我会讲怎么在框架层面做工具的分层和路由。
3. 工具治理的核心思路:分层、路由、收敛
3.1 把六十个工具当成一个组织来管理
我的核心思路很简单:不要让一个 Agent 面对所有工具,而是让工具像公司组织架构一样分层。
具体来说,把 60 个工具按照业务域分成若干组,每组 5 到 8 个工具,然后设置一个“路由层”来决定当前请求应该交给哪个组处理。这个路由层本身也可以是一个轻量级的 Agent,或者干脆用规则引擎加语义匹配来实现。
在 Spring AI 里,我用的方案是:
- 第一层:意图分类器,把用户请求分到 6 到 8 个业务域
- 第二层:每个业务域内有一个子 Agent,只挂载该域下的工具
- 第三层:子 Agent 执行具体工具调用
这样每个 Agent 实际面对的工具数量控制在 8 个以内,选择准确率能回到 90% 以上。代价是多了一次意图分类的调用,但这次调用的成本远低于把 60 个工具塞进上下文的成本。
3.2 工具描述的重写比工具本身更重要
前面提到压缩 description 能提升准确率,这里展开讲具体怎么做。
一个典型的工具描述长这样:
@Tool(description = "查询指定用户的订单信息,支持按时间范围、订单状态、支付方式筛选,返回订单列表包含订单号、金额、状态、创建时间等字段") public List<Order> queryOrders(String userId, String startDate, String endDate, String status) { // ... }这段描述信息很全,但在 60 个工具的上下文里,它和其他查询类工具的描述高度重叠。我的做法是把它改写成:
@Tool(description = "按用户ID查订单,可加时间/状态筛选") public List<Order> queryOrders(String userId, String startDate, String endDate, String status) { // ... }同时,在系统提示词里加一段工具选择指南,用自然语言说明什么场景该用什么工具。这样做的逻辑是:把区分度信息从每个工具的描述里抽出来,集中放在提示词里。模型读一段集中的指南,比读 60 段分散的描述更容易建立全局判断。
3.3 用 MCP 做工具隔离而不是工具堆叠
MCP 的正确用法不是把所有 Server 都挂到同一个 Agent 上,而是按业务域拆分 MCP Server,每个 Server 只暴露该域的工具。
比如:
mcp-order-server:只暴露订单相关工具mcp-user-server:只暴露用户相关工具mcp-report-server:只暴露报表相关工具
然后在 Agent 侧根据意图动态加载对应的 MCP Server 工具集。LangChain4j 支持在运行时动态添加和移除工具,这个能力在这里就派上用场了。
提示:动态加载工具时要注意工具注册的生命周期管理,避免同一个工具被重复注册导致上下文里出现重复描述。
4. Java 生态下的具体实现:Spring AI 与 LangChain4j 的配合
4.1 用 Spring AI 做意图路由层
Spring AI 的ChatClient很适合做轻量级的意图分类。我的做法是定义一个分类提示词,让模型输出业务域标签:
public String classifyIntent(String userInput) { String prompt = """ 你是一个意图分类器。根据用户输入,从以下类别中选择一个最匹配的: ORDER, USER, REPORT, INVENTORY, PAYMENT, SYSTEM, OTHER 只输出类别名称,不要输出其他内容。 用户输入:%s """.formatted(userInput); return chatClient.prompt() .user(prompt) .call() .content() .trim(); }这个分类调用的 token 消耗很小,因为提示词短、输出短。实测下来,分类准确率能到 95% 以上,而且延迟只有 200 到 300 毫秒。
4.2 用 LangChain4j 做工具执行层
LangChain4j 的AiServices在工具调用方面更灵活,特别是它支持动态工具注册。我把它用来做第二层的子 Agent:
public interface OrderAgent { @SystemMessage("你是订单处理助手,只处理订单相关请求。") String handle(@UserMessage String input); } OrderAgent orderAgent = AiServices.builder(OrderAgent.class) .chatLanguageModel(chatModel) .tools(orderTools) // 只挂载订单相关工具 .build();每个子 Agent 只挂载自己域内的工具,工具数量控制在 8 个以内。这样既保留了 LangChain4j 在工具调用上的便利性,又避免了工具数量膨胀。
4.3 两层之间的衔接与降级策略
路由层和子 Agent 之间的衔接需要处理几种边界情况:
- 分类失败:如果意图分类返回了未知类别,降级到通用 Agent,挂载少量高频工具
- 子 Agent 无法处理:如果子 Agent 判断当前请求超出自己的域,返回一个特殊标记,由路由层重新分配
- 多域请求:如果用户请求涉及多个域,路由层可以拆分成多个子请求,分别交给对应子 Agent,最后合并结果
这里有个实操心得:降级策略一定要有,而且要在日志里明确记录降级原因。我一开始没做降级,结果分类器偶尔抽风返回空字符串,整个请求就挂了。后来加了降级到通用 Agent 的逻辑,稳定性好了很多。
5. 实操过程:从六十个工具到稳定运行的完整改造
5.1 第一步:工具盘点与分类
改造的第一步不是写代码,而是把现有 60 个工具全部列出来,做一次彻底盘点。我用的表格格式如下:
| 工具名 | 所属域 | 调用频率 | 是否可合并 | 描述长度 |
|---|---|---|---|---|
| queryOrders | ORDER | 高 | 否 | 45字 |
| queryOrderDetail | ORDER | 高 | 可合并到queryOrders | 38字 |
| queryUserInfo | USER | 高 | 否 | 32字 |
| ... | ... | ... | ... | ... |
盘点过程中发现几个问题:有 8 个工具功能高度重叠,可以合并成 3 个;有 12 个工具调用频率极低,可以考虑下线或移到备用工具集;有 5 个工具的描述里包含了大量无关信息,需要重写。
这一步花了我大概半天时间,但非常值得。工具治理的第一步永远是先看清楚自己有什么。
5.2 第二步:合并与下线
合并的原则是:如果两个工具的输入参数有 80% 重叠,且输出结构相似,就合并成一个工具,用参数区分行为。
比如queryOrderDetail和queryOrders合并后:
@Tool(description = "查订单,传orderId查详情,不传则按条件查列表") public Object queryOrders(String userId, String orderId, String startDate, String endDate, String status) { if (orderId != null) { return orderDetailService.getById(orderId); } return orderService.query(userId, startDate, endDate, status); }下线低频工具时要谨慎,先观察一段时间,确认没有调用再移除。我是在日志里加了工具调用统计,跑了一周才决定下线哪些。
5.3 第三步:描述重写与提示词优化
描述重写的核心是去掉所有可以从参数名推断出来的信息。比如参数叫startDate,描述里就不用再说“开始日期”;参数叫status,描述里就不用再说“状态”。
重写后的工具描述控制在 15 到 25 字之间,只保留最核心的区分信息。然后在系统提示词里加一段工具选择指南:
工具选择指南: - 涉及订单查询、修改、取消的,用 ORDER 域工具 - 涉及用户信息、权限、偏好的,用 USER 域工具 - 涉及数据统计、报表生成的,用 REPORT 域工具 - 不确定时,先用 queryOrderSummary 获取概览这段指南大概 200 字,但它替代了原本分散在 60 个工具描述里的区分信息,整体 token 消耗反而降低了。
5.4 第四步:路由层与子 Agent 的搭建
路由层用 Spring AI 实现,子 Agent 用 LangChain4j 实现,两者通过一个简单的调度器衔接:
@Service public class AgentDispatcher { private final IntentClassifier classifier; private final Map<String, AgentHandler> handlers; public String dispatch(String userInput) { String intent = classifier.classify(userInput); AgentHandler handler = handlers.get(intent); if (handler == null) { handler = handlers.get("GENERAL"); // 降级 } return handler.handle(userInput); } }每个AgentHandler内部持有一个 LangChain4j 的AiServices实例,只挂载对应域的工具。
5.5 第五步:灰度与监控
改造完成后不要一次性全量切换,先灰度 10% 的流量,观察一周。监控指标包括:
- 工具选择准确率(人工抽检)
- 平均响应时间
- 误触发率
- 降级触发次数
- 用户满意度(如果有反馈渠道)
我灰度期间发现了一个问题:意图分类器对某些口语化表达识别不准,比如“帮我看看那个东西”这种模糊请求。后来在分类提示词里加了一些模糊请求的示例,准确率才上来。
6. 常见问题与排查技巧实录
6.1 工具选择准确率突然下降怎么排查
先看是不是最近新增了工具。新增工具后准确率下降是最常见的原因,因为新工具的描述可能和现有工具产生语义冲突。排查方法是把新增工具的描述和现有工具的描述做一次相似度计算,找出相似度超过 0.8 的工具对,检查它们是否真的需要区分。
如果最近没新增工具,那就看用户请求的分布是不是变了。有些业务场景的请求天然更容易混淆,比如“查询”和“搜索”这类词在不同域里含义不同。
6.2 MCP 工具注册后 Agent 不调用怎么办
这个问题我遇到过好几次,通常有三个原因:
- 工具描述太模糊:MCP Server 返回的工具描述可能过于技术化,模型看不懂。解决方法是加一层描述转换,把技术描述改写成自然语言。
- 工具名称有冲突:不同 MCP Server 可能暴露同名工具,导致注册时被覆盖。解决方法是给工具名加域前缀。
- 权限或连接问题:MCP Server 连接失败时工具会静默不可用。解决方法是加健康检查,连接失败时在日志里明确报错。
6.3 响应时间随工具数量增长太快怎么优化
除了前面说的分层路由,还有几个优化点:
- 工具描述缓存:把工具 schema 的序列化结果缓存起来,避免每次请求都重新生成
- 并行工具调用:如果多个工具之间没有依赖关系,可以让模型并行调用,LangChain4j 支持这个能力
- 流式输出:对于不需要工具调用的请求,直接用流式输出,减少等待感
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 准确率下降 | 新增工具冲突 | 计算工具描述相似度 | 合并或重写描述 |
| 误触发率高 | 工具描述重叠 | 检查描述区分度 | 压缩描述+提示词指南 |
| 响应慢 | 工具数量过多 | 统计 token 消耗 | 分层路由 |
| 工具不调用 | 描述模糊/名称冲突 | 检查注册日志 | 改写描述/加前缀 |
| 分类失败 | 提示词覆盖不足 | 分析失败样本 | 补充示例 |
6.5 几个踩过的坑
第一个坑是工具描述里的参数说明和实际参数不一致。有次我改了一个工具的参数名,但忘了改描述,结果模型按描述里的旧参数名传参,调用一直失败。后来我养成了习惯,改参数必改描述,而且加了单元测试来校验。
第二个坑是MCP Server 的超时设置。默认超时可能很短,网络稍微抖动就失败。我把超时调到了 10 秒,并且加了重试机制,稳定性好了很多。
第三个坑是子 Agent 之间的上下文隔离。一开始我让所有子 Agent 共享一个对话历史,结果订单 Agent 能看到用户 Agent 的对话,产生了奇怪的交叉影响。后来改成每个子 Agent 独立维护上下文,只在必要时传递摘要信息。
7. 工具治理的长期策略:让 Agent 保持清醒
工具数量增长是业务发展的必然结果,不可能永远控制在 10 个以内。所以关键不是“不让工具变多”,而是“工具变多之后依然能管好”。
我的长期策略有三条:
第一条是定期做工具审计。每个月花半天时间盘点一次工具,看哪些可以合并、哪些可以下线、哪些描述需要更新。这件事看起来琐碎,但不做的话工具集就会慢慢腐化。
第二条是建立工具准入标准。新工具接入前要回答三个问题:这个工具解决什么问题?现有工具能不能替代?它的描述是否足够区分?回答不清楚就不接。
第三条是保持路由层的简单。路由层越简单越稳定,不要试图让路由层做复杂的判断。意图分类就做意图分类,不要在里面加业务逻辑。
在 Java 生态里,Spring AI 和 LangChain4j 都在快速迭代,MCP 的支持也越来越完善。但工具多了会挑花眼这件事,框架解决不了,只能靠工程手段来治理。我现在的项目稳定在 60 多个工具,通过分层路由和描述优化,准确率维持在 90% 以上,响应时间控制在 3 秒以内。这个结果不算完美,但至少 Agent 不再站在自助餐厅里发呆了。
最后分享一个小技巧:如果你不确定某个工具该不该保留,就把它下线一周,看日志里有没有报错。没有报错就说明可以永久下线,有报错就说明还有用,但可以考虑合并到其他工具里。这个办法简单粗暴,但实测很有效。