本文解释 SSE(Server-Sent Events,服务器发送事件),并结合 mystu 项目的流式问答接口 /agent/api/chat/stream 说明它是怎么工作的。


1. 一句话定义

SSE 就是「一条不关闭的 HTTP 响应,服务器按 data: xxx\n\n 的格式源源不断地往浏览器推文本」——单向、基于普通 HTTP、简单可靠,是 AI 流式输出、消息通知、实时日志/行情这类「服务器主动推」场景的首选方案。


2. 整体时序图

下图展示一次流式问答的完整过程:浏览器发起请求后,连接保持打开,模型每生成一个片段就经由智能体、FastAPI 推送到浏览器,浏览器实时渲染,直到收到结束标志 [DONE]

DeepSeek 模型 智能体 agent.astream FastAPI /chat/stream 浏览器 / 前端页面 DeepSeek 模型 智能体 agent.astream FastAPI /chat/stream 浏览器 / 前端页面 响应头 Content-Type: text/event-stream 连接保持打开,不一次性返回 loop [模型每生成一个 token] 收到 [DONE],停止读取 POST /chat/stream { "message": "..." } 1 astream(message, stream_mode="messages") 2 发起生成请求 3 token 片段 4 yield 文本片段 5 data: {"token":"..."}\n\n 6 累计文本 + 实时渲染 Markdown 7 生成完成 8 生成器结束 9 data: [DONE]\n\n 10 关闭连接 11

3. 它解决什么问题

普通 HTTP 是「一问一答」:请求 → 一次完整响应 → 连接关闭。

但「大模型生成回答」是持续产出的过程。用普通 HTTP 只能:

  • 干等:等模型全部生成完再一次性返回(用户盯着空白等十几秒);
  • 反复轮询:浏览器每隔几百毫秒问一次「有新内容吗」(浪费请求、有延迟)。

SSE 提供第三种方案:连接先不关闭,服务器有新内容就推一段,浏览器收到就显示 —— 于是有了「打字机效果」。


4. 它的本质:一个“不关闭的 HTTP 响应”

SSE 没有发明新协议,它就是普通 HTTP,只是做了三件事:

  1. 响应头声明 Content-Type: text/event-stream —— 告诉浏览器「这是流,别等它结束」;
  2. 响应体不一次性写完,而是分多次持续写入;
  3. 内容遵循约定的文本格式(见第 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. 实战注意点

  1. 网络包 ≠ 事件边界:TCP 不保证「一次刚好读到一个事件」。前端必须用缓冲区按 \n\n 切,不完整的尾巴留到下次。
  2. 代理缓冲:Nginx 等反向代理可能缓冲响应导致不实时。接口加 X-Accel-Buffering: noCache-Control: no-cache 规避。
  3. 内容要转义data: 按行解析,正文换行会破坏格式;包成 JSON 可顺带解决。
  4. 要有结束标志:SSE 本身没有天然结束信号,所以约定 data: [DONE] 让前端知道何时停止。

本文配套 mystu 项目流式问答接口 /agent/api/chat/stream,可与《我的第一个智能问答》一文对照阅读。

Logo

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

更多推荐