Spring AI 2.0 Tool Calling 到底怎么跑起来的:从 @Tool 到两次模型调用

Spring AI 2.0 Tool Calling 到底怎么跑起来的:从 @Tool 到两次模型调用

作者|溪源

适合谁|2~5 年 Java / Spring Boot 开发者

版本基线|Java 21 / Spring Boot 4.1.2 / Spring AI 2.0.2 / DeepSeek deepseek-chat

仓库|https://gitee.com/erbay/spring_ai(`toolcalling` 包;发布时打 article-02 tag)


00 先看结果

先看一段日志:

text 复制代码
model#1 tool_calls=[getExchangeRate]
model#2 answer

一次提问,两次模型调用。

第一轮,模型没有回答,它返回了一个 tool_calls:想调 getExchangeRate。应用执行工具,把汇率结果回填,再调用一次模型;第二轮,模型才组织出最终回答。

这篇文章只做一件事:把这个循环打出来。你会看到工具在哪一步执行、模型被调用了几次、Advisor 的 order 为什么会让统计少一半。

跑起来只需要一个接口:

bash 复制代码
curl -s http://localhost:8080/api/lab/tool-calling \
  -H 'Content-Type: application/json' \
  -d '{"message":"100 美元现在能换多少人民币?"}'

返回(回答措辞会变,events 的调用链不变):

json 复制代码
{
  "answer": "按当前汇率,100 美元大约能换 718 元人民币。",
  "events": [
    "model#1 tool_calls=[getExchangeRate]",
    "model#2 answer"
  ],
  "modelCalls": 2
}

01 场景与问题

Tool Calling 最容易被误解成三件事:

  • 以为模型自己执行了工具;
  • 以为一次提问只调用一次模型;
  • 以为不写 while(true) 就没法循环。

前两个是理解问题,第三个是版本问题。

模型从头到尾没有执行任何代码。它只是输出了一段 JSON:我要调用 getExchangeRate,参数是 {"from":"USD","to":"CNY"}。真正执行的是你的 Java 方法。

这也是为什么工具调用需要应用参与:模型负责决策,应用负责执行,执行结果再回到模型,让它继续判断。

这不是 Spring AI 的设计怪癖,而是所有 Function Calling 协议的共同点:模型只能输出结构化的调用意图,执行权必须在应用侧。OpenAI、DeepSeek、Anthropic 都一样,只是字段名和回填格式不同。

工具调用是 Agent 的地基,但不是全部。Agent 还要有记忆、规划、边界和成本控制;这一篇只拆地基。

不理解这个循环,会带来三个实际问题:

现象 根因
工具没被调用,模型开始编 工具描述不清楚,或工具没挂到请求上
一次提问账单是两次调用 工具循环的每一轮都是一次模型调用
统计 Advisor 少算一半 它被放在了工具循环外面

02 一张图讲原理

getExchangeRate Model ToolCallingAdvisor ChatClient 用户 getExchangeRate Model ToolCallingAdvisor ChatClient 用户 #mermaid-svg-cng915Mm0PCqykSn{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-cng915Mm0PCqykSn .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-cng915Mm0PCqykSn .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-cng915Mm0PCqykSn .error-icon{fill:#552222;}#mermaid-svg-cng915Mm0PCqykSn .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-cng915Mm0PCqykSn .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-cng915Mm0PCqykSn .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-cng915Mm0PCqykSn .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-cng915Mm0PCqykSn .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-cng915Mm0PCqykSn .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-cng915Mm0PCqykSn .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-cng915Mm0PCqykSn .marker{fill:#333333;stroke:#333333;}#mermaid-svg-cng915Mm0PCqykSn .marker.cross{stroke:#333333;}#mermaid-svg-cng915Mm0PCqykSn svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-cng915Mm0PCqykSn p{margin:0;}#mermaid-svg-cng915Mm0PCqykSn .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-cng915Mm0PCqykSn text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-cng915Mm0PCqykSn .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-cng915Mm0PCqykSn .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-cng915Mm0PCqykSn .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-cng915Mm0PCqykSn .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-cng915Mm0PCqykSn #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-cng915Mm0PCqykSn .sequenceNumber{fill:white;}#mermaid-svg-cng915Mm0PCqykSn #sequencenumber{fill:#333;}#mermaid-svg-cng915Mm0PCqykSn #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-cng915Mm0PCqykSn .messageText{fill:#333;stroke:none;}#mermaid-svg-cng915Mm0PCqykSn .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-cng915Mm0PCqykSn .labelText,#mermaid-svg-cng915Mm0PCqykSn .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-cng915Mm0PCqykSn .loopText,#mermaid-svg-cng915Mm0PCqykSn .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-cng915Mm0PCqykSn .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-cng915Mm0PCqykSn .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-cng915Mm0PCqykSn .noteText,#mermaid-svg-cng915Mm0PCqykSn .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-cng915Mm0PCqykSn .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-cng915Mm0PCqykSn .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-cng915Mm0PCqykSn .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-cng915Mm0PCqykSn .actorPopupMenu{position:absolute;}#mermaid-svg-cng915Mm0PCqykSn .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-cng915Mm0PCqykSn .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-cng915Mm0PCqykSn .actor-man circle,#mermaid-svg-cng915Mm0PCqykSn line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-cng915Mm0PCqykSn :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 100 美元现在能换多少人民币?prompt + tool definitions第 1 次模型调用tool_calls: getExchangeRate执行工具ExchangeRate(USD, CNY, 7.18)第 2 次模型调用(带工具结果)最终回答response100 美元大约能换 718 元人民币

