Spring AI 核心接口 ChatModel 与 StreamingChatModel 深度解析:同步与流式调用实践
讲真Spring AI 系列写到第 4 篇终于轮到最关键的两个接口了。如果你之前跟着这个系列用 Spring AI 对接过大模型不管是 OpenAI、通义千问还是智谱 GLM平时业务代码里翻来覆去打交道最多的就是ChatModel和StreamingChatModel这俩接口。可以说这 2 个接口搞明白了Spring AI 在你手里就算真正入门了。这篇我打算把两个接口彻底拆开揉碎来讲包括它们在整个 Spring AI 抽象层里的位置、方法签名怎么设计的、底层请求是怎么流转的、实际对接智谱 AI 的完整配置过程以及我在用了大半年之后踩过的一堆坑。不管你是刚开始接触 Spring AI 的初学者还是已经写过几个 demo 但理解不够深的老手这篇都值得花 15 分钟看完看完基本就能在自己的项目里直接开干了。1. ChatModel 与 StreamingChatModel 在整个 Spring AI 框架里的定位1.1 为什么 Spring AI 非要抽出这两个抽象接口先说一个很多新手容易忽略的点Spring AI 本质上不是某个大模型厂商的官方 SDK它的核心价值是把“接大模型”这件事抽象成一套统一的编程模型。你想想OpenAI 的 API 和智谱的 API、通义的 API接口路径不一样、参数命名不一样、返回结构也不一样。如果业务代码里直接写死某一家 SDK 的调用方式将来换模型厂商几乎等于重写一遍。Spring AI 的解决办法就是定义ChatModel和StreamingChatModel两个顶层接口把所有大模型厂商的差异全部封装在各自的ChatModel实现类里。你的业务代码只需要面向这两个接口编程底层具体调的是 OpenAI 还是智谱还是通义对业务层完全透明。这就好比 JDBC 和数据库驱动的关系。你写 SQL 的时候面向的是java.sql.Connection、PreparedStatement这层标准接口至于底层连的是 MySQL 还是 PostgreSQLJDBC 驱动帮你屏蔽掉了。Spring AI 里的ChatModel就是那个“JDBC 标准接口”阿里、智谱、OpenAI 的适配实现就是“数据库驱动”。1.2 两个接口的边界划分与关系光看接口名字也挺直白ChatModel是同步调用发一个请求过去等模型生成完整个回复再返回StreamingChatModel是流式调用模型边生成边返回前端可以看到打字机一样的逐字输出效果。在实际代码里这两个接口的关系也很微妙。StreamingChatModel并没有继承ChatModel它们是平级的两个接口。有些实现类两个接口都实现了比如ZhipuAiChatModel、OpenAiChatModel基本都同时实现了同步和流式方法但理论上一个实现类也可以只实现其中一个。所以在你注入 Bean 的时候要留意一下容器里可能存在两个实现同时存在的情况。提示Spring AI 1.x 里还有ChatClient这样一个更高层的门面类它是构建在ChatModel之上的流式 API用起来更爽。但底层还是离不开ChatModel和StreamingChatModel所以先把这两个接口吃透看ChatClient源码的时候会轻松很多。2. ChatModel同步调用的核心接口拆解2.1 接口方法与参数模型体系ChatModel接口的定义相当克制核心方法就一个ChatResponse call(Prompt prompt);看这个签名你可能觉得简单得有点不像话但真正复杂的是Prompt这个入参对象。一个Prompt内部包含两部分一个是ListMessage也就是你发给模型的消息列表另一个是可选的ChatOptions用来控制模型参数比如 temperature、maxTokens、topP 这些。其中Message又分好几种类型最常用的是三种UserMessage用户输入的消息也就是你向模型提的问题。SystemMessage系统提示词告诉模型你希望它扮演什么角色、遵循什么规则。AssistantMessage模型的回复消息。在做多轮对话时需要把历史对话记录里的 AssistantMessage 也拼进消息列表模型才能理解上下文。ChatResponse的包装结构也值得看一眼它里面有一个ListChatGeneration每个ChatGeneration包含一个AssistantMessage和相应的元信息还有一个MapString, Object metadata里面会附带 token 消耗、模型名、响应耗时等信息。你如果要在业务里统计成本就得从这里捞数据。2.2 ChatOptions 参数传递的两种姿势使用ChatOptions传参时要注意Spring AI 里这套参数体系分两个层次。第一个层次是全局配置写在application.yml里比如模型名、API Key、默认的 temperature 等第二个层次是每次请求的局部配置通过Prompt传入优先级更高。ChatOptions options ZhipuAiChatOptions.builder() .withModel(glm-4-flash) .withTemperature(0.7) .withMaxTokens(2048) .build(); Prompt prompt new Prompt(userMessage, options); ChatResponse response chatModel.call(prompt);有一点得提醒你不同的实现类ChatOptions的 builder 类也不一样。比如用智谱就是ZhipuAiChatOptions用 OpenAI 就是OpenAiChatOptions用通义就是DashScopeChatOptions。这也就是为什么之前有网友问“spring ai maven 智谱 ai version 怎么搭”说白了核心就两部引入对应的 starter 依赖然后使用对应的 options 构建器。2.3 同步调用适合哪些场景同步模式最大的优点是逻辑简单、结果完整。你调用call方法之后线程会一直阻塞直到模型返回完整响应后面处理结果时拿到就是一整段完整的文本不用考虑拼装流式块的问题。我实际项目里的经验是同步模式适合这几类场景后端服务之间的大模型调用比如定时任务批量生成摘要。非交互式的数据处理流程比如解析文档以后让模型输出结构化内容。逻辑链路里必须要完整结果才能继续下一步的场景比如让模型做意图识别然后根据识别结果走不同分支。同步模式的缺点也明显大模型生成速度再快生成几百个 token 也得几秒钟。这段时间线程就干等着如果把同步调用直接暴露给前端 HTTP 接口用户体验会非常差而且 Tomcat 的线程池很容易被拖垮。2.4 同步调用的完整代码示例拿智谱 GLM 为例一个最基础的同步调用长这样Service public class ChatService { private final ChatModel chatModel; public ChatService(ChatModel chatModel) { this.chatModel chatModel; } public String chat(String userInput) { SystemMessage systemMessage new SystemMessage(你是一个乐于助人的智能助手回答尽量简洁。); UserMessage userMessage new UserMessage(userInput); Prompt prompt new Prompt(List.of(systemMessage, userMessage)); ChatResponse response chatModel.call(prompt); return response.getResult().getOutput().getContent(); } }这里注意chatModel注入的是接口类型而不是具体的ZhipuAiChatModel这是面向接口编程的标准写法。将来就算把spring-ai-zhipu-ai的依赖换成spring-ai-openai这段业务代码一行都不用改只要调整application.yml里的配置就行。3. StreamingChatModel流式响应为什么值得用3.1 流式模式到底解决了什么问题先想一个场景用户在前端输入一个问题点击发送以后等了 5 秒钟页面才一次性弹出完整答案。这个体验是不是很差但如果你用过 ChatGPT 官方页面会发现它是等大概几百毫秒就开始一个字一个字地往外蹦答案虽然整体生成完也需要几秒钟但用户感知到的等待时间大大缩短了。这就是流式响应的价值——它把“等待完整响应”的阻塞感变成了“实时接收生成内容”的流畅感。从技术实现上说大模型 API 本身也支持 SSEServer-Sent Events服务器推送事件方式模型每生成一小段 token就通过 HTTP 连接推送给客户端。StreamingChatModel就是把这种底层的 SSE 响应流封装成了 Reactor 的Flux。3.2 接口方法与 Flux 响应流StreamingChatModel的核心方法长这样FluxChatResponse stream(Prompt prompt);看到Flux你可能有点慌这玩意儿是 Project Reactor 里的响应式流类型。你可以把它想象成一根水管数据不是一个整体一次性流过来而是像水流一样一小段一小段地流过来。你在下游用subscribe或者doOnNext去接住每一段数据就行。RestController public class ChatController { private final StreamingChatModel streamingChatModel; public ChatController(StreamingChatModel streamingChatModel) { this.streamingChatModel streamingChatModel; } GetMapping(value /chat/stream, produces text/event-stream) public FluxString chatStream(RequestParam String message) { Prompt prompt new Prompt(new UserMessage(message)); return streamingChatModel.stream(prompt) .map(response - response.getResult().getOutput().getContent()); } }这段代码里接口的produces设置为text/event-stream表示返回的是 SSE 流。前端用EventSource或者 fetch 的流式读取能力就能实时接收数据。3.3 流式分片响应的数据结构细节这里有一个细节很多初学者会踩坑stream方法返回的FluxChatResponse每个ChatResponse里包含的只是模型当前这一步生成的增量内容不是全文。也就是说模型生成一句话“你好很高兴认识你”可能会分解成 3 个或 5 个 chunk 返回第一个 chunk 可能是“你好”第二个是“很高兴”第三个才是“认识你”。所以你在处理流式响应时绝不能直接拿某个ChatResponse的内容当完整结果必须自己拼接。我见过有人调试接口发现打印出来的响应不完整以为是大模型出 bug 了其实就是没搞懂分片机制。正确的缓存方式是这样StringBuilder fullContent new StringBuilder(); streamingChatModel.stream(prompt) .doOnNext(response - { String chunk response.getResult().getOutput().getContent(); fullContent.append(chunk); // 这里可以把 chunk 推给前端 }) .doOnComplete(() - { // 所有分片接收完成fullContent 里才是完整内容 }) .subscribe();另外还要注意流式响应的最后一个分片经常是空的ChatResponse只有元信息没有内容。处理时要做好判空不然往 StringBuilder 里 append 一个 null 就很尴尬。3.4 接前端时 SseEmitter 的正确用法实际业务里后端接口往往是给前端页面调用的这时候我更喜欢把Flux适配成 Spring MVC 的SseEmitter因为很多前端团队对SseEmitter更熟悉对接成本低。GetMapping(/chat/sse) public SseEmitter chatSse(RequestParam String message) { SseEmitter emitter new SseEmitter(0L); // 不设置超时时间 Prompt prompt new Prompt(new UserMessage(message)); streamingChatModel.stream(prompt) .doOnNext(response - { String chunk response.getResult().getOutput().getContent(); if (chunk ! null !chunk.isEmpty()) { emitter.send(SseEmitter.event().data(chunk)); } }) .doOnError(emitter::completeWithError) .doOnComplete(() - { emitter.complete(); }) .subscribe(); return emitter; }一步到位地说SseEmitter的send就是给前端推数据complete表示流结束了completeWithError表示出错。这套模式我在生产环境跑过稳定性还是不错的。4. 实操过程从零跑通一个智谱 AI 的对话接口4.1 Maven 依赖与版本选择网上搜“spring ai maven 智谱 ai version”能找到一堆帖子但版本这块坑特别多。Spring AI 1.x 的版本号变化比较频繁不同小版本的 API 可能有细微差异。我的建议是直接用 Spring Boot 的 BOM 来管理版本不要在dependency里写死版本号。先加父依赖管理在你的pom.xml里加上parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.3.5/version relativePath/ /parent然后引入 Spring AI 的 BOMdependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement最后加上智谱 AI 的 starter 依赖dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-zhipu-ai/artifactId /dependency这里有一个关键点Spring AI 的仓库默认不在 Maven Central 里需要在repositories里额外配置。很多新手卡在这一步卡半天依赖怎么都拉不下来其实就是少了仓库配置。repositories repository idspring-milestones/id nameSpring Milestones/name urlhttps://repo.spring.io/milestone/url snapshots enabledfalse/enabled /snapshots /repository /repositories4.2 application.yml 配置详解依赖引入以后接下来就是配置。智谱 AI 的配置项分三块API Key、基础 URL、默认模型参数。spring: ai: zhipu: api-key: ${ZHIPU_API_KEY:你的智谱APIKey} base-url: https://open.bigmodel.cn/api/paas/v4 chat: options: model: glm-4-flash temperature: 0.8 max-tokens: 2048api-key是智谱开放平台申请的密钥环境变量注入是更安全的做法。base-url默认值其实已经是智谱的地址了一般情况下不用改但显式写出来方便排查。model字段注意区分glm-4-flash是免费版速度快但能力弱一些glm-4-plus是付费版效果更好。刚开始调试建议先用glm-4-flash毕竟不用花钱。4.3 编写一个支持多轮对话的服务搞清楚了配置写一个多轮对话的 Service 就顺理成章了。多轮对话的本质就是把历史消息拼进Prompt的消息列表让模型拥有记忆能力。Service public class ChatService { private final ChatModel chatModel; public ChatService(ChatModel chatModel) { this.chatModel chatModel; } public String chatWithHistory(ListMessage historyMessages, String userInput) { ListMessage messages new ArrayList(historyMessages); messages.add(new UserMessage(userInput)); Prompt prompt new Prompt(messages); ChatResponse response chatModel.call(prompt); return response.getResult().getOutput().getContent(); } }消息列表的拼接顺序很关键必须是SystemMessage在最前面然后是历史对话的UserMessage和AssistantMessage交替最后是当前这条UserMessage。顺序错了模型对上下文的理解就会错乱。4.4 单元测试与验证写完了代码我习惯先写个单元测试验证配置能通再上 Controller。SpringBootTest class ChatServiceTest { Autowired private ChatService chatService; Test void testChat() { String response chatService.chat(用一句话介绍你自己); System.out.println(response); assertNotNull(response); assertFalse(response.isEmpty()); } }跑测试时第一次请求会比较慢因为要建立连接而且智谱那边首次请求可能会有几秒的冷启动。如果测试报 401先检查 API Key 对不对报 404检查 base-url 对不对报超时多半是网络问题或者模型响应太慢把 Spring Boot 的 HTTP 超时时间调大一点。5. 常见问题与排查技巧实录5.1 常见问题速查表现象可能原因解决办法启动报找不到ChatModelBean没引入对应 starter 依赖或没配置 api-key检查pom.xml依赖、检查application.yml配置调用时报 401 UnauthorizedAPI Key 错误或过期去智谱开放平台重新生成密钥调用时报 404 Not Foundbase-url 配置错误核对接口地址是否以/api/paas/v4结尾流式接口返回的是空内容没处理分片增量拼接逻辑错误用 StringBuilder 累积所有 chunk响应中文乱码请求/响应的 contentType 编码不对设置server.servlet.encoding或手动设置 UTF-8多轮对话时模型“失忆”历史消息没拼进 Prompt将历史 UserMessage/AssistantMessage 加入消息列表修改了 temperature 但没效果请求级 ChatOptions 覆盖了全局或模型本身不支持该参数确认传参方式查看模型文档5.2 流式模式容易踩的坑第一stream方法返回的Flux是冷的一定要有人subscribe才会真正发起请求。我见过有人把stream方法 return 给前端就以为完事了结果接口一直不输出内容就是这个原因。第二在流式处理链里做耗时操作要非常谨慎。doOnNext里的代码跑在 Reactor 的 IO 线程上如果你在里面写数据库查询、远程调用这种阻塞操作会把线程池憋死影响其他请求的流式输出。第三不要试图在流式响应中拿到精准的 token 消耗后再处理业务逻辑。流式模式下 token 统计分散在各个 chunk 的metadata里有些厂商给的还不全。真要统计成本更靠谱的做法是等流式结束以后拼接完整文本自己估算 token或者调用厂商的单独计费接口。5.3 我踩过最痛的一个坑最后分享一个让我印象深刻的教训。有一阵子我把StreamingChatModel暴露给网关层做 SSE 转发结果发现偶尔会出现连接被切断的情况而且没有任何异常日志。排查了很久发现问题出在网关的超时时间设置上。因为流式接口整体耗时很长从建立连接到最后一个分片返回可能长达 30 秒甚至更久而网关默认的写超时只有 10 秒。也就是说不是代码的问题是网关把连接掐了。排查链路长、排错难所以你要是做流式接口一定要先确认整个链路网关、负载均衡、Tomcat、代理层的超时配置都够长否则线上时不时断流会让你非常难受。提示调流式接口时之前在代理层开启缓冲也可能导致前端等很久才一次性收到全部内容。开发调试建议直接把缓冲关掉测试逐字输出是否正常。5.4 生产环境配置的一点建议如果把.stream()用在生产环境我建议至少做好两件事一是给流式接口加好日志记录每个请求的完整拼接结果方便出问题时定位是模型返回异常还是前端展示问题二是要做好降级方案如果流式调用失败可以自动切换成同步调用返回完整结果保证用户至少能拿到答案只是体验稍差一些。我对流式这个能力期望很高但说实话生产环境跑得久了就会发现稳定性和兜底策略比炫技更重要。同步调用做不到的事流式也不一定都合适接口设计时要先想清楚自己真实的业务诉求。写在最后的一点个人体会项目里从同步调用切换到流式调用的过程比我想象中顺利得多。Spring AI 把两套模式封装得很统一ChatModel.call()和StreamingChatModel.stream()之间切换业务代码的改动量很小。但两套模式背后的思维方式差别很大同步是结果导向流式是过程导向。写流式代码时心里要时刻记得每个回调都只是整个拼图的一部分不是完整答案。如果你是完全新手我的建议是先把ChatModel同步调用跑通理解Prompt、Message、ChatResponse这套模型之后再上手StreamingChatModel这样踩坑的几率会小很多。这个系列下一篇我会接着讲ChatClient这个高层的流式 API它是 Spring AI 1.x 里我目前最喜欢的一个组件。到时候写完这篇你再看那篇会非常顺畅。