
最近好多人在群里问 Spring AI 到底能干嘛是不是就是个“Java 版的 OpenAI SDK”除了聊天还是聊天。其实这玩意儿真正的价值在于跟现有 Java 工程打通而最经典、最容易被拿来验证思路的场景就是让模型去调用天气 API。这个例子看着不大但它几乎把大模型应用里最核心的机制都串起来了模型怎么决定调哪个工具、参数从哪来、结果怎么回填、上下文怎么衔接。搞懂这一条链路后面再做 Agent、RAG、自动化流程你会觉得顺了很多。这篇文章我就用 Spring AI 2.x 的姿势带着你从零写一个能查实时天气的助手。不光是贴代码我会把为什么这么写、哪些地方容易翻车、怎么排查都讲透。适合刚接触 Spring AI、想搞明白工具调用的人也适合已经在做智能体但想回头把基础链路理清楚的同学。1. 这个项目到底在解决什么问题1.1 大模型不是“不知道天气”而是“摸不到天气”很多人有个误解觉得大模型啥都知道。其实它知道的是截止到训练数据那一刻的“知识”之后的实时信息一概没有。天气预报这种东西别说今天了上一小时的数据它都拿不到。这个问题的本质不是“知识缺失”而是“触达能力缺失”——模型没有手没法发 HTTP 请求也没法连数据库。要让模型感知实时世界通常有几种路数把天气直接塞进提示词里但那是死数据每次都得手动更新做 RAG 把资料喂给模型但天气这玩意儿时刻在变索引了也没太大意义最合适的其实是让模型“学会使用工具”——它生成一个带有函数名和参数的调用意图我们自己的代码去执行再把结果拿回来给它看。这就是 Function Calling也叫工具调用。Spring AI 把这件事封装得非常 Java你写一个普通方法加个注解框架自动把方法的描述、参数结构生成给模型模型在合适的时候“点名”要求调用它。1.2 为什么用 Spring AI 而不是自己拼 HTTP 请求我见过不少人这么干直接用原生的 HTTP 客户端去请求大模型接口费了老大劲拼 JSON结果返回的 tool_calls 字段还得自己解析、自己维护会话状态一套下来少说几百行代码还不一定稳。Spring AI 帮你处理了这些脏活统一抽象不管你接的是 OpenAI、DeepSeek、通义千问还是百炼平台上的 QwenAPI 风格保持一致换模型只改配置核心代码基本不动。自动生成 JSON Schema你写一个 Java record 或者方法签名框架自动生成描述给模型。省去了手写 schema、更新后忘记同步的麻烦。工具回调管理模型说“我要调用 getWeather”框架直接把参数解析成 Java 对象调用你的方法然后把返回值序列化成消息再喂回模型形成完整的推理循环。Spring 生态集成工具方法天然就是一个 Bean你可以直接注入 Service 做数据库查询、调内部接口、加缓存、走事务这些能力不用额外适配。一句话总结自己拼 HTTP 是“写接口”用 Spring AI 是“声明能力”。前者是在跟协议细节搏斗后者是在描述你希望模型具备哪些动作。1.3 这套东西适合谁参考如果你正准备做一个客服机器人、内部运维助手、或者把 Dify 里的工作流搬到 Java 工程里这个项目就是很好的起步模板。它不是那种“为了演示而演示”的玩具而是把一条完整链路走通模型负责理解意图工具负责获取数据代码负责把两者粘起来。你把它跑通之后把 WeatherService 换成查订单、查库存、查日志一个生产级 Agent 的骨架就出来了。2. 整体架构与方案选型2.1 一条完整的调用链长什么样整个流程走一遍是这样的用户问“上海今天天气怎么样”模型分析这句话发现需要调用 getWeather 工具于是生成一个 tool call 请求参数里填上城市名或者经纬度。Spring AI 拦截到这个请求找到对应的 WeatherService 方法把你的参数塞进去真正发起对天气服务商 API 的请求。拿到 JSON 结果后框架把它作为一条 tool 消息返回给模型。模型基于返回的数据组织语言生成给用户看的最终回答“上海今天 24 到 28 度小雨……”这一步的关键在于模型不直接碰天气 API它只负责“决定要不要调用”和“填参数”真正执行的是你的代码。很多初学者卡在误以为要在提示词里教模型“去请求哪个 URL”——完全不需要模型只认函数名和参数。2.2 天气数据源怎么选做这个例子之前你得先想好天气数据从哪来。我调研下来常用有这么几类数据源是否需要 Key免费额度适合场景备注wttr.in不需要无明确限制本地开发、演示支持 JSON 格式响应快但数据源较简单和风天气需要有每日免费额度生产环境字段丰富、精度高、中文友好OpenWeatherMap需要有免费档国际项目英文为主心知天气需要有免费额度国内项目中文友好如果你只是想把链路跑通强烈建议先用 wttr.in零成本零配置。它有一个很省心的接口https://wttr.in/Shanghai?formatj1返回完整 JSON虽然字段结构比较复古但对演示足够了。如果你要上生产环境我建议直接选和风天气。原因几个中文城市名解析做得比较好国际化支持也强而且在 Spring AI 社区里有现成的参考实现遇到问题不容易抓瞎。免费额度对个人项目来说完全够用。2.3 模型选型为什么推荐百炼 Qwen很多人在最开始纠结选哪个模型。我的建议是如果只是个人学习哪个便宜用哪个如果涉及复杂的工具调用、多轮对话选函数调用能力强的。这里插一句最近热词里频繁出现“Spring AI 2.0 连接百炼 qwen3.7”我也实际测了一下。百炼平台本身就是阿里云上的大模型服务平台它对 Spring AI 有非常完整的适配环境变量配好之后几乎零成本接入。Qwen3.7 在工具调用上的表现比较稳尤其是在参数提取、多工具选择方面中文场景下比一些通用英文模型更贴合。配置方式很简单用 OpenAI 兼容的 endpoint 就能接入spring: ai: dashscope: api-key: ${DASHSCOPE_API_KEY} chat: options: model: qwen-plus注意Spring AI 官方对 dashscope 的集成模块在 2.x 版本里已经相对成熟直接加依赖就行dependency groupIdcom.alibaba.cloud.ai/groupId artifactIdspring-ai-alibaba-starter/artifactId /dependency顺便说一句网上有人说“spring ai alibaba 停更了吗”我也蹲了一些 issue 和 release 记录目前来看团队还在持续更新只是节奏没有 OpenAI 适配那么频繁。如果你只用来调用百炼的模型体验完全不受影响。2.4 Spring AI 版本怎么选1.x 还是 2.x这个需要单独提醒一下。Spring AI 1.0.0 标志着 API 走向稳定但 2.x 在工具注册方式、命名上有了明显调整。热词里搜出来的“spring ai 2.0.1”“spring ai 2.0”都在指向这套新版本。如果你现在才刚开始直接上 2.x 没毛病而且 2.x 对 Spring Boot 3.4 的兼容性更好。但网上很多教程还是 1.x 的写法看着看着就容易对不上号——最常见的就是Tool注解的位置变了以及ToolCallbacks的接收方式有些调整。我后面给代码会尽量用 2.x 的写法遇到 1.x 差异会点出来。3. 核心细节与实操要点3.1 三步走定义工具、注册工具、写提示词用 Spring AI 加一个工具就像给机器人装一个新技能总共三步定义工具写一个方法描述清楚它能干什么、需要哪些参数。注册工具把方法暴露给模型让模型知道“我有这个能力可用”。写提示词告诉模型在什么情况下用这个工具比如“用户问天气就用 getWeather”。这三步缺一不可。很多人第一步和第二步都做对了但第三步偷懒了结果模型有时候调用工具有时候干脆自己瞎编一个天气数据看起来像“薛定谔的工具调用”。3.2 在 2.x 里最顺手的工具写法Spring AI 2.x 里推荐用Tool注解来定义工具配合 record 做参数结构Component public class WeatherService { private final RestClient restClient; public WeatherService() { this.restClient RestClient.builder() .baseUrl(https://wttr.in) .build(); } Tool(description 查询指定城市的当前天气和未来几天的预报) public WeatherResult getWeather( ToolParam(description 城市英文名例如 Shanghai、Beijing) String city) { String body restClient.get() .uri(/{city}?formatj1, city) .retrieve() .body(String.class); // 这里先把 JSON 简化一下再给模型看 return simplify(body); } public record WeatherResult(String city, String temperature, String description) {} }解释几个关键点Tool(description ...)这个描述非常重要模型靠它判断“什么时候用这个工具”。一定要写清楚能做什么最好带上适用场景。ToolParam(description ...)参数描述同样关键模型靠它来填对参数。你写“城市英文名”模型就会老老实实把 Shanghai 翻译成英文。方法名getWeather会出现在模型的调用里所以命名要语义清晰别用doSomething之类的。返回结果不要太大。模型要读这段文本如果返回一整份几十 KB 的天气预报 JSON又费 token 又容易让模型抓不住重点。所以我这里用simplify()把结果压缩一下只保留关键字段。3.3 注册工具让模型知道你有这个能力定义好工具还不够得让它进入模型的“视野”。在 Spring AI 2.x 里最省事的做法是通过构造器注入ToolCallback然后传给ChatClientConfiguration public class AiConfig { Bean ChatClient chatClient( ChatClient.Builder builder, WeatherService weatherService) { ToolCallback weatherTool ToolCallbacks.from(weatherService); return builder .defaultTools(weatherTool) .build(); } }这样配置好之后ChatClient里就自动带上天气工具了后面调用时的每个请求都会把这个工具的能力信息发给模型。3.4 提示词别写废话但关键场景一定要点明提示词不是越多越好但有一条我认为不能省明确触发条件。比如你可以这么写你是天气助手。当用户询问任何与天气相关的信息时 你必须使用 getWeather 工具获取实时数据后再回答。 不要在没有工具返回值的情况下编造天气数据。这里两个信息很关键“必须使用 getWeather” 缩小了模型的决策空间防止它偶尔偷懒不调用工具。“不要编造” 是一个安全护栏模型一旦拿不到数据就容易开始脑补这句话能显著降低幻觉率。3.5 数据回来之后怎么给模型“看”模型拿到工具返回结果后还要组织语言输出给用户。如果工具返回的是结构化数据你最好直接传给模型它自己会总结。但有个细节如果返回内容里包含了很多无意义的字段比如 UTC 时间戳、内部状态码模型照样会一字不差地读进去浪费 token 不说还容易跑偏。所以我习惯把返回结果精简成一个干净的 record只留对用户有意义的字段。这不光是省 token更是帮模型“降噪”。4. 完整实操从零写一个天气助手4.1 工程初始化与依赖引入我用的是 Spring Boot 3.4 Spring AI 2.0.1Java 17。你新建工程时直接选 Spring Web 依赖就行额外的 Spring AI 依赖自己加。Gradle 写法dependencies { implementation org.springframework.boot:spring-boot-starter-web implementation org.springframework.ai:spring-ai-starter-model-chat-client:2.0.1 implementation org.springframework.ai:spring-ai-starter-model-openai:2.0.1 }如果接的是百炼平台那spring-ai-starter-model-openai也需要保留因为前端是走 OpenAI 兼容协议。同时加一个 alibaba 的 starterimplementation com.alibaba.cloud.ai:spring-ai-alibaba-starter:2.0.1你需要到百炼平台申请一个 API Key然后配到环境变量DASHSCOPE_API_KEY里。不要在代码里硬编码密钥这是我无数次强调的一旦代码传到公开仓库等于把钱包露给别人。4.2 写工具类WeatherService 完整版我直接给出一个能跑的版本几个小细节都带上Component public class WeatherService { private final RestClient restClient; public WeatherService() { this.restClient RestClient.builder() .baseUrl(https://wttr.in) .defaultHeader(Accept, application/json) .build(); } Tool(description 根据城市英文名查询实时天气城市名必须为英文例如 Shanghai、Beijing、Hangzhou) public WeatherResult getCurrentWeather( ToolParam(description 城市英文名如 Shanghai) String city) { String raw restClient.get() .uri(/{city}?formatj1, city) .retrieve() .body(String.class); JSONObject root new JSONObject(raw); JSONObject current root.getJSONArray(current_condition).getJSONObject(0); JSONObject weather root.getJSONArray(weather).getJSONObject(0); String temp current.getString(temp_C) °C; String feelsLike current.getString(FeelsLikeC) °C; String desc current.getJSONArray(weatherDesc).getJSONObject(0).getString(value); String humidity current.getString(humidity) %; String wind current.getString(windspeedKmph) km/h; String maxTemp weather.getJSONArray(maxtempC).getString(0) °C; String minTemp weather.getJSONArray(mintempC).getString(0) °C; return new WeatherResult( city, temp, feelsLike, desc, humidity, wind, maxTemp, minTemp ); } public record WeatherResult( String city, String currentTemp, String feelsLike, String description, String humidity, String wind, String maxTemp, String minTemp ) {} }注意这里我把city原样放进返回值没有做大小写规范化。如果要更稳妥可以在方法开头做一次city.toUpperCase(Locale.ROOT)之类处理防止模型传入小写导致天气 API 识别不了。4.3 配置 ChatClient 并烧录工具接下来做一个配置类把WeatherService注册成工具并预设系统提示词Configuration public class AiConfig { Bean ChatClient chatClient(ChatClient.Builder builder, WeatherService weatherService) { ToolCallback weatherTool ToolCallbacks.from(weatherService); return builder .defaultSystem( 你是天气助手。当用户询问天气时你必须使用 getCurrentWeather 工具。 先获取实时天气数据再基于数据组织回答。 你不可以直接编造天气信息。 如果工具返回失败请告知用户暂时无法获取天气并提供建议。 ) .defaultTools(weatherTool) .build(); } }defaultSystem是在这个ChatClient里注入系统提示词的简便方式比每次调用时手动传PromptTemplate干净得多。这里我把“必须用工具”和“不要编造”两条铁律直接写进了系统层后面所有走这个 ChatClient 的对话都会带上。4.4 写一个 Controller 验证效果RestController public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient chatClient) { this.chatClient chatClient; } PostMapping(/chat) public ChatResponse chat(RequestBody ChatRequest request) { String answer chatClient.prompt() .user(request.message()) .call() .content(); return new ChatResponse(answer); } public record ChatRequest(String message) {} public record ChatResponse(String answer) {} }启动应用后用 curl 试一下curl -X POST http://localhost:8080/chat \ -H Content-Type: application/json \ -d {message: 上海今天天气怎么样}我跑出来的结果大概长这样上海今天多云当前温度 24°C体感温度 26°C湿度 78%东南风 12 km/h。今天最高气温 28°C最低气温 21°C。出门建议带伞因为午后可能有短时阵雨。模型没有提“我查不到实时数据”也没有用“通常这时候”之类的模糊话术因为它真的拿到了实时数据并且基于这些数据做了回答。这就是工具调用的价值——让回复从“凭记忆猜测”变成“基于事实生成”。4.5 多轮对话从单次查询到上下文理解上面的例子是单次问题、单次工具调用足够演示。但真实场景里用户往往会有追问比如“那明天呢”“下雨吗”之类。要做到多轮你需要把历史消息传给模型。Spring AI 里可以用Memory或者手动拼接消息列表。为了保持简单我用PromptTemplate 消息历史演示一下public String chatWithHistory(ListMessage history, String userMessage) { return chatClient.prompt() .messages(history) .user(userMessage) .call() .content(); }当你把上一轮的对话和工具返回消息一并传给模型后它就能理解“明天”指的是某个具体的日期。这里有个容易被忽略的点工具调用本身的结果也可以作为上下文参与后续推理Spring AI 会自动管理这些消息不用你手动拼。4.6 多工具形态初探如果你的助手不止有天气工具还加了查机票的工具模型会在需要的时候自己决定调用哪一个。这其实就已经是 Agent 的雏形了——多工具 决策循环。ToolCallback weatherTool ToolCallbacks.from(weatherService); ToolCallback flightTool ToolCallbacks.from(flightService); ChatClient chatClient builder .defaultTools(weatherTool, flightTool) .build();在单次回复里模型甚至可以连环调用工具先查航班再查目的地天气最后生成一段完整建议。这种多工具编排的能力正是智能体框架最核心的卖点。5. 常见问题与排查技巧实录5.1 工具明明定义了模型就是不调用这是各位反馈最多的问题。排查思路按顺序来工具是否真的注册成功在 2.x 里defaultTools如果传空模型当然不知道有工具可用。可以在启动日志里搜一下是否打印了工具注册信息或者简单点在 Controller 里打印chatClient的默认工具列表看看。模型本身支持 Function Calling 吗一些轻量模型不支持或者支持得比较“含蓄”。如果你用的是 qwen-turbo 这类小模型建议换到 qwen-plus 或者更强一点的版本再试。提示词里有没有冲突如果你的系统提示词里写了“你是一个聊天机器人”模型可能觉得直接聊就行没必要调工具。把“必须使用 getCurrentWeather”写进去触发概率会高很多。描述太模糊如果工具描述是“查询天气”模型有时候会不确定什么情况下该触发。描述改成“当用户询问任意城市的实时天气、温度、降水、风力等情况时使用”触发逻辑就清楚了。5.2 模型把参数传错了比如“上海”没有翻译成英文中文模型在参数映射上通常做得不错但偶尔也会出现直接把“上海”填进参数而天气 API 只认Shanghai结果查询失败。处理办法可以在ToolParam描述里强制说明“必须使用城市的英文拼写例如上海写 Shanghai”。如果模型还是不听话可以在方法内部做一层兜底手动维护一个中英文城市名映射表或者干脆用经纬度作为参数避免翻译问题。用经纬度还有一个额外好处城市名有歧义。你说“南京”城市还是“南京”某地路名传经纬度就没这个问题了。和风天气就支持根据经纬度查询这是生产环境更推荐的方案。5.3 天气 API 响应太慢整个对话像卡住了一样工具调用是同步阻塞的模型在等工具结果回来网络一慢用户体验就很差。几种缓解办法给RestClient设置连接超时和读取超时RestClient.builder() .requestFactory(new JdkClientHttpRequestFactory( new HttpClient() .connectTimeout(Duration.ofSeconds(3)) .executor(...) ))在工具方法内部做结果缓存。天气数据五分钟内变化不大你用Cacheable缓存一下能显著减少外部请求次数。增加兜底逻辑如果天气 API 挂了直接返回预设的默认值或错误提示让模型根据错误提示组织一次安抚性的回复。这样至少比干等强。5.4 返回了解析异常模型看到了奇怪的报错工具方法的异常会直接传给模型。如果 JSON 解析抛异常、或者天气 API 返回 4xx模型很可能一脸懵地生成“我遇到了一个技术问题”这种废话。更好的做法是在方法内部捕获异常并返回一个统一的错误结构catch (Exception e) { return new WeatherResult(city, error, error, 天气服务暂时不可用请稍后重试, , , , ); }这样模型看到的是可控的文本而不是层层堆栈。如果你想让模型在错误时主动向用户道歉或者尝试别的方式只需在返回里带上可读的错误信息即可。5.5 升级到 Spring AI 2.x 后工具调不起来了我在升级过程中踩过几个坑列出来给你们防身问题原因解决办法Tool注解不生效2.x 要求必须是 Spring Bean 的方法确认Component有没有加ToolCallbacks.from()编译失败2.x 里方法变了改用 1.0 时代的MethodToolCallbackProvider或者直接传 Bean工具回调无法解析参数名被编译期混淆了用ToolParam显式声明参数名5.6 API Key 硬编码在代码里这个我必须单独说。很多人图省事直接在application.yml里写上 key然后 git 一提交整个仓库都暴露了。正确的做法是放到环境变量里application.yml用${DASHSCOPE_API_KEY}引用。.gitignore里把任何包含密钥的配置文件排除掉。如果已经泄露了立刻上平台把 key 删了重新生成。6. 延伸从“单工具”到 Agent 的进化路径6.1 热词里的 Spring AI Agent 到底是什么形态热词里老有人搜“Spring AI agent”其实 Spring AI 社区对 Agent 的界定比较务实一个能自主决定调用哪些工具、按什么顺序调用、如何根据中间结果调整后续动作的系统。跟 Dify 上那种可视化编排不太一样Spring AI 的表达方式是代码。你刚才用天气工具做的 ChatClient 就是一个最小 Agent。如果再给它加一个“判断是否需要持续调用工具”的循环就成了一个能自我规划的 Agent。Spring AI 2.x 里可以用ChatClient配合流式响应实现循环判断或者直接集成社区的 agent 模块。6.2 Dify 工作流转 Spring AI 的思路我看到很多人搜“dify 工作流转成 spring ai java 代码”应该是想把已经在 Dify 里验证过的流程迁移到 Java 工程里。我自己试过思路其实不复杂Dify 里的LLM 节点对应 Spring AI 的 ChatClient。工具节点对应Tool注解的方法。条件分支对应 Java 里的if/switch逻辑。Dify 用可视化的边去连你在 Java 里就是方法调用之间的判断。知识库检索节点对应 Spring AI 里的VectorStore或者自己写的检索 Service。你可以先把流程在 Dify 里跑通验证可行性然后照着节点拆解成 Java 类每个节点一个方法最后用一个编排类串起来。这个过程会帮你真正理解工作流的本质——它不是什么黑魔法就是“输入 - 处理 - 输出”的管道。6.3 扩展方向建议跑通天气助手之后你可以沿着这几个方向继续扩展把工具替换成真实的业务接口比如查订单、查库存、查物流变成一个内部客服助手。增加多工具编排比如“帮我订一间明天上海下雨天适合去的室内场所”——这会同时触发天气查询和地点推荐。把结果推送到钉钉、企业微信做成定时天气播报机器人。接入语音输入变成语音天气助手。每走一步你会发现 Spring AI 提供的不是一堆死板的 API而是一套可以往任意方向“长”的骨架。7. 写在最后的一点实操体会折腾这个项目最大的收获不是学会了调一个天气接口而是把“模型 工具 工程代码”这套三角关系理顺了。你会发现真正决定一个 Agent 好用不好用的往往不是模型多聪明而是工具定义得清不清晰、提示词有没有把触发条件讲明白、异常情况下有没有兜底。工具描述写得越好调用成功率越高这比换个大参数模型更划算。我后来把同样的模式套到公司内部的知识库查询和工单系统上基本上是把天气服务的模板复制粘贴再改改两天就把一个原型机器人搭出来了这大概就是 Spring AI 这类框架真正让人上瘾的地方。