欢迎光临
我们一直在努力

AI 聊天回答总是一次性出现?Nginx SSE 流式输出排查教程

自建 AI 聊天应用时,有一种情况很容易被误判为“模型太慢”:请求返回了 200,页面却一直等待,过了几秒才把整段回答显示出来。

如果应用原本支持逐段输出,这种现象可能发生在后端、Nginx、CDN 或前端。单纯增加服务器配置,未必能解决问题。

在莱卡云 Linux 云服务器上部署 AI 应用,也可以按本文的方法检查:先确认后端是否分段发送,再逐层比较数据到达的节奏。

一、先确认接口用的是 SSE

本文针对通过 HTTP 返回 SSE 事件流的接口,响应类型通常包含:

Content-Type: text/event-stream

一个简单的 SSE 事件如下,末尾需要有空行:

data: {"text":"你好"}

不过,返回这个响应头并不代表程序已经实现流式输出。如果后端先收集完整答案,最后才统一写入响应,浏览器仍然只能一起收到。

也不要把 SSE 与 WebSocket 混为一谈。SSE 不需要 WebSocket 的 Upgrade 握手配置。如果项目实际使用 WebSocket,可以参考本站的 Nginx WebSocket 400/502 排查教程。

从后端应用、源站 Nginx、CDN 网关到前端页面的 SSE 排查顺序示意

二、用一个测试接口建立排查基准

直接排查真实 AI 接口,往往还会涉及模型速度、鉴权、请求参数和应用框架。可以先启动一个简单的测试服务,确认 HTTP 链路能否逐段传输。

下面的脚本使用 Python 3 标准库,不需要模型或 API 密钥。它只监听本机 127.0.0.1:18080,每隔约 1 秒发送一个事件,总共发送 5 个,然后结束响应。

示例假设 Nginx 与脚本运行在同一台主机、同一个网络命名空间。如果 Nginx 在 Docker 容器内,127.0.0.1 指向容器自身,需要按实际网络调整上游地址。

将下面内容保存为 sse-demo.py:

import json
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def do_GET(self):
        if self.path != "/sse-test":
            self.send_error(404)
            return
        self.send_response(200)
        self.send_header("Content-Type", "text/event-stream; charset=utf-8")
        self.send_header("Cache-Control", "no-cache")
        self.send_header("Connection", "close")
        self.end_headers()
        self.close_connection = True
        try:
            for seq in range(1, 6):
                payload = json.dumps({"seq": seq, "server_time": time.time()})
                self.wfile.write(f"data: {payload}\n\n".encode("utf-8"))
                self.wfile.flush()
                time.sleep(1)
        except (BrokenPipeError, ConnectionResetError):
            pass

if __name__ == "__main__":
    ThreadingHTTPServer(("127.0.0.1", 18080), Handler).serve_forever()

在服务器终端启动,并保持这个终端运行:

python3 sse-demo.py

如果 18080 端口已被占用,请同时修改脚本、下文 Nginx 配置和测试命令中的端口。这个脚本只用于临时诊断,不是生产应用服务器。

三、用 curl 检查后端是否逐段发送

使用 curl -N 检查 SSE 测试接口每秒返回一个事件的操作示意

另开一个服务器终端,直接访问测试服务:

curl --noproxy '*' -N -i --max-time 15 \
  http://127.0.0.1:18080/sse-test

正常情况下,seq 为 1 到 5 的事件应陆续出现,相邻事件大约间隔 1 秒。观察重点是内容在终端出现的时间,而不是最后是否返回了完整的 5 条数据。

几个参数的作用分别是:

  • -N:关闭 curl 自己的输出缓冲。
  • -i:同时显示响应头,便于确认状态码和内容类型。
  • --max-time 15:让本次诊断最多运行 15 秒。
  • --noproxy '*':避免本次请求走环境变量配置的客户端代理。

