写这篇的时候,我其实是带着一点"终于写到这儿了"的心情。前面几篇把 Spring AI 1.x 的项目结构、自动配置、提示词模板都过了一遍,但真正让一个应用"活"起来、能和用户对话、能把结果一段一段吐给前端的,就是ChatModel和StreamingChatModel这两个接口。它们一个管一次性完整回答,一个管流式逐字输出,几乎你后面要做的 RAG、Agent、记忆管理、工具调用,全都绕不开这两个底座。这篇文章我不打算讲太虚的理念,直接按我实际集成时的思路拆:先搞清接口分工,再上手写阻塞调用,然后处理流式输出里的那些"坑",最后聊一聊它和后续 Agent/RAG 功能之间的衔接。
1. 先把 Model API 这条主线捋清楚:三个接口到底谁负责什么
我第一次看 Spring AI 源码的时候,最迷惑的就是ChatModel、StreamingChatModel、ChatClient这三者之间的关系。网上很多示例代码一会儿 new 一个ChatModel,一会儿又用ChatClient链式调用,看起来差不多,实际上分工完全不同。理解了这条主线,后面写代码才不会心里没底。
1.1 ChatModel 是那个"干苦力"的底层接口
ChatModel是 Spring AI 对"大语言模型调用"的最小抽象,它只有一个核心方法:
ChatResponse call(Prompt prompt);入参是Prompt,出参是ChatResponse。整个过程是阻塞的:你传入一组消息和参数,模型把所有 token 都生成完,框架封装成完整响应返回。这种"一锤子买卖"的模式对大多数后端接口都够用,尤其是你只需要最终答案、不需要展示中间过程的时候。
Prompt本身也很好理解,它就是一次完整对话请求的载体,内部由两部分组成:
List<Message>:消息列表,包含系统消息、用户消息、历史助手消息等。ChatOptions:模型参数,比如 temperature、maxTokens、model 名称。
ChatResponse则是模型响应的载体,核心是getResult()拿到的Generation,再通过.getOutput().getText()拿到助手回复的文本。这一串链式调用我第一次看挺啰嗦,但习惯之后反而觉得清晰:Spring AI 刻意把"消息"和"文本"分开,就是为了让你以后能拿到结构化的工具调用信息,而不是只拼字符串。
1.2 StreamingChatModel 是同一个模型的"异步流式面"
StreamingChatModel接口的核心方法是:
Flux<ChatResponse> stream(Prompt prompt);它和ChatModel唯一的本质区别在于返回类型:Flux是 Reactor 里的响应式流,模型每生成一个增量片段,就会 emit 一个ChatResponse,你的代码可以立刻拿到这一段文本去展示、去推送、去缓存,不用傻等整段回答。
如果你用 OpenAI 的实现类,会发现OpenAiChatModel同时实现了ChatModel和StreamingChatModel两个接口。也就是说,同一个 bean 既能call()又能stream(),底层走的是同一套 API 配置。这个设计的好处是:你业务上想切换阻塞/流式模式时,不需要换依赖,只需要换调用方法。
1.3 ChatClient 是给你写业务用的"高个子"
ChatClient不是模型调用层面的东西,它更像一个流式 API 门面,把Prompt组装、消息列表、参数设置、记忆增强、工具注册这些繁琐细节都包起来了。
String answer = chatClient.prompt() .system("你是一名资深的Java技术顾问,回答时要先给结论再解释。") .user("Spring AI 1.x 里 ChatModel 和 StreamingChatModel 有什么区别?") .call() .content();这句话读起来已经接近自然语言了,业务代码里可读性比手动 newPrompt高太多。但ChatClient底层调用到的还是ChatModel或StreamingChatModel,所以你想用好它,还是得先理解这两个底层接口的行为和边界。
我在项目里通常是这样分配的:
- 基础设施代码、需要精细控制消息结构和模型参数的地方,直接用
ChatModel/StreamingChatModel。 - Service 层、Controller 层、需要快速交付业务逻辑的地方,用
ChatClient。 - 要同时支持流式和非流式输出时,底层优先注入
StreamingChatModel,因为它在非流式场景也可以通过blockLast()拿到完整结果,反过来却不行。
2. ChatModel 落地:从第一个 Controller 到多轮对话
理解了接口定位,最直接的做法就是写一个能跑通的接口。这一节我按实际步骤来,把配置、代码、验证串起来,顺便解释几个我早期容易忽略的点。
2.1 依赖和配置:先激活一个模型提供方
不管你是用 OpenAI 还是本地 Ollama,Spring AI 1.x 的思路都很一致:引入 starter 依赖,然后用spring.ai.model.chat指定当前激活的是哪个提供方。
Maven 里加依赖:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency>如果本地有 Ollama,也可以换成:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-ollama</artifactId> </dependency>配置文件长这样:
spring: ai: model: chat: openai openai: api-key: ${OPENAI_API_KEY} chat: options: model: gpt-4o-mini temperature: 0.7 max-tokens: 1024如果你切到 Ollama,大概是:
spring: ai: model: chat: ollama ollama: base-url: http://localhost:11434 chat: options: model: llama3.1 temperature: 0.7这个spring.ai.model.chat是全局模型选择器,它会告诉自动配置:当代码里只有一个ChatModelbean 需要注入时,该用哪个实现。如果你同时引入了多个模型 starter,没有这个配置启动时很容易出现"不知道注入哪个 bean"的报错。
2.2 第一个阻塞式对话接口
创建一个最普通的 Service,注入ChatModel,把用户输入包成UserMessage再交给Prompt:
@Service public class ChatCompletionsService { private final ChatModel chatModel; public ChatCompletionsService(ChatModel chatModel) { this.chatModel = chatModel; } public String chat(String userMessage) { Prompt prompt = new Prompt(new UserMessage(userMessage)); ChatResponse response = chatModel.call(prompt); return response.getResult().getOutput().getText(); } }这段代码已经能工作了,但我必须提醒两点。
第一,getResult()返回的可能是一个列表,因为一次请求可以请求多个候选结果。虽然绝大多数情况下我们只用第一个,但新手如果直接把response.getResults().get(0)写死,后面解析结构化输出时会踩坑。用getResult()取第一条是最稳妥的默认写法。
第二,AssistantMessage.getText()在阻塞调用里返回的是完整回答,但在流式接口里,它返回的只是当前增量片段,不是一个累计值。这个差异是流式开发最容易出 bug 的地方,下一节我会重点说。
2.3 多轮对话的本质:把历史消息拼进去
很多教程会误导你,以为大模型有"记忆"。其实大多数对话模型是无状态的,所谓多轮对话,就是你把之前的对话历史全部塞到消息列表里再发一次。
public String chatWithHistory(String userInput, List<Message> history) { List<Message> messages = new ArrayList<>(); messages.add(new SystemMessage("你是一个严谨的Java架构师,回答尽量结合Spring生态。")); messages.addAll(history); messages.add(new UserMessage(userInput)); ChatResponse response = chatModel.call(new Prompt(messages)); return response.getResult().getOutput().getText(); }这里history要交替放UserMessage和AssistantMessage,顺序不能乱。我见过不少人把历史消息全塞成UserMessage,结果模型越聊越懵。
不过这种手动维护历史消息的方式在真实项目里撑不了多久。消息一多,token 成本爆炸,上下文窗口也会溢出。后面我会讲到用MessageWindowChatMemory来做窗口化记忆,那才是生产可用的方案。
2.4 用 ChatClient 重写一遍,看看差距
同样的逻辑,如果改用ChatClient:
@Service public class ChatClientService { private final ChatClient chatClient; public ChatClientService(ChatClient chatClient) { this.chatClient = chatClient; } public String chat(String userMessage, String history) { return chatClient.prompt() .system("你是一个严谨的Java架构师,回答尽量结合Spring生态。") .user(userMessage) .call() .content(); } }这里我刻意没有演示历史消息拼接,因为ChatClient处理多轮记忆通常是通过 Advisor 来实现的,直接在 prompt 里手写历史反而绕远了。记住一句话:ChatClient是业务友好层,ChatModel是精确控制层,两者不冲突。
3. StreamingChatModel 的正确打开方式:从 Flux 到 SSE 的完整链路
流式输出是 AI 应用里最影响体验的功能之一。用户发出问题后,如果等三四秒才看到完整回答,焦虑感是很明显的;但如果让 token 一个个蹦出来,用户会觉得"它还在工作",耐心立刻提高。这就是StreamingChatModel存在的意义,但也是问题最多的地方。
3.1 流式响应的数据切片逻辑
先看最简单的一段流式调用:
@Service public class StreamingChatService { private final StreamingChatModel streamingChatModel; public StreamingChatService(StreamingChatModel streamingChatModel) { this.streamingChatModel = streamingChatModel; } public Flux<String> stream(String userMessage) { Prompt prompt = new Prompt(new UserMessage(userMessage)); return streamingChatModel.stream(prompt) .map(response -> response.getResult().getOutput().getText()) .filter(text -> text != null && !text.isBlank()); } }stream()返回的是Flux<ChatResponse>,每一个ChatResponse代表模型生成的一小段内容。我用map把每个响应里的文本切片提取出来,再用filter把空片段丢掉。这一步非常关键——流式响应里经常夹杂空文本或只有角色信息的帧,不过滤的话前端会收到一堆无意义的空消息。
这里有个必须注意的点:流式输出里,后一个片段不等于完整答案。比如模型最终输出"你好世界",流式返回的切片可能是"你好"和"世界"两部分,也可能是"你"、"好"、"世"、"界"四个字。如果你在服务端直接拿最后一个片段当完整回答返回,前端永远只看到最后一个字。
要在服务端拼出完整结果,需要自己维护累加逻辑:
public String streamAndCollect(String userMessage) { StringBuilder fullContent = new StringBuilder(); streamingChatModel.stream(new Prompt(new UserMessage(userMessage))) .doOnNext(response -> { String text = response.getResult().getOutput().getText(); if (text != null) { fullContent.append(text); } }) .blockLast(); return fullContent.toString(); }blockLast()会一直阻塞到流结束,然后我们拿到了完整回答。这其实就是"用流式接口实现阻塞效果"的标准姿势。
3.2 在 WebFlux 里对接 SSE:直接返回 Flux
如果你用的是 Spring WebFlux,事情简单得多。Controller 可以直接返回Flux<String>,并指定TEXT_EVENT_STREAM_VALUE媒体类型:
@RestController public class StreamChatController { private final StreamingChatModel streamingChatModel; public StreamChatController(StreamingChatModel streamingChatModel) { this.streamingChatModel = streamingChatModel; } @GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> stream(@RequestParam String message) { return streamingChatModel.stream(new Prompt(new UserMessage(message))) .map(response -> response.getResult().getOutput().getText()) .filter(text -> text != null && !text.isBlank()); } }前端用EventSource或者fetch+ReadableStream就能逐段收到数据。不过生产环境里我一般不会直接返回裸字符串,而是包装成事件对象:
public record ChatStreamChunk(String delta) { }这样以后想追加角色信息、时间戳、token 用量都不会破坏前端协议。
3.3 在传统 Spring MVC 里做流式:SseEmitter 才是落地方案
很多遗留项目还是 Spring MVC + Tomcat,网上流传的"异步返回 Flux 就行"在 MVC 里其实没那么顺畅。Spring MVC 对响应式类型的支持有限,直接返回Flux<String>也能跑,但控制力不够。我更推荐用SseEmitter:
@RestController public class LegacyStreamChatController { private final StreamingChatModel streamingChatModel; public LegacyStreamChatController(StreamingChatModel streamingChatModel) { this.streamingChatModel = streamingChatModel; } @GetMapping("/chat/legacy-stream") public SseEmitter legacyStream(@RequestParam String message) { SseEmitter emitter = new SseEmitter(60_000L); Flux<String> flux = streamingChatModel.stream(new Prompt(new UserMessage(message))) .map(response -> response.getResult().getOutput().getText()) .filter(text -> text != null && !text.isBlank()); flux.subscribe( text -> { try { emitter.send(text); } catch (IOException e) { emitter.completeWithError(e); } }, emitter::completeWithError, emitter::complete ); return emitter; } }SseEmitter的本质是一个长连接响应,Tomcat 会持有一个工作线程直到连接关闭。所以这种方案不能支撑太高的并发,一般用于内部管理系统、管理后台这种低并发场景。如果要做面向 C 端的高并发流式接口,要么上 WebFlux,要么把流式输出前移到网关层,不要让 Tomcat 线程池成为瓶颈。
我在一个餐饮 SaaS 项目里就吃过这个亏:最开始用SseEmitter给门店老板做 AI 营业助手,上线一周后发现 Tomcat 线程被流式连接占满了,高峰期接口响应直接雪崩。后来我们把这一层单独拆成 WebFlux 服务,问题才缓解。这个案例也说明:选流式方案时,不能只看能不能跑通,还要想你未来要扛多少并发。
3.4 流式输出下的超时与中断
流式连接还有一个隐蔽问题:超时。模型生成速度取决于 token 长度和模型负载,一个很长的问题可能前半段很流畅,后半段突然变慢。SseEmitter构造参数里我传了 60 秒,意思是 60 秒内没有任何事件就会超时断开;但如果中途一直没有新的 token 生成,连接也会被判定超时。
处理思路有两个:
- 前端主动中断时,调用
emitter.complete()清理连接,后端也要在doOnCancel里释放资源。 - 后端设置合理的 idle 超时,并在流结束后立即 complete,不要等框架默认超时。
Spring AI 的流式接口内部是对接了模型 API 的 SSE 流的,你这边断了连接,底层 HTTP 调用通常也会随之取消,但最好还是自己验证一遍,避免底层连接泄漏。
4. 会写接口不算完:参数、元数据、重试这些隐藏问题
很多博客讲到ChatModel.call()就结束了。可真到生产环境,参数配错、token 超限、重试策略不对,每一个问题都能让服务在流量稍微大一点的时候哗啦啦地挂掉。我把自己踩过的和帮别人排查过的几类问题集中放在这一节。
4.1 ChatOptions 里的参数,不是随便调大的
ChatOptions可以通过ChatOptions.builder()构建,也可以直接用 YAML 里的spring.ai.openai.chat.options.*配置。常用参数就这几个:
| 参数 | 作用 | 我的建议 |
|---|---|---|
| temperature | 控制随机性,值越高回答越发散 | 客服/知识问答用 0.2-0.3,创意文案用 0.7-0.9 |
| maxTokens | 限制单次回答的最大 token 数 | 默认值往往偏大,按业务控制 |
| topP | 核采样,与 temperature 有协同效应 | 不要同时大幅调这两个,二选一即可 |
| stop | 停止序列列表 | 生成结构化内容时很实用 |
有个很容易混淆的点:maxTokens限制的是"本次请求模型最多生成多少 token",并不代表上下文窗口。你发给模型的消息长度由模型自身的 context window 决定,maxTokens只是回答的上限。如果把maxTokens设得太大,长文本生成时费用翻倍;设得太小,回答会被截断,而且截断位置还很尴尬。
我见过的一个真实案例是:调用方把maxTokens设成 128,结果所有带代码示例的回答都在代码中间被切断,大模型"看起来就很不聪明"。排查了半天才发现不是提示词问题,是 token 上限卡的。
4.2 ChatResponseMetadata 里藏着 token 消耗
每次调用完,ChatResponse都会带上元数据:
ChatResponseMetadata metadata = response.getMetadata();在 OpenAI 的实现里,你可以拿到:
promptTokens:请求消耗的 token。completionTokens:回答消耗的 token。totalTokens:总计。
生产环境我建议把totalTokens落库。原因很简单:token 直接对应钱。不做计量的话,月底账单出来你根本说不清哪个部门、哪个功能在烧钱。我们就在对话记录表里加了一个total_tokens字段,每次调用完顺手存一下,成本归因很清晰。
4.3 重试:不要对模型 API 做无脑重试
Spring AI 底层对很多模型 API 都内置了重试机制,默认会对连接错误、5xx 这类临时错误重试几次。但在业务代码里,我不建议你对"超时"和"内容截断"做无脑重试。
原因很直白:
- 超时重试会成倍增加被调用方的压力,雪崩往往就是这么来的。
- 内容截断是因为
maxTokens不够,重试多少次都一样,应该做的是调大参数或拆分问题。
正确做法是区分错误类型:网络错误可以重试,业务参数错误直接报错,模型限流错误要退避重试。Spring AI 1.x 里可以通过自定义RetryTemplate覆盖默认策略,但别在 Service 层再包一层 for 循环重试,两层重试叠加起来很难排查。
4.4 多模型共存时怎么注入
一个稍微复杂点的项目里,很可能同时接了 OpenAI 和 Ollama,一个用于高精度生产,一个用于本地开发和测试。这时候ChatModel就不止一个 bean 了,直接构造器注入会报NoUniqueBeanDefinitionException。
解决办法是给 bean 加限定符。Spring AI 的自动配置实际注册的 bean 名称通常是openAiChatModel、ollamaChatModel这类。
@Service public class HybridChatService { private final ChatModel openAiChatModel; private final ChatModel ollamaChatModel; public HybridChatService( @Qualifier("openAiChatModel") ChatModel openAiChatModel, @Qualifier("ollamaChatModel") ChatModel ollamaChatModel) { this.openAiChatModel = openAiChatModel; this.ollamaChatModel = ollamaChatModel; } }这里还要注意:spring.ai.model.chat决定的是"谁是默认的ChatModelbean",如果你显式用@Qualifier,就能绕开默认选择,精确使用某个提供方。
5. 离 RAG 和 Agent 还有多远:记忆、工具调用与选型
写到这里,ChatModel 和 StreamingChatModel 本身已经算讲透了。但你在真实项目里大概率不是只做一个单轮问答,而是要做 RAG 知识库问答、Agent 工具调用、多轮有状态的助手。这一节我把这两条常见进阶路线和底层接口的关系理一理,也顺便回应一个最近被问很多的问题:现在到底用 Spring AI 还是 LangGraph4j。
5.1 多轮记忆:从手拼历史到 MessageWindowChatMemory
前面我演示了手动拼接历史消息,那是最笨拙的方式。Spring AI 1.x 提供了ChatMemory和对应的 Advisor,你可以把记忆管理交给框架。
ChatMemory chatMemory = MessageWindowChatMemory.builder() .chatMemoryStore(InMemoryChatMemoryStore.builder().build()) .maxMessages(20) .build(); ChatClient chatClient = ChatClient.builder(chatModel) .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build();Advisor 会在每次调用ChatModel之前自动从记忆仓库里取出最近的 N 条消息,拼进 Prompt;模型返回后,再把新的提问和回答写回记忆仓库。maxMessages(20)限制的是消息条数,本质是滑动窗口。这样你不在 Service 层重复造轮子,也不会因为消息无限堆积撑爆 token。
这里其实藏着一个和流式相关的坑:如果你用StreamingChatModel自己做流式对话,同时又要持久化记忆,必须在doOnComplete里把完整回答写回记忆,而不是在每个切片里写一次。我就见过有人把增量文本一次一次写进记忆,最后整个上下文变成了一大段重复文本,模型越聊越糊涂。
5.2 工具调用:让模型能"动手"
Agent 和普通聊天的最大区别,就是模型可以决定调用你注册的工具,去查数据库、调接口、算数据,再把结果整理成最终回答。在 Spring AI 里,工具调用是ChatModel层级的扩展能力,而不是另一个接口。
最简单的做法是定义一个带@Tool注解的组件:
@Component public class OrderTools { @Tool(description = "根据订单号查询订单状态") public String getOrderStatus(String orderId) { // 调用订单服务 return "订单 " + orderId + " 已发货"; } }然后在构建ChatClient时注册:
ChatClient chatClient = ChatClient.builder(chatModel) .defaultTools(new OrderTools()) .build();当用户说"帮我查一下订单 A10086 到哪了",模型会解析出工具调用意图,框架后台帮你调用getOrderStatus,再把返回结果作为上下文交给模型生成最终回复。整个流程对上层是透明的。
为什么说这块要依赖你理解ChatModel去深入?因为ChatResponse里的Generation不只是文本,还可能包含ToolCall列表。如果你直接手动解析AssistantMessage.getText(),可能会漏掉工具调用信息。Spring AI 把这些都封装在响应结构里,读懂ChatResponse的层次结构,你的 Agent 开发才会顺。
5.3 Spring AI 和 LangGraph4j 到底怎么选
搜索热词里有人问"现在到底用 spring ai 还是 langgraph4j",我给的看法可能比较直接:
如果你的目标是在 Spring Boot 项目里快速交付 AI 功能,对话、RAG、轻量 Agent、函数调用,那就用 Spring AI。它和 Spring 生态的融合是天然的,ChatClient的学习曲线很缓,遇到问题也容易在社区找到答案。我们部门现在大部分业务功能都跑在 Spring AI 上,稳定性和迭代速度都够。
如果你的场景是复杂的图状态编排,比如多角色多条件分支、循环执行、人工审核节点、复杂的重试回退流程,那 LangGraph4j 这类图编排框架会更贴近 LangGraph 的模型。它的抽象层级更高,状态管理、节点流转、条件边都在框架里,适合把 Agent 流程画成一张图来执行和维护。
我的选型经验是:先用 Spring AI 快速搭出最小可用闭环,等发现流程复杂度真的超出 Spring AI 的舒适区,再把 Agent 编排层替换成图框架,底层模型调用依然可以用 Spring AI 抽象好的 ChatModel。两个不是二选一的关系,而是可以在不同层级配合。
5.4 RAG 的上层组装依然绕不开 ChatModel
最后聊一下 RAG。检索增强生成的基本链路是:用户问题 -> 向量检索 -> 拼接上下文 -> 调用大模型 -> 返回回答。Spring AI 有专门的知识库抽象和 Advisor,但真正让模型"理解"检索内容并组织回答的,还是底层那一次ChatModel.call()。
所以你会发现,无论你上层是接数据库、接向量库、接图谱,最终都会汇聚到同一个底层接口:把一堆消息和一个Prompt交给模型。这也就是我为什么强调,学 Spring AI 的第一站,必须是ChatModel和StreamingChatModel。接口长什么样、阻塞和流式行为有何不同、响应结构怎么解析,这些基本功不扎实,后面做 RAG 和 Agent 时你会不断回来翻这几个类的源码。
我个人在实际项目里的习惯是:每接入一个新模型,第一件事就是写一个最简单的ChatModel.call()接口和一个StreamingChatModel.stream()接口,把文本解析、空片段过滤、完整回答拼装全部跑通,再往上叠加提示词模板、记忆和工具。因为这个两个接口是 AI 应用的"地基层",地基稳了,上面盖多高的楼都不慌。