🤖
AI审核中

Spring AI 2.0 如何解决Agent工具选择难题

Java 26分钟 108浏览 1评论

用户只问了一句话:

找出昨天退款失败的订单,重试可以恢复的记录,再把仍然失败的订单创建为工单并通知负责人。

为了完成这个任务,Agent 可能只需要四个工具:

  1. 查询退款记录;
  2. 判断错误是否可以重试;
  3. 创建工单;
  4. 发送通知。

但在一个真实系统里,它看到的往往不只有这四个工具。

订单中心、支付系统、工单平台、消息服务、GitHub、数据库和多个 MCP Server,可能一共向 Agent 暴露了几十个甚至上百个工具。

此时,用户的问题还没真正进入推理阶段,模型已经先收到一大批工具名称、描述和参数 Schema。Spring AI 官方文档提到,一个由多个 MCP Server 组成的系统很容易聚合出 50 多个工具,在对话开始前就消耗超过 55,000 Token;相似工具超过 30 个后,模型的选择准确率也可能下降。Spring AI Tools Reference

这暴露出一个容易被忽略的问题:

大型 Agent 的瓶颈,可能不是模型不会调用工具,而是它每次都要阅读太多与当前任务无关的工具。

一、工具调用的成本,发生在工具执行之前

一个工具交给模型的并不是简单的方法名,而是一段完整定义:

{
  "name": "findFailedRefunds",
  "description": "查询指定时间范围内退款失败的订单",
  "parameters": {
    "type": "object",
    "properties": {
      "merchantId": {
        "type": "string",
        "description": "商户编号"
      },
      "startTime": {
        "type": "string",
        "description": "查询开始时间"
      },
      "endTime": {
        "type": "string",
        "description": "查询结束时间"
      }
    },
    "required": ["merchantId", "startTime", "endTime"]
  }
}

如果系统注册了 60 个工具,传统做法会把 60 份定义一起发送给模型。

输入 Token 可以近似表示为:

单轮输入
≈ 系统提示词
+ 对话历史
+ 业务资料
+ 所有工具定义

Agent 通常还不止调用模型一次。

模型第一次决定查询订单,第二次拿到查询结果后决定执行重试,第三次根据重试结果创建工单,第四次再发送通知。工具调用循环每增加一轮,上下文就会继续累积。

问题因此被放大为:

总输入 Token
≈ 调用轮数 ×(系统提示词 + 历史消息 + 业务资料 + 工具定义)

工具还没有执行,成本已经产生了。

更麻烦的是,模型还要在大量近似定义中区分:

findRefundOrder
findFailedRefundOrder
queryRefundResult
retryRefundOrder
rebuildRefundTask

从程序的角度看,这些方法边界不同;从语言模型的角度看,它们却共享大量相似语义。

因此,单纯缩短提示词解决不了问题。

真正需要改变的,是工具进入上下文的方式。

二、工具目录不应该是提示词,而应该是可检索的数据

传统 Tool Calling 隐含了一个假设:

工具数量很少,所以模型可以一次看完,再自行选择。

当工具数量变大后,这个假设就不成立了。

更合理的架构,是把工具选择拆成两个阶段:

第一阶段:候选工具检索

根据用户当前任务,从完整工具目录中找出少量相关工具。

第二阶段:工具决策与执行

只把这些候选工具的完整 Schema 提交给模型,让模型决定具体调用哪个工具、如何组织参数。

这个过程与 RAG 很相似。

区别在于,RAG 检索的是知识片段,而这里检索的是可执行能力:

flowchart LR
    A["用户请求"] --> B["模型只看到 toolSearchTool"]
    B --> C["生成工具搜索词"]
    C --> D["ToolIndex 检索"]
    D --> E["返回少量 ToolDefinition"]
    E --> F["模型选择具体工具"]
    F --> G["ToolCallingManager 执行"]
    G --> H["工具执行结果"]
    H --> F
    F -->|"不再调用工具"| I["生成最终回答"]

这就是渐进式工具披露:

工具不是在会话开始时全部暴露,而是在任务进行过程中按需发现。

三、Spring AI 2.0 把工具循环从黑盒里拿了出来

