告别“转圈等待”:Spring Boot + SSE 让大模型像 ChatGPT 一样流式输出

导语

调用过大模型 API 的开发者,多半体会过这种痛苦:用户抛出一个问题,你的后端要把完整答案全部生成完才一次性返回,界面卡在那儿转圈十几秒甚至半分钟,体验差到用户以为程序卡死了。

而 ChatGPT、豆包、Kimi 这些产品,回答是一个字一个字"蹦"出来的------这就是流式输出(Streaming) ,它背后最常用、最简单的技术叫 SSE(Server-Sent Events,服务器推送事件)。

本文从原理到落地,用 Spring Boot 手把手实现一个流式输出接口,代码可直接复制运行(OpenAI 兼容接口,通吃 DeepSeek、Qwen、GLM 等)。读完你会掌握三件事:为什么用 SSE、SSE 和 WebSocket/轮询怎么选、以及一个能直接扩展的生产级骨架。

一、先想清楚:为什么要流式输出

大模型生成一段长回答,通常要几秒到几十秒。返回方式有两种:

  • 一次性返回:后端等模型吐完所有 token,再拼成完整文本返回。用户看到的是一段漫长的空白等待。
  • 流式返回:模型每生成一个 token,就立刻推给前端,前端边收边渲染。用户在第 100 毫秒就能看到第一个字。

对用户体验来说,这是天壤之别。业界有个共识:用户对"有东西在动"的容忍度,远高于"一动不动"。首字延迟(TTFT,Time To First Token)越低,用户越觉得"快"。

对后端来说,流式还能显著降低内存占用:不用把完整回答缓存在内存里,来一段推一段,长文本场景尤其明显。

二、三种实时推送方案,为什么选 SSE

方案 方向 协议 复杂度 适用场景
轮询 Polling 客户端→服务端 HTTP 低 简单但延迟高、浪费请求
WebSocket 双向 WS 高 聊天室、需要双向的实时通信
SSE 服务端→客户端 HTTP 低 单向推送,如 AI 流式回答、实时通知

大模型的流式输出是典型的"单向推送":客户端发一次请求,服务端持续吐结果。用 WebSocket 属于杀鸡用牛刀------需要额外握手、心跳、连接管理;而 SSE 基于普通 HTTP,天然支持断线重连,实现成本极低。

所以结论很清晰:AI 流式回答,优先 SSE。

三、SSE 原理,30 秒看懂

SSE 本质上就是一个"不会结束的 HTTP 响应"。要点就三条:

  1. 响应头 Content-Type: text/event-stream,告诉浏览器这是事件流;
  2. 每条消息格式固定:data: 内容\n\n,用空行分隔各条消息;
  3. 连接保持,服务端可以持续写,直到主动关闭。
text 复制代码
Content-Type: text/event-stream

data: 你

data: 好

data: !

浏览器端的 EventSource 对象会自动解析这些帧,触发 onmessage 回调。就这么简单。

四、可运行实战:Spring Boot 实现流式输出

下面实现一个最小可用闭环:前端发一个问题,后端调用 OpenAI 兼容接口流式生成,再把每个 token 实时推给前端。

4.1 后端:SseEmitter + 流式调用

Spring Boot 里实现 SSE 的核心类是 SseEmitter。创建它返回给客户端后,你就可以在任意时刻往里面 send() 数据。

java 复制代码
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

@RestController
@RequestMapping("/api/chat")
public class StreamChatController {

    private static final String API_KEY = "sk-xxxxxxxx";
    private static final String BASE_URL = "https://api.deepseek.com";
    private static final String MODEL = "deepseek-chat";

    private final ObjectMapper mapper = new ObjectMapper();
    // 虚拟线程池:每个请求占一个虚拟线程,阻塞读流也不心疼
    private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

    @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter stream(@RequestBody ChatRequest req) {
        SseEmitter emitter = new SseEmitter(0L); // 0 = 永不超时

        executor.submit(() -> {
            try {
                streamFromLLM(req.question(), emitter);
                emitter.complete();
            } catch (Exception e) {
                emitter.completeWithError(e);
            }
        });

        return emitter;
    }

    private void streamFromLLM(String question, SseEmitter emitter) throws Exception {
        Map<String, Object> payload = Map.of(
                "model", MODEL,
                "stream", true,
                "messages", List.of(Map.of("role", "user", "content", question))
        );
        String body = mapper.writeValueAsString(payload);

        HttpClient client = HttpClient.newHttpClient();
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(BASE_URL + "/chat/completions"))
                .header("Authorization", "Bearer " + API_KEY)
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(body))
                .build();

        HttpResponse<java.io.InputStream> resp =
                client.send(request, HttpResponse.BodyHandlers.ofInputStream());

        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(resp.body(), StandardCharsets.UTF_8))) {
            String line;
            while ((line = reader.readLine()) != null) {
                if (!line.startsWith("data:")) continue;
                String data = line.substring(5).trim();
                if ("[DONE]".equals(data)) break;

                JsonNode node = mapper.readTree(data);
                JsonNode delta = node.path("choices").get(0).path("delta").path("content");
                if (delta.isMissingNode() || delta.asText().isEmpty()) continue;

                // 把每个 token 作为一条 SSE 事件推给前端
                emitter.send(SseEmitter.event()
                        .name("message")
                        .data(delta.asText()));
            }
        }
    }

    record ChatRequest(String question) {}
}

