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-02tag)

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 元人民币
拆成四步:
- 模型返回
tool_calls,它只表达意图,不执行; ToolCallingAdvisor识别出这是工具调用;ToolCallingManager找到对应的ToolCallback,执行工具;- 工具结果追加到对话,再次调用模型;直到模型不再请求工具。
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 方法。
所以工具出问题,先检查两处:
- 描述有没有写清触发条件;
- 参数类型和 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。
它每轮做四件事:
- 把工具定义挂到请求上;
- 识别响应里的
tool_calls; - 交给
ToolCallingManager执行; - 把工具结果追加到对话,再次调用模型。
直到某一轮响应里没有 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 验证与测试
测试覆盖四件事:
- 一次工具调用 = 2 次模型调用 + 1 次工具执行;
- Advisor 在 400(循环内)能看到 2 轮;
- Advisor 在 200(循环外)只看到 1 轮;
- 默认 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 是两条但回答不对,再去看工具返回值。
调试顺序我建议固定为:
- 先看请求里有没有工具定义;
- 再看原始响应里有没有
tool_calls; - 再看工具方法有没有真的被调用;
- 最后看用量和轮次。
先看链路,再看措辞。
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 的循环看清楚了,点个收藏;有问题评论区聊。