Spring AI 1.x 的工具执行循环主要封装在不同模型的实现内部。

开发者可以注册工具,却很难在每轮工具调用之间插入自己的逻辑,例如:

  • 动态筛选工具;
  • 记录每轮调用;
  • 调整工具参数;
  • 对调用结果进行校验;
  • 根据执行结果决定是否继续;
  • 在工具执行前加入审批。

Spring AI 2.0 将这套循环提升到了 Advisor Chain 中,由 ToolCallingAdvisor 负责:

调用模型
   ↓
响应中是否存在工具请求?
   ├─ 是:执行工具,将结果加入历史,再次调用模型
   └─ 否:返回最终回答

它是一个递归 Advisor,会一直运行到模型不再请求工具为止。工具调用因此变成了可以观察、组合和扩展的调用链,而不再是某个模型实现里的私有流程。Spring AI 2.0 Tool Calling Architecture

ToolSearchToolCallingAdvisor 则在这套循环上进一步增加了工具索引和动态发现:

  1. 会话开始时索引全部工具,但不提交给模型;
  2. 第一轮只暴露内置的 toolSearchTool
  3. 模型根据任务生成搜索词;
  4. ToolIndex 返回候选工具;
  5. 下一轮才把候选工具的完整定义交给模型;
  6. 模型调用实际工具;
  7. 工具结果重新进入调用循环。

这不是简单地在调用模型之前搜索一次。

因为复杂任务可能分阶段发现能力:

先发现“查询退款”工具
        ↓
查询完成后发现“退款重试”工具
        ↓
重试完成后发现“创建工单”工具
        ↓
最后发现“通知负责人”工具

工具集合会随着任务执行逐步扩展。

四、在 Spring Boot 中接入渐进式工具发现

首先引入工具搜索 Starter:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-tool-search-advisor</artifactId>
</dependency>

然后启用 ToolSearchToolCallingAdvisor

spring:
  ai:
    chat:
      client:
        tool-search-advisor:
          enabled: true
          tool-index-type: lucene
          max-results: 5
          lucene:
            min-score-threshold: 0.4
          eviction:
            ttl: 30m

启用后,它会替换默认的 ToolCallingAdvisor,现有的 ChatClient 代码不需要重新实现工具循环。

不过,每次请求必须提供稳定的会话编号:

public String chat(
        String conversationId,
        String tenantId,
        String userId,
        String question) {

    return chatClient.prompt()
            .advisors(advisor -> advisor.param(
                    ChatMemory.CONVERSATION_ID,
                    conversationId
            ))
            .toolContext(Map.of(
                    "tenantId", tenantId,
                    "userId", userId
            ))
            .user(question)
            .call()
            .content();
}

工具索引是会话级的。

如果不提供会话编号,系统就无法可靠地判断当前会话已经发现过哪些工具,也无法在多用户场景下隔离工具集合。Spring AI 默认从 ChatMemory.CONVERSATION_ID 获取该值,也允许更换成现有的 tenantId 或其他上下文键。Tool Search Tool Reference

五、索引不是越“智能”越好

Spring AI 目前提供三种工具索引。

索引 适用场景 优点 主要限制
Regex 工具命名高度规范 简单、快速、无额外依赖 难以处理自然语言和同义表达
Lucene 名称、描述中存在稳定关键词 可解释、部署成本低 依赖工具描述的关键词质量
Vector 用户表达与工具名称差异较大 能处理语义和模糊表达 引入 Embedding、向量存储和召回调优

我更倾向于先使用 Lucene。

原因不是它最先进,而是它更容易回答这些生产问题:

  • 为什么这个工具被检索出来?
  • 哪个关键词命中了?
  • 为什么另一个工具没有命中?
  • 调整描述后结果发生了什么变化?
  • 不调用 Embedding 服务时系统能否继续工作?

只有当大量用户表达无法通过稳定关键词映射到工具时,才需要考虑 Vector。

例如:

用户表达:看看哪些退款单还有救
工具名称:findRecoverableRefundOrders

这类表达存在明显的语义差异,向量检索可能比关键词检索更合适。

但向量检索不是免费的。它会增加嵌入调用、索引维护、相似度阈值和召回漂移等问题。

