导语
调用过大模型 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 响应"。要点就三条:
- 响应头
Content-Type: text/event-stream,告诉浏览器这是事件流; - 每条消息格式固定:
data: 内容\n\n,用空行分隔各条消息; - 连接保持,服务端可以持续写,直到主动关闭。
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()虚拟线程池,阻塞读流不占平台线程,天然抗高并发。
五、踩坑清单(每一条都是线上血泪)
- 超时必断 。
SseEmitter默认 30 秒超时,长回答必须new SseEmitter(0L)或调大,否则前端莫名其妙断流。 - 别用 EventSource 发 POST 。
EventSource只支持 GET,想 POST 就用fetch+ReadableStream,代码见 4.2。 - 一定要有 keep-alive 。中间代理(Nginx 等)可能掐掉空闲连接,长时间无数据时要定时发心跳(比如
data: :keep-alive\n\n)。 - Nginx 要关缓冲 。反向代理默认会缓冲响应,导致"看起来像一次性返回",需配置
X-Accel-Buffering: no或关闭 proxy buffering。 - 客户端断线要感知 。
emitter.send()会抛异常,捕获后及时complete(),否则线程池泄漏。 - 异常要 completeWithError。别让 emitter 悬空,前端要靠 error 事件做兜底重试。
总结
SSE 是个被低估的技术:它简单、基于 HTTP、天然支持断线重连,却能让你的 AI 应用从"转圈等待"变成"丝滑打字机"。理解"一个不结束的 HTTP 响应 + data: 帧"这八个字,你就掌握了流式输出的核心。
本文代码是一个可直接扩展的骨架:把 BASE_URL/MODEL 换成你的服务商,加上鉴权、历史上下文、限流,就是一套能上生产的流式对话后端。建议你动手跑一遍,亲眼看看 token 一个个蹦出来的效果,比读十篇理论更有感觉。
如果你在落地时遇到断流、乱码或代理缓冲的问题,欢迎在评论区一起讨论。