Spring AI + Qwen 实现 Function Call:从入门到实战

发布时间:2026/9/9 12:14:33
Spring AI + Qwen 实现 Function Call:从入门到实战 最近在把开源的 Qwen 模型接到业务系统里绕来绕去发现最核心的还是 Function Calling。平时我们问大模型“明天杭州天气怎么样”模型再聪明也只能靠训练数据猜但一旦接上函数调用它就能去调天气服务的 API拿到实时数据再回答你。这个东西在 Spring AI 里叫 Function Call本质就是把一组 Java 方法描述给大模型让它按需调用。如果你正在做开源模型应用落地或者刚接触 Spring AI 想搞懂工具调用是怎么玩的这篇内容基本能帮你把主流程和坑都踩平。我这边实际项目里用的就是 Spring AI 本地运行的 Qwen 系列模型调用方式是 OpenAI 兼容接口。后面讲到的代码都是可以直接跑起来的环境是 Spring Boot 3.2 Spring AI 1.0.0 GA。好不绕弯子直接开始。1. Function Calling 解决了什么问题1.1 大模型没法碰实时数据这是硬伤用过 ChatGPT、Qwen、GLM 这类模型的人应该都有感觉模型回答的内容质量很高但涉及实时天气、股票价格、自己系统里的订单数据时它只能一本正经地胡说八道因为大模型的训练数据是有时间截点的它根本“看不到”你业务系统的私有数据。要解决这个问题第一反应是“把数据放进 Prompt 里”。小数据量还行比如你只查一个用户的信息拼进提示词也能跑。可一旦数据量大或需要动态查询、写库、算价格Prompt 塞不下而且每次查询都要重新组装延迟不可控。传统做法是训练模型但微调一次成本高、周期长而且模型的知识更新仍然跟不上业务变化。Function Calling 的思路完全是另一个方向模型自己决定要不要调用外部函数调用哪个函数入参是什么。数据还是留在你的系统里模型只负责“理解用户意图”和“生成调用参数”真正查数据库、调第三方 API、执行计算的任务还是交给 Java 代码来做。这样模型不用记住数据只需要知道“有一个什么功能的函数可以用”。1.2 Spring AI 对 Function Call 的封装思路Spring AI 做的事情是把这个流程标准化让 Java 开发者不用关心 OpenAI 或开源模型 API 底层的工具协议差异。你只需要定义一个普通的 Java 方法给它加一行描述Spring AI 会自动生成模型需要的 JSON Schema然后在推理过程中完成函数调用的多轮交互。我的理解是Spring AI 把 Function Calling 抽象成了两层第一层是“函数定义层”你通过Description注解描述方法用途通过入参 record 定义结构Spring AI 负责把这些映射成模型能理解的工具定义。第二层是“调用执行层”模型返回“需要调用某个函数”的消息后框架自动找到对应的 Java 方法执行后将结果包装成 Tool 消息再送回模型模型根据结果生成最终回复。所以从开发者视角看你只是在调用ChatClient.prompt().functions(...)但实际上背后发生了至少两次模型请求。这也是我一开始容易犯迷糊的地方后面章节会详细讲。2. 环境准备与依赖配置2.1 版本选型千万别用太老的版本Spring AI 的迭代速度很快我记得 0.8.x 和 1.0.0 之间 API 改动很大很多教程里的写法已经废了。我目前用的是 Spring Boot 3.2.5 Spring AI 1.0.0 GA这个组合相对稳定。如果你还在用 0.8.1 那套FunctionCallbackWrapper建议尽早升级1.0 里的编程式注册方式更简洁文档也更多。pom 里需要加入 Spring AI BOM然后引入核心模块dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement核心依赖按需选如果只是用 OpenAI 兼容接口只需要spring-ai-openai一个dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai/artifactId /dependency如果你是接 spring ai alibaba 那套生态或者接本地 Ollama也可以引入对应的 starter。不过我的建议是除非你只用某个特定云厂商否则先统一走 OpenAI 兼容协议后面换模型只需要改 base-url 和 api-key代码基本不用动。2.2 对接本地开源模型的配置方式我本地跑的是 ollama 拉起的 qwen2.5:7b启动后默认监听 11434 端口。Spring AI 这边只需要在 application.yml 里把 base-url 指过去spring: ai: openai: base-url: http://localhost:11434/v1 api-key: ollama chat: options: model: qwen2.5:7b temperature: 0.7这里有个小细节api-key随便填一个非空字符串就行因为 Ollama 不校验 key但 Spring AI 的 OpenAI 客户端会做基础校验空着会报错。如果你接的是智谱、DeepSeek 这类兼容 OpenAI 接口的服务把 base-url 和 model 换掉即可。实测下来只要模型支持 function calling配置方式都差不多。关键在于选对模型很多开源小模型对 Function Call 的支持并不可靠后面我会专门讲这个问题。2.3 ChatClient 与 ChatModel 的选择Spring AI 1.0 里面最常用的编程入口是ChatClient它更像一个流式 Builder链式调用非常舒服。我习惯先定义一个配置类把 ChatClient 作为 Bean 暴露出去Configuration public class AiConfig { Bean public ChatClient chatClient(ChatModel chatModel) { return ChatClient.builder(chatModel).build(); } }如果你只想快速测试不引入额外的 Bean直接注入ChatModel也行。两者在 Function Calling 上都能用但我更推荐ChatClient因为它处理多轮上下文、系统提示词和函数注册更顺手而且代码可读性高。3. 从零实现一个 Function Call带参数查天气3.1 先定义函数和入参结构我先拿“查天气”做例子这是最典型的工具调用场景。定义一个 Java record 作为入参字段叫city表示城市名。Spring AI 会把 record 自动构建为 JSON Schema所以字段命名和注释尽量规范public record WeatherRequest(String city) {}然后写一个函数实现。这个函数可以是普通方法但需要注册成FunctionWeatherRequest, WeatherResponse形式的 Bean。函数内部你可以调用真实天气 API、查数据库或者直接返回写死的数据。我这里为了演示简单返回一个结果Description(查询指定城市的当前天气) Bean public FunctionWeatherRequest, WeatherResponse currentWeather() { return request - { // 真实场景可以调用第三方天气接口 return new WeatherResponse(request.city(), 晴, 25); }; } public record WeatherResponse(String city, String weather, int temperature) {}注意函数返回类型我建议也用 record 定义这样模型在生成自然语言回复时能引用结构化的字段值。返回 String 也行但结构化对象更利于 Spring AI 后续做类型转换。3.2 用 ChatClient 调用并触发函数写好函数后在业务代码里只需要一句话注册给模型String answer chatClient.prompt() .user(杭州今天天气怎么样) .functions(currentWeather) .call() .content(); System.out.println(answer);.functions(currentWeather)的参数就是 Bean 名称。Spring AI 会自动找到对应的 Bean并把函数定义附加到 Chat Options 中。当模型判断需要查询天气时就会返回一个函数调用消息。实测出来的现象是模型先输出类似{name:currentWeather,arguments:{\city\:\杭州\}}的内部消息Spring AI 拿到后自动执行你的 Bean再把结果回传最后模型基于结果说“杭州今天晴25度。”整个过程外部感知只有一次content()调用内部其实是多轮。3.3 传参方式System Prompt 与 Tool 消息的区别有人会问如果我不想在每次请求里都注册函数怎么办Spring AI 支持把函数调用挂在系统提示词里也可以做到全局生效。我常用的是在 ChatClient 的defaultSystem里加上一句说明例如String answer chatClient.prompt() .system(你可以使用工具查询实时天气工具名为 currentWeather城市参数必须采用中文。) .user(北京天气怎么样) .functions(currentWeather) .call() .content();这里需要理解 Tool 消息和 System Prompt 的分工系统提示词是给模型的一套“行为准则”它不会直接触发函数真正触发函数的是模型在生成 Token 时看到了 Tools 定义并且判断用户问题需要调用工具。所以就算系统提示词写得再详细如果.functions()没传模型还是不会调用。反过来只传.functions()不写说明模型也能靠工具描述推断出该什么时候用。我更推荐两者结合工具描述写清楚“何时用”系统提示词约束“怎么用”。4. 实现细节与原理解读4.1 模型怎么决定调用哪一个函数很多人第一次做 Function Call 都会好奇模型是不是把我所有 Java 方法都看了一遍其实不是。模型只知道你通过 API 传过去的工具定义也就是一组“函数名 功能描述 参数 JSON Schema”。你注册了哪些 Bean它才能看到哪些工具。当用户提问时模型内部会做一次意图判断。比如“杭州今天适合穿什么”它发现有一个叫currentWeather的函数描述里写着“查询指定城市的当前天气”那么它就会生成一个 Tool Call 请求参数是{city: 杭州}。如果问题与工具无关比如“帮我写一首诗”模型就不调用任何工具直接生成文本回答。这里有个关键参数叫tool_choiceSpring AI 在底层默认是auto也就是由模型自己决定调不调、调哪个。如果你希望强制某个模型必须调用某个工具可以设置tool_choice为required或者指定函数名。但实测下来开源模型对强制调用支持不算好容易产生幻觉参数所以我的建议是尽量让模型自动决策不要强制。4.2 JSON Schema 自动构建的原理Spring AI 之所以能让你只写一个 record 就生成完整的函数定义是因为它在启动时用反射读取了类的字段名、字段类型和注解。Description注解会被解析为工具描述字段会被映射为 JSON Schema 的 properties。比如WeatherRequest(String city)最终会生成{ type: object, properties: { city: { type: string } }, required: [city] }所以如果入参 record 字段很多一定要保证每个字段名有明确含义最好加JsonFieldDescription注解补充说明这会显著提高模型生成参数的正确率。比如public record WeatherRequest( JsonFieldDescription(城市名称必须使用中文全称例如杭州) String city, JsonFieldDescription(日期格式为 yyyy-MM-dd默认当天) String date ) {}这类字段描述在模型触发调用时会作为 JSON Schema 的一部分发给模型模型生成参数时会参照描述来填值。实际过程中我发现描述越具体参数幻觉越少。4.3 多轮对话中的 Function Call 状态管理Function Calling 并不是一次独立的请求它经常出现在多轮对话里。比如用户先说“帮我查一下杭州天气”模型调用工具你说“那北京呢”模型需要结合上一轮上下文明白这里的“那北京呢”是想查北京天气于是再次调用工具。在 Spring AI 里多轮上下文需要你自己维护 Message 列表。我通常用MessageHistory或者手动把历史消息传给ChatClientListMessage messages new ArrayList(); messages.add(new SystemMessage(你是智能助手可以查询天气。)); messages.add(new UserMessage(杭州天气怎么样)); String first chatClient.prompt(messages) .functions(currentWeather) .call() .content(); messages.add(new AssistantMessage(first)); messages.add(new UserMessage(那北京呢)); String second chatClient.prompt(messages) .functions(currentWeather) .call() .content();注意这里的AssistantMessage(first)是模型上一轮的最终文本回答。如果上一轮内部发生了工具调用你其实还需要保存工具调用记录和工具返回结果Spring AI 内部会处理但如果你手动持久化上下文往往会忘了保存 Tool 消息导致下一轮模型不知道你刚才查过什么。最简单的做法是直接使用 Spring AI 的ChatMemory接管对话历史让框架管理消息列表。5. 进阶技巧结构化输出与 Function Call 结合5.1 将函数返回值解析为实体类很多业务系统需要的不只是模型一句自然语言回复而是一个结构化的 JSON 对象。比如用户问“杭州天气怎么样”你希望直接拿到WeatherResponse而不用再解析字符串。Spring AI 支持将模型最终输出转换成指定实体类这是通过StructuredOutputConverter实现的。我比较常用的方式是让模型“只输出 JSON”然后通过 Bean 配置String json chatClient.prompt() .user(杭州天气怎么样返回JSON) .functions(currentWeather) .call() .content(); ObjectMapper mapper new ObjectMapper(); WeatherResponse result mapper.readValue(json, WeatherResponse.class);但这种做法比较脆弱模型偶尔会输出额外解释文字。更推荐使用 Spring AI 自带的BeanOutputConverterBeanOutputConverterWeatherResponse converter new BeanOutputConverter(WeatherResponse.class); String answer chatClient.prompt() .user(杭州天气怎么样 converter.getFormat()) .functions(currentWeather) .call() .content(); WeatherResponse response converter.convert(answer);converter.getFormat()会在提示词里加上 JSON Schema 说明引导模型输出贴合结构的 JSON转换成功率会高很多。5.2 函数返回结构设计直接影响模型回答质量这是我认为最容易被忽略的细节。函数完完全全可以返回一段格式化好的字符串比如return 杭州天气晴温度25度;这种返回没问题但问题在于模型拿到这段话后只能原样引用或简单改写。如果你希望模型帮你做对比、汇总、翻译最好返回结构化数据。比如返回return new WeatherResponse(杭州, 晴, 25);模型就能从字段层面理解“天气”和“温度”的语义当用户追问“杭州和北京哪个更热”时模型会分别调用currentWeather两次再基于两个结构化结果做比较。这个能力看似简单但用字符串返回就很容易把两次调用结果混在一起导致模型分析错误。5.3 函数注册的几种方式除了用Bean注册函数Spring AI 还支持在请求时动态构造函数。比如你可以用FunctionCallbackWrapper编程式创建Bean public FunctionCallback weatherFunctionCallback() { return FunctionCallback.builder() .description(查询指定城市的当前天气) .function(currentWeather, (WeatherRequest request) - { return new WeatherResponse(request.city(), 晴, 20); }) .inputType(WeatherRequest.class) .build(); }然后在 prompt 中注册chatClient.prompt() .user(北京天气怎么样) .functionCallbacks(weatherFunctionCallback()) .call() .content();如果你只需要在某个场景里临时定义一个函数又不想污染 Spring 容器这种方式比Bean灵活。我的经验是通用函数用Bean一次性特殊逻辑用 FunctionCallback 动态创建。6. 常见问题与排查实录6.1 定义了函数却没生效模型根本不调用这个问题最常见我踩过好几次。排查思路按顺序来确认模型本身支持 Function Calling。Qwen 2.5 7B 是支持的但很多更小的量化版本就不稳定。最简单的验证方法是用同一个模型在命令行或网页端直接发一句“帮我查杭州天气”看它是否能正确输出函数调用结构。确认.functions(xxx)里的 Bean 名称和实际 Bean 方法名一致。默认 Bean 名称是方法名比如public FunctionWeatherRequest, WeatherResponse currentWeather()Bean 名就是currentWeather。确认入参 record 是否在启动时能被扫描到。Spring AI 需要能反射获取类信息如果用了内部类或私有类可能会生成不完整的 JSON Schema。打开 Spring AI 的 debug 日志看请求里是否带了tools字段。如果没带说明函数注册没生效。日志配置加一句logging: level: org.springframework.ai: DEBUG日志里会打印发给模型的实际请求体你直接搜索tools就能确认函数定义有没有传过去。6.2 参数解析报错模型生成了乱值开源模型在生成函数参数时经常出现字段名拼错、类型错误、多余字段比如模型生成了{city: 杭州, date: 123}这样明显不对的结果。Spring AI 底层会先把模型输出解析成ToolCall再通过 JSON 反序列化到你的入参类型。我常用的规避手段有三个字段类型尽量用String别用复杂嵌套对象。对开源小模型来说简单扁平的结构生成成功率最高。在JsonFieldDescription里写清楚取值格式比如“只能传中文不能传数字”。函数内部做兜底比如校验字段为空时返回固定错误信息让模型重新生成答案。这里也提醒一点不要完全相信模型生成的参数真实业务中入参一定要做服务端校验防注入和防脏数据。6.3 连接中断和重连问题Spring AI 接本地模型时偶尔会出现连接中断特别是在流式输出场景。大部分情况下是本地推理服务超时或负载过高。我这里的做法是给 HTTP 客户端配置合理的超时时间同时保证函数调用逻辑足够快。如果函数本身执行了 5 秒模型那边早等不及中断了。可以通过spring.ai.openai.client.connect-timeout和read-timeout设置spring: ai: openai: client: connect-timeout: 10s read-timeout: 60s另外本地方模型并发量低如果多个请求同时触发 Function Call很容易把显存打满。我的建议是给函数调用加一层业务级限流或者把模型换成更高并发参数的版本别在客户端无限重试。6.4 依赖冲突导致奇怪的函数报错网上能看到类似call to undefined function intervention\image\gd\imagettfbbox()这种报错其实那不是 Java 或 Spring AI 的问题而是 PHP 环境里的 GD 库没启用。Spring AI 项目里如果出现奇怪报错先检查是不是 Maven 依赖冲突。常见冲突点是 JSON 库Jackson 和 Gson、SLF4J 绑定、以及 Spring AI 不同 starter 之间的版本不一致。排查方式很简单在 pom 里执行mvn dependency:tree搜索 spring-ai 相关的依赖看是否有重复或旧版本被自动引入。一般情况下只保留一个 spring-ai-openai starter不要同时引入多个模型厂商的 starter冲突概率会大幅下降。6.5 Function Call 与流式输出结合时的细节如果你用stream()而不是call()函数调用的流程会不太一样。模型第一次返回的是一段工具调用指令而不是可读文本Spring AI 会在内部执行完函数后再把最终结果以流式返回给调用方。我最初用流式输出时发现content()拿不到任何结果因为流式消息的类型是ToolCallMessage或AssistantMessage不是纯文本。处理方式是自己监听消息类型或者干脆先用非流式的方式跑通业务链路再考虑流式优化。如果必须流式建议使用 Spring AI 的StreamChatClient它是专门处理这类场景的封装内部会自动 handling function call。7. 最后的一点使用建议实战中我发现Function Calling 的上限其实取决于两件事模型的理解能力和函数描述的质量。你在模型选型上花再多时间也不如把函数的描述、参数描述写得清晰准确见效快。开源模型整体上比闭源模型更容易在参数生成上出问题所以函数设计要简单、粒度要小、注释要全。我现在的习惯是每个函数都遵循一个模板描述里写“何时调用”参数里写“每个字段的格式和范围”返回类型里写“字段的语义”。这样模型在意图分析、参数生成和结果理解三个阶段都有一份参考整个调用链路会顺畅得多。后续我打算在这个系列里继续写多函数并发调用、Function Call 与向量检索的结合以及如何用 Function Call 实现业务系统的自主决策。你有类似场景的话可以先按这篇文章把基础链路搭起来有问题评论区一起讨论。