-N 不会让一个非流式后端自动变成流式接口;curl -I 只请求响应头,也不能用来判断正文是否逐段到达。初次排查时,建议直接观察终端,不要再接可能缓冲输出的格式化工具。

如果直连真实后端时,数据就已经成批到达,应先检查模型调用是否启用了流式模式,以及应用是否逐段读取、写出并刷新响应。

注意:这里的 /sse-test 是 GET 测试接口。 测试真实 AI 接口时,需要保留原来的 POST 方法、鉴权头、请求体和流式参数,不能只替换 URL。本文测试接口成功,也不能直接证明另一条业务路由已经配置正确。

四、检查 Nginx 的响应缓冲设置

后端直连正常后,可以在对应域名现有的 Nginx server 块内,临时加入以下测试路由。下例针对通过 proxy_pass 转发的 HTTP 上游,不要用它覆盖整个站点配置:

location = /sse-test {
    proxy_pass http://127.0.0.1:18080;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header Connection "";

    proxy_buffering off;
    proxy_cache off;
    gzip off;
    proxy_read_timeout 120s;
}

这里最关键的是 proxy_buffering off;,它关闭该路由的上游响应缓冲。小块内容可能因缓冲而延后到达,但开启缓冲并不意味着所有响应都一定等到结束才发送。

在本文的本地小响应对照测试中,同一个脚本直连时约每秒收到一条事件;开启 Nginx 响应缓冲后,5 条事件约在第 5 秒一起到达;关闭缓冲后恢复逐条到达。这是本次诊断案例的结果,不是所有应用的固定表现。

proxy_cache off; 用于取消可能继承的代理缓存配置;gzip off; 用于在该路由排除 Nginx 压缩的影响。应用或 CDN 自己的压缩行为仍需要单独检查。

proxy_http_version 1.1; 明确指定 Nginx 访问上游使用的 HTTP 版本,并不要求浏览器与站点之间也使用 HTTP/1.1。

另外,下面两个写法经常被用错:

  • proxy_request_buffering off; 控制的是客户端请求体缓冲,不能代替响应端的 proxy_buffering off;。
  • add_header X-Accel-Buffering no; 只是给下游增加响应头,不能代替当前这一层 Nginx 的响应缓冲配置。

由后端返回 X-Accel-Buffering: no 是另一种控制方式,但会受到 Nginx 是否忽略该上游响应头的配置影响。对本次诊断,直接在命中的路由中设置 proxy_buffering off; 更容易核对。

对于通过系统软件包安装、由 systemd 管理的 Nginx,修改后执行:

sudo nginx -t && sudo systemctl reload nginx

只有检查通过后才会执行重载。使用面板、容器或自定义 Nginx 实例时,应通过对应实例检查并重载,避免检查的是一套配置,运行的却是另一套。

将这些设置应用到真实业务路由时,要保留该路由已有的上游地址、鉴权、限流和必要的转发头。

Nginx SSE 测试路由关闭响应缓冲并设置读取超时的配置示意

五、分别测试源站 Nginx 和 CDN 入口

通过后端直连、源站、CDN 和网页显示结果定位 SSE 延迟的对照表

如果域名前面还有 CDN、负载均衡或其他代理,必须继续分层对比。

先在测试电脑设置实际域名和源站 IPv4 地址。下面两个值只是示例,需要替换:

domain='www.example.com'
origin_ip='203.0.113.10'

先绕过域名解析指向的 CDN,直接连接源站 Nginx:

curl --noproxy '*' -N -i --max-time 15 \
  --resolve "${domain}:443:${origin_ip}" \
  "https://${domain}/sse-test"

--resolve 将本次连接指向指定 IP,同时保留 URL 中的域名用于 HTTPS 访问和证书校验。

这个测试要求源站允许测试电脑连接,并提供客户端信任、匹配域名的证书。如果访问被源站规则拦截或证书校验失败,应先处理对应条件,不能据此判断 SSE 缓冲。

