本节目标:掌握 SSE 的
text/event-stream报文格式与 FastAPIStreamingResponse用法,真跑通流式输出与Last-Event-ID断线续传,并在 SSE 与 WebSocket 之间做出有理有据的选型。
适用版本:Python 3.12+(实测 3.14.6);fastapi 0.143.0、starlette 1.7.0、httpx 0.28.1
10.2 SSE 与流式响应
10.1 的 WebSocket 是「双向通道」。但很多场景其实只需要服务端单向推:AI 逐字吐答案、日志实时滚动、任务进度条、股票报价。这类场景开一条全双工 WebSocket 是杀鸡用牛刀——SSE(Server-Sent Events)用一条普通的、只读的 HTTP 响应流就够,而且天生带自动重连和断点续传。
10.2.1 SSE 是什么
SSE 本质就是:服务端返回一个不结束的 HTTP 响应,Content-Type 是 text/event-stream,然后一条一条往里写文本,浏览器用 EventSource 自动解析。
它和「普通流式下载」的区别在于有格式约定:每个事件是若干 字段: 值 行,用一个空行结束。浏览器收到空行才认为「这个事件完整了」,触发一次 message/onmessage 回调。
关键在于它完全建立在 HTTP 之上:没有协议升级、没有新帧格式、浏览器原生支持 EventSource(自带重连)、可以穿过绝大多数只认 HTTP 的代理和 CDN。代价是只能服务端→客户端单向,客户端要发消息得另开一个 HTTP 请求。
10.2.2 text/event-stream 的格式
SSE 的字段只有四个,全是行首关键字:
| 字段 | 含义 | 备注 |
|---|---|---|
data: | 事件数据 | 多条 data: 会用 \n 拼接;只有它才算「有内容」 |
event: | 事件类型名 | 客户端用 addEventListener("log", ...) 按名监听 |
id: | 事件 ID | 浏览器记住它,重连时放进 Last-Event-ID 请求头 |
retry: | 重连等待毫秒数 | 服务端告诉客户端「断了我该等多久再试」 |
用代码拼一个事件块(实测输出):
import json
def sse_pack(event: str, data: dict, eid: int | None = None, retry: int | None = None) -> str:
lines = []
if eid is not None:
lines.append(f"id: {eid}")
lines.append(f"event: {event}")
if retry is not None:
lines.append(f"retry: {retry}")
lines.append(f"data: {json.dumps(data, ensure_ascii=False)}")
return "\n".join(lines) + "\n\n" # 末尾空行 = 事件结束
原始字节(repr):
'id: 1\nevent: log\nretry: 3000\ndata: {"seq": 1, "text": "log-1"}\n\n'
两个极易踩的坑:分隔符必须是 \n\n(空行),不是单个 \n——少了空行浏览器会一直攒着不触发;以及 data 里的换行必须拆成多条 data:,直接塞 \n 会截断事件。JSON 序列化后没有裸换行,所以安全。
10.2.3 StreamingResponse:流式输出
FastAPI 用 StreamingResponse 接收一个异步生成器,边产出边发送,而不是等整个响应体拼完。这正是「逐字输出」的实现方式:
import asyncio
from collections.abc import AsyncIterator
from fastapi import FastAPI, Header, Request
from fastapi.responses import StreamingResponse
app = FastAPI()
EVENTS = [f"log-{i}" for i in range(1, 6)]
@app.get("/events")
async def events(request: Request, last_event_id: str | None = Header(default=None)):
start = int(last_event_id) if last_event_id else 0
async def gen() -> AsyncIterator[str]:
for i in range(start, len(EVENTS)):
if await request.is_disconnected(): # 客户端走了就停
break
yield sse_pack("log", {"seq": i + 1, "text": EVENTS[i]}, eid=i + 1, retry=3000)
await asyncio.sleep(0.05)
yield sse_pack("done", {"total": len(EVENTS)}, eid=len(EVENTS))
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
用 httpx 的 ASGITransport 直接把请求打给 ASGI 应用(不需要起真实服务器),逐行读事件流,真跑(实测):
import httpx
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
async with client.stream("GET", "/events") as resp:
print("Content-Type:", resp.headers["content-type"])
async for line in resp.aiter_lines():
print(repr(line))
=== 首次请求(无 Last-Event-ID)===
Content-Type: text/event-stream; charset=utf-8
'id: 1'
'event: log'
'retry: 3000'
'data: {"seq": 1, "text": "log-1"}'
''
'id: 2'
'event: log'
'retry: 3000'
'data: {"seq": 2, "text": "log-2"}'
''
... (略) ...
'id: 5'
'event: done'
'data: {"total": 5}'
''
注意每块之间的那个 ''(空行)——它就是事件分隔符在 aiter_lines() 里的可见形态。三个工程要点:
request.is_disconnected()必须查。用户关掉页面后,生成器若不退出会一直空转,白占一个连接。- 两个响应头:
Cache-Control: no-cache防中间层缓存流;X-Accel-Buffering: no关掉 Nginx 的响应缓冲(否则事件会被攒着一起发,流式效果消失)。 - 生成器里别做阻塞调用。
await之间若夹了同步time.sleep或重查询,整个事件循环会被卡住。
10.2.4 断线续传:Last-Event-ID
SSE 最值钱的能力在这里。浏览器 EventSource 断线后自动重连,并且会把最后收到的 id 放进 Last-Event-ID 请求头带回来。服务端只要按这个 ID 续发,用户就感觉不到断过线:
last_event_id: str | None = Header(default=None)
start = int(last_event_id) if last_event_id else 0
真跑(客户端带 Last-Event-ID: 3,实测输出):
=== 断线续传(Last-Event-ID: 3)===
'id: 4'
'event: log'
'retry: 3000'
'data: {"seq": 4, "text": "log-4"}'
''
'id: 5'
'event: log'
'retry: 3000'
'data: {"seq": 5, "text": "log-5"}'
''
'id: 5'
'event: done'
'data: {"total": 5}'
''
从 id: 4 开始——前三个事件被跳过,因为客户端已经收过了。这套机制要真正好用,服务端必须把 id 设成「可定位的序号」(如数据库自增 ID、日志 offset),而不是随便一个 UUID:拿到 ID 就能从那个位置继续读。若 ID 不可定位,续传就无从谈起。
用 Python 客户端(httpx)自己实现同样的续传逻辑——边读边记住最后的 id,断线后用 Last-Event-ID 头重连:
async def consume(client: httpx.AsyncClient, last_id: str | None) -> str | None:
headers = {"Last-Event-ID": last_id} if last_id else {}
async with client.stream("GET", "/events", headers=headers) as resp:
eid = last_id
async for line in resp.aiter_lines():
if line.startswith("id: "):
eid = line[4:] # 记住最后收到的事件 ID
elif line.startswith("data: "):
print(f" [last_id={last_id}] data -> {line[6:]}")
return eid
真跑(消费到 id=2 主动断开,再带 Last-Event-ID=2 重连,实测):
=== 第一段:消费到 id=2 后主动断开 ===
[client] 收到 id=2,模拟断线
=== 第二段:带 Last-Event-ID=2 重连 ===
[last_id=2] data -> {"seq": 3, "text": "log-3"}
[last_id=2] data -> {"seq": 4, "text": "log-4"}
[last_id=2] data -> {"seq": 5, "text": "log-5"}
[last_id=2] data -> {"total": 5}
重连后从 seq: 3 无缝接上,一条不重、一条不漏。这就是为什么 SSE 特别适合「日志滚动」「任务进度」这类可续传的追加流:客户端只要记住一个整数,就能在任何断点原地复活。
注意:
Last-Event-ID是EventSource自动带上的。如果你用自己的客户端(如 httpx)消费,得手动把上次的 id 记下来、下次请求时放进头里——上面这段代码做的就是这件事。
10.2.5 SSE vs WebSocket:怎么选
两者都能推消息,选型看通信方向和生态约束:
| 维度 | SSE | WebSocket |
|---|---|---|
| 方向 | 服务端 → 客户端(单向) | 双向 |
| 协议 | 普通 HTTP,无升级 | HTTP 升级到 ws:// |
| 断线重连 | EventSource 自动 + Last-Event-ID 续传 | 要自己实现(见 10.3) |
| 二进制 | 不支持,只能文本 | 支持(opcode 0x2) |
| 浏览器 API | EventSource(原生) | WebSocket(原生) |
| 代理/CDN 兼容 | 好(就是 HTTP) | 一般(部分代理掐长连接) |
| 并发连接限制 | HTTP/1.1 下每域名 6 条 | 无此限制 |
| 典型场景 | 通知、进度、AI 流式输出、行情只读 | 聊天、协同编辑、游戏 |
一句话决策:只需要服务端推 → SSE;需要客户端也频繁主动发 → WebSocket。AI 对话的「逐字吐答案」是 SSE 的经典用法——客户端发一次提问(普通 POST),服务端用 SSE 流式回答案,全程单向。
10.2.6 工程注意点
- HTTP/1.1 的 6 连接上限:浏览器对同一域名最多 6 条 HTTP/1.1 连接,SSE 会长期占用其中一条。多标签页场景要留意,生产建议上 HTTP/2(多路复用,不再有每域名 6 条的限制)。
- 心跳注释保活:中间代理常把长时间无数据的连接掐掉。SSE 没有协议级 ping,惯例是定期发一个注释行
: keep-alive\n\n(以冒号开头的行被浏览器忽略),既保活又不触发事件。 - 连接是有成本的:每个 SSE 连接 = 一个长期占用的协程 + socket。万级并发要配合反向代理的超时设置与文件描述符上限一起调。
- 错误也要发事件:流中途出错,别直接断——发一个
event: error的事件再正常结束,客户端才能区分「服务端说完了」和「连接被掐了」。
延伸阅读
- Python 网络编程:socket、HTTP 客户端与服务端开发完全指南 —— HTTP 长连接与流式传输的底层机制
- 路由、中间件与请求上下文 —— 流式响应如何穿过中间件链
- HTTP、WSGI 与 ASGI —— ASGI 的流式响应体协议
小结
- SSE 是建立在普通 HTTP 上的一条只读流,
Content-Type: text/event-stream,浏览器EventSource原生支持。 - 报文只有四个字段:
data(内容)、event(类型)、id(续传锚点)、retry(重连间隔),事件以空行分隔。 - FastAPI 用
StreamingResponse+ 异步生成器实现流式输出,别忘了查request.is_disconnected()和设X-Accel-Buffering: no。 Last-Event-ID是 SSE 的杀手锏:浏览器自动重连并带回最后的事件 ID,服务端按 ID 续发即可无缝续传——前提是 ID 可定位。- 选型口诀:只需服务端推 → SSE;需要双向 → WebSocket。AI 流式输出、进度、只读行情用 SSE 更省事。
- 生产要上 HTTP/2(绕开 6 连接上限),并用注释行
: keep-alive保活。
SSE 用 HTTP 的「自动重连 + 续传」省了事,但 WebSocket 没有这套免费午餐——断线重连、心跳保活、广播扇出全得自己写。这正是下一节的主题。
阅读导航:上一节:WebSocket 与消息协议 · 下一节:心跳、重连与广播 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。