Jw
首页
技术
英语
首页
技术
英语
  • 本站部署

    • 本地构建 + 同步到服务器
  • 知微(知识库 Agent)

    • 生产部署记录
    • HTTPS 证书基础
    • SSE 流式响应

SSE 流式响应:从协议到 Nginx 配置

SSE(Server-Sent Events) 是一次不结束的 HTTP 响应:服务端持续写 data: ...\n\n,前端按空行解析;要真正「流」起来,服务端必须及时 flush、Nginx 必须关闭缓冲、代理超时必须足够长、前端不能用 response.text() 等整段响应。

示例域名统一使用 kb.example.com。本文是 生产部署记录 第 9、12.4 节的展开。

一、SSE 是什么

SSE 全称 Server-Sent Events,中文一般叫「服务器发送事件」。

它是一种基于 HTTP 的单向流式通信协议:

浏览器  ──────── HTTP 请求 ────────►  服务器
浏览器  ◄────── 持续返回文本流 ─────  服务器

特点:

  • 只能由服务器向浏览器推送
  • 浏览器不能通过同一条 SSE 连接向服务器发消息
  • 基于普通 HTTP/1.1 或 HTTP/2
  • 数据格式是纯文本
  • 浏览器断线后,原生 EventSource 可以自动重连

一句话:

SSE 就是「一次 HTTP 请求,服务器不结束响应,持续往里写文本」。

二、SSE 和 WebSocket、轮询的区别

方案通信方向协议自动重连适用场景
轮询客户端反复请求HTTP不需要简单、实时性低
长轮询服务器挂起请求HTTP需要自己实现兼容性要求高
SSE服务器 → 客户端HTTPEventSource 自带通知、日志、AI 流式回答
WebSocket双向独立协议需要自己实现聊天室、协同编辑、游戏

AI 问答很适合 SSE,因为:

  • 用户提问只需要发一次
  • 回答由模型持续生成
  • 不需要双向实时通信
  • 直接复用现有 HTTP 鉴权、Nginx、HTTPS

三、SSE 的报文格式

SSE 响应的 Content-Type 必须是:

Content-Type: text/event-stream; charset=utf-8

服务器返回的内容由一行行字段组成:

data: 第一段内容

data: 第二段内容

注意:

  • 每个事件以空行结束
  • 空行之前的所有 data: 行属于同一个事件
  • 多行 data: 会用换行拼接
  • 以 : 开头的行是注释,浏览器会忽略

3.1 常见字段

event: message
id: 123
retry: 3000
data: hello

字段作用
data事件内容
event事件类型,对应前端的事件名
id事件 ID,断线重连时可配合 Last-Event-ID
retry重连等待时间,单位毫秒
:注释 / 心跳,不触发事件

3.2 心跳

很多代理会在连接长时间没有数据时断开,所以服务端定期发送注释:

: ping

浏览器不会把它当成业务事件,但 TCP 连接会保持活跃。

本项目使用 15 秒一次心跳。

3.3 OpenAI 风格的结束标记

有些服务会在最后发送:

data: [DONE]

这不是 SSE 规范强制要求,而是接口自定义的结束约定。本项目使用自己的 done 事件,不依赖 [DONE]。

四、为什么本项目不用 EventSource

浏览器原生提供:

const source = new EventSource('/api/events')
source.onmessage = (event) => {
  console.log(event.data)
}

它很方便,但有几个限制:

限制说明
只能 GET不能发送复杂的 POST Body
不能自定义请求头不能加 Authorization、自定义 Header
事件格式固定自定义事件需要用 addEventListener
自动重连不可控重连策略、错误处理不如 fetch 灵活

本项目需要:

  • POST 发送 query、history、model
  • 自定义请求头和请求体
  • 中断生成
  • 把多个事件类型统一处理
  • 统一做超时和错误兜底

所以使用:

fetch + ReadableStream

自己解析 SSE。

五、服务端如何实现

以 Express 为例:

app.post('/api/chat', async (request, response) => {
  response.status(200)
  response.set({
    'Content-Type': 'text/event-stream; charset=utf-8',
    'Cache-Control': 'no-cache, no-transform',
    Connection: 'keep-alive',
    'X-Accel-Buffering': 'no'
  })

  // 立即发送响应头,不要等业务逻辑结束
  response.flushHeaders()

  const send = (type, payload = {}) => {
    response.write(`data: ${JSON.stringify({ type, ...payload })}\n\n`)
  }

  // 心跳,避免代理因空闲断开
  const heartbeat = setInterval(() => {
    response.write(': ping\n\n')
  }, 15000)

  try {
    send('status', { text: '正在检索…' })
    send('text', { delta: '根据知识库资料,' })
    send('done', { sources: [] })
  } finally {
    clearInterval(heartbeat)
    response.end()
  }
})

关键点:

  1. 先设置响应头
  2. 调用 flushHeaders()
  3. 每次写完一个事件不要 end()
  4. 最终统一 end()
  5. 注册心跳
  6. 客户端断开时清理资源

5.1 客户端断开

