AI 聊天回答明明开启了流式输出,放到 VPS 后却等到最后一次性出现?先别换模型或加内存。用同一条请求,分别测后端直连、Nginx 入口和公网域名,确认数据从哪一层开始积攒。
本文排查 HTTP 响应流,重点是 SSE、Nginx 缓冲和前端读取。它不是 WebSocket 升级教程;如果请求依靠 101 握手建立双向连接,应看 WebSocket 连接排查。
| 现象 | 优先检查 | 下一步 |
|---|---|---|
| 后端直连也最后才出数据 | 应用、SDK、模型端是否真正流式返回 | 检查生成与 flush 的时间 |
| 直连分段正常,过 Nginx 就成整段 | 响应缓冲、压缩、实际命中的 location | 修改流式端点的代理配置 |
| 源站正常,经 CDN 才变慢 | 边缘缓存、网关转换、响应策略 | 逐层对比到达时间 |
| curl 分段正常,网页最后才更新 | response.text、解析与 UI 更新 | 检查前端消费方式 |
| 连接先正常,空闲后断开 | 心跳与各层读取超时 | 看最后一段数据时间 |
HTTP 200 只代表请求有正常响应,不能证明数据实时送达。首字慢和后续内容被攒住也要分开看:首个 token 本身尚未生成时,关闭代理缓冲不会让模型提前完成计算。
先准备一个能定时输出小段数据的测试端点。下面的 /events/test 是示例路径,后端必须实际实现它;不要直接拿不存在的 URL 验收。
在 VPS 本机测后端,再测域名:
curl -sS -N -i --max-time 10 \
-H 'Accept: text/event-stream' \
http://127.0.0.1:3000/events/test
curl -sS -N -i --max-time 10 \
-H 'Accept: text/event-stream' \
https://api.example.com/events/test
替换端口和域名。-N 关闭的是 curl 自己的输出缓冲,不能替服务器关闭代理缓冲。真实接口若要求 POST、登录或请求体,保持原请求的方法和协议,不要为了方便改成 GET,也不要把 API Key 放进 URL 或公开日志。持续流达到 10 秒后报超时,可能只是本例设置的观察期限。
比较时不只看文字内容,还要记录每个事件什么时候出现。应用日志若显示每半秒产生一条,而客户端都在结束时收到,才有依据继续查中间缓冲。
NGINX 反代指南解释了代理缓冲:接收上游数据与向客户端发送数据可以采用不同节奏。普通页面适合的配置,未必适合实时事件。
假设流式路径为 /events/,后端在本机 3000 端口,将以下片段合并到已有 HTTPS server 中:
location /events/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Accept-Encoding "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 90s;
gzip off;
}
这不是完整站点配置,要保留现有证书、鉴权、限流和其他路由。proxy_pass 没有末尾斜杠,后端仍收到 /events/...;如果实际流式地址在 /api/chat,应配置实际端点,不能只新增一个永远匹配不到的 /events/。
这里分别处理响应缓冲、代理缓存和压缩。Accept-Encoding 清空是让这次上游请求不主动协商压缩;gzip off 只关闭当前 Nginx 的这条路径压缩,不会自动解压任意上游响应。可先用未压缩响应定位,再按应用需要评估压缩策略。
proxy_request_buffering 管的是客户端上传请求体,不是回答返回的缓冲。不要把关闭它当成修复 SSE 的关键,也不需要为了输出文本去扩大上传大小限制。
应用还可在上游响应头返回 X-Accel-Buffering: no,让 Nginx 按响应关闭缓冲;但若配置了忽略该头的规则,它可能不生效。配置含义见 NGINX proxy 模块文档。不要只检查浏览器是否看得到这个内部控制头,就判定是否生效;应实际测量事件到达节奏。
修改后先验证,再加载:
sudo nginx -t
sudo systemctl reload nginx
确认面板、容器或部署脚本使用的就是这份配置。已有其他代理层时,每层都要检查,不能只改最内层 Nginx。
MDN 的 SSE 指南要求使用 text/event-stream,事件以空行分隔,例如:
data: {"text":"你好"}
data: {"text":",世界"}
服务端要按事件及时发送并 flush。只在字符串变量里不断追加、到请求结束才返回,即使 Content-Type 写对,也不是实时传输。应用框架、压缩中间件或 SDK 也可能先聚合完整结果,再交给响应层。
TCP/HTTP 分块和 SSE 事件不是一一对应:一次读取可能包含半条事件,也可能包含多条。客户端应保留未完成的片段,按协议解析;不要假设每个网络 chunk 都是完整 JSON。普通 JSON、NDJSON 和 SSE 也不能混用解析规则。
如果模型服务需要显式开启流式模式,按该服务的接口定义设置;代理不能把一个已经生成完毕的普通 JSON 回答自动变成真实 token 流。错误响应则继续返回真实状态和正确类型,不要统一伪装成 200 的事件流。
有些“后端流式正常”的页面,问题在这一步:
const text = await response.text();
Response.text 文档说明,它会把响应读取到结束,再给出完整文本。之后才更新页面,自然看不到逐段效果。
需要增量消费时,可使用响应体的流式 reader 或已验证的 SSE 客户端,参考 ReadableStream.getReader。解码 UTF-8 时还要正确保留跨块字符,解析完整事件后再更新 UI,避免中文被分割造成乱码。
浏览器原生 EventSource 适合它支持的 GET 事件流,但不能直接替代任意需要 POST 请求体、自定义 Authorization 头的 AI 接口。保留应用已有认证方案,不要为了换客户端把密钥塞进查询参数。若 Network 中请求本身被跨域拦截,按 CORS 排查指南另行处理。
回答只是攒到最后,但最终完整返回,先查缓冲;连接已经被关闭、事件丢失,再查超时。读取超时通常看相邻两次上游读取之间的间隔,而不是简单规定整个请求最多持续多久。
90 秒只是示例值,要结合上游首段等待时间、服务端心跳以及其他网关限制调整。协议允许时,服务端可定期发送 SSE 注释心跳,例如 : ping 后接空行;它不应成为业务消息。不要往普通 JSON 或其他协议中随意插入心跳字符。
心跳不能保证绕过所有网关的总时长限制,也不能解决模型端未产生数据的问题。已经开始返回事件后发生错误,前后端还应约定错误事件、结束标记和断开恢复行为,避免 UI 永远停在“正在生成”。
分别验证后端直连、Nginx 入口和公网入口,确认首条事件和后续事件按预期到达;再验证一条真实登录请求和一次受控错误,检查流结束时客户端能够正确收尾。
本地使用现有 Nginx 1.19.6 与同一模拟后端做了四组对照。后端在第 0、0.5、1 秒发送三个小事件,下面是客户端相对请求开始的实际接收时间;四组内容均完整一致。
| 对照方式 | 第 1 / 2 / 3 条事件到达时间(秒) |
|---|---|
| 直连模拟后端 | 0.038 / 0.541 / 1.045 |
| Nginx 缓冲开启 | 1.006 / 1.006 / 1.006 |
| Nginx 缓冲关闭 | 0.002 / 0.505 / 1.007 |
| 缓冲开启,上游头要求关闭 | 0.002 / 0.506 / 1.007 |
这组结果说明,在该测试条件下,缓冲开启时事件被聚合,关闭缓冲或由上游控制头关闭后,事件按节奏到达。它不是模型速度或生产延迟测试,也没有覆盖 TLS、CDN、浏览器 UI 和长时超时。
