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 | 服务器 → 客户端 | HTTP | EventSource 自带 | 通知、日志、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()
}
})
关键点:
- 先设置响应头
- 调用
flushHeaders() - 每次写完一个事件不要
end() - 最终统一
end() - 注册心跳
- 客户端断开时清理资源
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 的问题。排查顺序:
- 服务端没有调用
flushHeaders() - 服务端使用了
res.send()而不是res.write() - 中间件开启了 gzip / compression
- Nginx 开启了
proxy_buffering - CDN / WAF 缓冲了响应
- 前端使用了
response.text() - 代理的 read timeout 太短
- 响应头不是
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 续期
- 本地构建 + 同步部署到服务器:本站静态站点的发布方式
- 技术笔记总览:全部技术栈入口