引子
上一篇文章中,我们虽然用三步快速实现了 Spring Boot 集成 LLM,但这种同步响应的方式会让用户体验大打折扣。尤其当问题过于复杂时,大模型需要更多的时间来处理,这会导致用户不得不长时间面对空白屏幕,这种体验显然无法与逐字浮现的“打字机效果”相媲美。这种实时反馈的交互体验,正是流式响应的独特魅力,也已成为AI应用的标配。
在本篇文章中,我们将对项目进行升级改造,通过使用 Spring AI 的流式API与 SSE(Server-Sent Events) 技术,让 AI 响应如“打字机”般自然呈现。
认识SSE
在动手编码之前,我们有必要先花点时间了解一下本次实现的关键技术——SSE,全称 Server-Sent Events,即“服务器发送事件”。你可以把它想象成你关注了一个新闻App的“突发新闻”推送。你只需要在App里点击一次“允许通知”(这就是建立连接),之后只要有新的大新闻发生,App服务器就会主动把消息推送到你的手机上,你不用一遍遍地去刷新App。
你的浏览器(客户端)和我们的服务器(服务端)建立一个连接后,服务器就能随时把新数据(AI生成的新词语)主动“推送”给浏览器,而浏览器只管接收就行。这是一个从服务器到客户端的单行道。

这里我们引申一下,可能有的读者会问为啥不用 WebSocket?这里我们对比下:
- WebSocket:像一个微信电话。你和服务器都能随时说话,是双向的。它功能强大,但对于我们这个场景来说,有点“杀鸡用牛刀”。
- SSE:就是我们上面说的新闻推送。只有服务器能“说话”,你只管听。是单向的。
在AI对话的场景里,我们问完问题后,只需要静静地听AI把答案一个字一个字“说”出来就行了。AI并不需要中途再听我们说什么。所以,更轻量、更简单的SSE,就是我们这个场景下的完美选择。
项目迭代
理论知识已经储备完毕,现在让我们开始动手改造项目。
1.搭建SSE通信管道
首先,我们需要创建一个SSE服务管理器,它负责管理所有客户端的连接。可以把它想象成一个"调度中心",负责记录哪些用户连接了进来,并向指定用户推送消息。
package com.cc.utils;
import com.cc.enums.SSEMsgType;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.CollectionUtils;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
@Slf4j
public class SSEServer {
// 存放所有用户的SseEmitter连接
private static final Map<String, SseEmitter> sseClients = new ConcurrentHashMap<>();
// 建立连接
public static SseEmitter connect(String userId) {
// 设置超时时间为0,即不超时,默认是30秒,超时未完成任务则会抛出异常
SseEmitter sseEmitter = new SseEmitter(0L);
// 注册连接完成、超时、异常时的回调函数
sseEmitter.onTimeout(timeoutCallback(userId));
sseEmitter.onCompletion(completionCallback(userId));
sseEmitter.onError(errorCallback(userId));
sseClients.put(userId, sseEmitter);
log.info("SSE connect, userId: {}", userId);
return sseEmitter;
}
// 发送消息
public static void sendMsg(String userId, String message, SSEMsgType msgType) {
if (CollectionUtils.isEmpty(sseClients)) {
return;
}
if (sseClients.containsKey(userId)) {
SseEmitter sseEmitter = sseClients.get(userId);
sendEmitterMessage(sseEmitter, userId, message, msgType);
}
}
public static void sendMsgToAllUsers(String message) {
if (CollectionUtils.isEmpty(sseClients)) {
return;
}
sseClients.forEach((userId, sseEmitter) -> {
sendEmitterMessage(sseEmitter, userId, message, SSEMsgType.MESSAGE);
});
}
private static void sendEmitterMessage(SseEmitter sseEmitter,
String userId,
String message,

最低0.47元/天 解锁文章
2万+

被折叠的 条评论
为什么被折叠?



