ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Agent工具过载崩塌与治理:Spring AI+LangChain4j+MCP三层路由实战

Agent工具过载崩塌与治理:Spring AI+LangChain4j+MCP三层路由实战 1. 六十个工具塞给 Agent 之后到底发生了什么先说结论Agent 的工具数量一旦超过某个阈值它的表现不是线性下降而是断崖式崩塌。这个阈值在实测中大概在 15 到 25 之间具体取决于模型的上下文窗口大小、工具描述的精细程度以及你用的 Agent 框架对工具路由的处理方式。六十个工具远远超过了任何主流模型的“舒适区”。我最近在做一个 Java 技术栈的智能助手项目底层用 Spring AI 做模型接入层LangChain4j 做 Agent 编排工具协议走 MCP。项目初期一切顺利接了七八个工具的时候Agent 选工具又快又准。后来业务方不断提需求——查数据库、调接口、生成文档、发消息、查日志、做代码审查、跑单元测试、生成图表……一路加到了六十个。然后灾难开始了。最典型的表现是用户问“帮我查一下昨天订单表里退款金额超过五百的记录”Agent 居然去调了“发送企业微信消息”这个工具。你没看错它把“退款”和“消息通知”关联起来了因为那个工具的描述里写了“用于通知退款相关事宜”。这就是工具过载后的典型症状——语义漂移。工具描述之间的语义空间开始重叠、干扰模型在向量检索或注意力分配时抓错了重点。所以这篇东西我想把“Agent 工具数量膨胀”这件事从头到尾拆一遍。从为什么会崩、怎么判断已经崩了、到怎么在 Java 生态里用 Spring AI LangChain4j MCP 做工具治理最后给出一套可复现的“工具分层路由”方案。如果你正在做 Agent 开发或者刚接触 MCP 协议想搞清楚工具注册的坑这篇应该能帮你省掉至少两周的试错时间。2. 工具过载的根因拆解为什么六十个工具会让 Agent 变傻2.1 上下文窗口不是无限大的收纳箱很多人有一个错觉现在模型上下文都 128K 甚至 200K 了塞六十个工具的描述算什么每个工具描述平均 200 个 token六十个也就 12000 token连零头都不到。这个算法忽略了一个关键问题工具描述不是被动存储的文本而是参与推理的活跃信息。模型在每一轮对话中都需要在全部工具描述之间做注意力分配。工具越多注意力越分散。这就像你在一个六十人的会议室里找人每个人都在同时说话你反而听不清任何一个人的声音。更致命的是MCP 协议下的工具注册往往包含完整的 JSON Schema 参数定义。一个稍微复杂点的工具光参数描述就能到 500 到 800 token。六十个工具加起来光工具定义就吃掉 30000 到 40000 token。这还没算系统提示词、对话历史、RAG 检索回来的文档片段。上下文窗口被工具定义占掉三分之一甚至一半留给真正推理的空间被严重压缩。我在 Spring AI 里做过一个对比测试同一个模型同一段用户输入工具数量从 10 增加到 60首 token 延迟从 800ms 涨到了 3200ms工具选择准确率从 94% 掉到了 41%。这个 41% 是什么概念就是 Agent 有一半以上的概率选错工具然后要么报错要么产生幻觉式的“假装调用成功”。2.2 工具描述的语义重叠是隐形杀手六十个工具里不可能每个工具的职责都完全正交。一定存在功能相近、描述相似的簇。比如“查询订单信息”和“查询订单详情”“发送通知”和“推送消息”“生成报表”和“导出数据”这些工具在人类看来可能确实有区别但在模型的向量空间里它们的描述嵌入向量可能非常接近。当用户说“帮我看看订单”模型在多个相似工具之间摇摆选哪个都有道理选哪个都可能不对。LangChain4j 默认的工具选择机制是基于函数调用的。它把工具定义转换成模型支持的 function calling 格式由模型自己决定调哪个。这个机制在工具少的时候很好用但工具一多模型面对的是一个“多分类问题”类别越多分类边界越模糊。我实测过一个极端案例两个工具的描述只差一个词——“查询用户余额”和“查询用户积分”。用户问“我的账户还有多少钱”模型有 30% 的概率选了积分查询工具。原因很简单两个工具的参数结构完全一样描述嵌入向量的余弦相似度高达 0.97模型在注意力分配时几乎无法区分。2.3 MCP 协议下的工具发现机制加剧了问题MCP 的设计初衷是好的让工具提供方和 Agent 消费方解耦通过标准协议动态发现和调用工具。但在实际落地中MCP Server 往往会把所有可用工具一股脑注册到 Agent 端。Agent 启动时拉取工具列表六十个工具全部进入上下文。这里有一个容易被忽略的细节MCP 工具注册是运行时动态的。也就是说你没法在编译期就知道最终会有多少个工具。业务方今天加一个“查询物流”的 MCP Server明天加一个“生成发票”的 MCP ServerAgent 端的工具列表就像滚雪球一样越滚越大。Spring AI 对 MCP 的支持是通过McpToolCallbackProvider来做的。它会把 MCP Server 暴露的所有工具自动转换成 Spring AI 的ToolCallback。这个自动转换很方便但也意味着你失去了对工具注册的精细控制。默认情况下所有工具都会被注册进去没有过滤、没有分组、没有优先级。2.4 模型在工具选择上的“决策疲劳”有一个心理学概念叫“决策疲劳”当一个人面对太多选项时决策质量会急剧下降。模型虽然不是一个有心理状态的人但在注意力机制层面它面临的问题本质是一样的。六十个工具意味着模型在每一轮对话中都要做一次六十选一或者六十选零即不调工具的决策。这个决策的难度随着工具数量的增加呈指数级上升。而且工具之间还存在“干扰效应”——即使某个工具跟当前问题完全无关它的存在也会稀释模型对相关工具的注意力权重。我在 LangChain4j 的DefaultToolExecutor里加过日志观察模型返回的工具调用请求。工具数量少的时候模型返回的 tool name 非常稳定同一个问题每次问都选同一个工具。工具数量到六十之后同一个问题问五次模型可能选出三到四个不同的工具。这种不稳定性在生产环境里是致命的。3. 工具治理的 Java 实战Spring AI LangChain4j MCP 三层路由方案3.1 整体架构设计思路解决工具过载的核心思路不是“减少工具”而是“让 Agent 在每一轮对话中只看到它真正需要的工具”。这就像给一个图书馆配一个导览员而不是让读者自己去六十个书架里翻。我设计的方案叫三层工具路由第一层意图分类层。用一个轻量级的分类模型或规则引擎把用户输入映射到有限的几个“工具域”。比如“订单域”“用户域”“报表域”“通知域”。每个域下面挂 5 到 10 个工具。第二层工具域内路由。根据第一层的分类结果只把对应域的工具注册到当前对话的上下文中。其他域的工具对模型不可见。第三层MCP 动态发现兜底。如果第一层分类置信度低或者用户输入跨域则触发 MCP 的动态工具发现按需拉取工具列表。这个方案在 Spring AI 里的实现关键是不要一次性把所有 ToolCallback 都注册到 ChatClient。而是根据对话状态动态构建 ToolCallback 列表。3.2 用 Spring AI 的 ToolCallback 做动态注册Spring AI 的ChatClient支持在每次请求时传入不同的ToolCallback数组。这意味着你可以在运行时决定这一轮对话暴露哪些工具。Service public class DynamicToolRouter { private final MapString, ListToolCallback toolDomainMap; private final IntentClassifier intentClassifier; public DynamicToolRouter(ListToolCallback allTools, IntentClassifier classifier) { this.intentClassifier classifier; this.toolDomainMap groupToolsByDomain(allTools); } public ListToolCallback routeTools(String userInput) { String domain intentClassifier.classify(userInput); ListToolCallback domainTools toolDomainMap.getOrDefault(domain, List.of()); // 兜底如果域内工具少于3个补充通用工具 if (domainTools.size() 3) { domainTools new ArrayList(domainTools); domainTools.addAll(toolDomainMap.getOrDefault(common, List.of())); } return domainTools; } private MapString, ListToolCallback groupToolsByDomain(ListToolCallback allTools) { // 根据工具名称前缀或注解分组 return allTools.stream() .collect(Collectors.groupingBy(this::resolveDomain)); } private String resolveDomain(ToolCallback tool) { String name tool.getName(); if (name.startsWith(order)) return order; if (name.startsWith(user)) return user; if (name.startsWith(report)) return report; if (name.startsWith(notify)) return notify; return common; } }然后在调用 ChatClient 的时候ListToolCallback routedTools dynamicToolRouter.routeTools(userInput); ChatResponse response chatClient.prompt() .user(userInput) .toolCallbacks(routedTools.toArray(new ToolCallback[0])) .call() .chatResponse();这样每一轮对话实际暴露给模型的工具数量从六十降到了五到十个。实测下来工具选择准确率从 41% 回升到了 89%首 token 延迟从 3200ms 降到了 1100ms。3.3 意图分类层的实现选择意图分类层不需要用大模型。用大模型做分类是杀鸡用牛刀而且延迟高。我试过三种方案方案一关键词规则匹配。维护一个关键词到工具域的映射表。优点是零延迟、零成本。缺点是覆盖不全用户换个说法就匹配不上了。方案二轻量级文本分类模型。用 ONNX Runtime 跑一个小的 BERT 分类模型延迟在 20ms 左右。准确率能到 85% 以上。方案三Embedding 相似度匹配。把每个工具域的描述做 embedding用户输入也做 embedding算余弦相似度取最高。这个方案在 LangChain4j 里很容易实现用EmbeddingModel就行。我最终选了方案三因为它在 LangChain4j 生态里最自然而且可以随着工具域的变化动态更新 embedding不需要重新训练模型。public class EmbeddingIntentClassifier implements IntentClassifier { private final EmbeddingModel embeddingModel; private final MapString, Embedding domainEmbeddings; public EmbeddingIntentClassifier(EmbeddingModel embeddingModel, MapString, String domainDescriptions) { this.embeddingModel embeddingModel; this.domainEmbeddings domainDescriptions.entrySet().stream() .collect(Collectors.toMap( Map.Entry::getKey, e - embeddingModel.embed(e.getValue()) )); } Override public String classify(String userInput) { Embedding inputEmbedding embeddingModel.embed(userInput); return domainEmbeddings.entrySet().stream() .max(Comparator.comparingDouble(e - cosineSimilarity(inputEmbedding, e.getValue()))) .map(Map.Entry::getKey) .orElse(common); } }3.4 MCP 工具注册的过滤与分组MCP 协议本身没有提供工具分组的元数据。但 MCP Server 在注册工具时工具名称通常有命名规范。我用的策略是在 MCP Client 端做一层适配把 MCP 工具转换成 Spring AI ToolCallback 时根据名称前缀打上域标签。Spring AI 的McpToolCallbackProvider返回的是ToolCallback[]。我在这层外面包了一个DomainAwareToolCallbackProviderComponent public class DomainAwareToolCallbackProvider { private final McpToolCallbackProvider mcpProvider; public DomainAwareToolCallbackProvider(McpToolCallbackProvider mcpProvider) { this.mcpProvider mcpProvider; } public MapString, ListToolCallback getToolsByDomain() { ToolCallback[] allTools mcpProvider.getToolCallbacks(); return Arrays.stream(allTools) .collect(Collectors.groupingBy(this::extractDomain)); } private String extractDomain(ToolCallback tool) { String name tool.getName(); // MCP 工具命名规范domain_action如 order_query, user_update int underscoreIndex name.indexOf(_); if (underscoreIndex 0) { return name.substring(0, underscoreIndex); } return common; } }这里有一个实操心得MCP 工具命名一定要有规范。我见过有的团队工具名是queryOrderInfo、getUserDetail、sendNotification驼峰命名没有统一前缀。这种情况下你只能靠人工维护映射表维护成本很高。建议在 MCP Server 开发阶段就定好命名规范比如域_动作_对象的格式。4. 工具描述优化让每个工具在模型眼里独一无二4.1 工具描述的三要素结构工具路由解决了“模型看到多少个工具”的问题但即使只看到十个工具如果描述写得含糊模型照样选错。工具描述的质量直接决定了工具选择的准确率。我总结了一个工具描述的三要素结构做什么一句话说清楚这个工具的核心功能。不要用“用于处理订单相关操作”这种模糊表述要用“根据订单号查询订单的支付状态和退款金额”。什么时候用给出明确的触发场景。比如“当用户询问订单是否已付款、退款是否到账时使用此工具”。什么时候不用给出排除条件。比如“不要用此工具查询用户积分积分查询请用 user_query_points”。第三点特别重要。大多数工具描述只写了“做什么”没写“不做什么”。模型在多个相似工具之间摇摆时一个明确的排除条件能大幅降低误选概率。4.2 用 LangChain4j 的 Tool 注解做描述优化LangChain4j 用Tool注解来定义工具。注解的value字段就是工具描述。很多人只写一句话这是不够的。public class OrderTools { Tool( 根据订单号查询订单的支付状态和退款金额。 适用场景用户询问订单是否已付款、退款是否到账、订单金额是否正确。 不适用场景查询用户积分请用 UserTools.queryPoints 查询物流信息请用 LogisticsTools.queryTracking。 参数 orderId 必须是有效的订单号格式为 ORD 开头加 12 位数字。 ) public OrderStatus queryOrderStatus( P(订单号格式 ORD12位数字) String orderId) { // 实现逻辑 } }注意这里用了 Java 的文本块text block来写多行描述。LangChain4j 会把整个字符串作为工具描述传给模型。实测下来这种结构化描述比单行描述的工具选择准确率高出 20 到 30 个百分点。4.3 参数描述的精确化参数描述同样重要。模型不仅要选对工具还要填对参数。参数描述模糊会导致模型填错参数值或者漏填必填参数。几个实操要点必填参数和可选参数要明确标注。LangChain4j 的P注解有required属性默认是 true。可选参数要显式设为 false。参数格式要写清楚。比如日期格式是yyyy-MM-dd还是yyyy/MM/dd金额单位是元还是分枚举值有哪些。参数之间的依赖关系要说明。比如“如果 type 为 REFUND则 refundId 必填”。Tool(查询订单列表支持按状态和时间范围过滤) public ListOrder queryOrders( P(订单状态可选值PAID, UNPAID, REFUNDED, CANCELLED) String status, P(value 开始日期格式 yyyy-MM-dd, required false) String startDate, P(value 结束日期格式 yyyy-MM-dd, required false) String endDate, P(value 退款单号仅当 status 为 REFUNDED 时必填, required false) String refundId) { // 实现逻辑 }4.4 工具描述的版本管理工具描述不是写完就完了。业务在变工具在变描述也要跟着变。我建议把工具描述当成代码一样做版本管理。具体做法把工具描述抽到配置文件或数据库里而不是硬编码在注解里。Spring AI 支持从外部配置加载工具描述。这样业务方改描述不需要重新编译部署。tools: order: queryOrderStatus: description: | 根据订单号查询订单的支付状态和退款金额。 适用场景... 不适用场景... parameters: orderId: description: 订单号格式 ORD12位数字 required: true然后在代码里用Tool注解引用配置键或者用 Spring AI 的FunctionCallback动态构建工具定义。这个方案在工具数量多、变更频繁的场景下特别有用。5. 常见问题与排查技巧实录5.1 Agent 选错工具的排查思路当你发现 Agent 选错工具时不要急着改提示词。按以下顺序排查确认工具数量。先数一下当前对话暴露了多少个工具。超过 20 个就先做路由分流。检查工具描述的重叠度。把选错的两个工具描述拿出来对比看是否有语义重叠。用 embedding 算一下余弦相似度超过 0.9 的基本可以判定为描述需要重写。检查参数结构。如果两个工具的参数结构完全一样模型更容易混淆。考虑合并工具或增加区分性参数。检查对话历史。有时候是上一轮对话的残留信息干扰了本轮的工具选择。LangChain4j 的ChatMemory会保留历史消息如果历史消息里提到了某个工具模型可能会被带偏。检查 MCP 工具注册顺序。MCP 工具注册顺序可能影响模型的注意力分配。虽然理论上模型对顺序不敏感但实测中调整注册顺序有时能改善选择准确率。5.2 工具调用超时和失败的处理工具数量多的时候工具调用的失败率也会上升。常见原因和解决方案问题现象可能原因解决方案工具调用超时MCP Server 响应慢设置合理的超时时间增加重试机制工具返回格式错误MCP Server 返回了非预期格式在 ToolCallback 层做格式校验和转换工具调用被模型拒绝模型认为工具不安全或参数不合法检查工具描述中的安全约束放宽不必要的限制工具调用循环模型反复调用同一个工具设置最大调用次数增加循环检测工具调用结果被忽略模型没有正确解析工具返回检查工具返回格式是否符合模型预期我在 Spring AI 里给工具调用加了超时和重试Bean public ToolCallbackProvider resilientToolCallbackProvider( McpToolCallbackProvider mcpProvider) { return () - Arrays.stream(mcpProvider.getToolCallbacks()) .map(this::wrapWithResilience) .toArray(ToolCallback[]::new); } private ToolCallback wrapWithResilience(ToolCallback original) { return new ToolCallback() { Override public String getName() { return original.getName(); } Override public String getDescription() { return original.getDescription(); } Override public String call(String input) { try { return CompletableFuture.supplyAsync(() - original.call(input)) .get(5, TimeUnit.SECONDS); } catch (TimeoutException e) { return {\error\: \工具调用超时请稍后重试\}; } catch (Exception e) { return {\error\: \工具调用失败: e.getMessage() \}; } } }; }5.3 工具数量膨胀的预防策略与其等到六十个工具崩了再治理不如从一开始就做好预防。我的建议是设定工具数量红线。单个 Agent 的工具数量不超过 20 个。超过就拆分成多个 Agent用路由层做分发。工具准入机制。新增工具需要评审确认没有功能重叠、描述清晰、参数规范。定期工具审计。每季度审计一次工具使用情况下线零调用或低调用工具。工具域划分。从项目第一天就定义好工具域新工具必须归属到某个域。5.4 一个真实的踩坑记录说一个我踩过的坑。项目初期我们有一个“查询用户信息”的工具描述写的是“查询用户的基本信息”。后来业务方加了一个“查询用户画像”的工具描述写的是“查询用户的画像信息”。两个工具的参数都是 userId返回的都是 JSON。上线后用户问“帮我看看这个用户”Agent 有 50% 的概率选错。排查了半天发现两个工具的描述在 embedding 空间里几乎重合。解决方案是把“查询用户信息”的描述改成“查询用户的姓名、手机号、注册时间等基础字段”把“查询用户画像”的描述改成“查询用户的消费偏好、活跃度、兴趣标签等分析字段”。改完之后准确率立刻上去了。这个坑的教训是工具描述要具体到字段级别不要用“基本信息”“详细信息”这种模糊词。模型不是人它没有常识它只能根据你写的字面意思做判断。6. 工具分层路由的进阶玩法6.1 基于对话状态的多轮工具路由单轮意图分类有时候不够准。用户第一句说“帮我查一下订单”第二句说“顺便看看这个用户的积分”。如果每轮都独立分类第二句会被分到“用户域”但第一句的“订单域”上下文丢失了。解决方案是维护一个对话级的工具域状态。每一轮分类结果不直接覆盖上一轮而是做一个平滑过渡。public class ConversationToolState { private final DequeString domainHistory new ArrayDeque(); private static final int HISTORY_SIZE 3; public String resolveDomain(String currentDomain) { domainHistory.addLast(currentDomain); if (domainHistory.size() HISTORY_SIZE) { domainHistory.removeFirst(); } // 如果最近三轮有两个以上相同域优先使用该域 MapString, Long counts domainHistory.stream() .collect(Collectors.groupingBy(d - d, Collectors.counting())); return counts.entrySet().stream() .max(Map.Entry.comparingByValue()) .map(Map.Entry::getKey) .orElse(currentDomain); } }这个方案在跨域对话场景下效果很好。实测下来多轮对话的工具选择准确率比单轮分类高出 15 个百分点。6.2 工具调用链的预编排有些业务场景下工具调用是有固定顺序的。比如“生成月度报表”可能需要先查订单数据再查用户数据最后生成图表。这种场景下与其让模型自己选工具不如预编排一个工具调用链。Spring AI 支持用ToolChain或者自定义的ToolExecutor来做工具编排。LangChain4j 也有类似的ToolChain概念。预编排的好处是模型只需要选“报表生成”这一个入口工具后续的工具调用由编排层自动完成。public class ReportToolChain { private final OrderTools orderTools; private final UserTools userTools; private final ChartTools chartTools; public String generateMonthlyReport(String month) { ListOrder orders orderTools.queryOrders(PAID, month -01, month -31, null); ListUser users userTools.queryActiveUsers(month); String chartUrl chartTools.generateBarChart( 月度订单趋势, orders.stream().collect(Collectors.groupingBy( o - o.getCreatedAt().toLocalDate().toString(), Collectors.counting() )) ); return 报表已生成图表地址 chartUrl; } }这个方案把六十个工具收敛成了几个“入口工具”模型只需要选入口不需要关心内部细节。对于业务流程固定的场景这是最稳的方案。6.3 工具调用的可观测性建设工具数量多的时候没有可观测性就是盲人摸象。你根本不知道模型为什么选了这个工具为什么没选那个工具。我在项目里加了一套工具调用日志记录以下信息每一轮对话暴露了哪些工具模型最终选了哪个工具工具调用的参数是什么工具返回的结果是什么工具调用耗时如果选错了正确工具是哪个这些日志用 JSON 格式输出方便后续做分析和告警。Spring AI 的ChatClient支持通过Advisor机制做拦截可以在工具调用前后插入日志。public class ToolCallLoggingAdvisor implements CallAdvisor { private static final Logger log LoggerFactory.getLogger(ToolCallLoggingAdvisor.class); Override public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) { long start System.currentTimeMillis(); ChatClientResponse response chain.nextCall(request); long elapsed System.currentTimeMillis() - start; response.chatResponse().getResults().forEach(result - { if (result.getOutput().getToolCalls() ! null) { result.getOutput().getToolCalls().forEach(toolCall - { log.info(Tool called: name{}, args{}, elapsed{}ms, toolCall.name(), toolCall.arguments(), elapsed); }); } }); return response; } }这套日志上线后我们发现了几个之前完全没意识到的问题有些工具从来没被调用过有些工具被调用的场景完全不对还有些工具在特定对话历史下会被反复调用。这些问题靠人工测试是发现不了的。6.4 工具治理的长期维护工具治理不是一次性的工作而是一个持续的过程。我的建议是建立一个工具治理的 SOP每周检查工具调用日志发现异常调用模式。每月审计工具使用率下线零调用工具。每季度重新评估工具域划分根据业务变化调整。每次新增工具走准入评审确认描述规范、参数清晰、无功能重叠。这套 SOP 执行下来工具数量能稳定控制在合理范围内不会出现“不知不觉就六十个了”的情况。7. 一些实操心得和最后的建议工具数量膨胀这个问题本质上不是技术问题而是架构治理问题。技术方案再漂亮如果团队没有治理意识工具还是会越加越多。我在项目里推行的做法是把工具当成 API 来管理。每个工具都要有负责人、有文档、有版本、有下线机制。新增工具要走评审下线工具要通知使用方。另一个心得是不要迷信大模型的工具选择能力。模型再强面对六十个工具也会懵。与其指望模型变聪明不如把工具治理做好。工具路由、描述优化、预编排这三板斧下去大部分问题都能解决。最后分享一个我常用的判断标准如果你自己作为人类面对这六十个工具的描述能不能在五秒内选出正确的那个如果你都选不出来模型大概率也选不出来。工具治理的目标就是让这个选择变得显而易见。这个项目后续我还在做工具调用的自动优化——根据历史调用日志自动调整工具描述的措辞和工具路由的权重。等跑出稳定结果了再另开一篇聊。
返回列表