
1. 为什么在这个时间点聊 SpringAI 新特性项目生态现状与版本脉络1.1 SpringAI 到底解决了什么问题这几年做 AI 应用的团队基本都经历过一段拼接地狱今天对接 OpenAI明天换国产模型后天又要支持本地部署的模型服务。每次切换模型厂商都要重写一遍 HTTP 调用、重调 JSON 解析、重新封装流式响应的逻辑。接口风格不统一错误处理各有各的妖整个团队疲于应付连接这件事真正有价值的业务逻辑反而没人写。SpringAI 解决的就是这个核心痛点——把大模型接入抽象成一套统一的编程模型。你可以把它理解成 JDBC 之于数据库不管底层连的是 MySQL 还是 PostgreSQL上层都通过统一的 Connection、Statement、ResultSet 接口操作。SpringAI 里的 ChatClient、ChatModel、EmbeddingModel 就是这套JDBC 接口底层接 OpenAI、通义千问、DeepSeek、Ollama 都行业务代码几乎不用动。我第一次在项目里引入 SpringAI 时最大的直观感受是不再需要自己维护一堆 RestTemplate 调用和 JSON 解析工具类。原来手写一个流式对话接口至少要看半天官方文档搞清楚 messages 数组怎么构造、stream 参数怎么传、SSE 数据格式长什么样用 SpringAI 之后这些细节全部封装在框架内部我只关心给模型一段用户消息把返回的流式内容推给前端。1.2 版本演进的几个关键节点SpringAI 的版本演进速度说实话比大多数人想象的要快。我很早之前就在博客里关注这个项目当时它还挂在 Spring 的孵化器里版本号带着0.8.x这种前缀API 三天两头变今天写的ChatClient用法下周可能就废弃了。但那批早期用户的价值在于把很多设计问题提前暴露了出来促使框架在正式版发布前完成了一轮大重构。到 1.0.0 正式发布ChatClient的 API 基本稳定成型Builder 模式成为主流写法。再到后面几个迭代版本框架陆续引入了结构化输出Structured Output、多模态消息封装、Advisor 切面机制、Observation 可观测性埋点等一批重要特性。从能用到好用的转变非常明显。这里要特别提醒刚接触 SpringAI 的读者去网上搜教程时认准 1.0.0 及以上版本的 API 写法。早期 0.x 版本里很多类名和方法签名跟现在完全不同照着旧教程写代码编译都过不去。如果你看到有人还在用AiClient而不是ChatClient那大概率是旧时代的代码。1.3 选型视角SpringAI 与 LangChain4j 的取舍聊 SpringAI绕不开另一个项目 LangChain4j。两个框架解决的是同一类问题但设计哲学差异明显。LangChain4j 更偏套件型抽象层次更高内置了 Prompt Template、Chain、Memory、RAG 等一整套高层组件上手快但遇到复杂场景时要理解它的抽象层级反而需要更多时间。SpringAI 则更Spring 原生——它不是一个独立的框架而是 Spring 生态的一等公民。依赖注入、自动配置、Starter 机制、Spring Boot Actuator、Micrometer 可观测性全部是一套语言。如果你所在的团队本来就是 Spring Boot 技术栈引入 SpringAI 的学习成本极低配置项跑在application.yml里连 Bean 都不用自己 new。我的选择逻辑很简单团队是 Spring 系就选 SpringAI如果团队偏 Python 或并不依赖 Spring 生态LangChain4j 可能更合适。没必要在选型上纠结太久两个框架的底层能力其实越来越接近把业务跑通、跑稳才是关键。2. 从零搭建对话机器人工程基本对话与流式输出双核心落地2.1 工程骨架与依赖配置SpringAI 的工程搭建本质上是往一个标准 Spring Boot 项目里引入对应的 Starter 依赖。以对接 OpenAI 兼容接口的场景为例项目结构大概长这样dialogue-robot ├── pom.xml ├── src │ ├── main │ │ ├── java │ │ │ └── com/example/dialoguerobot │ │ │ ├── DialogueRobotApplication.java │ │ │ ├── controller │ │ │ │ └── ChatController.java │ │ │ ├── service │ │ │ │ └── ChatService.java │ │ │ └── config │ │ │ └── ChatModelConfig.java │ │ └── resources │ │ └── application.ymlpom.xml里需要引入 Spring Boot 3.2 的父 POM然后加两个关键依赖dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId version1.0.0/version /dependency再补一个 Web 依赖用于对外提供 HTTP 接口dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency注意一点SpringAI 的 Starter 依赖命名和 Spring Boot 官方 Starter 有区别它是spring-ai-starter-model-*这种格式。不同的模型厂商对应不同的 Starter比如spring-ai-starter-model-openaiOpenAI 及兼容接口spring-ai-starter-model-ollama本地 Ollamaspring-ai-starter-model-qwen阿里通义千问spring-ai-starter-model-azure-openaiAzure OpenAI2.2 基本对话ChatClient 的第一行代码SpringAI 1.0 之后所有对话能力都收口到ChatClient这个核心接口上。创建它有两种方式一种是直接注入自动配置好的ChatModelBean再用 Builder 构建另一种是使用ChatClient.builder()配合自定义模型对象。先说最简单、也是大多数项目默认的方式。在application.yml里配好模型服务地址和密钥spring: ai: openai: base-url: http://localhost:8080 # 指向你自己的模型网关也可以是云端服务地址 api-key: sk-你的密钥 chat: options: model: gpt-4o-mini temperature: 0.7 max-tokens: 4096然后在服务类里写一个同步对话方法Service public class ChatService { private final ChatClient chatClient; public ChatService(ChatClient.Builder builder) { this.chatClient builder.build(); } public String chat(String userMessage) { return chatClient.prompt() .user(userMessage) .call() .content(); } }这段代码干的事很直白构造一个 Prompt塞入用户消息发起同步调用取回模型回答内容。chatClient.prompt()返回一个 PromptSpec 对象支持链式调用配置 system 提示词、历史消息、模型参数、工具函数等等。call()是同步阻塞调用适合对延迟不敏感的后端处理场景。2.3 流式输出Flux 的正确使用姿势对话机器人里真正让人用得上的往往是流式输出。如果等模型把整段话生成完再一次性返回慢的模型动辄几十秒用户体验非常糟糕。流式输出则是一边生成一边吐字前端像打字机一样把内容逐字显示出来体感上快很多。SpringAI 的流式调用基于 Project Reactor 的 Flux代码如下public FluxString chatStream(String userMessage) { return chatClient.prompt() .user(userMessage) .stream() .content(); }调用方拿到的是一个FluxString需要自行订阅消费。如果要把流式内容通过 Web 接口暴露给前端可以返回FluxString配合 Spring WebFlux 的text/event-stream或普通文本流PostMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString chatStream(RequestBody ChatRequest request) { return chatService.chatStream(request.message()); }前端用 EventSource 或 fetch 的 ReadableStream 都能接住这段流。这里有一个非常容易踩的坑Flux 是惰性的如果你在 Service 层返回 Flux 之前做了doOnNext(log::info)这种副作用操作但 Controller 层因为某种异常没有订阅整个流根本不会执行。调试时不要奇怪为什么日志一条都没有因为没人订阅就没人干活。2.4 流式与同步的选择逻辑同步还是流式不是拍脑袋决定的。我的经验判断标准是内部服务间调用、需要完整结果后才能继续处理的用同步call()面向用户交互、需要快速呈现首字的用流式stream()需要拿模型输出做结构化解析、校验后再落库的先走同步再处理别自己实现半套流式拼接。另外从框架层面看call()和stream()底层是两套不同的执行链路。同步调用内部直接拿到完整响应流式调用则要逐个接收数据块再合并。如果你在流式场景里同时启用了 RAG 召回或工具调用要留意 Advisor 链路的执行顺序有些组件是为同步设计在流式下可能不生效。3. Tool 注解的核心机制与函数调用实战3.1 注解属性解析name、description 与参数绑定大模型自己不会查数据库、不会调天气接口、不会查订单状态它只会编。要让模型在需要时真正去调外部服务就得靠 Function Calling 机制——模型决定我需要调用某个工具框架负责把参数解析出来真正执行工具函数再把结果回传给模型。SpringAI 把这一整套机制封装成了Tool注解。在 1.1 版本里Tool注解最常用的三个属性是name工具名称。模型通过它来识别该调哪个函数所以名字要语义清晰一般用动词名词的组合比如getWeather、searchOrder。description工具描述。这是给模型看的说明它的质量直接决定了模型会不会在合适的时机调用这个工具。resultType部分版本支持指定返回值类型的元数据帮助模型理解返回结构。方法参数上使用的注解是PParameter可以配置每个参数的名字和描述。下面看两个典型写法Component public class WeatherTools { Tool(name getWeather, description 根据城市名称查询当前天气情况) public String getWeather(P(name city, description 城市名如北京、上海) String city) { // 这里写真实的天气查询逻辑 return {\city\:\ city \,\temperature\:25,\condition\:\晴\}; } }另一种写法是返回值智能自动匹配不再绑定到具体方法参数注解而是统一走一个数据类Tool(name getWeather, description 根据城市名称查询当前天气情况) public WeatherResult getWeather(WeatherRequest request) { // request 里包含 city 字段 return weatherService.query(request.city()); }第二种写法更利于复杂工具入参直接是个结构体字段描述写在 DTO 上。3.2 一个完整的搜索工具开发示例用一个完整的内部文档检索工具来说明整个链路会更直观。假设系统里有一批产品文档需要模型在回答问题时先检索文档再作答Component public class DocSearchTools { private final DocumentSearchService searchService; public DocSearchTools(DocumentSearchService searchService) { this.searchService searchService; } Tool(name searchInternalDoc, description 在内部产品文档库中搜索相关内容返回匹配文档的标题和摘要列表) public String searchInternalDoc( P(name query, description 搜索关键词建议使用名词短语如\退款流程\) String query) { ListDocSearchResult results searchService.search(query, 5); if (results.isEmpty()) { return 未找到相关文档; } StringBuilder sb new StringBuilder(); for (DocSearchResult r : results) { sb.append(标题: ).append(r.title()) .append(\n摘要: ).append(r.summary()) .append(\n链接: ).append(r.url()) .append(\n---\n); } return sb.toString(); } }把DocSearchTools注册成 Spring Bean 之后在ChatClient构建时通过.defaultTools()挂载Bean ChatClient chatClient(ChatClient.Builder builder, DocSearchTools docSearchTools) { return builder .defaultSystem(你是公司内部的智能客服助手回答问题时请优先使用提供的工具获取真实信息。) .defaultTools(docSearchTools) .build(); }模型收到用户问题后会先判断是否需要调用searchInternalDoc如果需要框架自动把query参数提取出来执行方法拿到结果再让模型组织语言回答。3.3 工具调用失败的常见原因我在生产环境里排查过不少次工具不生效的问题总结下来高频原因有三个第一description写得太含糊。比如写了查询信息模型完全不知道什么时候该用、参数传什么。好的描述应该明确触发场景、参数含义、返回内容。我的习惯是描述里至少包含什么时候用 输入什么 返回什么三段信息。第二工具方法所在的 Bean 没被注入。Tool的解析依赖 Spring 容器扫描如果你手工new了一个对象而不是使用 Spring Bean框架根本发现不了这些方法。一定要确保工具类被Component或Service注解标注并且通过构造器注入到ChatClient.Builder。第三工具返回内容里没有模型需要的信息。模型拿到工具返回值后会基于这些信息组织最终回答。如果返回的字符串是空串模型只能被迫编造这时候看起来就像工具没生效。所以工具方法里要做好异常兜底至少返回明确的错误说明比如查询超时请稍后重试。4. 模型接入的核心升级多模态、国产模型与向量存储的架构演进4.1 Qwen 视觉语言模型的核心架构升级在 SpringAI 里的落点最近技术圈讨论度最高的模型升级里Qwen 视觉语言系列占了很大篇幅。从 Qwen2-VL 到 Qwen2.5-VL再到 Qwen3-VL背后核心架构的演进方向可以概括为三条线视觉编码能力的增强、跨模态对齐的优化、以及长上下文处理能力的提升。不过站在 SpringAI 应用开发者的视角我不太建议过分纠结这些模型底层的架构细节——你更该关注的是SpringAI 是否已经把对这些新模型的支持封装好了业务层代码能不能无痛切换到新模型。实际上SpringAI 对多模态模型的支持把它分为文字能力和视觉能力两部分。像 Qwen-VL 这类视觉模型接入后你可以直接传图片 URL 或 Base64 图片数据让模型做图片理解——识别截图、分析图表、抽取身份证信息都可以做到。4.2 多模态消息的工程写法在 SpringAI 里传图片比我想象的简单。以 OpenAI 兼容接口为例多模态消息封装在UserMessage里可以通过 MediaData 携带图片内容public String analyzeImage(String imageUrl, String prompt) { Message userMessage UserMessage.builder() .text(prompt) .media(MediaData.builder() .url(imageUrl) .build()) .build(); return chatClient.prompt() .messages(userMessage) .call() .content(); }如果是本地上传的图片需要先转成 Base64 再构造 MediaData同时指定 mimeTypeString base64 Base64.getEncoder().encodeToString(Files.readAllBytes(path)); MediaData media MediaData.builder() .data(base64) .mimeType(image/jpeg) .build();这里要提醒一点业务里如果频繁使用 Base64 大图会显著消耗 Token 并增加延迟。简单粗暴的建议是在做多模态任务前先压缩图片控制长边在 1024 像素以内质量损失一般肉眼感知不明显但 Token 消耗和传输时间能省不少。4.3 向量存储与检索含 ES7 相关实践多模态模型负责看懂内容RAG检索增强生成则负责记住私有知识。SpringAI 1.0 之后将VectorStore作为统一抽象接各类向量数据库。目前支持的类型包括 Redis、Pinecone、Milvus、Chroma、PGVector以及 Elasticsearch。网上经常有人搜es7 新特性这里有必要澄清一下可能存在的混淆在 SpringAI 语境里提到的 ES一般指的是 Elasticsearch 7.x 版本。它的特点是自带稠密向量dense_vector字段类型和 KNN 检索能力可以直接作为 RAG 方案的向量存储不需要额外引入专用向量数据库。在 SpringAI 里接入 Elasticsearch 做向量检索大致步骤是定义 Document 的索引结构用EmbeddingModel给文档生成向量然后写入VectorStore查询时把用户问题也转成向量再做相似度检索。代码层面基本是声明式配置Starter 拉好之后核心代码可能只有十几行Service public class RagService { private final VectorStore vectorStore; private final ChatClient chatClient; public RagService(VectorStore vectorStore, ChatClient.Builder builder) { this.vectorStore vectorStore; this.chatClient builder.build(); } public String chatWithRag(String question) { // 先向量检索 ListDocument docs vectorStore.similaritySearch( SearchRequest.builder().query(question).topK(5).build()); // 拼装上下文 String context docs.stream() .map(Document::getContent) .reduce((a, b) - a \n---\n b) .orElse(暂无相关资料); return chatClient.prompt() .system(请基于以下资料回答问题:\n context) .user(question) .call() .content(); } }这个实现思路在大部分中小项目里都很够用。如果文档量达到百万级以上再考虑分片、混合检索、重排模型这些进阶手段。5. 本地 Web 界面连接远端大模型的工程化折腾记录5.1 典型架构后端流式转发 前端 SSE本地 Web 界面连远端大模型其实是一种很常见的部署形态前端页面跑在本地后端 Spring Boot 服务也跑在本地但大模型 API 在云端。这样做的价值在于敏感信息不落在公网服务上API 密钥也只在本地后端持有前端拿不到明文密钥。之前我把一个毕设级别的对话机器人从单体 Controller 改造成这种架构切身体会到了流式转发的细节坑。整体链路是浏览器 → 本地 Spring Boot /chat/stream → 远端大模型 API → 流式响应 → 本地后端 → 浏览器 EventSource前端的连接方式最简单的是用原生 EventSourceconst eventSource new EventSource(/chat/stream?message${encodeURIComponent(text)}); eventSource.onmessage (event) { // 每次收到一个数据块就追加到对话框 appendMessage(event.data); };后端 Controller 直接返回FluxStringSpring 会以 SSE 协议写出每一条 Flux 元素就是一次消息推送。5.2 跨域与代理配置细节如果前端和你本地后端是同一个服务打成一个包就不存在跨域问题。但如果前端独立起了一个开发服务器比如 Vue 的vite dev默认端口是 5173后端是 8080那么就会遇到跨域。我当时的处理方式分两层第一层是允许 CORS。在 Spring Boot 后端加一个配置类Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(http://localhost:*) .allowedMethods(GET, POST, OPTIONS) .allowedHeaders(*) .allowCredentials(true); } }我习惯用allowedOriginPatterns而不是allowedOrigins(*)因为后者和allowCredentials(true)一起使用时会被浏览器拦截白名单模式下要带上http://localhost:5173这种具体地址。第二层是注意OPTIONS预检请求。POST Content-Type: application/json会触发预检如果后端不做处理可能直接 403。加上 Spring 的 CORS 映射后预检由框架自动处理这一步基本不用自己写。5.3 API Key 安全与连接池设置本地 Web 后端的一个重要职责是保管 API Key。一个常见误区是为了方便把 API Key 直接下发给前端让浏览器直连大模型接口。这在自用项目里看着挺省事但一旦页面被浏览器插件抓到请求、或者被调用方复制Key 就泄露了。所以正经做法始终是API Key 只存在于后端环境变量或application.yml中后端所有对远端模型的调用走同一个内部接口如果有多人共用这个后端在 Controller 层加简单的访问令牌校验别裸奔。连接池方面OpenAI 类接口一般用 Java 自带的 HTTP 客户端SpringAI 默认走RestClient或 WebClient。如果你的并发量上来注意调大连接池参数。我在application.yml里加过这么一段实测对吞吐有肉眼可见的改善spring: threads: virtual: enabled: trueJDK 21 项目可以开启虚拟线程让 Tomcat 不再为每个请求分配一个昂贵的平台线程。流式转发这种 IO 密集型任务在虚拟线程下表现得非常省资源。6. 从踩坑到实战性能、并发与上下文管理的经验清单6.1 流式回归和背压问题流式接口最常见的回归现象是前端只见光标闪不见字出来。这类问题十有八九出在后端的 Flux 到 HTTP Response 的桥接上。我遇到过一种情况Controller 方法标注的produces MediaType.TEXT_EVENT_STREAM_VALUE后端返回FluxString但前端的 EventSource 一直没有收到任何事件。后来排查发现部分浏览器对 SSE 有缓冲限制如果服务端很久不刷新一个 heartbeat连接会被中间层或浏览器静默断开。解决方案是在 Flux 上叠加一个心跳信号FluxString answer chatService.chatStream(request.message()); FluxString heartbeat Flux.interval(Duration.ofSeconds(15)) .map(i - : heartbeat\n\n); return Flux.merge(answer, heartbeat);Flux.merge将模型输出流和心跳流并行合并前端每 15 秒至少收到一个注释型事件连接就不会被判定为僵尸连接。另一个常见问题是流量大时后端向模型服务发请求过多导致远端限流返回 429。应对策略是给流式调用加上简单的并发控制比如用 Semaphore 限制同时进行的流式会话数量private final Semaphore slots new Semaphore(20); public FluxString limitedChatStream(String message) { if (!slots.tryAcquire()) { return Flux.just(系统繁忙请稍后再试); } return chatClient.prompt() .user(message) .stream() .content() .doFinally(signal - slots.release()); }6.2 上下文 Token 管理与长对话策略对话机器人做得越深越绕不开模型记不住前面聊了啥的问题。模型本身是无状态的每次请求的 messages 数组长什么样完全由调用方组装。SpringAI 的 ChatClient 里可以手动维护历史消息列表public String chatWithMemory(String userMessage, String conversationId) { ListMessage history memoryStore.get(conversationId); Message userMsg new UserMessage(userMessage); history.add(userMsg); String answer chatClient.prompt() .messages(history) .call() .content(); history.add(new AssistantMessage(answer)); memoryStore.save(conversationId, history); return answer; }但历史消息无限膨胀之后Token 消耗会越来越大甚至超出模型上下文窗口。靠谱的做法是滑动窗口裁剪只保留最近 N 轮消息较早的消息直接丢弃或者对早期消息做摘要用一段精简的此前对话小结替换完整历史。我在项目里实践下来一个实用的经验值是短期记忆最多保留 10 轮原始对话超过之后就把最旧的 5 轮折叠成一句摘要这样既保证模型对前面话题有基本感知又不会让 Token 很快被打满。6.3 并发场景下连接复用与限流把对话接口放在公网供多个用户使用时要注意限流和配额管理。你在本地测试时怎么压都行但远端模型服务往往有每分钟请求数RPM和每分钟 Token 数TPM限制。超了不是被熔断就是计费剧增。我的做法是给后端加一层最简单的令牌桶限流按用户维度控制请求频率Component public class RateLimiter { private final ConcurrentHashMapString, AtomicInteger counters new ConcurrentHashMap(); public boolean allow(String userId, int maxPerMinute) { AtomicInteger counter counters.computeIfAbsent(userId, k - new AtomicInteger(0)); return counter.incrementAndGet() maxPerMinute; } public void reset(String userId) { counters.remove(userId); } }上面的实现只适合自用或内部工具。生产级建议直接用 Bucket4j 或 Redis 滑动窗口来做别自己造限流轮子。6.4 几个容易被忽略的小坑最后整理几个我实际踩过、且经常在社群里看到别人再犯一遍的细节问题第一JDK 版本。SpringAI 1.0 要求 JDK 17 起如果你还在用 JDK 8要么升级项目基础环境要么老老实实用老版本框架。网上有人搜jdk8新特性却想在 SpringAI 上跑属于两个时代的东西硬凑编译期就会暴露问题。第二模型名称与接口版本的匹配。同一个供应商的不同模型可能使用不同的 API 协议版本SpringAI 适配时一定要确认 Starter 版本和模型版本兼容。比如通义千问的某些新模型走兼容 OpenAI 格式的接口配置时base-url和model名称都别填错。第三超时设置。流式调用和同步调用的超时逻辑完全不同。同步调用很容易被远端模型长思考时间打挂建议在 ChatModel 配置里调整responseTimeout流式调用则要注意 ReadTimeout 别设太短否则模型思考超过几秒连接就断了。第四日志和排查。把所有模型请求的出入参打日志尤其是Messages数组里的 system 提示词和本次上下文长度。很多为什么答得不对的问题看日志第一屏就能定位是不是 context 拼错了。第五部署形态的取舍。如果只是个人项目或毕业设计图省事可以把前端静态文件直接放到 Spring Boot 的resources/static下一个 jar 包全搞定如果是正经产品建议后端只做 API前端独立部署这样后续扩展 Web 端、小程序端时共用一套 AI 能力接口不必重构。