接了大模型问答的活儿,第一版我们用普通 HTTP 请求,前端点完"发送"就开始转圈,最长 18 秒才一次性吐出整段答案。产品同学盯着转圈圈说了一句话:"能不能像 ChatGPT 那样一个字一个字蹦?"于是有了这次 SSE 改造。
SSE 是什么,为什么不用 WebSocket
大模型生成是单向的:服务端不断往外吐 token,客户端只收不发(发问是另一个请求)。这种场景 SSE(Server-Sent Events)比 WebSocket 轻——它是 HTTP 长连接上的文本流,天然走标准网关、好做鉴权、断线重连协议内置。WebSocket 适合双向高频,杀鸡用牛刀。
Spring 的 SseEmitter 上手
后端用 SseEmitter 最简单,但要注意线程模型。一开始我写成了这样:
@GetMapping("/chat/stream")
public SseEmitter stream(@RequestParam String q) {
SseEmitter emitter = new SseEmitter(60_000L);
// 错误示范:在 Tomcat 的 IO 线程里同步阻塞调用模型
String full = chatClient.generate(q);
emitter.send(full);
emitter.complete();
return emitter;
}
这在高并发下直接把 Tomcat 线程池占满。正确做法是用虚拟线程或异步线程池把生成过程挪出去:
executor.execute(() -> {
try (var stream = chatClient.stream(q)) {
for (String token : stream) {
emitter.send(SseEmitter.event().data(token));
}
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
});
我们用的 JDK 21 虚拟线程,executor 是 Executors.newVirtualThreadPerTaskExecutor(),单机能稳稳撑住几千路并发流。
前端配合:EventSource 不是万能
前端用原生 EventSource 最省事,但它只支持 GET、不能自定义请求头。我们的鉴权 token 走 Authorization 头,GET 拼参数字段丑且不耐。折中是把 token 放查询参数(短时效 + HTTPS),或者用 fetch + ReadableStream 手动解析 SSE。后者稍重,但能带自定义头:
const res = await fetch('/chat/stream', {headers: {Authorization: 'Bearer ' + token}});
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const {value, done} = await reader.read();
if (done) break;
// 按 "\n\n" 切分 data: 块,逐片渲染
}
断线重连与背压
SSE 自带重连:客户端断连后会按 Last-Event-ID 自动重连。我们给每个事件带上 ID,服务端缓存最近 100 个 ID 对应的片段,重连时从断点续传,避免答案从头再来。背压方面,模型吐 token 比前端渲染快,内存里会堆积。我们加了令牌桶,服务端每 30ms 最多发出 20 个 token,超过就缓一缓,前端帧率稳定在 30fps 左右,体验顺滑且不爆内存。
踩坑记录
- 超时:默认
SseEmitter超时抛异常,长思考模型容易触发。我们把超时设为 120 秒,并在超时前主动发一个keep-alive注释行:\n\n; - 代理缓冲:Nginx 默认会缓冲响应,导致前端还是等整段才收到。必须加
proxy_buffering off; proxy_cache off;; - 编码:中文 token 出现乱码,确认
Content-Type: text/event-stream; charset=UTF-8且emit数据用 UTF-8。
小结
SSE 把"等 18 秒"变成了"边想边出",首字延迟从 18 秒降到 400 毫秒左右,产品体验立竿见影。工程上真正的坑不在发送,而在超时、代理缓冲、背压这三处。用虚拟线程承载流,并发和内存都轻松。如果你的场景也是"服务端单向推",SSE 比 WebSocket 省心太多。