如果你和我一样是个纯后端开发,第一次在Spring项目里看到“Tool Calling”这个词,大概率会先愣一下:大模型对话还能调用我的Java方法?
我先给一个最直白的定义:Tool Calling(工具调用)是指让大模型在对话过程中,根据你的提问输出一个结构化的“函数调用意图”,你的应用截获这个意图后,执行真实的代码逻辑,再把执行结果回传给模型,由模型组织成最终的自然语言回答。听起来绕,实际上就是一句话——模型负责做决定,你的代码负责干活。
这个能力解决的是大模型最尴尬的边界问题:它再聪明,也只知道训练时见过的数据。它不知道此刻你所在城市的温度,也不知道你们公司数据库里那个订单的状态,更没有权限替你给客户发一封邮件。Tool Calling就是给这个“只会说话、不会动手”的顾问装上手脚。对正在做AI应用集成、企业内部知识库机器人、智能客服这类项目的开发来说,这几乎是绕不开的一环。这篇文章我会从原理讲到实操,再把我踩过的坑一并摊开,希望能帮你少走点弯路。
1. 先把Tool Calling解决的问题说清楚
1.1 大模型不是数据库,也不是业务系统
很多人刚接触大模型应用时都会有一个错觉:模型既然能回答天文地理,那我直接把业务问题丢给它不就行了?真做了你就会发现,模型对实时数据和私有数据的回答基本靠猜,而且猜得振振有词。
举个例子。你问一个通用模型“我们公司昨天销售额是多少”,它只能给你一个看似合理的数字,实际上毫无依据。这本质上是它的训练机制决定的:模型记住的是文本规律,而不是事实数据库。想要让AI真正对接业务系统,你必须主动把“实时数据”或“业务动作”作为外部能力提供给模型。
传统做法有几种,但都不太好用。
一是用提示词硬引导模型输出一段特殊结构,然后代码用正则去解析。这种做法极其脆弱,模型的输出格式稍微一偏,解析就直接崩,而且维护成本极高。
二是把所有业务逻辑封装成HTTP接口,再让模型输出一个URL,代码自己去拼请求。问题在于接口发现和权限校验全靠约定,模型并不知道那些接口存在,除非你一条条写进提示词。接口一多,提示词就爆了。
三是把数据一股脑塞进上下文。比如把整个数据表都转成文本放进Prompt里,让模型自己找答案。这确实能解决一部分问题,但成本高、隐私风险大,上下文一长,模型注意力还会分散。
1.2 Tool Calling的核心价值是“决定”和“执行”分离
Tool Calling改变的是整个交互范式。以前是代码规定模型能做什么,现在变成模型根据工具描述自己决定要调哪个函数。你不需要在代码里写“如果用户问到天气,就调用weather方法”这类硬编码逻辑,模型看到“查询指定城市天气情况”这段描述,自己就知道该调用它。
这里面有三层价值非常关键。
第一层是标准化。模型输出的调用请求是一个结构化指令,应用侧天然可解析,不用再靠正则碰运气。第二层是能力动态发现。你给模型什么工具,它就有什么能力,新增一个工具时,业务代码几乎不用改,只要注册进去就行。第三层是决策智能化。模型会根据上下文、用户意图、历史对话,从多个工具里挑最合适的那个,甚至编排多个工具的执行顺序。
我在实际项目中感受最深的是第三层。以前做智能客服,意图识别要单独训一个分类模型,意图少的时候还行,意图一多准确率就往下掉。用Tool Calling之后,工具本身就是意图,模型直接根据用户描述去匹配,维护成本降了一个数量级。
2. Spring AI把工具调用做成了什么样
2.1 一次工具调用的完整旅程
先看一次完整的工具调用是怎么流转的,这一步理解透了,后面写代码才有感觉。
假设你做了一个天气助手,注册了一个查询天气的工具,用户问“上海今天适合跑步吗”。
第一步,应用把用户问题连同工具定义一起发给模型。这个工具定义不是自然语言,而是一份结构化的描述,包含工具名称、功能说明、参数名、参数类型、是否必填等。
第二步,模型发现自己不知道实时天气,于是它不直接回答,而是在返回内容里带出一个“调用指令”:工具名是getWeather,参数是{"city":"上海"}。
第三步,应用收到这个指令后,在本地查出天气数据,再把这个数据作为“工具执行结果”回传给模型。
第四步,模型基于查询结果组织最终回答:“上海今天多云,气温18℃,湿度适中,很适合跑步。”
整个过程中,你的代码需要负责的事情就是拦截这个调用指令、执行你的方法、把结果原样回传。模型不会直接执行任何代码,它只负责“发指令”。
这里有个容易被忽略的点:这不是两轮独立的对话,而是发生在同一条消息链里。工具调用过程中产生的中间消息都会保留在历史中,模型在后续生成时会把这些内容一并作为依据。这也是实现时要注意消息组织的原因,我后面会细说。
2.2 核心抽象就三个:@Tool、ToolCallback、ToolCallbacks
Spring AI把上面这套流程抽象得非常简洁,核心就三样东西。
@Tool注解用来标记一个Java方法作为可调用工具,写在普通方法上就行。ToolCallback是一个接口,封装了从“模型发指令”到“应用执行并返回结果”的全过程。ToolCallbacks是一个工具类,负责把一组被@Tool标记的对象转换成ToolCallback实例。
平时你只需要关心@Tool怎么用,后面两个Spring AI都帮你包好了。真正需要手写ToolCallback的场景很罕见,比如某个已有函数没办法加注解,或者同一个工具需要根据不同上下文动态变化参数,才需要动它。
注册链路也不复杂。你要么自己在构造ChatClient的时候显式传入ToolCallbacks,要么把工具类声明成Bean交给框架自动收集。两种方式我都试过,各有适合的场景,第三章会展开对比。
2.3 工具描述和参数Schema是怎么“凭空”出来的
Spring AI会自动把方法名、方法注释、参数结构生成工具描述,这件事第一次看到时会觉得有点魔法,但它背后就是一套“工具规范”的构建过程。
方法名会变成工具名,比如getWeather。方法类上的@Tool注解description属性会变成功能说明,如果你没写description,框架会尝试读取方法上的Javadoc注释。方法的参数会被递归解析成一个JSON Schema,比如一个嵌套对象会展开成对象结构,枚举会变成可选值列表,数组会变成数组类型。
这个自动生成机制很方便,但有一个衍生问题:工具描述的好坏直接决定模型愿不愿意调用、会不会调错。我见过一个同事在方法上只写了“获取信息”四个字,结果模型遇到所有模糊问题都优先调这个工具,输出完全没法看。@Tool注解里的description一定要写得具体,让模型明确“什么时候该用、什么时候别用”。
2.4 和Function Calling到底是什么关系
行业里更常见的叫法是Function Calling,很多模型服务商的文档都这么写。Tool Calling和它本质上是同一件事,只是Spring AI在Java生态里做了一层统一封装。
不同的模型服务商在接口定义上细节有差异,比如有的工具参数格式更严格、有的支持并行工具调用、有的工具描述字数有限制。Spring AI把这层差异屏蔽掉了,你写一套@Tool方法,底层适配不同模型时,代码基本不用改。这也是我选Spring AI做工具调用而不是自己裸调接口的原因之一。
3. 实操:做一个让模型自己选工具的天气助手
3.1 工程准备与依赖取舍
先说环境。你只需要一个Spring Boot工程,JDK 17及以上,再引入Spring AI相关依赖。用初始化页面生成工程时,勾选你所用模型服务商对应的starter即可。
如果你是自己手动加依赖,大概长这样:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-${provider}</artifactId> <version>1.0.0</version> </dependency>${provider}替换成你用的模型服务商对应的artifactId后缀。不同服务商的starter命名不一样,但用法基本相通。
配置文件方面,你至少要接入三个信息:模型服务的请求地址、密钥、模型名称。具体配置项的前缀因模型厂商而异,但大致是这样一个形态:
spring: ai: chat: base-url: ${AI_BASE_URL} api-key: ${AI_API_KEY} model: ${AI_MODEL}一个提醒:Spring AI在演进期API变化比较快,尤其是工具调用相关的API,在1.0.0正式版前后有过几次调整。动手前先确认自己用的版本,网上很多老文章里的代码直接复制是跑不起来的。
3.2 用@Tool定义一个真实业务方法
下面写一个最简单的天气查询工具。这个工具本身不接真实天气API,先用假数据把链路跑通,等确认没问题再替换成真实调用。
先定义一个返回结构:
public record WeatherResult(String city, String weather, int temperature) { }然后是工具类:
public class WeatherTool { @Tool(description = "查询指定城市当前天气情况,返回天气状况和温度") public WeatherResult getWeather(String city) { // 这里先返回模拟数据,方便验证调用链路 return new WeatherResult(city, "多云", 18); } }注意几个点。工具类本身不一定要加@Component,但如果你想让它被框架自动发现,最好还是声明为Bean。方法名getWeather会成为模型看到的工具名,命名要小驼峰且语义完整。@Tool的description是重中之重,我写的是“查询指定城市当前天气情况,返回天气状况和温度”,模型看到这个描述就知道用户问天气时该调它,而且知道调用后能得到什么。
返回值用record是有意的:不可变、结构清晰、JSON序列化友好。如果返回的是普通类且字段命名不规范,生成的参数Schema可读性会变差,模型传参的时候容易出错。
3.3 把工具注册到ChatClient的两种方式
有了工具类,下一步就是把它挂到ChatClient上。第一种方式,显式注册:
List<ToolCallback> toolCallbacks = new ArrayList<>(); toolCallbacks.addAll(ToolCallbacks.from(weatherTool)); ChatClient chatClient = ChatClient.builder(chatModel) .defaultToolCallbacks(toolCallbacks) .build();调用时和平常没什么区别:
String answer = chatClient.prompt("上海今天适合跑步吗?") .call() .content();第二种方式,利用Spring Boot的Bean自动收集。把WeatherTool声明为Bean,然后在构建ChatClient时不手动传,框架会从应用上下文里自动找所有带@Tool方法的对象并注册进去。这种方式在工具数量比较多时很省事,但代价是隐式。你在排查问题时需要知道框架收集了哪些工具,如果发现问题,反而要花时间看日志。
我个人的习惯是:工具少时用显式注册,每个方法来源清清楚楚;工具超过十个再切换到自动收集,但会在启动日志里确认工具列表是否完整。
3.4 验证链路与日志观察
跑起来之后,重点不是看模型回答得对不对,而是确认工具方法到底有没有被执行。最快的验证方式,是在getWeather方法第一行打一个日志,或者直接让方法抛一个异常:
@Tool(description = "查询指定城市当前天气情况,返回天气状况和温度") public WeatherResult getWeather(String city) { throw new RuntimeException("工具方法被调用但触发了测试异常"); }如果模型回答变成了类似“查询天气时出现错误”的内容,恭喜,工具链路已经通了。因为模型不会自己抛这个异常,它只是把你的Java方法的异常结果原样回传了。
确认链路通之后,再把异常换成真实业务逻辑就行了。
如果发现模型既不报错也不调用工具,而是一本正经地胡说八道,多半是工具描述没有被模型正确理解,或者注册链路根本没生效。此时打开Spring AI的DEBUG日志,查看请求发出前是否带了工具定义,就能定位问题。
4. 进阶:参数、上下文与多工具协作
4.1 @Tool里容易被忽略的几个参数
@Tool注解除了description,还有几个参数值得注意。第一个是name,默认使用方法名,但你可以显式指定一个对模型更友好的名称。第二个是resultConverter,当你的返回值需要特殊处理时,可以用它把返回对象转成模型需要的文本格式。
还有一个隐藏点:方法参数是简单类型时,Schema生成很简单;参数是自定义对象时,框架会递归展开所有字段。比如你定义一个查询参数的record:
public record WeatherRequest(String city, WeatherUnit unit) { } public enum WeatherUnit { CELSIUS, FAHRENHEIT }框架生成的参数Schema里,unit会成为一个枚举列表。绝大多数模型都能正确处理,但我遇到过个别模型对枚举字段支持不稳定的情况,模型传了一个不在枚举里的字符串,导致反序列化失败。解决方式是把枚举改成字符串参数,在方法内部再做校验,兼容性更好。
参数默认值是我踩过最多次的坑。Java方法参数没有默认值这一说,模型又偶尔会漏传非必填参数。所以工具方法内部一定要写健壮容错,比如city为空时给个默认城市或返回提示,而不是指望模型永远聪明。
4.2 ToolContext:把请求上下文安全地传进工具方法
实际业务里,工具方法经常需要知道当前调用者的身份。比如查询订单状态,你得知道是哪个用户在查,否则就是越权。Spring AI提供了ToolContext机制,专门干这个事。
@Tool方法可以声明一个ToolContext参数,框架会自动注入:
@Tool(description = "查询指定城市当前天气情况,返回天气状况和温度") public WeatherResult getWeather(String city, ToolContext toolContext) { String userId = toolContext.getContext().get("userId"); // 按用户做权限校验或业务处理 return new WeatherResult(city, "多云", 18); }调用端这样传上下文:
String answer = chatClient.prompt("上海今天适合跑步吗?") .toolContext(Map.of("userId", "u_12345")) .call() .content();这里有个安全细节很多人不知道:ToolContext不会发给模型,它只存在于应用内部。所以像用户ID、内部traceId这类敏感信息,放在ToolContext里是安全的,不用怕泄漏给模型。工具方法内部做鉴权时,优先用ToolContext里的身份信息,不要信模型在参数里自带的内容。
4.3 多个工具注册与外层工具选择
一个工具类里可以写多个@Tool方法,也可以注册多个工具Bean。框架会把这些工具全部收集起来,统一作为候选列表发给模型。模型收到用户问题后,从候选列表里自己挑。
这里考验的就是描述质量了。我见过一个项目里同时注册了“查询订单”和“取消订单”两个工具,两个描述都写得很模糊,模型经常选错,用户说“帮我看看订单”,模型直接调了取消订单方法。这种事故一旦发生,后果很严重。
我的经验是,描述里要有边界词和触发条件。比如“查询指定用户的订单列表,适用于用户询问订单状态、订单详情场景”,以及“取消指定订单,仅在用户明确表达取消意图时使用,其他情况不要调用”。把“什么时候不该用”也写进描述里,能显著降低误调用概率。
还有一点关于工具数量。每个工具的完整Schema都会被放进每次请求的tools参数里,工具越多,请求token就越多,响应也会变慢。我之前把所有十几二十个工具一股脑挂上去,结果每次对话光工具定义就占了上千token。正确做法是按场景分组,比如售前机器人只挂商品查询类工具,售后机器人只挂订单售后类工具,而不是全局挂一整套。
4.4 流式场景下的工具调用注意点
如果你给用户展示的是流式输出,也就是一个字一个字蹦出来的效果,工具调用过程会变得更“迟滞”。因为工具调用是分片返回的,Spring AI需要先等完整的工具指令收齐,再执行你的方法,最后把工具结果回传模型,模型再生成最终文本。这个过程中用户看到的就是“卡了一下”。
如果前端直接把流式片段转发给用户,有可能会出现一种诡异的现象:工具结果还没回来,模型先说了一段无关的过渡文字,然后又接着输出答案。解决方式是在前端做一个状态管理,工具调用过程中展示“正在查询天气...”这类占位,等服务端最终文本整体拼接完再展示。
从后端视角看,工具方法执行是阻塞的,流式传输并不等同于实时工具执行,这一点务必和前端同事对齐预期。
5. 踩过的坑:工具调用问题排查速查
5.1 模型就是不调用工具,怎么办
这是我被问得最多的问题,没有之一。模型不调用工具,听上去是模型的问题,但大多数时候是应用侧配置没到位。
最常见的原因是工具根本没注册成功。你写了@Tool方法,但工具类没有被扫描到,或者你用的是显式注册但传错了list,模型根本没见过这个工具,自然不会调用。排查办法很简单,打开DEBUG日志,看请求体里是否带上了工具定义。
第二个原因是模型服务本身不支持工具调用。有些接口虽然走的是主流Chat Completion协议,但工具调用能力没开放,或者你用的模型版本不支持。这类问题换一个支持工具调用的模型就能验证出来。
第三个原因是描述不清晰。工具描述写得过于模糊,模型不知道这个工具是干嘛的,自然就不会用。试着把描述改成“触发条件+功能说明+返回值说明”三段式结构,命中率会明显提升。
第四个原因是提示词里没有提示工具可用。某些模型比较“老实”,用户问题里没有相关关键词,它就不会主动用工具。这时候可以在系统提示词里加一句指导,比如“如果用户询问天气,你必须使用天气查询工具,不要自行编造数据”。
5.2 工具方法被调用了,但执行报错
工具方法报错之后,错误会被序列化成文本回传给模型,模型通常会回答“查询失败,请稍后重试”。如果你看到这种回答,就说明链路已经通了,但方法内部有异常。
常见错误之一是参数解析失败。模型生成参数时,偶尔会把数字写成字符串,或者枚举值写错。这种情况要么在方法内部做宽松的类型转换,要么在描述里把参数的取值边界写清楚。
第二个常见问题是方法签名不匹配。@Tool方法参数类型必须是框架能解析的类型,如果用了自定义类型而没有对应的反序列化支持,就会报错。解决方案是把复杂参数折叠成若干简单参数,或者确保自定义类型的字段可被正常序列化。
第三是返回值过大。我把工具结果返回过一个特别大的对象,里面带了一整张表格,结果后续请求的token直接爆炸,单次对话成本翻了好几倍。给模型返回的结果要“够用即可”,大数据量的场景,最好在工具方法内先做聚合、摘要,再返还给模型。
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| 模型不调用任何工具 | 工具未注册成功 | 开DEBUG日志,确认请求体带工具定义 |
| 模型不调用指定工具 | 描述模糊或不当触发 | “触发条件+功能+返回值”三段式重写描述 |
| 工具调用报function not found | 工具名不一致 | 显式指定@Tool的name,两端保持一致 |
| 参数反序列化失败 | 模型传参类型不匹配 | 内部做宽松转换,避免依赖枚举与复杂泛型 |
| 工具方法抛异常后对话中断 | 异常未转成结果回传 | 在工具方法内捕获并返回结构化错误内容 |
| 流式输出顺序混乱 | 工具执行与文本生成混排 | 前端做状态占位,等完整结果再展示 |
5.3 容易被忽视的三个安全细节
工具方法被模型调用,本质上是在执行一段代码,而这段代码的触发者可能只是用户的一句自然语言。这里面有安全性问题,容易吃大亏。
第一个细节是权限校验。模型可能根据用户话术去调用任何可用工具。如果你有一个“删除订单”的工具,用户说“把我所有的订单都删了”,模型可能真的会调用它。工具方法内部必须做身份鉴权,用ToolContext里的真实用户ID,而不是模型生成内容里携带的信息。
第二个细节是幂等设计。模型调用工具时,可能因为网络抖动、接口重试等原因,同一个工具被执行多次。如果这个工具有副作用,比如发短信、扣余额、改状态,就会出大事。业务工具尽量设计成幂等的,至少要有按请求ID去重的机制。
第三个细节是描述里的信息泄露。工具描述会被模型看到,也会被存进对话历史。不要在描述里写内网地址、数据库连接、密钥之类的信息,这些内容等于公开给了模型。
5.4 调试工具调用链的三板斧
我自己调试这类问题,基本靠三板斧。
第一板斧是抓请求。用DEBUG日志或者拦截器,把发给模型的请求体完整打出来,重点看tools字段结构是否正确。第二板斧是本地模拟。先用一个返回固定结果的假工具把链路跑通,再替换成真实现,这样出了问题能很快区分是链路问题还是业务逻辑问题。第三板斧是对比测试。同一个请求,先不带工具发一遍,再带工具发一遍,对比模型响应差异,往往很快就能定位是模型理解问题还是代码问题。
这三个手段配合起来,绝大部分工具调用问题都能在十分钟内定位。
最后再分享一个小体会。我把Tool Calling接到真实系统里之后,最明显的变化不是对话变聪明了,而是模型终于没必要“猜”数据了。以前模型为了回答你的问题,会在没有依据的情况下编一个数字,现在它可以先查再说。这种“先执行后回答”的模式,看起来只是流程多了一步,实际上是把AI从“会说话”变成了“能办事”。如果你还在犹豫要不要上工具调用,我的建议是先挑一个最高频、最没风险的工具接上去跑一段时间,比如库存查询、订单状态查询。等你在真实场景里体会过“模型自己决定调用你的业务方法”这个瞬间,后面想停都停不下来。