深入拆解:为什么 JAVA中AI 流式对话需要 SSE(一)
·
一、先理解 HTTP 的本质限制
传统 HTTP 请求-响应模型:
客户端 服务端
│ │
│──── GET /chat ───────────────▶│
│ │ ← 服务端开始生成响应
│ │ ← 生成 token1...
│ │ ← 生成 token2...
│ │ ← 生成 token3...
│ │ ← 全部生成完毕
│◀─── 完整响应体 ──────────────│ ← 一次性返回
│ │
问题在哪?
ChatGPT 生成一段 500 字的回答,可能需要 10-30 秒。如果用传统方式:
- 用户发完请求后,干等 30 秒,什么反馈都没有
- 浏览器转圈圈,用户体验极差
- 用户不确定是卡死了还是在生成
用户期望的是:第一个字出来就立刻看到,然后像打字机一样逐字显示。
二、底层机制:HTTP Chunked Transfer Encoding
SSE 底层依赖的是 HTTP/1.1 的 分块传输编码 (Chunked Transfer Encoding):
HTTP/1.1 200 OK
Content-Type: text/event-stream
Transfer-Encoding: chunked
7\r\n ← 这一块 7 字节
data: 你好\r\n
\r\n
8\r\n ← 这一块 8 字节
data: ,今天\r\n
\r\n
6\r\n ← 这一块 6 字节
data: 怎么样\r\n
\r\n
0\r\n ← 0 字节 = 传输结束
\r\n
关键点: HTTP 连接没有关闭,响应头发完之后,TCP 连接保持打开,服务端可以随时往里写数据,客户端实时收到。
这在纯 HTTP 层面是完全合法的。SSE 不是新技术,只是对这个机制的标准化封装。
三、SSE 做了什么标准化?
直接用 Chunked Transfer 你要自己解决这些问题:
| 问题 | 纯 Chunked | SSE 帮你做了 |
|---|---|---|
| 消息边界 | 你得自己定义分隔符 | data: ...\n\n 就是一条完整消息 |
| 消息类型 | 你得自己定义 | event: message/error/done 原生支持 |
| 重连机制 | 你得自己实现 | 浏览器 EventSource 自动重连 |
| 断点续传 | 你得自己实现 | Last-Event-ID 头自动带上 |
| 编码处理 | 你得自己处理 | 默认 UTF-8,标准定义 |
四、Java 服务端实际怎么做的?
以 Spring Boot 为例,对比三种方式:
方式 1:传统同步(❌ 用户干等)
@PostMapping("/chat")
public String chat(@RequestBody ChatRequest req) {
// 阻塞等待 AI 全部生成完毕
String fullResponse = aiService.generate(req.getMessage());
return fullResponse; // 一次性返回
}
方式 2:SSE(✅ 推荐)
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chatStream(@RequestParam String message) {
SseEmitter emitter = new SseEmitter(180_000L); // 3分钟超时
// 异步推送,每生成一个 token 就推一次
CompletableFuture.runAsync(() -> {
try {
aiService.generateStream(message, token -> {
emitter.send(SseEmitter.event()
.data(token) // 每个 token 作为一条 SSE 事件
.id(UUID.randomUUID().toString()));
});
emitter.send(SseEmitter.event().name("done").data("[DONE]"));
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter; // 立即返回,HTTP 连接保持打开
}
客户端(浏览器)收到的数据流:
event: message
data: {"content":"你"}
event: message
data: {"content":"好"}
event: message
data: {"content":","}
event: message
data: {"content":"今天"}
event: done
data: [DONE]
方式 3:WebSocket(✅ 能用,但重)
@ServerEndpoint("/chat/ws")
public class ChatWebSocket {
@OnMessage
public void onMessage(String message, Session session) {
// 需要自己管理连接、心跳、断线重连
aiService.generateStream(message, token -> {
session.getAsyncRemote().sendText(token);
});
}
}
WebSocket 的代价:
WebSocket 握手:
客户端 服务端
│ │
│──── GET /chat/ws ────────────▶│
│ Upgrade: websocket │
│ Connection: Upgrade │
│ Sec-WebSocket-Key: ... │
│ │
│◀─── 101 Switching Protocols ──│
│ Upgrade: websocket │
│ Sec-WebSocket-Accept: ... │
│ │
│════ 双向通信通道建立 ══════════│
之后每一帧还要带 WebSocket 帧头(2-14 字节开销),还需要 Ping/Pong 心跳维持连接。
而 AI 对话场景的数据流是这样的:
客户端 ──"你好"──▶ 服务端
◀──"你"────┤
◀──"好"────┤
◀──","────┤
◀──"今"────┤
◀──"天"────┤ 服务端→客户端:大量 token 推送
◀──"怎"────┤
◀──"么"────┤
◀──"样"────┤
──"谢谢"──▶ │ 客户端→服务端:偶尔发一条新消息
单向推送 >> 双向通信,WebSocket 的全双工能力完全浪费了。
五、为什么不用其他方案?
轮询(Polling)
客户端每 500ms 发一次请求:
"有新 token 吗?" → "没有" (空响应,浪费)
"有新 token 吗?" → "没有" (空响应,浪费)
"有新 token 吗?" → "有,'你'" (拿到了一个字)
"有新 token 吗?" → "没有" (空响应,浪费)
...
- 延迟:最坏 500ms
- 请求数:10 秒的回复 = 20 次请求,19 次空响应
- 服务器压力:每个客户端持续占连接
长轮询(Long Polling)
客户端 ──"有新 token 吗?"──▶ 服务端
│ 等待...等待...等待...
◀──"你"───────────── │ 有新数据了,返回
客户端 ──"有新 token 吗?"──▶ 服务端
│ 等待...
◀──"好"───────────── │
每次响应后连接断开,客户端立刻发起新请求。相比 SSE:
- 多了反复握手开销
- 实现复杂(超时、重试、消息队列)
- 不如 SSE 实时
六、总结对比
实时性 复杂度 资源开销 协议标准 浏览器支持
SSE ⭐⭐⭐ ⭐ ⭐ ⭐⭐⭐ ⭐⭐⭐
WebSocket ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐
HTTP Chunked ⭐⭐⭐ ⭐⭐⭐ ⭐ ⭐⭐ ⭐⭐
长轮询 ⭐⭐ ⭐⭐ ⭐⭐ ⭐ ⭐⭐⭐
轮询 ⭐ ⭐ ⭐⭐⭐ ⭐ ⭐⭐⭐
SSE 是 HTTP 单向流场景的最优解:用最简单的协议、最低的开销,实现了标准化的实时推送。 WebSocket 是为聊天室、游戏等双向通信设计的,用在 AI 流式场景属于大材小用。
更多推荐





所有评论(0)