一、先理解 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 流式场景属于大材小用。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