拆成四步:

  1. 模型返回 tool_calls,它只表达意图,不执行;
  2. ToolCallingAdvisor 识别出这是工具调用;
  3. ToolCallingManager 找到对应的 ToolCallback,执行工具;
  4. 工具结果追加到对话,再次调用模型;直到模型不再请求工具。

Spring AI 2.0 把这段循环从 ChatModel 实现里搬到了 Advisor 链,DefaultChatClient 会自动注册一个 ToolCallingAdvisor。所以业务代码不用手写循环。

但"不用写"不等于"不用懂"。后面三个坑,都跟这个循环的位置有关。


03 分步实现

先看这次加了什么:

文件 作用
ExchangeRateTools @Tool getExchangeRate,返回固定汇率,不支持的货币对报错
ToolCallTraceAdvisor 记录每一轮模型调用
ToolCallingLabService 为每次请求构建 ChatClient + 独立 Trace
ToolCallingLabController POST /api/lab/tool-calling
ToolCallingLoopTest 假模型 + 四个循环断言

不碰订单项目,是因为订单里还有 HITL、退款、Token 统计,变量太多;只看循环时,一个工具、一个 Advisor 就够了。

3.1 一个最小工具

为了只看循环,我单独做了一个汇率工具,不碰订单项目:

java 复制代码
@Component
public class ExchangeRateTools {

    @Tool(name = "getExchangeRate",
          description = "查询两种货币之间的当前汇率。用户询问汇率、换汇时必须调用此工具,禁止编造汇率。")
    public ExchangeRate getExchangeRate(
            @ToolParam(description = "源货币代码,例如 USD") String from,
            @ToolParam(description = "目标货币代码,例如 CNY") String to) {
        String pair = from.trim().toUpperCase() + "/" + to.trim().toUpperCase();
        return switch (pair) {
            case "USD/CNY" -> new ExchangeRate("USD", "CNY", new BigDecimal("7.18"), "2026-10-28");
            case "EUR/CNY" -> new ExchangeRate("EUR", "CNY", new BigDecimal("7.82"), "2026-10-28");
            default -> throw new IllegalArgumentException("暂不支持该货币对: " + pair);
        };
    }
}

@Tool 的 description 不是注释,是 Prompt 的一部分。它决定模型什么时候调用、传什么参数。你写得含糊,模型就自由发挥。

参数描述也一样。"货币代码"不如"源货币代码,例如 USD"。给一个例子,模型传参的准确率会高很多。

为了突出主线,上面省略了演示用的调用计数和参数校验;完整代码见仓库的 ExchangeRateTools。不支持的货币对直接抛错,让模型知道查不到,而不是返回一个假汇率。

3.2 模型看到的不是你的 Java 方法

@Tool 最终会被转换成 ToolCallback,再暴露成 ToolDefinition:

  • name:工具名;
  • description:什么时候调用;
  • inputSchema:参数的 JSON Schema。

