一文讲清楚: SSE、WebSocket 与 HTTP的关系
前言
最近在研究 AI 编程工具的时候,我发现一个很有意思的现象:客户端向 AI 服务端发送请求时,很多场景下并不是等服务端一次性返回完整结果,而是通过 SSE 的方式持续接收模型生成的内容。
也就是说,AI 不是"想完之后一次性把答案发回来",而是一边生成,一边把内容流式推送给前端。于是我顺着这个方向研究了一下 SSE 的请求和响应机制,并整理了这篇文章,主要讲清楚:什么是 SSE?它的底层原理是什么?为什么 AI 对话、代码生成这类场景特别适合用 SSE?
一、协议层次关系
bash
┌─────────────────────────────────────────────────────┐
│ 应用层 │
│ │
│ HTTP/1.1 SSE WebSocket │
│ (请求-响应) (HTTP 长响应) (独立帧协议) │
│ │
│ 普通接口 AI 流式输出 聊天室 / 游戏 │
│ GET /api/user GET /api/stream ws://xxx/chat │
├─────────────────────────────────────────────────────┤
│ 传输层 │
│ TCP 连接 │
│ (三次握手建立,四次挥手关闭) │
├─────────────────────────────────────────────────────┤
│ 网络层 │
│ IP 协议 │
│ (寻址、路由、分包) │
└─────────────────────────────────────────────────────┘
核心结论:
- SSE 不是新协议,它就是 HTTP 响应的一种特殊用法(不关闭响应,持续写数据)
- WebSocket 是新协议 ,通过 HTTP 握手后升级为独立的帧协议(
ws://) - 三者底层都是 TCP 连接,没有任何魔法
二、普通 HTTP ------ 一问一答
工作模式
bash
客户端 服务端
│ │
│─── GET /api/user/info ───>│ ← 发起请求
│ │ ← 服务端处理
│<── 200 { "name":"张三" } ──│ ← 返回响应
│ │
│ TCP 连接关闭 │ ← 结束
特点
- 一次请求,一次响应,响应写完就关闭
- 响应头
Content-Length: 1234告诉客户端"一共这么多字节" - 客户端收完数据才能处理,不能"收一半就用"
代码示例(Spring Boot)
bash
@GetMapping("/api/user/info")
public Map<String, Object> getUserInfo() {
return Map.of("name", "张三", "age", 25);
}
bash
// 前端
const res = await fetch('/api/user/info')
const user = await res.json() // 必须等响应完整返回
三、SSE ------ 服务端单向推送
工作模式
bash
客户端 服务端
│ │
│── GET /api/stream ─────────────>│ ← 普通 HTTP GET
│ │
│<── 200 OK │
│ Content-Type: text/event-stream
│ Transfer-Encoding: chunked │ ← 响应头,不关闭连接
│ │
│<── data:{"d":"你"} │ ← 第 1 块数据(时刻 1s)
│<── data:{"d":"好"} │ ← 第 2 块数据(时刻 1.1s)
│<── data:{"d":"世"} │ ← 第 3 块数据(时刻 1.2s)
│<── data:{"d":"界"} │ ← 第 4 块数据(时刻 1.3s)
│ ... │ ← 持续推送
│<── event:done │ ← 结束事件
│ data: │
│ │
│ TCP 连接关闭 │ ← 服务端主动关闭
本质
一个不关闭的 HTTP 响应。 服务端返回响应头后,不调用 response.close(),有数据就往响应体里 write,客户端的 TCP socket 一直在 read,读到数据就触发回调。
所谓"推送",就是服务端往一个没有关闭的 HTTP 响应里继续写数据。
两个关键响应头
| 响应头 | 作用 |
|---|---|
Content-Type: text/event-stream |
告诉浏览器这是事件流,收到一条处理一条,不要等全部写完 |
Transfer-Encoding: chunked |
分块传输,每次发一小块,不需要提前知道总长度 |
数据格式
SSE 规定了一个极简的纯文本格式:
bash
event: 事件名\n ← 可选,默认是 "message"
data: 数据内容\n ← 必须
\n ← 空行表示一条消息结束
多行数据:
bash
data: 第一行\n
data: 第二行\n
\n
带事件名:
bash
event: build_complete\n
data: {"status": "success"}\n
\n
代码示例(Spring Boot + WebFlux)
bash
@GetMapping(value = "/api/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> stream() {
return Flux.create(sink -> {
// 模拟 AI 逐字输出
tokenStream
.onPartialResponse(token -> {
sink.next(token); // → 变成 data:xxx\n\n 写入 TCP
})
.onComplete(() -> {
sink.complete(); // → 写入 event:done,关闭连接
});
});
}
bash
// 前端
const es = new EventSource('/api/stream')
es.onmessage = (event) => {
const data = JSON.parse(event.data)
// 每收到一条就处理,不需要等全部完成
appendToPage(data.d)
}
es.addEventListener('done', () => {
es.close() // 手动关闭
})
浏览器内置能力
EventSource 是浏览器原生 API,自带:
- 自动重连:连接断开后自动重新连接(默认 3 秒)
- 事件解析 :自动按
data:\n\n格式解析,触发回调 - Last-Event-ID:重连时自动带上上次收到的事件 ID,服务端可据此续传
四、WebSocket ------ 全双工双向通信
工作模式
bash
客户端 服务端
│ │
│── GET /chat (Upgrade: websocket)│ ← HTTP 握手请求
│<── 101 Switching Protocols ─────│ ← 协议升级成功
│ │
│ ═══════ WebSocket 连接建立 ═══════│ ← 不再是 HTTP 了
│ │
│──── {"msg": "你好"} ──────────>│ ← 客户端发
│<─── {"msg": "你好!有什么可以帮你?"}│ ← 服务端发
│──── {"msg": "帮我写代码"} ─────>│ ← 客户端发
│<─── {"msg": "好的,请稍等"} ────│ ← 服务端发
│<─── {"msg": "代码写好了"} ──────│ ← 服务端主动发
│ │
│ ═══════ 任意一方关闭 ═════════════│
本质
WebSocket 是真正的新协议。握手阶段借用 HTTP,握手完成后切换为独立的二进制帧协议。
握手过程(唯一和 HTTP 有关的部分)
客户端请求:
bash
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket ← 请求升级协议
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZQ== ← 随机 key
Sec-WebSocket-Version: 13
服务端响应:
bash
HTTP/1.1 101 Switching Protocols ← 101 表示协议切换
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= ← 验证 key
握手之后,这条 TCP 连接上传输的就不再是 HTTP 文本了,而是 WebSocket 的二进制帧格式。
帧格式(握手后的数据传输)
bash
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+-------------------------------+
| Masking-key (if MASK=1) | |
+-------------------------------+-------------------------------+
| Payload Data |
+---------------------------------------------------------------+
这已经和 HTTP 完全无关了,是独立的二进制协议。
代码示例(Spring Boot)
bash
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(chatHandler(), "/ws/chat");
}
@Bean
public WebSocketHandler chatHandler() {
return new TextWebSocketHandler() {
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
// 收到客户端消息
String payload = message.getPayload();
// 服务端可以随时主动发
session.sendMessage(new TextMessage("收到:" + payload));
}
};
}
}
bash
// 前端
const ws = new WebSocket('ws://localhost:8123/ws/chat')
ws.onopen = () => {
ws.send('你好') // 客户端 → 服务端
}
ws.onmessage = (event) => {
console.log(event.data) // 服务端 → 客户端
}
ws.send('再发一条') // 随时可以发
五、三者核心对比
bash
普通 HTTP SSE WebSocket
协议 HTTP/1.1 HTTP/1.1 ws://(新协议)
(不变) (HTTP 升级握手后切换)
通信方向 客户端 → 服务端 服务端 → 客户端 双向
(请求-响应) (单向推送) (任意一方随时发)
连接生命周期 短(一问一答就关闭) 中长(服务端控制关闭) 长(任意一方关闭)
数据格式 任意 纯文本 文本或二进制
(JSON/XML/HTML) (data: xxx\n\n) (帧格式)
浏览器 API fetch / XMLHttpRequest EventSource WebSocket
断线重连 无(每次新请求) 自动重连 需自己实现
服务端实现 @GetMapping Flux<String> WebSocketHandler
返回对象 + text/event-stream + @EnableWebSocket
适用场景 查数据、提交表单 AI 流式输出 聊天室、游戏
实时通知 协同编辑
构建状态推送 双向交互
六、从 TCP 连接的视角看三者
普通 HTTP
bash
TCP 连接建立 → 发请求 → 收响应 → TCP 连接关闭
(0.1s) (0.3s)
一条 TCP 连接只活了 0.3 秒。
SSE
bash
TCP 连接建立 → 发请求 → 收响应头 → 收数据块 → 收数据块 → ... → TCP 连接关闭
(0.1s) (1s) (1.1s) (45s)
一条 TCP 连接活了 45 秒,期间服务端不断往里写数据。
WebSocket
bash
TCP 连接建立 → HTTP 握手 → 协议升级 → 双向帧传输 → ... → TCP 连接关闭
(0.1s) (0.2s) (0.2s) (持续数小时) (用户关闭页面)
一条 TCP 连接可能活几个小时,双方随时互发数据。
三者用的都是同一条 TCP 连接,区别只在于上面跑的是什么格式的数据、谁能发、什么时候关。
七、为什么 AI 流式输出选 SSE 而不是 WebSocket
| 考量 | SSE | WebSocket |
|---|---|---|
| 方向需求 | AI 输出只需要服务端 → 客户端(单向够用) | 双向能力浪费了 |
| 实现成本 | 极低,Flux + 一个注解 | 需要握手、帧处理、心跳 |
| 断线重连 | 浏览器自动 | 需自己写 |
| HTTP 兼容 | 完全兼容,能过所有代理和 CDN | 部分企业防火墙会拦截 ws:// |
| 用户输入 | 用户发消息走普通 POST 即可 | --- |
AI 流式输出的场景:用户发一条消息(POST),AI 逐字返回(SSE)。 这是一个"一问、多答"的模式,SSE 完美匹配,WebSocket 大材小用。
八、轮询 vs SSE 推送 ------ 两种"等结果"的方式
当你需要等待一个异步操作的结果时(比如等构建完成),有两种方式:
轮询(Polling)
bash
前端 后端
│── GET /build/status ────>│
│<──── "building" ─────────│
│ (等 2 秒) │
│── GET /build/status ────>│
│<──── "building" ─────────│
│ (等 2 秒) │
│── GET /build/status ────>│
│<──── "success" ──────────│ ← 终于完成了
│ 停止轮询,刷新预览 │
本质:客户端定时发 HTTP 请求问"好了没?"
优点:实现极简(一个 setInterval + 一个 GET 接口)
缺点:大部分请求是无效的(返回 "building")
SSE 推送
bash
前端 后端
│── GET /build/events ────────>│
│<── 200 text/event-stream ────│ ← 连接保持
│ (安静等待) │
│ │ ← 构建完成
│<── event:build_complete ─────│ ← 服务端主动推
│ 关闭连接,刷新预览 │
本质:客户端建立长连接,服务端有结果时主动写入
优点:零延迟,零无效请求
缺点:需要管理长连接的生命周期(超时、断开、清理)
选择依据
bash
事件频率低(构建完成通知)→ 轮询就够了,简单可靠
事件频率高(AI 逐字输出) → 必须 SSE,轮询做不了
需要双向通信(聊天室) → WebSocket
九、一句话总结
HTTP 是"你问我答",SSE 是"你问一次我一直答",WebSocket 是"咱俩随便聊"。三者底层都是 TCP 连接,区别只在于谁能说话、说多久、什么时候挂。