
如果你和我一样是个纯后端开发第一次在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、ToolCallbacksSpring 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 groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-${provider}/artifactId version1.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上。第一种方式显式注册ListToolCallback 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生成很简单参数是自定义对象时框架会递归展开所有字段。比如你定义一个查询参数的recordpublic 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从“会说话”变成了“能办事”。如果你还在犹豫要不要上工具调用我的建议是先挑一个最高频、最没风险的工具接上去跑一段时间比如库存查询、订单状态查询。等你在真实场景里体会过“模型自己决定调用你的业务方法”这个瞬间后面想停都停不下来。