Spring AI动态工具发现:省下75% token消耗

AI对话太烧钱?这个配置让token消耗直接打二五折

你的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。工具越多,省得越狠。这思路就是按需加载,别把家当全背身上。