彻底搞懂 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 可以看到:

image.png

具体解析的类型是:

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 里面包含详细的工具调用信息:

image.png

最近 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 的警示

  1. 别假设 schema 中立:同样语义、不同形状,在新 Claude 上成功率可能差一截。能贴近主流 harness 形状(扁平参数)时,优先扁平。
  2. 应用侧要有容错策略:未知字段过滤、参数别名、校验失败把错误清晰喂回模型(Spring 的 ToolExecutionExceptionProcessor 可定制)。
  3. 关键路径可开 provider 的 strict / structured output(若 API 支持且 schema 不太复杂)。
  4. debug 时先看 AssistantMessage.ToolCall.arguments 原文,不要只看业务方法入参——多余字段往往在反序列化前就被丢掉或直接炸在框架层。
0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
leikooo
作者分享
ARTS 0815: 分隔链表、大删除是加活不是减负与协议栈如何一层层拆信封
4
DeepSeek Harness 缓存命中率太惊人了,有时候竟然能到 99%,看鱼皮哥的视频竟然还出现过 100% 😱 https://www.bilibili.com/video/BV1VkgK6NEZS
7
ARTS 0809: 反转链表、Shopify 如何用 MySQL 解决超卖与 AI 时代程序员的价值
5
译文:《SwiftUI 七年:平庸的故事》 SwiftUI 在 2019 年高调发布,本应成为苹果全平台成熟、可量产的 UI 未来。七年过去,到了 2026 年,它仍像一场永不结束的 beta:布局难预期、性能不稳、数据流混乱,还几乎没有可靠的向后兼容,开发者被迫写一堆 shim 和 workaround。 作者用苹果官方教程(甚至是有问题的)以及与 UIKit 的对比说明:SwiftUI 用“看起来方便”换掉了精确的工程控制。更深一层,他认为这反映了苹果从 Cocoa、Aqua、Auto Layout 那种不妥协的工艺,转向“够用就行”的企业文化。 SwiftUI 为何存在 苹果并非单纯想提供更好工具,而是不得不应对竞争:React、React Native、Flutter 让“一套代码多端跑”变得诱人;Mac 上原生应用又日渐被网页和 Electron 吃掉。SwiftUI 要同时拴住原生生态、并降低移植到 Mac 的成本。卖点是:响应式数据流、声明式布局、跨平台复用。 数据流 “单一数据源”听起来很美,实际却是 @State、@Binding、ObservedObject,再到 Observation / @Observable 的不断换代。你很难确定视图会更新几次、为何更新;它该忽略的变化会反应,该关心的变化又可能忽略 - - 像个黑盒。 布局系统 基于尺寸协商的布局在 Keynote 里很合理,做浮动视图、自定义侧边栏时却极度不稳定。官方教程里一个很普通的侧边栏,多年仍有问题。布局脆弱到最后往往只能上 GeometryReader - - 一旦用了,声明式优势就没了,还要手算坐标,而且下一版布局规则一变,数学还得重写。 API 稳定与功能对等 代码里满是 if #available。滚动收起键盘要到 iOS 16;工具栏定制很晚才来;网络图片 AsyncImage 要到 iOS 15,缓存相关 API 到 2026 年 7 月仍在 beta。旧 API 常被换掉(如 NavigationView → NavigationStack),开发者要维护多套实现,等于替苹果做 QA。对比 Android 的 Jetpack Compose 可作为依赖打包回退到旧设备,SwiftUI 做不到“写最新 API、稳定回退”。 性能 在真实对比里,即便做了后台解码等优化,SwiftUI 图片网格滚动仍明显不如 UIKit。若展示一堆 JPEG 都得靠顶级芯片撑,架构本身就有问题。 跨平台神话 苹果说的是“学一次、到处用”,不是“写一次、到处跑”。iOS 上学到的布局很少直接适用 Mac;同一套 view 跨平台实现也不一致。结果常变成:学一次、再学一次、某处能用、处处要调。 哲学转向 最大的问题是“够用就行”:覆盖 90% 用例就算成功,用 velocity 掩盖质量下降。作者列举系统与一线应用中的各种瑕疵,认为这不是偶然,而是苹果主动降低质量门槛 - - 所以即使过了七年,他仍不信任 SwiftUI。 结论 对构建稳定、高性能、可维护系统真正重要的部分,SwiftUI 几乎都有问题。它不是“极差”,而是平庸 - - 用假便利换真精度,要么你花时间给框架打补丁,要么把半成品发出去。作者更宁愿继续用“遗留”的 UIKit / AppKit。 最后小总结: 最让人不能接受的不是“SwiftUI 还有 bug”,而是它把“看起来很快”当成了工程上的完成态。声明式、预览、跨平台,每一项都在秀高级感,可真正写进业务后,你面对的是难预测的重绘、脆弱的布局、层层 #available,以及把兼容和排错外包给业务方的现实。七年够长了,若还靠“框架还年轻”解释,那更像是对标准的侮辱。技术选型从来不只是语法偏好,而是你选的是可预期性、可维护成本,以及对用户体验的态度。平庸的“成功”往往比明显失败更危险 ,你说它能上线、能 demo、能交差,但是却在细节里一点点磨损信任。工具可以换代,但对质量的要求不该跟着一起降级。
4
ARTS 0802: 合并有序链表、AI 时代的技术断层与 TCP 200ms 延迟之谜
5
下载 APP