SSE 详解:服务器如何把回答“一个字一个字”推给浏览器
本文解释 SSE(Server-Sent Events,服务器发送事件),并结合 mystu 项目的流式问答接口
/agent/api/chat/stream说明它是怎么工作的。
1. 一句话定义
SSE 就是「一条不关闭的 HTTP 响应,服务器按 data: xxx\n\n 的格式源源不断地往浏览器推文本」——单向、基于普通 HTTP、简单可靠,是 AI 流式输出、消息通知、实时日志/行情这类「服务器主动推」场景的首选方案。
2. 整体时序图
下图展示一次流式问答的完整过程:浏览器发起请求后,连接保持打开,模型每生成一个片段就经由智能体、FastAPI 推送到浏览器,浏览器实时渲染,直到收到结束标志 [DONE]。
3. 它解决什么问题
普通 HTTP 是「一问一答」:请求 → 一次完整响应 → 连接关闭。
但「大模型生成回答」是持续产出的过程。用普通 HTTP 只能:
- 干等:等模型全部生成完再一次性返回(用户盯着空白等十几秒);
- 或反复轮询:浏览器每隔几百毫秒问一次「有新内容吗」(浪费请求、有延迟)。
SSE 提供第三种方案:连接先不关闭,服务器有新内容就推一段,浏览器收到就显示 —— 于是有了「打字机效果」。
4. 它的本质:一个“不关闭的 HTTP 响应”
SSE 没有发明新协议,它就是普通 HTTP,只是做了三件事:
- 响应头声明
Content-Type: text/event-stream—— 告诉浏览器「这是流,别等它结束」; - 响应体不一次性写完,而是分多次持续写入;
- 内容遵循约定的文本格式(见第 5 节)。
对比 mystu 项目里的两个接口:
# 非流式:返回完整对象,连接随即关闭
@router.post("/chat")
async def chat(...) -> apirsp.Response:
answer = await achat(request.message)
return apirsp.Response(data=answer)
# 流式:返回“持续产出的流”,边生成边推
@router.post("/chat/stream")
async def chat_stream(...) -> StreamingResponse:
async def event_generator():
async for token in astream_chat(request.message):
yield f"data: {json.dumps({'token': token})}\n\n" # 推一段
yield "data: [DONE]\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")
StreamingResponse + 异步生成器(yield)就是「不一次写完、持续往外推」的实现。
5. SSE 报文格式
SSE 数据是纯文本,按「字段: 值」一行行写,用一个空行(\n\n)表示一个事件结束。规范有 4 个字段:
| 字段 | 作用 |
|---|---|
data: |
事件数据内容(最常用,可多行) |
event: |
事件类型(自定义名字,前端可分类监听) |
id: |
事件 ID,断线重连时告诉服务器「收到哪了」 |
retry: |
断线后浏览器自动重连的等待毫秒数 |
mystu 项目实际发出的报文(把内容包成 JSON 以区分片段/错误/结束):
data: {"token": "中国"}
data: {"token": "钢厂"}
data: {"token": "利润"}
data: [DONE]
| 约定事件 | 含义 |
|---|---|
data: {"token": "片段"} |
一段新生成的文本 |
data: {"error": "..."} |
出错信息 |
data: [DONE] |
生成结束 |
把内容做成 JSON 是自定义约定,不是 SSE 强制的;好处是顺带解决了正文换行/特殊字符破坏格式的问题。
6. 浏览器怎么收:EventSource vs fetch
标准方式:EventSource(自带自动重连)
const es = new EventSource('/some/stream');
es.onmessage = (e) => console.log(e.data);
硬限制:只支持 GET,不能自定义请求体/请求头。
mystu 项目用的方式:fetch + 手动读流
因为要 POST(请求体带 message),用 fetch 手动读流并自己解析:
const reader = resp.body.getReader();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split('\n\n'); // 按空行切出完整事件
buffer = events.pop(); // 半个事件留到下次再拼
for (const evt of events) { /* 解析 data: ... */ }
}
代价:失去 EventSource 的自动重连,需要时得自己实现。
7. SSE vs 轮询 vs WebSocket
| 特性 | 轮询 (Polling) | SSE | WebSocket |
|---|---|---|---|
| 方向 | 客户端反复问 | 服务器→客户端,单向 | 双向 |
| 底层 | 多次 HTTP | 一条长 HTTP | 独立 ws 协议(需握手升级) |
| 实时性 | 差(有间隔) | 好 | 最好 |
| 自动重连 | 不适用 | 有(EventSource) |
需自己实现 |
| 复杂度 | 低 | 低 | 中高 |
| 适用场景 | 偶尔更新 | AI 流式、通知、日志、行情 | 聊天室、协同编辑、游戏 |
为什么大模型流式输出几乎都用 SSE? 因为这是纯单向场景(服务器不停发、客户端只收),SSE 够用又比 WebSocket 简单(走普通 HTTP、对代理/防火墙友好)。OpenAI、DeepSeek 的流式 API 用的都是 SSE。
8. 实战注意点
- 网络包 ≠ 事件边界:TCP 不保证「一次刚好读到一个事件」。前端必须用缓冲区按
\n\n切,不完整的尾巴留到下次。 - 代理缓冲:Nginx 等反向代理可能缓冲响应导致不实时。接口加
X-Accel-Buffering: no、Cache-Control: no-cache规避。 - 内容要转义:
data:按行解析,正文换行会破坏格式;包成 JSON 可顺带解决。 - 要有结束标志:SSE 本身没有天然结束信号,所以约定
data: [DONE]让前端知道何时停止。
本文配套 mystu 项目流式问答接口 /agent/api/chat/stream,可与《我的第一个智能问答》一文对照阅读。
更多推荐



所有评论(0)