所以选型不应该依据工具数量硬切:

工具少于 20 个用 Regex
工具超过 20 个就用 Vector

更合理的依据是:

  • 工具定义实际占用了多少 Token;
  • 用户语言与工具描述的差异有多大;
  • Top-K 是否能够召回正确工具;
  • 工具误选是否已经影响任务成功率;
  • 增加一次检索后,整体延迟是否可以接受。

六、接入工具搜索后,工具描述成了系统的“检索语料”

下面这种工具描述,在传统 Tool Calling 中就不够好:

@Tool(description = "查询数据")
public List<Order> queryData(...) {
    // ...
}

接入工具搜索以后,它几乎不可用。

搜索模块无法判断它查询的是什么数据、适合什么任务、是否会修改业务状态。

更合理的描述应该同时说明:

  • 工具能够做什么;
  • 什么情况下应该使用;
  • 什么情况下不应该使用;
  • 是否存在副作用;
  • 关键参数代表什么。

例如:

@Tool(description = """
    只读查询退款失败的订单。
    适用于用户要求排查退款失败、统计退款错误或寻找可重试订单的场景。
    支持按商户、时间范围和失败代码过滤。
    本工具不会重新发起退款,也不会修改订单状态。
    """)
public List<RefundOrder> findFailedRefundOrders(
        @ToolParam(description = "商户编号") String merchantId,
        @ToolParam(description = "查询开始时间,ISO-8601 格式") String startTime,
        @ToolParam(description = "查询结束时间,ISO-8601 格式") String endTime) {
    // ...
}

工具描述不能只写给模型看,它同时还是:

  • 检索索引的文档;
  • 开发者理解能力边界的说明;
  • 安全审查的输入;
  • 排查工具误选问题的证据。

多个 MCP Server 接入后,工具名称最好也带上清晰的领域边界:

refund.find_failed_orders
refund.retry_failed_order
ticket.create_incident
notification.send_to_owner

而不是让每个 Server 都提供模糊的:

search
query
create
send

七、工具被检索出来,不代表用户有权执行它

渐进式工具发现解决的是相关性,不是权限。

一个工具与当前任务高度相关,并不代表当前用户有权调用它。

生产系统至少需要两道权限控制。

第一道:检索前过滤

构建当前会话的工具索引时,只加入当前用户能够使用的工具。

普通用户不应该在搜索结果里看到:

refund.force_success
user.disable_account
database.execute_sql

即使后续执行阶段会拒绝,这些工具的存在和描述本身也可能泄漏系统能力。

第二道:执行时再次鉴权

不能因为工具已经通过了检索过滤,就跳过业务鉴权。

用户角色、租户状态和资源归属都可能在会话过程中发生变化。因此,工具执行时必须重新校验:

@Tool(description = """
    重新执行一笔允许重试的退款。
    本工具会修改退款状态并请求支付渠道,属于有副作用操作。
    仅在用户明确要求重试退款时使用。
    """)
public RefundResult retryRefund(
        String refundOrderNo,
        ToolContext toolContext) {

    String tenantId = (String) toolContext
            .getContext()
            .get("tenantId");

    String userId = (String) toolContext
            .getContext()
            .get("userId");

    authorizationService.requirePermission(
            tenantId,
            userId,
            "refund:retry"
    );

    return idempotentExecutor.execute(
            "refund-retry:" + refundOrderNo,
            () -> refundService.retry(refundOrderNo)
    );
}

ToolContext 中的数据不会发送给模型,因此适合传递租户、用户、审批状态等可信执行上下文。Spring AI Tool Context

对于删除、支付、退款和对外发送消息等操作,还需要进一步增加:

  • 人工确认;
  • 幂等键;
  • 操作审计;
  • 超时控制;
  • 重试上限;
  • 失败补偿。

模型负责提出调用意图,业务系统负责决定能不能执行。

两者不能混在一起。

八、Memory 放在工具循环内外,结果完全不同

Spring AI 2.0 还有一个很容易踩坑的设计:Advisor 的顺序决定了它位于工具循环内部还是外部。

默认情况下,MessageChatMemoryAdvisor 位于工具循环外部。

它只会保存:

用户原始问题
模型最终回答

