彻底搞懂 Spring AI Tool Calling:从底层协议到源码执行全流程
什么是工具调用?
一句话:模型不会真的去调你的 Java 方法。它只会「说」:我要用哪个工具、参数是什么;真正执行工具、把结果塞回对话的,是你的应用(Spring AI / Agent harness)。
官方也强调这一点:tool calling 看起来像模型能力,本质是客户端协议——模型只负责发起请求,应用负责执行并回传结果。模型永远拿不到你工具背后的真实 API,这也是安全边界。
一次完整调用长什么样
▼mermaid复制代码sequenceDiagram participant U as 用户 participant App as 应用 / Spring AI participant LLM as 大模型 U->>App: 提问(例如:明天星期几?) App->>LLM: Prompt + ToolDefinition 列表(工具名/描述/JSON Schema) LLM-->>App: AssistantMessage(含 ToolCall:name + arguments) Note over App: 模型此时只是「请求调用」,还没执行 App->>App: ToolCallingManager 按 name 找到 ToolCallback 并执行 App->>LLM: ToolResponseMessage(工具执行结果) LLM-->>App: 最终自然语言回答 App-->>U: 返回结果
可以把它想成「带协议的函数调用」:
| 角色 | 做什么 | 不做什么 |
|---|---|---|
| 模型 | 根据 schema 生成 name + arguments(通常是 JSON 字符串) | 不直接访问数据库 / HTTP / 文件系统 |
| 应用 | 校验、执行、把结果写回对话 | 不替模型「发明」业务决策(除非你自己写逻辑) |
Tool Call 本质是文本
模型侧并没有魔法 RPC。服务端把 transcript、system prompt、可用工具列表揉进带特殊标记的大 prompt;模型在训练过的格式上采样,吐出一段可被解析成「调用某某工具」的文本。
一般来说会把工具接收到的是类似下面的信息:
▼json复制代码"tool_calls": [ { "index": null, "id": "call_019f4b577f9479c0826f7f8b", "type": "function", "function": { "name": "queryCustomer", "arguments": "{\"userId\":\"1001\"}" } } ]
Spring AI 框架会进行解析&执行,把模型吐出的 tool call 解析成 AssistantMessage.ToolCall,再交给 ToolCallingManager 执行最后返回给 LLM。
如何搭配 Spring 实现工具调用
SpringAI 版本 1.1.2、SpringBoot 版本 3.5.4。
源码仓库:https://github.com/lieeew/SpringAIToolCall
最小可跑示例
▼java复制代码package com.leikooo.springaitoolcall; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; @SpringBootApplication public class SpringAiToolCallApplication { private static final Logger log = LoggerFactory.getLogger(SpringAiToolCallApplication.class); public static void main(String[] args) { SpringApplication.run(SpringAiToolCallApplication.class, args); } @Bean @ConditionalOnProperty(name = "tool-call.debug.enabled", havingValue = "true") CommandLineRunner toolCallDebugRunner(ChatModel chatModel) { return args -> { String response = ChatClient.create(chatModel) .prompt(""" You are debugging Spring AI tool calling. Complete the task only by using the provided tools. Task: 1. Call queryCustomer with userId 1001. 2. Call createTicket for userId 1001 and issue "Mouse cannot connect". 3. Reply in Chinese with the customer name, customer level, and ticket id. Do not invent tool results. """) .tools(new DebugTools()) .call() .content(); log.info("Tool calling final response: {}", response); System.out.println("==== Spring AI Tool Calling Result ===="); System.out.println(response); }; } public static class DebugTools { private static final Logger log = LoggerFactory.getLogger(DebugTools.class); // Put breakpoints in these methods to inspect the generated tool arguments. @Tool(description = "Query customer profile by user id") public String queryCustomer(@ToolParam(description = "Customer user id, for example 1001") String userId) { log.info("Tool invoked: queryCustomer(userId={})", userId); if ("1001".equals(userId)) { return "userId=1001, name=Alice, level=VIP, region=Shanghai"; } return "No customer found for userId=" + userId; } @Tool(description = "Create a customer support ticket") public String createTicket( @ToolParam(description = "Customer user id") String userId, @ToolParam(description = "Issue summary") String issue) { log.info("Tool invoked: createTicket(userId={}, issue={})", userId, issue); return "ticketId=TICKET-" + userId + "-001, userId=" + userId + ", status=CREATED, issue=" + issue; } } }
模型工具调用「返回」了什么?
我们通过 debug org.springframework.ai.openai.OpenAiChatModel#internalCall 中的 response 可以看到:

具体解析的类型是:
▼java复制代码// org.springframework.ai.chat.messages.AssistantMessage.ToolCall public record ToolCall( String id, // 本次 tool call id,回传结果时要对上 String type, // 一般是 "function" String name, // 工具名,对应 ToolDefinition.name / @Tool name String arguments // ★ 重点:模型生成的参数,通常是 JSON 字符串 ) {}
简化版本 toString 之后的内容,可以看到没有什么黑魔法,就是单纯的 String 所以很有可能会出现错误(反序列化的错误、多参数、少参数等等),所以需要在工程上做兜底,比如请求 LLM 的时候如果了网络问题会进行重试。
▼json复制代码{ "index": null, "id": "call_019f4b577f9479c0826f7f8b", "type": "function", "function": { "name": "queryCustomer", "arguments": "{\"userId\":\"1001\"}" } }
工具调用如何执行的
我们可以看到在 org.springframework.ai.openai.OpenAiChatModel#internalCall 里面有判断是否包含工具调用,包含工具调用直接通过 this.toolCallingManager.executeToolCalls 直接执行工具调用。框架封装的比较好,导致咱们不能进行一些自己的扩展,比如:异常出现进行重试、system-reminder 强提醒更新 todoList 等(我们是用覆盖源码的操作实现一些自定义逻辑)...
▼java复制代码if (this.toolExecutionEligibilityPredicate.isToolExecutionRequired(prompt.getOptions(), response)) { var toolExecutionResult = this.toolCallingManager.executeToolCalls(prompt, response); if (toolExecutionResult.returnDirect()) { // Return tool execution result directly to the client. return ChatResponse.builder() .from(response) .generations(ToolExecutionResult.buildGenerations(toolExecutionResult)) .build(); } else { // Send the tool execution result back to the model. return this.internalCall(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions()), response); } }
后面执行的核心代码:
▼java复制代码@Override public String call(String toolInput, @Nullable ToolContext toolContext) { Assert.hasText(toolInput, "toolInput cannot be null or empty"); logger.debug("Starting execution of tool: {}", this.toolDefinition.name()); this.validateToolContextSupport(toolContext); // {"userId":"1001","issue":"Mouse cannot connect"} Map<String, Object> toolArguments = this.extractToolArguments(toolInput); // "1001"、"Mouse cannot connect" Object[] methodArguments = this.buildMethodArguments(toolArguments, toolContext); // 真正是用反射执行工具调用 Object result = this.callMethod(methodArguments); logger.debug("Successful execution of tool: {}", this.toolDefinition.name()); // 判断返回类型 Type returnType = this.toolMethod.getGenericReturnType(); // 根据类型进行 cover return this.toolCallResultConverter.convert(result, returnType); }
最终通过反射调用,以下是核心代码:
▼java复制代码@SuppressWarnings("null") @Nullable private Object callMethod(Object[] methodArguments) { if (isObjectNotPublic() || isMethodNotPublic()) { this.toolMethod.setAccessible(true); } Object result; try { result = this.toolMethod.invoke(this.toolObject, methodArguments); } catch (IllegalAccessException ex) { throw new IllegalStateException("Could not access method: " + ex.getMessage(), ex); } catch (InvocationTargetException ex) { throw new ToolExecutionException(this.toolDefinition, ex.getCause()); } return result; }
发给模型的 schema 从哪来?
这里就是 LLM 怎么知道有哪工具可以进行调用,其实是发送给 LLM 一个工具调用的列表,告诉了 AI 有哪些工具。在 SpringAI 中可以是用 @Tool 注解很方便的标识工具。
核心解析代码在:org.springframework.ai.tool.method.MethodToolCallbackProvider#getToolCallbacks 里面是用反射拿到 Tool 的参数信息:
▼java复制代码@Override public ToolCallback[] getToolCallbacks() { // 反射获取方法参数 var toolCallbacks = this.toolObjects.stream() .map(toolObject -> Stream .of(ReflectionUtils.getDeclaredMethods( AopUtils.isAopProxy(toolObject) ? AopUtils.getTargetClass(toolObject) : toolObject.getClass())) .filter(this::isToolAnnotatedMethod) .filter(toolMethod -> !isFunctionalType(toolMethod)) .filter(ReflectionUtils.USER_DECLARED_METHODS::matches) .map(toolMethod -> MethodToolCallback.builder() .toolDefinition(ToolDefinitions.from(toolMethod)) .toolMetadata(ToolMetadata.from(toolMethod)) .toolMethod(toolMethod) .toolObject(toolObject) .toolCallResultConverter(ToolUtils.getToolCallResultConverter(toolMethod)) .build()) .toArray(ToolCallback[]::new)) .flatMap(Stream::of) .toArray(ToolCallback[]::new); validateToolCallbacks(toolCallbacks); return toolCallbacks; }
我们 debug 可以看到整个 toolCallbacks 是下面的样子:
▼text复制代码DefaultToolDefinition[name=queryCustomer, description=Query customer profile by user id, inputSchema={ "$schema" : "https://json-schema.org/draft/2020-12/schema", "type" : "object", "properties" : { "userId" : { "type" : "string", "description" : "Customer user id, for example 1001" } }, "required" : [ "userId" ], "additionalProperties" : false }]
最终在请求的时候会携带上 org.springframework.ai.openai.api.OpenAiApi#chatCompletionEntity,我们可以看到 chatRequest 里面包含详细的工具调用信息:

最近 Claude 新模型的幺蛾子~
Pi 工具调用现象
较新的 Claude(文中提到 Opus 4.8、Sonnet 5)在某些 嵌套 tool schema(例如 Pi 的 edits[])上,会在对象末尾凭空加字段:
▼json复制代码{ "oldText": "...", "newText": "...", "requireUnique": true }
或 oldText2 / type / in_file / event.0.additionalProperties 等一堆随机 key。
更气人的是:oldText / newText 内容经常是对的,只是尾巴多了废话 → schema 校验失败 → harness 拒掉 → 要求重试。
特点:
- 单轮「编辑这个文件」很难复现;长 agent 历史里更容易炸。
- 去掉 thinking block,失败率可能下降;开 strict tool invocation 后作者侧问题消失。
- 老模型反而更稳——「更好的模型,更差的工具适配」。
为什么会变差?
▼mermaid复制代码flowchart TB A[Post-training 贴近 Claude Code 等 dominant harness] --> B[模型学到「成功 tool call」的先验形状] B --> C[扁平 edit:file_path / old_string / new_string / replace_all] C --> D[你的 schema 若是嵌套 edits 数组 → 偏离训练分布] D --> E[高熵位置乱采样可选字段名] E --> F[多余 key / 别名 / sloppy JSON] G[Claude Code 客户端很宽容:别名、滤未知 key、重试] --> A G --> H[RL 对「略畸形但仍成功」几乎不惩罚]
同时:嵌套数组参数往往是「tag 里再塞一大段转义 JSON」,模型在关掉超长 newText 字符串后,要在 } 和 , "..." 之间做决定——正好是最容易胡写 key 的点。
Anthropic 的 strict mode 推测会在服务端按 schema 约束采样(禁止非法 key),所以能压住这类问题;但 tool definition 有复杂度限制,Claude Code 自己也未必开 strict。
对 Spring / 自建 Harness 的警示
- 别假设 schema 中立:同样语义、不同形状,在新 Claude 上成功率可能差一截。能贴近主流 harness 形状(扁平参数)时,优先扁平。
- 应用侧要有容错策略:未知字段过滤、参数别名、校验失败把错误清晰喂回模型(Spring 的
ToolExecutionExceptionProcessor可定制)。 - 关键路径可开 provider 的 strict / structured output(若 API 支持且 schema 不太复杂)。
- debug 时先看
AssistantMessage.ToolCall.arguments原文,不要只看业务方法入参——多余字段往往在反序列化前就被丢掉或直接炸在框架层。
