你的AI助手带了个智能工具箱,但工具箱里有一万个工具
每次跟AI聊天,它都得先把所有工具的名字和用法背一遍,再听你说话。工具越多,背得越久,你的钱包就越疼。Spring AI搞了个动态工具发现机制,让AI变成了一个懒人,需要用啥工具才去翻工具箱,不用提前把一万个工具全扛在肩上。本文把这个机制拆开给你看。
工具越多,开场白越长
你让AI帮你查航班。它脑子还没开始想航线,先把所有家当摆出来给你看。
这不是AI脑子进水,是程序就这么设计的。你在代码里注册了十个工具,它就把十个工具的定义全塞进每次对话的上下文。你注册了一百个,它就塞一百个。这些工具定义长什么样?每个工具都有名字、功能描述、参数说明,这些东西翻译成AI能懂的语言,就是一堆token。
token是啥?简单说就是AI看文字的最小单位,一个汉字大概算一到两个token。AI每看一个token,你都要付钱。
假设你只注册了五个工具,每个工具定义五百个token,每次对话开始AI先吞两千五百个token打底。你的问题本身才几十个token。这就好比你进餐厅还没点菜,先听服务员把后厨所有调料念了一遍。
Spring AI之前就是这么干的。所有工具,不管用不用得上,全塞进去。反正AI自己会挑。但挑的过程也得先看过才知道挑哪个,这"看"的动作就得花钱。
偷懒式发现:先只给AI一个搜索框
新方案的核心操作是:别把所有工具都告诉AI,只告诉它一个工具——搜索工具。
这个搜索工具的作用就是让AI自己去问:我要找个能查航班的工具,你有吗?
系统后台有个工具索引,所有注册的工具都存放在ToolSearcher里。AI问的时候,ToolSearcher用配置好的策略去匹配,找到最相关的五个,再把它们的定义塞进下一轮对话。
整个过程分三步走。
第一步,启动的时候把所有工具索引好,但索引归索引,不发给AI。
第二步,用户提问,AI手上只有搜索工具。AI一看用户要查航班,它就先调用搜索工具,用自然语言搜一把,比如"找查航班的工具"。
第三步,系统返回匹配到的工具定义,AI拿到这些定义之后,再真正去调用查航班那个工具。拿到结果,返回给用户。
这流程多了一步"搜索",但省掉了前面那一大堆工具定义的开销。
你家助理只会订机票,还要带把铲子干嘛
举个具体例子。你写了个旅游助手,能查航班、查酒店、查天气、查景点。这四个工具合情合理,都挺有用。
但你的代码里可能还躺着一堆别的工具。比如之前给别的项目写的随机数生成器、计算器、字符串反转器。这些东西跟旅游八竿子打不着,但如果你用老方案,它们照样会被塞进每次对话。
Spring AI文章里给的这个例子,专门加了一堆RandomTools,就是那些跟旅游毫无关系的工具。但用了动态发现之后,这些随机工具永远不会被AI看到,因为AI根本不会去搜"随机数"相关的词。
你的问题只是"找罗马尼亚到克罗地亚的航班",搜索工具匹配到的只有FlightTools。其他工具虽然注册在系统里,但就像你家抽屉里的过期优惠券,存在但不会被翻出来。
Token计数器告诉你省了多少钱
文章里写了个TokenCounterAdvisor,专门数token。这玩意就像购物小票,告诉你每次对话花了多少token。
对比数据是这样的:用动态工具发现,总token消耗974个。不用,3685个。
差了将近四倍。
这差距怎么来的?老方案每次对话开始,所有工具定义都塞进去。新方案第一次对话只塞搜索工具的定义,后续需要什么再补什么。
第一次对话的token消耗差不了太多,但问题就出在"每次"。如果你跟AI聊十轮,老方案每一轮都重新塞一遍所有工具定义,新方案只有第一轮塞搜索工具,后面几轮只塞被搜到的工具。
省下来的token,如果你用的是付费API,那就是省下来的钱。如果你用的是按量计费的模型,那就是省下来的额度。
配置代码长这样,不用怕
文章里给了完整配置,看着唬人,拆开看就几行。
先加依赖。两个包,一个是tool-search-tool,一个是regex-searcher。后者是搜索策略的一种,用正则表达式匹配工具名字。
xml
org.springaicommunity
tool-search-tool
${tool-search-tool.version}
再添加正则搜索器的依赖:
xml
org.springaicommunity
tool-searcher-regex
${tool-search-tool.version}
用上这个依赖,你就有了一个基于正则表达式的工具搜索策略。社区里还有其他策略可以选。
飞行工具长这样,一个简单的Java类,用@Tool注解标记方法:
java
public class FlightTools {
@Tool(description = "Searches available flights between two cities")
public List searchFlights(String from, String to, String departureDate) {
return List.of(
new FlightOption(
"Romania Airlines",
from,
to,
departureDate,
249.99
)
);
}
}
这里返回了一个航班选项,航司名字、起降城市、日期、价格都有。
Token计数器是个拦截器(Advisor),在AI处理完请求之后跑一下,把用掉的token数加总:
java
public class TokenCounterAdvisor implements BaseAdvisor {
private static final Logger log = LoggerFactory.getLogger(TokenCounterAdvisor.class);
private final AtomicInteger totalTokenCounter = new AtomicInteger(0);
@Override
public String getName() {
return "TokenCounterAdvisor";
}
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE - 1;
}
@Override
public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) {
return chatClientRequest;
}
@Override
public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) {
var usage = chatClientResponse.chatResponse().getMetadata().getUsage();
totalTokenCounter.addAndGet(usage.getTotalTokens());
log.info("Total tokens spent: {}", totalTokenCounter.get());
return chatClientResponse;
}
}
这段代码里,AtomicInteger就是个线程安全的计数器,多个请求同时来也不会数错。after方法在AI返回响应之后触发,从响应的元数据里取出token用量,累加到总数上。
配置类把所有东西串起来:
java
@Configuration
public class TravelAssistantConfig {
@Bean
ToolSearcher toolSearcher() {
return new RegexToolSearcher();
}
@Bean
ToolSearchToolCallAdvisor toolSearchToolCallAdvisor(ToolSearcher toolSearcher) {
return ToolSearchToolCallAdvisor.builder()
.toolSearcher(toolSearcher)
.maxResults(5)
.build();
}
@Bean
ChatClient chatClient(ToolSearchToolCallAdvisor toolSearchToolCallAdvisor, OpenAiChatModel model) {
return ChatClient.builder(model)
.defaultTools(
new FlightTools(),
new RandomTools()
)
.defaultAdvisors(toolSearchToolCallAdvisor, new TokenCounterAdvisor())
.build();
}
@Bean
ChatClient chatClientWithoutToolsSearch(OpenAiChatModel model) {
return ChatClient.builder(model)
.defaultTools(
new FlightTools(),
new RandomTools()
)
.defaultAdvisors(new TokenCounterAdvisor())
.build();
}
}
这个配置做了几件事。第一,new了一个RegexToolSearcher作为搜索器。第二,用建造者模式造了一个ToolSearchToolCallAdvisor,把搜索器塞进去,最多返回5个匹配结果。第三,造了一个带动态发现的ChatClient,注册了飞行工具和随机工具,同时挂上了工具发现拦截器和Token计数器。第四,又造了一个不带动态发现的ChatClient,只挂Token计数器,用来做对比。
注意,两个客户端注册的工具一样,区别只在于有没有挂ToolSearchToolCallAdvisor。
测试代码两边各跑一次:
java
@SpringBootTest
@ActiveProfiles("toolsearchtool")
class ToolsSearchToolLiveTest {
@Autowired
private ChatClient chatClient;
@Autowired
private ChatClient chatClientWithoutToolsSearch;
@Test
void shouldFindFlightsBetweenRomaniaAndCroatiaUsingToolsSearch() {
String response = getClientResponseString(chatClient);
assetClientResponse(response);
}
@Test
void shouldFindFlightsBetweenRomaniaAndCroatiaWithoutToolsSearch() {
String response = getClientResponseString(chatClientWithoutToolsSearch);
assetClientResponse(response);
}
private static void assetClientResponse(String response) {
assertThat(response).isNotBlank();
assertThat(response).containsIgnoringCase("Croatia");
assertThat(response).containsIgnoringCase("flight");
}
private String getClientResponseString(ChatClient chatClientWithoutToolsSearch) {
return chatClientWithoutToolsSearch.prompt()
.user("""
Find available flights from Romania to Croatia next week.
""")
.call()
.content();
}
}
两个测试方法,一个用带动态发现的客户端,一个用不带动态发现的客户端。断言部分检查回答里有没有包含Croatia和flight这两个关键词,确保回答内容符合预期。
跑完看日志,token对比结果就出来了:
[2026-05-24 11:39:07] [INFO] [c.b.s.t.TokenCounterAdvisor] - Total tokens spent: 974 //用了工具搜索
[2026-05-24 11:39:10] [INFO] [c.b.s.t.TokenCounterAdvisor] - Total tokens spent: 3685 //没用工具搜索
搜索策略不只有正则一种
文章里用的是正则匹配。你搜"flight",它就把名字里带flight的工具捞出来。这招对付简单的命名规则够用了。
但如果你工具名字起得乱七八糟,或者你想让AI用语义去理解工具功能,就得换别的策略。
社区里还有向量搜索。向量搜索就是把工具描述转成向量,用户搜索的时候把搜索词也转成向量,算个相似度。这招更灵活,但实现起来也重一点。
你甚至能自己写搜索策略。只要实现ToolSearcher接口,爱怎么搜怎么搜。
底层逻辑:把"选择"拆成"搜"和"用"
这整个机制拆穿了就一句话:以前是"给所有工具让AI选",现在是"给搜索工具让AI搜,再给搜到的工具让AI用"。
多了一层搜索,少了一大堆冗余传输。
关键点在于,AI第一次调用搜索工具的时候,它其实不知道具体有什么工具,它只知道怎么用搜索工具。这就像你去图书馆,图书管理员不告诉你所有书在哪,只告诉你有个检索台,你自己查。
你查到了《罗马尼亚航班指南》这本书在哪,图书管理员再去把那本书拿给你。你翻完书,得到答案,走人。
整个过程你从来没看过图书馆里那本《铲子使用大全》,虽然它就在书架上。
真正省钱的不是单次,是多次
有人会说,第一次对话多了一次工具调用,这不是也消耗token吗?
确实,搜索工具本身调用一次也要消耗token。但搜索工具的调用消耗是固定的,跟工具总数无关。而老方案每次对话都要把所有工具定义塞进去,工具越多,消耗越大。
如果你的系统里只有两个工具,动态发现可能不划算,因为多了一次搜索调用,省掉的工具定义没多少。
但如果系统里有两百个工具,动态发现就香了。你每次只搜出几个相关的,其他一百九十多个工具永远不会进入上下文。
文章里那句结论很直白:系统里工具越多,Tool Search Tool省下的token就越多。
实测:两个客户端问同一个问题
文章里的测试代码写得很清楚。两个客户端,一个带动态发现,一个不带。问同一个问题:找罗马尼亚到克罗地亚下周的航班。
两个客户端的回答内容一样,都包含了航班信息和克罗地亚这个目的地。
但token消耗差了三倍多。
这测试还验证了一点:动态发现不会影响回答质量。AI最终拿到的工具定义是一样的,只是拿到的方式和时机不同。
先搜再用,和一次性全给,最终AI看到的工具定义没区别。区别在于过程中间传输了多少冗余信息。
这个机制解决了什么问题
AI集成系统里有个老大难问题:工具数量膨胀。
一开始只有三五个工具,全塞进去也没感觉。业务扩展了,加了二十个工具,开始有点疼。等加到一百个,每次对话光工具定义就占掉几千token,用户还没打字呢钱已经花出去了。
动态发现把这个问题从"每次对话"变成了"每次需要"。需要什么搜什么,不需要的永远不出现。
这思路不只适用于AI工具调用,很多系统设计都是这个道理。一次性加载所有资源,和按需加载,后者在资源受限的场景下永远更优。
Spring AI把这个机制做成了插件级别的组件,加几行配置就能用。那些已经写了大量工具的老项目,不用重构,直接套个搜索器就行。
总结
老方案让AI背着一百个工具回答一个简单问题,新方案让AI只带一个搜索框。多一次搜索调用,省下三千token。工具越多,省得越狠。这思路就是按需加载,别把家当全背身上。