专栏:AI 全栈开发|12
|----------------------------------------------------------------------------------------------------------------------------------------------|
| 上一篇我们已经把 HTTP Streaming 跑通了:Response Body 可以持续到达。但裸 Streaming 只解决"能持续传",并没有告诉前端"一条业务消息从哪里开始、在哪里结束"。SSE(Server-Sent Events)就是在这条流上再加一层标准事件格式。 |
一、先看裸 Streaming 为什么还不够
假设后端连续发送:
|------------------------|
| 你好 正在搜索 找到了 3 条资料 回答完成 |
如果只是把字节不停地往浏览器推,前端还要自己约定:
• 哪一段是正文。
• 哪一段是进度。
• 哪一段代表错误。
• 怎样判断一个事件结束。
网络 chunk 本身又不能当业务边界,因为上一章已经讲过:一次 `read()` 可能拆开或合并多个应用数据块。
图 1 SSE 的价值:在持续传输的 HTTP Body 上,增加稳定的事件格式
二、SSE 是什么:一种 text/event-stream 事件格式
SSE 的服务端响应使用:
|---------------------------------|
| Content-Type: text/event-stream |
正文不是随便写,而是按照事件流格式组织。最简单的一条事件:
|----------|
| data: 你好 |
注意最后有一个**空行**,它表示这条事件结束。
如果想给事件命名:
|----------------------------------|
| event: delta data: {"text":"你好"} |
浏览器就可以把它当成 `delta` 类型事件处理。
三、event、data、id、retry 分别做什么
图 2 SSE 常见字段各有职责,而且不是每条事件都必须把四个字段写全
|-----------|-----------------------------|-----------------|
| 字段 | 作用 | 是否每条必须 |
| data | 事件数据;可以出现多行 | 最常用,但协议并不要求每条都有 |
| event | 自定义事件类型 | 可选 |
| id | 设置客户端保存的 Last Event ID | 可选 |
| retry | 建议后续自动重连等待时间(毫秒) | 可选 |
| : comment | 注释;客户端不会作为 message 事件交给业务代码 | 可选,常用于心跳 |
如果一个事件里有多行 `data:`,浏览器会把这些行按规则拼接成事件数据。
所以手写 SSE parser 时,不能只找第一行 `data:` 就结束。
四、EventSource 为什么比手读 ReadableStream 省事
浏览器内置了 `EventSource`:
const es = new EventSource("/events");
es.onmessage = (event) => {
console.log(event.data);
};
es.addEventListener("progress", (event) => {
console.log("进度:", event.data);
});
es.onerror = () => {
console.log("连接异常或正在重连");
};
它会帮你做三件很实用的事:
• 解析 SSE 事件格式。
• 按 `event:` 类型分发。
• 连接意外断开时按规则自动尝试重连。
但不要写成"onerror 就说明彻底失败"。EventSource 在准备重连时也会触发 error 事件,具体状态还要结合 `readyState` 和业务需求判断。
五、EventSource 的一个重要限制:请求方式和 Header 不够自由
原生 `EventSource(url)` 是一个面向 SSE 的浏览器 API,它建立的是 GET 请求。
你不能像 `fetch()` 那样随意传一个自定义 `Authorization` Header。
这对 AI Chat 很重要,因为聊天常见的是:
|--------------------------------------|
| POST /chat body = {"message": "..."} |
这时有几种常见设计:
• 直接用 `fetch()` 发 POST,同时读取 `text/event-stream` 响应。
• 先 POST 创建任务,再用 EventSource GET `/tasks/{id}/events`。
• 同源 Web 应用把认证放在安全 Cookie 中,让 EventSource 自动携带合适的 Cookie。
|-----------------------------------------------------------------------------------------------------------------------|
| 所以"SSE = 必须 EventSource"也是错误理解。SSE 是事件流格式;EventSource 是浏览器消费 SSE 的一个方便 API。需要 POST 或自定义 Header 时,可以使用 fetch 流式读取 SSE。 |
六、用 FastAPI 写一个最小 SSE 端点
import asyncio
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
async def events():
for i in range(3):
yield (
f"event: progress
"
f"data: {i + 1}
"
)
await asyncio.sleep(1)
yield "event: done
data: ok
"
@app.get("/events")
async def stream_events():
return StreamingResponse(
events(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache"},
)
前端:
const es = new EventSource("/events");
es.addEventListener("progress", (e) => {
console.log("progress:", e.data);
});
es.addEventListener("done", (e) => {
console.log("done:", e.data);
es.close();
});
这个例子只做一件事:让你看见 `event + data + 空行` 怎样被浏览器自动变成事件。
七、自动重连到底自动到了什么程度
图 3 EventSource 会尝试自动重连,Last-Event-ID 能帮助服务端定位位置,但它本身不提供 exactly-once 保证
如果连接意外中断,EventSource 通常会重新连接。
如果之前的事件带:
|------------------|
| id: 41 data: ... |
浏览器会记住这个 Last Event ID,并在后续重连时让服务器知道上一次处理到哪个 ID。
但这并不会自动做到"一条不丢、一条不重"。
要真正续传,服务端还需要:
• 保存一定范围的事件历史。
• 收到 Last-Event-ID 后找到后续事件。
• 处理历史过期、服务重启等情况。
• 对于有副作用的业务消费,自己考虑幂等和去重。
SSE 提供的是**重连机制和位置线索**,不是 exactly-once 消息队列。
八、心跳为什么经常是一行冒号
SSE 连接可能很久没有业务事件。某些代理、网关或负载均衡器会把长时间空闲的连接断掉。
服务端可以周期性发送注释:
|--------------|
| : keep-alive |
冒号开头的行是 SSE comment,浏览器不会把它当普通 `message` 事件交给你的业务回调,但网络上确实有数据流过。
心跳间隔没有一个适合所有部署环境的固定数字,要结合代理 idle timeout、成本和业务实时性决定。
九、SSE 不是因为"只能 GET"就自动更安全
原稿把"只能 GET,所以攻击面少一半"写得太简单。
SSE 端点同样需要考虑:
• 谁有权订阅这条数据流。
• Cookie 认证时的跨站请求和 CORS 配置。
• 敏感数据是否会被错误订阅者看到。
• query 参数里的 Token 是否进入 URL、日志和监控系统。
EventSource 不能自定义 Authorization Header,不意味着"只能把 token 放 query"。Cookie、同源部署、任务票据等都是可选方案。
安全设计留到后面 Auth 和 Security 专题,这里只纠正概念。
十、什么时候适合 SSE
SSE 特别适合:
• 服务器向浏览器持续发送状态、日志、进度、通知。
• AI 生成结果以事件形式单向返回。
• 客户端主要通过普通 HTTP 请求发命令,服务器只需要持续推送结果。
如果客户端和服务器都需要高频、长期、双向发送消息,WebSocket 可能更自然。
但具体怎么选留到 14 的 SSE vs WebSocket,对第 12 篇来说先知道这个边界就够了。
十一、一个非常容易忽略的解析问题:fetch 读 SSE 不能只按 \n\n 粗暴切
教学 Demo 里经常这样写:
|------------------------------------------------------------------------------------------|
| buffer += decoder.decode(value, { stream: true }); const parts = buffer.split("\n\n"); |
对于自己严格控制、只使用 LF 的简单服务,这可以工作。
但完整 SSE 解析还要考虑 CRLF、跨 chunk 的字段、多行 `data:`、BOM 等细节。
如果你使用 EventSource,浏览器已经帮你处理了这些规则;如果用 fetch 手写 parser,生产项目更适合使用经过测试的 SSE parser,而不是自己写十几行字符串切分就宣称"完整协议实现"。
十二、几个最容易学错的说法
• 误区 1:SSE 是另一种底层网络协议。它仍然建立在 HTTP 响应之上,关键是 `text/event-stream` 事件格式。
• 误区 2:event / data / id / retry 每条都必须有。它们都是字段,按事件需要使用。
• 误区 3:EventSource 自动重连就能保证不丢不重。服务端必须配合保存和重放,业务仍要考虑幂等。
• 误区 4:SSE 必须用 EventSource。fetch 也可以消费 SSE 流,并能使用 POST / 自定义 Header。
• 误区 5:SSE 只能 GET,所以天然安全。GET 端点一样需要认证、授权、CORS 和敏感数据保护。
• 误区 6:所有 AI 聊天都应该直接用 EventSource。POST + fetch SSE、任务创建 + EventSource 都是常见方案。
十三、这一篇只记住 4 句话
• HTTP Streaming 解决"持续传",SSE 解决"持续传的数据如何组织成事件"。
• EventSource 能自动解析和重连,但不是可靠消息队列。
• `id` / Last-Event-ID 是续传线索,不是 exactly-once 保证。
• SSE 是事件格式,EventSource 只是浏览器消费它的一种 API。
十四、下一篇
下一篇 13《WebSocket 到底是什么?》会进入真正的双向长连接:为什么客户端和服务器都需要主动推消息时,SSE 开始不够用;WebSocket 的连接、消息帧、心跳和重连又分别解决什么问题。