Spring AI 核心接口 ChatModel 与 StreamingChatModel 深度解析:同步与流式调用实践
2026/9/24 23:31:00 网站建设 项目流程

讲真,Spring AI 系列写到第 4 篇,终于轮到最关键的两个接口了。如果你之前跟着这个系列用 Spring AI 对接过大模型,不管是 OpenAI、通义千问还是智谱 GLM,平时业务代码里翻来覆去打交道最多的就是ChatModelStreamingChatModel这俩接口。可以说这 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 的解决办法就是定义ChatModelStreamingChatModel两个顶层接口,把所有大模型厂商的差异全部封装在各自的ChatModel实现类里。你的业务代码只需要面向这两个接口编程,底层具体调的是 OpenAI 还是智谱还是通义,对业务层完全透明。

这就好比 JDBC 和数据库驱动的关系。你写 SQL 的时候面向的是java.sql.ConnectionPreparedStatement这层标准接口,至于底层连的是 MySQL 还是 PostgreSQL,JDBC 驱动帮你屏蔽掉了。Spring AI 里的ChatModel就是那个“JDBC 标准接口”,阿里、智谱、OpenAI 的适配实现就是“数据库驱动”。

1.2 两个接口的边界划分与关系

光看接口名字也挺直白:ChatModel是同步调用,发一个请求过去,等模型生成完整个回复再返回;StreamingChatModel是流式调用,模型边生成边返回,前端可以看到打字机一样的逐字输出效果。

在实际代码里,这两个接口的关系也很微妙。StreamingChatModel并没有继承ChatModel,它们是平级的两个接口。有些实现类两个接口都实现了,比如ZhipuAiChatModelOpenAiChatModel基本都同时实现了同步和流式方法;但理论上一个实现类也可以只实现其中一个。所以在你注入 Bean 的时候要留意一下,容器里可能存在两个实现同时存在的情况。

提示:Spring AI 1.x 里还有ChatClient这样一个更高层的门面类,它是构建在ChatModel之上的流式 API,用起来更爽。但底层还是离不开ChatModelStreamingChatModel,所以先把这两个接口吃透,看ChatClient源码的时候会轻松很多。

2. ChatModel:同步调用的核心接口拆解

2.1 接口方法与参数模型体系

ChatModel接口的定义相当克制,核心方法就一个:

ChatResponse call(Prompt prompt);

看这个签名你可能觉得简单得有点不像话,但真正复杂的是Prompt这个入参对象。一个Prompt内部包含两部分:一个是List<Message>,也就是你发给模型的消息列表;另一个是可选的ChatOptions,用来控制模型参数,比如 temperature、maxTokens、topP 这些。

其中Message又分好几种类型,最常用的是三种:

  • UserMessage:用户输入的消息,也就是你向模型提的问题。
  • SystemMessage:系统提示词,告诉模型你希望它扮演什么角色、遵循什么规则。
  • AssistantMessage:模型的回复消息。在做多轮对话时,需要把历史对话记录里的 AssistantMessage 也拼进消息列表,模型才能理解上下文。

ChatResponse的包装结构也值得看一眼:它里面有一个List<ChatGeneration>,每个ChatGeneration包含一个AssistantMessage和相应的元信息,还有一个Map<String, 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 本身也支持 SSE(Server-Sent Events,服务器推送事件)方式,模型每生成一小段 token,就通过 HTTP 连接推送给客户端。StreamingChatModel就是把这种底层的 SSE 响应流封装成了 Reactor 的Flux

3.2 接口方法与 Flux 响应流

StreamingChatModel的核心方法长这样:

Flux<ChatResponse> 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 Flux<String> 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方法返回的Flux<ChatResponse>,每个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; }

一步到位地说,SseEmittersend就是给前端推数据,complete表示流结束了,completeWithError表示出错。这套模式我在生产环境跑过,稳定性还是不错的。

4. 实操过程:从零跑通一个智谱 AI 的对话接口

4.1 Maven 依赖与版本选择

网上搜“spring ai maven 智谱 ai version”能找到一堆帖子,但版本这块坑特别多。Spring AI 1.x 的版本号变化比较频繁,不同小版本的 API 可能有细微差异。我的建议是直接用 Spring Boot 的 BOM 来管理版本,不要在dependency里写死版本号。

先加父依赖管理,在你的pom.xml里加上:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.3.5</version> <relativePath/> </parent>

然后引入 Spring AI 的 BOM:

<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>1.0.0</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>

最后加上智谱 AI 的 starter 依赖:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-zhipu-ai</artifactId> </dependency>

这里有一个关键点:Spring AI 的仓库默认不在 Maven Central 里,需要在repositories里额外配置。很多新手卡在这一步卡半天,依赖怎么都拉不下来,其实就是少了仓库配置。

<repositories> <repository> <id>spring-milestones</id> <name>Spring Milestones</name> <url>https://repo.spring.io/milestone</url> <snapshots> <enabled>false</enabled> </snapshots> </repository> </repositories>

4.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: 2048

api-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(List<Message> historyMessages, String userInput) { List<Message> 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在最前面,然后是历史对话的UserMessageAssistantMessage交替,最后是当前这条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同步调用跑通,理解PromptMessageChatResponse这套模型之后,再上手StreamingChatModel,这样踩坑的几率会小很多。

这个系列下一篇我会接着讲ChatClient这个高层的流式 API,它是 Spring AI 1.x 里我目前最喜欢的一个组件。到时候写完这篇,你再看那篇会非常顺畅。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询