模型看到的是这三个东西,不是你的类和方法。它按 schema 生成 JSON 参数,Spring AI 再把 JSON 反序列化成方法参数,执行你的 Java 方法。

所以工具出问题,先检查两处:

  1. 描述有没有写清触发条件;
  2. 参数类型和 schema 是否一致。

以 getExchangeRate 为例,模型看到的大致是:

字段 值
name getExchangeRate
description 查询两种货币之间的当前汇率......
inputSchema {"type":"object","properties":{"from":{...},"to":{...}}}

它不知道 ExchangeRateTools 这个类,也不知道 BigDecimal。它只知道工具名、描述和参数 schema。所以描述和 schema 就是你和模型之间的契约。

3.3 循环由 ToolCallingAdvisor 驱动

ChatClient 构建时,如果注册了工具,DefaultChatClient 会自动加一个 ToolCallingAdvisor。它的默认 order 是 HIGHEST_PRECEDENCE + 300。

它每轮做四件事:

  1. 把工具定义挂到请求上;
  2. 识别响应里的 tool_calls;
  3. 交给 ToolCallingManager 执行;
  4. 把工具结果追加到对话,再次调用模型。

直到某一轮响应里没有 tool_calls,循环结束。

把这几步对应到类上:

步骤 谁来做 关键方法
识别 tool_calls ToolCallingAdvisor isToolCallResponse
执行工具 ToolCallingManager executeToolCalls
找到工具 ToolCallback getToolDefinition / call
回填结果 ToolCallingAdvisor 再次调用模型

ToolCallingManager 默认还会处理异常和调用上限。工具抛异常时,错误信息可能回传给模型;超过上限时,按配置返回错误响应。这两件事都不是模型自己做的。

放在 Advisor 链里还有一个好处:Memory、日志、统计、重试都能按 order 组合。你不用改业务代码,就能决定谁在循环里、谁在循环外。

如果你从 Spring AI 1.x 升上来,第一件事是把原来手写的工具循环删掉。两套循环叠在一起,工具会被执行两次,日志看起来像见了鬼。

3.4 为什么不用手写 ReAct 循环

如果你在 1.x 或别的框架里手写过,大概是这个结构:

java 复制代码
while (true) {
    ChatResponse response = model.call(prompt);
    if (!response.hasToolCalls()) break;
    ToolExecutionResult result = manager.executeToolCalls(prompt, response);
    prompt = append(prompt, result);
}

Spring AI 2.0 把这段循环放进了 ToolCallingAdvisor,你只需要注册工具。区别不是"少写几行",而是:

手写循环 Advisor 循环
每个业务自己维护 统一在链上
容易和 Memory / 统计顺序打架 order 可组合
升级要改业务代码 升级改配置 / Advisor
容易重复执行工具 只有一个 ToolCallingAdvisor

手写不是不行,但在已经有 Advisor 链的框架里,两套循环叠在一起,工具会被调用两次。这个坑比"少写几行"更值得避免。

3.5 把循环打出来

要看到每一轮模型调用,加一个自定义 Advisor:

java 复制代码
public class ToolCallTraceAdvisor implements CallAdvisor {

    private final AtomicInteger modelCalls = new AtomicInteger();
    private final List<String> events = new CopyOnWriteArrayList<>();

    @Override
    public ChatClientResponse adviseCall(ChatClientRequest request,
                                         CallAdvisorChain chain) {
        ChatClientResponse response = chain.nextCall(request);
        int call = this.modelCalls.incrementAndGet();
        AssistantMessage output = response.chatResponse().getResult().getOutput();
        List<AssistantMessage.ToolCall> toolCalls = output.getToolCalls() == null
                ? List.of()
                : output.getToolCalls();
        List<String> toolNames = toolCalls.stream()
                .map(AssistantMessage.ToolCall::name)
                .toList();
        String event = toolNames.isEmpty()
                ? "model#" + call + " answer"
                : "model#" + call + " tool_calls=" + toolNames;
        this.events.add(event);
        return response;
    }

    @Override
    public String getName() {
        return "toolCallTraceAdvisor";
    }

    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE + 400;
    }
}

为了突出主线,上面省略了空值检查和日志;完整代码见仓库的 ToolCallTraceAdvisor。

