Spring AI 1.x核心:ChatModel与StreamingChatModel从阻塞到流式输出全解析

📅 发布时间:2026/9/30 0:08:35
Spring AI 1.x核心:ChatModel与StreamingChatModel从阻塞到流式输出全解析
写这篇的时候我其实是带着一点终于写到这儿了的心情。前面几篇把 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本身也很好理解它就是一次完整对话请求的载体内部由两部分组成ListMessage消息列表包含系统消息、用户消息、历史助手消息等。ChatOptions模型参数比如 temperature、maxTokens、model 名称。ChatResponse则是模型响应的载体核心是getResult()拿到的Generation再通过.getOutput().getText()拿到助手回复的文本。这一串链式调用我第一次看挺啰嗦但习惯之后反而觉得清晰Spring AI 刻意把消息和文本分开就是为了让你以后能拿到结构化的工具调用信息而不是只拼字符串。1.2 StreamingChatModel 是同一个模型的异步流式面StreamingChatModel接口的核心方法是FluxChatResponse 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 还是本地 OllamaSpring AI 1.x 的思路都很一致引入 starter 依赖然后用spring.ai.model.chat指定当前激活的是哪个提供方。Maven 里加依赖dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency如果本地有 Ollama也可以换成dependency groupIdorg.springframework.ai/groupId artifactIdspring-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再交给PromptService 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, ListMessage history) { ListMessage 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 重写一遍看看差距同样的逻辑如果改用ChatClientService 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 FluxString 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()返回的是FluxChatResponse每一个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 可以直接返回FluxString并指定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 FluxString 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或者fetchReadableStream就能逐段收到数据。不过生产环境里我一般不会直接返回裸字符串而是包装成事件对象public record ChatStreamChunk(String delta) { }这样以后想追加角色信息、时间戳、token 用量都不会破坏前端协议。3.3 在传统 Spring MVC 里做流式SseEmitter 才是落地方案很多遗留项目还是 Spring MVC Tomcat网上流传的异步返回 Flux 就行在 MVC 里其实没那么顺畅。Spring MVC 对响应式类型的支持有限直接返回FluxString也能跑但控制力不够。我更推荐用SseEmitterRestController 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); FluxString 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.9maxTokens限制单次回答的最大 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 应用的地基层地基稳了上面盖多高的楼都不慌。