4.2 前端:接收并渲染流

注意:EventSource 只支持 GET,而这里要 POST 传问题,所以前端用 fetch + ReadableStream 来读取流。

html 复制代码
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>流式聊天</title></head>
<body>
  <input id="q" placeholder="输入问题" value="用一句话解释虚拟线程">
  <button onclick="ask()">发送</button>
  <div id="out"></div>

  <script>
    async function ask() {
      const q = document.getElementById('q').value;
      document.getElementById('out').innerHTML = '';
      const resp = await fetch('/api/chat/stream', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({question: q})
      });
      const reader = resp.body.getReader();
      const decoder = new TextDecoder();
      let buffer = '';
      while (true) {
        const {done, value} = await reader.read();
        if (done) break;
        buffer += decoder.decode(value, {stream: true});
        // 按空行切帧,解析 "data:" 内容
        const frames = buffer.split('\n\n');
        buffer = frames.pop();
        for (const frame of frames) {
          const data = frame.split('\n')
            .filter(l => l.startsWith('data:'))
            .map(l => l.slice(5).trim())
            .join('');
          if (data) document.getElementById('out').textContent += data;
        }
      }
    }
  </script>
</body>
</html>

4.3 运行效果

启动后访问页面,点击发送,你会看到回答一个字一个字蹦出来,和 ChatGPT 一样丝滑。

代码要点:

  • SseEmitter(0L) 表示永不超时(默认 30 秒,长回答会断);
  • 流式调用放在独立线程里执行,controller 立即返回 emitter,不阻塞 Tomcat 线程;
  • 用 newVirtualThreadPerTaskExecutor() 虚拟线程池,阻塞读流不占平台线程,天然抗高并发。

五、踩坑清单(每一条都是线上血泪)

  1. 超时必断 。SseEmitter 默认 30 秒超时,长回答必须 new SseEmitter(0L) 或调大,否则前端莫名其妙断流。
  2. 别用 EventSource 发 POST 。EventSource 只支持 GET,想 POST 就用 fetch + ReadableStream,代码见 4.2。
  3. 一定要有 keep-alive 。中间代理(Nginx 等)可能掐掉空闲连接,长时间无数据时要定时发心跳(比如 data: :keep-alive\n\n)。
  4. Nginx 要关缓冲 。反向代理默认会缓冲响应,导致"看起来像一次性返回",需配置 X-Accel-Buffering: no 或关闭 proxy buffering。
  5. 客户端断线要感知 。emitter.send() 会抛异常,捕获后及时 complete(),否则线程池泄漏。
  6. 异常要 completeWithError。别让 emitter 悬空,前端要靠 error 事件做兜底重试。

总结

SSE 是个被低估的技术:它简单、基于 HTTP、天然支持断线重连,却能让你的 AI 应用从"转圈等待"变成"丝滑打字机"。理解"一个不结束的 HTTP 响应 + data: 帧"这八个字,你就掌握了流式输出的核心。

本文代码是一个可直接扩展的骨架:把 BASE_URL/MODEL 换成你的服务商,加上鉴权、历史上下文、限流,就是一套能上生产的流式对话后端。建议你动手跑一遍,亲眼看看 token 一个个蹦出来的效果,比读十篇理论更有感觉。

如果你在落地时遇到断流、乱码或代理缓冲的问题,欢迎在评论区一起讨论。

相关推荐
天天被压力22 分钟前
【A股分钟级高频数据完全手册 #02】Python调用股票1分钟数据接口:Pro版最新分时取数与字段解析
java·人工智能·python
代码不停23 分钟前
Spring Aop
java·数据库·spring
楚山之石25 分钟前
【MRM】CAPLEX 对比剂灌注 MRI 开源术语库:ISMRM OSIPI 共识报告
图像处理·人工智能·学习·mri
Godspeed Zhao32 分钟前
从零开始学AI22——集成学习
人工智能·机器学习·集成学习
道尔柯南38 分钟前
C++ STL 入门详解:从认识标准模板库到掌握学习方法
java·c++·学习方法
杨凯凡41 分钟前
Spring Boot 4 升级为什么卡在 Jackson 3?包名、配置与兼容迁移指南
java·spring boot 4·技术升级·jackson 3·spring framework
天天被压力41 分钟前
【A股分钟级高频数据完全手册 #01】Pro版接口体系总览与REST鉴权:42篇地图与从0接入
java·人工智能·python
IT_陈寒44 分钟前
React实战技巧总结
前端·人工智能·后端
一缕82年的清风1 小时前
被中产唾弃的“十元快剪”,藏着最狠的极客工程学
人工智能·设计模式