ToolCallingLabService 每次请求都新建一个 ChatClient 和一个 Trace Advisor。这样做不是为了性能,而是为了让每次请求的 events 互不污染。生产里更常见的做法是 ChatClient 只建一次,Trace 用请求作用域或 MDC 传递。

重点在 400。

ToolCallingAdvisor 默认是 300。数字越小越靠外,越大越靠里。400 在 300 里面,所以它能看到循环里的每一轮模型调用;放在 200,它包住的是整个循环,只能看到最后一轮响应。

这不是理论。测试里把同一个 Advisor 放两个位置,结果完全不同:

text 复制代码
order = 400(循环内):
model#1 tool_calls=[getExchangeRate]
model#2 answer

order = 200(循环外):
model#1 answer

模型实际都调用了两次,但循环外的 Advisor 只看到一次。你按这个数字算成本、打日志,就会少一半。

3.6 一个容易踩的测试坑

为了不每次都打真实模型,我用一个假 ChatModel 跑测试:

java 复制代码
private static final class FakeChatModel implements ChatModel {

    private final AtomicInteger calls = new AtomicInteger();

    @Override
    public ChatOptions getOptions() {
        return ToolCallingChatOptions.builder().build();
    }

    @Override
    public ChatResponse call(Prompt prompt) {
        int call = this.calls.incrementAndGet();
        AssistantMessage message = call == 1
                ? AssistantMessage.builder()
                    .content("")
                    .toolCalls(List.of(new AssistantMessage.ToolCall(
                        "call-1", "function", "getExchangeRate",
                        "{\"from\":\"USD\",\"to\":\"CNY\"}")))
                    .build()
                : new AssistantMessage("按当前汇率,100 美元大约能换 718 元人民币。");
        return ChatResponse.builder()
                .generations(List.of(new Generation(message)))
                .metadata(ChatResponseMetadata.builder()
                    .usage(new DefaultUsage(100, 20, 120))
                    .build())
                .build();
    }
}

这里有个我卡了半天的坑:假模型必须重写 getOptions(),返回 ToolCallingChatOptions。

ChatClient 只在 ChatOptions 是 ToolCallingChatOptions 时,才把工具回调挂到请求上。真实模型(OpenAI / DeepSeek)默认就是这种 options,所以线上没问题;但你自己写假模型时,如果只实现 call(),请求里没有工具,ToolCallingAdvisor 会直接跳过循环,表现为"模型返回了 tool_calls,但工具没执行"。

这个坑很隐蔽,因为日志里确实有 tool_calls,看起来像工具执行失败了。实际是工具根本没挂上。


04 验证与测试

测试覆盖四件事:

  1. 一次工具调用 = 2 次模型调用 + 1 次工具执行;
  2. Advisor 在 400(循环内)能看到 2 轮;
  3. Advisor 在 200(循环外)只看到 1 轮;
  4. 默认 order 确实大于 ToolCallingAdvisor.DEFAULT_ORDER。

mvn test 结果:

text 复制代码
Tests run: 14, Failures: 0, Errors: 0, Skipped: 0

其中 4 个是本次新增的 Tool Calling 循环测试,9 个是原有订单业务测试,1 个是上下文加载。

测试用一个假 ChatModel 做状态机:第一次返回 tool_calls,第二次返回最终回答。这样不需要真实 key,也不需要网络。

四个测试分别验证:

测试 断言
oneToolCallProduces... 2 次模型调用、1 次工具执行、events 两条
traceAdvisorInside... order 400 看到 2 轮
traceAdvisorOutside... order 200 只看到 1 轮,但模型仍调用 2 次
traceAdvisorOrderIsInside... 400 > ToolCallingAdvisor.DEFAULT_ORDER

这套测试只验证"循环有没有发生",不验证模型选工具的准确率。后者要靠真实模型 + 评测集,不是单元测试能覆盖的。这四组断言加起来,才能同时证明"循环发生了"和"Advisor 在正确的位置"。

真实模型验证留给你:设置 DEEPSEEK_API_KEY,启动应用,跑 00 节的 curl。模型措辞会变,但 events 应该是两次调用、第一次带 getExchangeRate。

如果 events 只有一条 tool_calls,说明工具没执行:先检查工具有没有挂到请求上,再检查参数 schema 是否匹配,最后看工具方法有没有抛异常。如果 events 是两条但回答不对,再去看工具返回值。