再按照域名正常解析的入口访问:

curl --noproxy '*' -N -i --max-time 15 \
  "https://${domain}/sse-test"

两次请求应比较同一条测试路由:

观察结果优先检查的位置
后端直连就成批输出应用生成、读取和刷新响应的方式
后端逐段,经过源站 Nginx 后成批实际命中的 Nginx 路由及其他源站代理
源站逐段,经过 CDN 后成批CDN 或前置网关的流式支持、缓存及响应处理规则
公网 curl 逐段,网页仍整段显示前端读取、事件解析与渲染逻辑

这些结果用于缩小范围,不是单凭一次请求就给某一层定责。诊断路由正常后,还需要对真实业务接口按同样顺序复查。

六、curl 正常,为什么网页仍然整段显示?

如果相同业务请求在 curl 中已经能逐段到达,就应检查前端怎样消费响应。

例如,先执行 await response.text(),等读取完整响应后才更新页面,用户看到的自然是一次性出现的内容。

使用 Fetch 处理流式响应时,需要持续读取 response.body,保留未完成的片段,解析出完整事件后再更新界面。一次网络读取不等于一个完整 SSE 事件:一个事件可能被拆成多个片段,多个事件也可能合并到同一次读取中。

还要注意 SSE 事件末尾的空行。如果服务端发送了自定义 event: 类型,使用原生 EventSource 的页面也要注册对应的事件监听器。

原生 EventSource 与使用 Fetch 发起的 POST 流式请求,不是可以随意互换的两种调用方式。修改前端时,应按照项目原有接口约定处理方法、鉴权和事件格式。

七、输出一段时间后断开,先区分空闲超时

“最后一次性出现”和“输出中途断开”是两类问题。关闭缓冲后仍然断流,需要继续检查超时。

proxy_read_timeout 120s; 表示 Nginx 两次读取上游数据之间允许的最长等待时间,不是把整次回答固定限制为 120 秒。

如果模型思考或外部工具调用期间长时间没有任何字节返回,即使任务仍在运行,链路上的某一层也可能因为空闲超时关闭连接。

应用可以在等待阶段发送并刷新 SSE 注释心跳,例如以下内容,末尾同样保留空行:

: ping

心跳间隔应小于整条链路中最短的空闲超时。它不能绕过服务商对请求总时长的硬性限制,也不能替代对应用异常的检查。

另外,本文 curl 命令主动设置了 15 秒上限。拿它测试长期运行的真实接口时,如果到点出现超时退出,不能直接认定服务器发生了故障。

如果请求一开始就返回 502,优先检查上游服务是否运行、地址和端口是否正确。可以继续参考本站的 Nginx 出现 502 Bad Gateway 怎么办?常见原因与排查教程。

八、排查完成后,移除临时测试入口

确认问题位置后,将需要的配置合并到真实业务路由,并再次测试实际 AI 请求。

随后删除临时添加的 /sse-test 路由,检查并重载 Nginx,再回到运行脚本的终端按 Ctrl+C 停止测试服务。

不要只以状态码 200 作为验收标准。至少应确认:内容能够逐段到达、长时间等待不会意外断开,以及前端能够正确结束加载状态。

九、总结

AI 聊天回答一次性出现,未必是服务器性能不足。后端没有及时写出、代理缓冲了响应,或者前端等待完整正文后才渲染,都可能产生相似现象。

排查时,先用可控的 SSE 测试接口建立基准,再按“后端直连、源站 Nginx、CDN 入口、前端页面”的顺序逐层比较。找到数据从哪一层开始延迟,再调整对应配置,才能避免反复修改无关参数。

对于已经上线的 AI 应用,最终应以真实业务请求的逐段显示、超时表现和结束状态作为验证结果,而不只是测试接口能够打开。

赞(0)
未经允许不得转载:莱卡云 » AI 聊天回答总是一次性出现?Nginx SSE 流式输出排查教程