中间发生的工具搜索、工具调用和工具结果,由 ToolCallingAdvisor 在当前循环内维护,不会全部写入长期记忆。

这通常是更安全的默认行为,因为很多 Memory Repository 并不能正确序列化 ToolResponseMessage

如果将 Memory Advisor 放入循环内部,它就会看到每一轮工具消息:

var memoryAdvisor = MessageChatMemoryAdvisor
        .builder(chatMemory)
        .order(BaseAdvisor.HIGHEST_PRECEDENCE + 400)
        .build();

这样做适合需要跨轮分析工具轨迹的系统,但也会带来三个问题:

  1. 工具结果可能包含敏感业务数据;
  2. 会话存储量显著增加;
  3. Repository 必须支持工具请求和工具响应类型。

因此,不能仅仅为了“记住更多内容”就把 Memory 移进循环。

Spring AI 2.0 默认把 Memory 放在循环外,正是为了让大多数存储实现保持安全兼容。Spring AI Memory and Tool Loop

九、不要只看 Token,还要看任务成功率

渐进式工具披露通常能降低输入 Token,但它不保证一定降低延迟。

Spring 官方曾用 28 个工具进行初步测试,其中包含 3 个相关工具和 25 个无关工具。不同模型和索引策略下,总 Token 降低了约 34%~64%,但模型请求次数也从原来的 3~4 次增加到了 4~5 次。

更重要的是,官方明确说明这些是少量手动测试,不是经过多轮平均的正式基准结果。因此,34%~64% 应该被理解为架构潜力,而不是生产系统的固定收益。Spring AI Tool Search Benchmark

上线前至少应该比较下面这些指标:

指标 要回答的问题
工具定义 Token 实际减少了多少上下文?
工具召回率 正确工具是否出现在 Top-K 中?
工具选择准确率 模型最终是否调用了正确工具?
平均搜索轮数 模型需要搜索几次才能找到能力?
平均工具循环数 一个任务需要经过多少轮模型调用?
首字延迟与总延迟 Token 下降是否换来了过高延迟?
任务完成率 用户问题是否真正解决?
越权调用数 是否出现不应暴露或执行的工具?
工具超时率 外部 MCP 或业务接口是否拖垮循环?

Spring AI 已经为 ChatClient、模型和工具调用提供了观测数据,包括输入输出 Token、工具名称、工具调用 ID 和执行耗时。工具参数与结果默认不进入观测数据,因为它们可能包含敏感信息。Spring AI Observability

这意味着评估不能只看模型账单。

如果 Token 下降了 50%,但因为漏召回关键工具导致任务成功率从 95% 降到 80%,这次优化就是失败的。

十、真正的分界线不是工具数量,而是能力治理

十个工具不一定少,一百个工具也不一定多。

如果十个工具每个都有复杂 Schema,并且名称高度相似,模型同样可能误选。

如果一百个工具拥有明确的领域划分、稳定的描述、可靠的检索和严格的授权,系统反而可以稳定扩展。

所以判断是否需要渐进式工具发现,不应该只问:

我现在有多少个工具?

而应该问:

当前任务真正需要几个工具,而模型每次被迫阅读了多少个工具?

Spring AI 2.0 的价值,不只是提供了一个 ToolSearchToolCallingAdvisor

更重要的是,它把 Agent 的工具体系拆成了三个相互独立的层次:

能力目录:系统拥有哪些工具
能力检索:当前任务可能需要哪些工具
能力执行:当前用户最终允许调用哪些工具

检索解决相关性,授权解决安全性,工具循环负责完成任务。

当这三件事被混在一次模型调用里时,工具越多,系统越难控制。

当它们被拆开后,Agent 才真正具备扩展到几十个 MCP Server、数百个工具的可能。

这也是我对渐进式工具披露最核心的理解:

它不是一次提示词优化,而是把“模型从所有工具中猜答案”,重构成了“系统先召回候选能力,模型再做局部决策”。

工具调用的下一阶段,不是继续往模型上下文里塞更多 Schema。

而是让工具像数据一样可以被检索,像接口一样受到约束,像生产操作一样能够被审计。

1 条评论
如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  1 条评论
伴我   湖南省衡阳市