Administrator
发布于 2024-03-22 / 2018 阅读
28

大模型流式响应 SSE 的工程化实现

接了大模型问答的活儿,第一版我们用普通 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 虚拟线程,executorExecutors.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-8emit 数据用 UTF-8。

小结

SSE 把"等 18 秒"变成了"边想边出",首字延迟从 18 秒降到 400 毫秒左右,产品体验立竿见影。工程上真正的坑不在发送,而在超时、代理缓冲、背压这三处。用虚拟线程承载流,并发和内存都轻松。如果你的场景也是"服务端单向推",SSE 比 WebSocket 省心太多。

参考