1.背景
为什么需要使用SSE
对于体验过大语言模型的人(chatgpt,文心一言,通义千问...)都知道,大模型的回答是一边思考一边返回数据的,属于流式响应。要达到这种效果就需要实现前后端的即时通讯。
即时通讯的实现方式有4种:轮询、长轮询(comet)、长连接(SSE)、WebSocket,目前主流的技术方案都是使用SSE长连接,具体各个实现方式的描述及优缺点如下:
轮询(短轮询),长轮询(comet),长连接(SSE),WebSocket - 简书
2.服务端实现SSE
在Spring Boot中实现Server-Sent Events (SSE) 可以通过SseEmitter类来实现。
以下是一个使用SseEmitter在Spring Boot中创建SSE长连接的步骤:
- 添加依赖:确保在你的build.gradle或pom.xml中引入了Spring Web的依赖。
- 创建Controller: 创建一个Controller来处理SSE连接请求,并使用SseEmitter作为响应。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>
@RequestMapping(value = "/sub", method = RequestMethod.POST, produces = {MediaType.TEXT_EVENT_STREAM_VALUE}) public SseEmitter subscribe(@RequestBody Body body) { System.out.println("accept_new_question,id=" + JSON.toJSONString(body)); // 添加订阅,建立sse链接 SseEmitter emitter = SSEUtils.addSub(body.getSubId()); new Thread(() -> { try { for (int i = 0; i < 60; i++) { // 发送消息 SSEUtils.pubMsg(body.getSubId(), "", String.valueOf(i), body.getSubId() + " - hmg come " + i); Thread.sleep(60 * 1000); } } catch (Exception e) { e.printStackTrace(); }finally { // 消息发送完关闭订阅 SSEUtils.closeSub(body.getSubId()); } }).start(); return emitter; }
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import static org.springframework.web.servlet.mvc.method.annotation.SseEmitter.event; public class SSEUtils { // timeout private static Long DEFAULT_TIME_OUT = 5*60*1000L; private static final Map<String, SseEmitter> subscribeMap = new ConcurrentHashMap<>(); /** 添加订阅 */ public static SseEmitter addSub(String subId) { if (null == subId || "".equals(subId)) { return null; } SseEmitter emitter = subscribeMap.get(subId); if (null == emitter) { emitter = new SseEmitter(DEFAULT_TIME_OUT); emitter.onTimeout(() -> { // 注册超时回调,超时后触发 System.out.println("onTimeout,subId=" + subId); closeSub(subId); }); emitter.onCompletion(() -> { // 注册完成回调,调用 emitter.complete() 触发 System.out.println("onCompletion,subId=" + subId); closeSub(subId); }); subscribeMap.put(subId, emitter); } return emitter; } public static void pubMsg(String subId, String name, String id, Object msg) { SseEmitter emitter = subscribeMap.get(subId); if (null != emitter) { try { System.out.println(msg); // 更规范的消息结构看源码 emitter.send(event().name(name).id(id).data(msg)); } catch (Exception e) { e.printStackTrace(); } } } // 关闭订阅 public static void closeSub(String subId) { SseEmitter emitter = subscribeMap.get(subId); if (null != emitter) { try { emitter.complete(); } catch (Exception e) { e.printStackTrace(); } }finally{ subscribeMap.remove(subId); } } }
3.使用SSE的踩坑
3.1 Async support must be enabled on a servlet and for all filters involved in async request processing
java.lang.IllegalStateException: Async support must be enabled on a servlet and for all filters involved in async request processing. This is done in Java code using the Servlet API or by adding "<async-supported>true</async-supported>" to servlet and filter declarations in web.xml. at org.springframework.util.Assert.state(Assert.java:76) ~[spring-core-5.3.9.jar:5.3.9] at org.springframework.web.context.request.async.StandardServletAsyncWebRequest.startAsync(StandardServletAsyncWebRequest.java:112) ~[spring-web-5.3.9.jar:5.3.9] at org.springframework.web.context.request.async.WebAsyncManager.startAsyncProcessing(WebAsyncManager.java:483) ~[spring-web-5.3.9.jar:5.3.9]
异常堆栈中已经说明:异步请求必须对servlet和所有涉及到的filter都需要开启异步支持
adding "<async-supported>true</async-supported>" to servlet and filter declarations
springboot项目中,filter一般通过@WebFilter注解引入,开启asyncSupported的方式如下:
3.2流式输出未生效
流式输出未生效的表现为:sse长连接已成功建立,但是并没有达到实时推送消息给客户端的效果,而是要等流式输出已完成时一次性将所有内容返回客户端。
造成这个情况的原因是流式输出的内容被nginx代理或servlet缓存下来,需要设置关闭缓存。
3.2.1 nginx配置
nginx默认会开启缓存,当服务端响应消息时,不会直接返回客户端,而是放入缓冲区,待缓冲区放满了以后再返回客户端一次,重复这个步骤。
表现为:客户端收到的消息是一段一段的
解决方案:在nginx.conf文件中,对sse的location关闭缓存
解决SSE流被Nginx缓存的问题_nginx sse-CSDN博客
location ^~ /sse/ { proxy_pass http://127.0.0.1:7001; add_header X-Location "sse"; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header X-Real-IP $remote_addr; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # SSE 连接时的超时时间 proxy_read_timeout 300s; # 取消缓冲 proxy_buffering off; # 关闭代理缓存 proxy_cache off; # 重要!因为项目实际不止有一个nginx代理,还有可能会有统一的上层nginx网关。 # 只在第一个nginx种关闭缓存还不够,由于第二个nginx默认会缓存数据,所以sse流就再一次被缓存了。 # 解决办法是在第一个nginx的配置里面,继续加上这个header,表示这个路径下的请求需要带上这个header,这样第二个nginx才能继续收到这个header并且不缓存数据。 add_header X-Accel-Buffering "no"; }
4.关于各种超时时间
SSE长连接有很多地方需要配置到超时时间,这些超时时间设置起到什么作用,如果超时会有产生什么样的情况,具体如下:
4.1 new SseEmitter(DEFAULT_TIME_OUT);
springboot实现SSE长连接是通过SseEmitter对象实现的,在SseEmitter的构造方法需要传入timeout,单位milliseconds。
这里timeout的作用是长连接和前端保持的时间,到达超时时间之后,服务端就会与前端断开连接,即使后续还有推流内容前端也无法接收到。
服务端在超时之后继续推流,代码会抛出ResponseBodyEmitter has already completed异常。
所以SseEmitter的超时时间需要根据实际情况合理设置:
如果设置的太短,消息无法完整给到前端;
如果设置的太长又没有正确的主动关闭长链接,会造成服务端资源浪费。
4.2.nginx proxy_read_timeout
proxy_read_timeout:设置从后端/上游服务器读取响应的超时时间,默认为60s,此超时时间指的是两次成功读操作间隔时间,而不是读取整个响应体的超时时间,如果在此超时时间内上游服务器没有发送任何响应,则Nginx关闭此连接。
如果触发proxy_read_timeout客户端表现:
服务端表现:
如果继续往这个通道推送消息,代码会抛出ResponseBodyEmitter has already completed异常。
proxy_read_timeout的超时时间可以设置大一些,让的SseEmitter来控制整个长连接的超时。
4.3 OkHttpClient readTimeout
上面讲的都是java应用作为服务端配置的超时时间。
实际的情况中,java应用往往需要扮演两个角色,作为大语言模型与用户之间的中转
- 作为服务端给用户流式推送数据
- 作为客户端与大模型建立SSE连接,流式接受数据
java应用与大模型建立SSE链接,一般都是用okhttp实现,创建OkHttpClient时,需要设置readTimeOut。
readTimeOut的作用和上面提到过的nginx proxy_read_timeout作用类似,如果大模型两次推送的时间间隔超过readTimeOut,okhttpclient关闭链接,触发EventSourceListener的onFailure方法
readTimeOut的超时时间可以设置大一些,让的SseEmitter来控制整个长连接的超时。