调试顺序我建议固定为:

  1. 先看请求里有没有工具定义;
  2. 再看原始响应里有没有 tool_calls;
  3. 再看工具方法有没有真的被调用;
  4. 最后看用量和轮次。

先看链路,再看措辞。


05 边界与坑

Tool Calling 适合"模型需要决定调哪个工具、传什么参数"的场景,比如查汇率、查订单、查库存。固定三步的流程、强事务的写操作,用状态机或工作流更稳;模型只负责理解意图,执行交给业务接口。这些边界不是 Spring AI 特有的,任何 Agent 框架都要面对。

第一,工具描述太抽象。 模型不知道什么时候调用,表现就是"该查汇率却编数据"。描述里写清触发条件和禁止行为。

第二,工具返回值太大。 把整个对象、整个列表都回填给模型,每一轮都在为同一份数据付费。只返回模型需要的字段。

第三,Advisor 顺序放错。 统计、日志、审计 Advisor 放在循环外,只能看到最后一轮。记住 300 和 400 这两个数。

第四,假模型没挂工具。 测试里只实现 call(),没重写 getOptions(),ChatClient 不会把工具挂到请求上。这是本文踩过的坑。

第五,忽略调用上限。 模型反复调同一个工具时,日志会刷得很快,账单也是。application.yml 里设置单工具和单轮上限:

yaml 复制代码
spring:
  ai:
    tools:
      limits:
        max-calls-per-tool-default: 8
        max-total-tool-calls: 20
        on-limit-exceeded: RETURN_ERROR_RESPONSE

第六,把内部堆栈扔给模型。 工具抛 RuntimeException 时,message 可能回传给模型。错误信息要人话、可行动,不要带堆栈。

第七,Boot 3.x 别直接抄。 Spring AI 2.x 需要 Boot 4;Boot 3 项目用 1.x,工具循环的位置和部分 API 不一样。

生产环境还要补三件事:

  • 超时:工具调用要有超时,不要让模型等一个永远不返回的外部 API;
  • 幂等:查询类工具可以重试,写操作要幂等,或者走人工确认;
  • 可观测性 :把工具名、耗时、成功 / 失败、token 打成指标,不要只打一行 chat completed。

06 下一步

上一篇是订单 Agent 实战,这一篇把 Tool Calling 的循环拆开;下一篇回到订单 Agent,讲人工确认、异常、超时和成本。ChatMemory 放到后面单独写。

这次只做了"看见循环"。下一步可以继续拆:

  • 工具调用失败、超时、重试怎么处理;
  • 把统计 Advisor 做成 Micrometer 指标;
  • 流式输出下,工具循环怎么表现;
  • 多工具协作时,模型怎么选。

如果你正在写自己的 Agent,建议先把循环打出来,再谈记忆和规划。循环没看清,后面每加一个 Advisor 都会更乱。

代码在 Gitee:https://gitee.com/erbay/spring_ai,`toolcalling` 包。发布时会打 article-02 tag;mvn test 可以直接跑。

如果这篇帮你把 Tool Calling 的循环看清楚了,点个收藏;有问题评论区聊。

相关推荐
步行cgn1 小时前
Spring MVC 九大核心角色详解:请求处理链路中的每一个关键组件
java·spring·mvc
智恒百亿1 小时前
一台 5090 八卡服务器怎么分给一个团队用?多模型多任务的显存分配实战
java·大数据·人工智能
瑞码空间2 小时前
面向接口编程与开闭原则在模拟 JDBC 驱动中的应用
java·jdbc
Félix2512 小时前
Nacos学习笔记(SpringBoot/SpringCloud)
java·spring boot·spring
文丑颜不良啊2 小时前
LeetCode1581~1600题目解析
java·leetcode
香蕉大香蕉(06敬畏市场)2 小时前
MySQL 单点详解(06)
java·数据库·mysql·博客·实习
武子康2 小时前
同一台 ESP32,GSM 能消回声,VoIP 为什么不行?
人工智能·后端·agent
lqbzlll2 小时前
Spring Boot 开启虚拟线程后接口反而变慢了?4 个排查方向和对应修法
java·spring boot·后端
七夜zippoe2 小时前
LangChain4j vs Spring AI:Java AI 框架怎么选(对比表)
java·人工智能·spring·ai·langchain4j