Java 后端 AI 转型实录 Day6:让大模型调用我的 Java 方法|深挖 Spring AI Tool‑Calling 底层反射机制
AI 转型系列・Day6
很多人跑通 Tool‑Calling 就觉得完事,但很少有人去深究框架背后到底发生了什么。
今天不仅完成 Spring AI 1.1.0 Tool‑Calling 完整跑通,还通过javap -c反汇编字节码,拆解完整调用链路,厘清记忆共享、参数编译开关这些极易踩坑的生产隐患。全部为实测结论,可以直接复制代码使用。
开工前置校验:先确认模型是否真的支持工具调用
遵循本周实战准则:不要直接上手写业务代码,先验证模型本身能力。
我没有直接写 Spring 代码,先用 Ollama 原生 Chat 接口做能力探针,模拟工具调用入参,提前确认 qwen3.8:27b‑mlx 是否可以输出标准tool_calls结构。
curl http://localhost:11434/api/chat -d '{ "model":"qwen3.8:27b-mlx", "messages":[ { "role":"user", "content":"帮我查询订单10086物流" } ], "stream":false }'实测得到 3 个关键信息:
- 模型可以输出标准
tool_calls,不是文本假装函数调用,模型能力没问题,不需要做降级兼容方案; - 参数抽取效果稳定,用户输入
订单10086,可以正确解析出参数{"orderId":"10086"}; - 模型返回携带
thinking推理字段,后续日志看到该字段属于正常现象,不必恐慌。
💡重要:Tool‑Calling 会发起两轮大模型请求,这个耗时是正常现象,不要误判为程序卡死。
这一步属于预判验证,不是线上踩坑。写博客要实事求是,不能把前期验证包装成踩坑事故。
30 分钟跑通最小 Tool‑Calling 示例
定义工具 Bean,使用@Tool注解声明可供大模型调用的 Java 业务方法。
重点:
description描述文本质量直接决定模型会不会、什么时候调用这个函数。
importorg.springframework.ai.tool.annotation.Tool;importorg.springframework.stereotype.Component;@ComponentpublicclassOrderTools{@Tool(name="getOrderStatus",description="根据订单号查询订单的物流状态。当用户询问订单物流、快递进度、货到哪了的时候调用该工具。入参为订单号字符串。")publicStringgetOrderStatus(StringorderId){returnswitch(orderId){case"10086"->"已发货,顺丰 SF1234567,预计明天送达";default->"订单号不存在,请核对订单编号";};}}编写 Controller,.tools()传入工具 Bean,完成最小 Demo 接口:
importorg.springframework.ai.chat.client.ChatClient;importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.RequestParam;importorg.springframework.web.bind.annotation.RestController;@RestController@RequestMapping("/api")publicclassToolChatController{privatefinalChatClientchatClient;privatefinalOrderToolsorderTools;publicToolChatController(ChatClientchatClient,OrderToolsorderTools){this.chatClient=chatClient;this.orderTools=orderTools;}@GetMapping("/chat/tools")publicStringchatWithTools(@RequestParamStringq){returnchatClient.prompt().user(q).tools(orderTools).call().content();}}测试两条 case:
# 场景1:命中工具,模型主动调用getOrderStatus curl -sG "http://localhost:8080/api/chat/tools" --data-urlencode "q=我的订单10086到哪了" # 场景2:和工具业务无关,直接闲聊,不触发工具调用 curl -sG "http://localhost:8080/api/chat/tools" --data-urlencode "q=今天天气如何"现象:
- 查询订单:模型识别意图,调用 Java 工具,拿到模拟物流结果再整理自然语言返回;
- 询问天气:不触发任何工具,直接返回对话结果。
模型是否调用工具完全自主决策,决策依据来源于我们写的
description描述。
Demo 跑通只是开始,真正容易埋坑的都藏在框架内部。下面抛出三个问题,全部通过字节码反汇编找答案。
三问底层:跑通 Demo 之后再深挖
Q1:.tools(orderTools)是怎么执行到我写的 Java 方法?
很多初学者会误以为.tools()这行就直接执行了方法,其实完全不是。
结论:.tools()仅仅做注册登记,不会执行业务逻辑;真正执行我们业务方法依靠 JDK 反射Method.invoke()。是否调用、调用哪一个,决策权交给大模型,方法执行发生在本地 JVM 内部。
我反编译了spring‑ai‑model、spring‑ai‑client‑chat、spring‑ai‑ollama三个 Jar 包,完整调用链路梳理如下:
.tools(orderTools)└─ToolCallbacks.from(orderTools)└─MethodToolCallbackProvider.builder().toolObjects(obj).build().getToolCallbacks()扫描类内部所有@Tool注解方法,封装成MethodToolCallbacktoolMethod :被@Tool标记方法的反射Method对象 toolObject :Spring容器中的Bean实例 toolDefinition :组装name、description、入参JSONSchema[第1次向大模型发起请求]把工具的name、描述、参数Schema随Prompt一起传给模型[大模型侧决策返回]返回ToolCall,携带工具名称、JSON格式入参参数[SpringAI框架内部执行,DefaultToolCallingManager主循环]· 打印DEBUG日志:Executingtool call:xxx · 根据工具name匹配对应的MethodToolCallback;匹配不到直接抛出IllegalStateException,不会静默失败 · callback.call(arguments):1.将工具返回JSON解析为Map2.通过反射拿到方法形参名,从Map取出对应参数值3.做类型转换,调用 toolMethod.invoke(toolObject,args)👉 执行我们自己写的业务方法[第2次向大模型发起请求]将工具执行结果追加进对话历史,交给大模型生成最终自然语言回答对于 Java 后端开发者,这套模式非常熟悉,可以类比 SpringMVC:
表格
| Spring MVC | Spring AI Tool‑Calling |
|---|---|
| @RequestMapping | @Tool |
| HandlerMapping 根据 URL 匹配处理器 | 根据工具 name 匹配 MethodToolCallback |
| HandlerAdapter 通过反射执行目标 Controller 方法 | Method.invoke () 执行 @Tool 标记的业务方法 |
| 用户浏览器输入 URL | 大模型输出工具名称与参数 |
本质逻辑没有创新,唯一变化:选择执行哪个函数的权力,交给了大模型输出的结果。
也正是这个原因,@Tool里面 description 写得好不好,直接决定整个工具调用成功率。
Q2:接口没有写任何记忆代码,连续提问为什么还保留上下文?
测试中发现,连续多次请求/api/chat/tools,模型可以记住上一轮对话内容,但 Controller 接口代码中,完全看不到任何会话记忆相关代码。
结论:会话记忆不是绑定在单个接口上,而是绑定在全局单例ChatClientBean 上。我们之前配置的MessageChatMemoryAdvisor对使用这个 Bean 的所有接口全部生效。
大模型本身是无状态,不存在 “记忆”。每一次请求到来,Advisor 会从存储器把历史会话全部读取出来,拼接进 Prompt 再传给模型。
梳理MessageChatMemoryAdvisor内部执行逻辑:
- 请求执行前 (before):根据
conversationId读取历史消息,把历史消息合并到当前 Prompt;同时把当前用户消息存入存储。
⚠️注意:用户消息在大模型还没返回结果前就入库。如果中间工具调用发生异常,数据库会残留只有用户提问、没有 AI 回复的 “孤儿消息”。
- 请求执行后 (after):拿到大模型最终回答,把 assistant 回复存入会话存储。
框架防御性设计:工具调用中间过程(tool_calls、工具返回数据)不会存入会话记忆,只会保存最终用户‑assistant 对话。如果把中间工具消息持久化,下一轮提交给 Ollama 会直接报 400 参数错误。
几个实测得到的重要细节:
- 如果不传
conversationId,默认使用字符串default作为会话 ID; - 生产重大隐患:多个不同用户不传递 cid,全部共用 default 会话池,发生会话互相串数据,用户隐私会互相泄露!
- 当前使用 H2 内存数据库,应用重启,全部会话记忆直接丢失。
小实验:在
/api/chat接口说一句暗号,再访问/api/chat/tools接口,模型可以读取到之前对话;只有传入不同的conversationId,会话才会彻底隔离。
Q3:ChatClientConfig 配置类什么时候执行?
结论:仅在 Spring 容器启动、Bean 实例化阶段执行一次。后续每一次 HTTP 请求不会再次执行配置类逻辑。
每次调用chatClient.prompt()会生成一个全新请求级别的 Spec 对象,.tools()、临时参数全部挂载在这个临时对象上,请求结束就销毁。
表格
| 对象 | 创建时机 | 生命周期 |
|---|---|---|
| chatClient、chatMemory、Advisor | Spring 容器启动 | 全局单例,整个进程复用 |
| prompt () 返回的 Spec、.tools () 注册的临时工具 | 每一次 HTTP 请求 | 请求级别,请求完成就销毁 |
这也解释了之前踩坑:修改
@Bean配置、yml 配置之后,必须重启服务。旧的单例对象依旧存活在 Spring 容器,不重启代码修改不会生效。
Tool‑Calling 高频踩坑清单
表格
| 现象 | 解决方案 |
|---|---|
网上搜到FunctionCallback相关代码编译报错 | Spring AI1.1.0 已经重构工具调用体系,优先使用@Tool注解 + ToolCallback,复制代码前核对 Jar 包版本签名 |
| 工具调用整体响应很慢,误以为程序卡死 | Tool‑Calling 会两轮调用大模型;首次模型加载会额外增加耗时,热态 5‑8s 属于正常区间 |
| 工具名称匹配失败 | 框架直接抛出IllegalStateException,不会静默返回错误结果;核对@Tool的 name,和模型返回工具名称保持一致 |
| 工具拿到的参数全部为 null,参数匹配异常 | 方法形参名依赖编译时‑parameters编译开关。spring‑boot‑starter‑parent 默认开启;Gradle、Ant 构建默认关闭,关闭之后方法参数名全部变成arg0,参数解析直接失效。这个坑排查难度极高。 |
| 会话互相串消息,多用户上下文混乱 | 业务场景下必须传入独立 conversationId 做会话隔离,禁止依赖 default 默认会话;持久化不要使用 H2 内存库。 |
Day6 实战总结
- Demo 跑通只是入门门槛。调用一行
.tools()人人都可以做到,但弄懂框架内部发生了什么,才是普通 API 使用者和 AI 应用开发工程师的分水岭。今天三个问题,很多是写 Demo 不会遇到,上线生产才会爆发的隐患。 javap / javap -c反汇编校验这套工作方式继续发挥价值。AI 框架文档更新往往滞后版本,看字节码,比网上教程、AI 生成代码更加可靠。- Spring AI 的 Tool‑Calling 没有很多想象中的黑魔法。本质:注解扫描 + 名称匹配 + JDK 反射执行,全是 Spring 生态传统技术;唯一变化,调用哪个方法的决定权交给了大模型。把新技术拆解成熟悉的老技术,学习成本会大幅降低。
下一篇预告:多工具编排实战,注册多个 @Tool 工具,观察模型如何自主选择;做一组 description 删减对照实验,验证工具描述文本对调用成功率的影响。
本文是「Java 后端 AI 转型实录」系列第 6篇。项目后续开源在 GitHub(链接见主页),每周更新。