response.on('close', () => {
  if (!response.writableEnded) {
    controller.abort()
  }
})

当用户关闭页面、点击停止生成、网络断开时,服务端应该停止上游模型请求,避免继续消耗 token。

六、前端如何解析

本项目的简化版解析逻辑:

const response = await fetch('/api/chat', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ query, history }),
  signal: controller.signal
})

const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''

while (true) {
  const { done, value } = await reader.read()
  if (done) break

  buffer += decoder.decode(value, { stream: true })
  buffer = buffer.replace(/\r\n/g, '\n')

  let boundary = buffer.indexOf('\n\n')
  while (boundary !== -1) {
    const block = buffer.slice(0, boundary)
    buffer = buffer.slice(boundary + 2)

    const data = block
      .split('\n')
      .filter((line) => line.startsWith('data:'))
      .map((line) => line.slice(5).trimStart())
      .join('\n')

    if (data) {
      const event = JSON.parse(data)
      handleEvent(event)
    }

    boundary = buffer.indexOf('\n\n')
  }
}

6.1 为什么需要 buffer

一次 reader.read() 拿到的数据不一定是完整事件,可能是:

data: {"type":"te

下一次才收到:

xt","delta":"你好"}

所以必须把未完成的内容留在 buffer 里,等出现空行后再解析。

6.2 不要用 response.text()

错误写法:

const text = await response.text()

这样会等整个响应结束才拿到内容,虽然也能解析,但完全失去流式效果。

6.3 事件分发

本项目的事件大致是:

status   → 当前阶段
thought  → Agent 执行链
sources  → 引用列表
text     → 增量文本
done     → 结束
error    → 错误

前端根据 type 分发到不同状态。

七、项目中的完整数据流

用户提问
  │
  ▼
POST /api/chat
  │
  ▼
Node 设置 text/event-stream
  │
  ├─ status  正在分析
  ├─ thought 调用检索工具
  ├─ sources 返回引用片段
  ├─ text    逐 token 输出
  └─ done    结束
  │
  ▼
浏览器 fetch + ReadableStream
  │
  ├─ 追加 Markdown
  ├─ 更新思考链
  ├─ 更新引用
  └─ 自动滚动

八、Nginx 为什么必须关闭缓冲

这是部署时最容易踩的坑。

8.1 proxy_buffering off

默认情况下,Nginx 可能会先把后端响应缓存到自己的缓冲区,等缓冲区满了或响应结束再发给浏览器。

结果就是:

模型已经生成完 → Nginx 一次性发给浏览器 → 用户看到一次性出现

关闭后:

后端写一段 → Nginx 立刻转发一段 → 浏览器立刻渲染

对应配置:

proxy_buffering off;

8.2 proxy_cache off

避免把 SSE 响应写入缓存。

proxy_cache off;

SSE 是实时内容,缓存没有意义,还可能导致不同用户拿到旧数据。

8.3 proxy_request_buffering off

这个指令主要影响请求体,不是响应。

对于大文件上传,默认 Nginx 会先把整个请求体缓存到临时文件,再转给后端。关闭后可以边收边转发。

proxy_request_buffering off;

它不直接解决 SSE 响应缓冲,但本项目同时涉及大文件上传,所以保留。

8.4 proxy_read_timeout

Nginx 等待后端响应的超时时间。

模型生成可能持续几十秒到几分钟,默认 60 秒可能提前断开:

proxy_read_timeout 300s;

8.5 proxy_send_timeout

Nginx 向后端发送请求的超时时间。

proxy_send_timeout 300s;

8.6 X-Accel-Buffering: no

后端可以主动告诉 Nginx 不要缓冲:

X-Accel-Buffering: no

这是 Nginx 的特殊响应头,不是标准 HTTP 头。

它适合无法直接修改 Nginx 配置的环境,但生产环境还是建议显式配置 proxy_buffering off。

九、其他会导致「非流式」的原因

SSE 变成一次性输出,不一定是 Nginx 的问题。排查顺序:

  1. 服务端没有调用 flushHeaders()
  2. 服务端使用了 res.send() 而不是 res.write()
  3. 中间件开启了 gzip / compression
  4. Nginx 开启了 proxy_buffering
  5. CDN / WAF 缓冲了响应
  6. 前端使用了 response.text()
  7. 代理的 read timeout 太短
  8. 响应头不是 text/event-stream

9.1 不要对 SSE 开启 gzip

如果使用 compression 中间件:

app.use(compression())

它可能会等缓冲区达到一定大小才压缩输出,破坏流式效果。

可以排除 SSE 路由,或者对 text/event-stream 关闭压缩。

9.2 Cache-Control: no-transform

Cache-Control: no-cache, no-transform

no-transform 告诉代理不要修改响应内容,例如压缩、转码。

9.3 CDN / Cloudflare

如果前面还有 CDN:

  • 确认 CDN 支持流式响应
  • 确认没有开启响应缓冲
  • 确认空闲超时时间足够
  • 心跳间隔要小于代理超时

十、SSE 的断线、超时和重连

10.1 EventSource 的自动重连

EventSource 断线后会按照 retry 字段等待并自动重连,还会带上:

Last-Event-ID: 上一次收到的事件 ID

服务端可以据此续传。

10.2 fetch 方式没有自动重连

使用 fetch + ReadableStream 时,需要自己实现:

  • 超时监控
  • 错误提示
  • 重试按钮
  • 请求中断

本项目的做法:

  • 服务端每 15 秒发送心跳
  • 前端 45 秒没有收到任何数据就判定连接异常
  • 用户点击停止时调用 AbortController.abort()
  • 失败后显示错误和「重新生成」

10.3 为什么心跳能防超时

代理通常按「连接空闲时间」判断超时。

即使模型暂时没有输出 token,只要每 15 秒发送:

: ping

连接就不会被当作空闲连接。

十一、SSE 与鉴权

原生 EventSource 不能自定义 Authorization 头,所以常见方案是:

  • 同源 Cookie 鉴权
  • 把 token 放在 URL(不推荐,容易出现在日志里)
  • 使用 fetch 发送 Authorization 头

本项目的聊天使用 fetch,因此可以发送 POST Body 和自定义头。

十二、SSE 与 HTTP/2

SSE 可以跑在 HTTP/2 上,但要注意:

  • HTTP/2 是多路复用,不像 HTTP/1.1 有每域名 6 个连接的限制
  • 代理仍然可能缓冲
  • 代理的 idle timeout 仍然存在
  • 浏览器 DevTools 的 EventStream 面板可以查看事件

不管 HTTP/1.1 还是 HTTP/2,proxy_buffering off 仍然重要。

十三、如何调试 SSE

13.1 curl

curl -N -X POST https://kb.example.com/api/chat \
  -H "Content-Type: application/json" \
  -d '{"query":"测试","history":[]}'

-N 表示禁用 curl 自己的缓冲。

如果能看到一段一段输出,说明服务端和代理是流式的。

13.2 浏览器 DevTools

打开:

F12 → Network → 找到 /api/chat → Response

如果支持,可以在 EventStream 面板看到每个事件。

13.3 检查响应头

curl -I https://kb.example.com/api/chat

确认:

Content-Type: text/event-stream
Cache-Control: no-cache
X-Accel-Buffering: no

13.4 直接绕过 Nginx

curl -N -X POST http://127.0.0.1:3001/api/chat \
  -H "Content-Type: application/json" \
  -d '{"query":"测试","history":[]}'

如果直连 Node 是流式,经过 Nginx 不是,问题就在 Nginx / CDN。

十四、常见问题速查

现象可能原因
回答最后一次性出现Nginx 缓冲、gzip、前端用了 text()
30 秒 / 60 秒后断开代理 read timeout 太短
每隔一段时间自动重连心跳间隔大于代理空闲超时
中文乱码没有使用 TextDecoder 流式解码
事件解析失败没有按空行拆分,或一次 read 不是完整事件
点击停止后模型还在生成服务端没有监听 close 或没有 abort 上游
生产环境偶发 502上游生成时间超过代理超时
首字节延迟很高模型首 token 慢或检索耗时长,不一定是 SSE 问题

小结

SSE 是一次不结束的 HTTP 响应。服务端不断写 data: ...\n\n,前端按空行解析;要真正「流」起来,服务端必须及时 flush,Nginx 必须关闭缓冲,代理超时必须足够长,前端不能用 response.text() 等整段响应。

速查

# 放在 Nginx 反向代理的 location 块中
# 关闭响应缓冲:后端写一段就转发一段
proxy_buffering off;

# 关闭缓存:SSE 是实时内容
proxy_cache off;

# 关闭请求体缓冲:大文件上传时边收边转发
proxy_request_buffering off;

# 放宽读写超时:模型生成可能持续几分钟
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# 直连 Node:确认服务端本身是流式
curl -N -X POST http://127.0.0.1:3001/api/chat \
  -H "Content-Type: application/json" \
  -d '{"query":"测试","history":[]}'

# 经过 Nginx / HTTPS:确认代理链路是流式
curl -N -X POST https://kb.example.com/api/chat \
  -H "Content-Type: application/json" \
  -d '{"query":"测试","history":[]}'

# 检查响应头是否为 text/event-stream
curl -I https://kb.example.com/api/chat
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache, no-transform
X-Accel-Buffering: no
场景关键点
回答一次性出现依次排查 proxy_buffering、gzip、response.text()
几十秒后断开调大 proxy_read_timeout,心跳间隔小于代理空闲超时
直连流式、过代理不流式问题在 Nginx / CDN 的缓冲或超时
点击停止仍在生成服务端监听 close 并 abort 上游请求

相关

  • 生产部署记录:Nginx、systemd、HTTPS 上线的完整步骤
  • HTTPS 证书基础:证书、SAN、ACME 与 Certbot 续期
  • 本地构建 + 同步部署到服务器:本站静态站点的发布方式
  • 技术笔记总览:全部技术栈入口
最近更新: 2026/10/25 10:17
Contributors: liujw
Prev
HTTPS 证书基础