用户只问了一句话:
找出昨天退款失败的订单,重试可以恢复的记录,再把仍然失败的订单创建为工单并通知负责人。
为了完成这个任务,Agent 可能只需要四个工具:
- 查询退款记录;
- 判断错误是否可以重试;
- 创建工单;
- 发送通知。
但在一个真实系统里,它看到的往往不只有这四个工具。
订单中心、支付系统、工单平台、消息服务、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 则在这套循环上进一步增加了工具索引和动态发现:
- 会话开始时索引全部工具,但不提交给模型;
- 第一轮只暴露内置的
toolSearchTool; - 模型根据任务生成搜索词;
ToolIndex返回候选工具;- 下一轮才把候选工具的完整定义交给模型;
- 模型调用实际工具;
- 工具结果重新进入调用循环。
这不是简单地在调用模型之前搜索一次。
因为复杂任务可能分阶段发现能力:
先发现“查询退款”工具
↓
查询完成后发现“退款重试”工具
↓
重试完成后发现“创建工单”工具
↓
最后发现“通知负责人”工具
工具集合会随着任务执行逐步扩展。
四、在 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();
这样做适合需要跨轮分析工具轨迹的系统,但也会带来三个问题:
- 工具结果可能包含敏感业务数据;
- 会话存储量显著增加;
- 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