🤖
AI审核中

SSE与Nginx流式传输排障实战

Java 15分钟 119浏览 0评论

设想这样一个场景:AI 接口已经启用流式输出,后端日志也能看到内容持续生成,但页面仍然要等待几秒,随后突然出现一大段文字。

第一反应可能是模型太慢,也可能是 Nginx 配置不对。于是,有人增加超时时间,有人关闭各种缓冲,还有人直接换成 WebSocket。

但这些修改,都绕过了最关键的问题:

内容究竟停在了哪一层?

应用生成了数据,不代表数据已经写入网络;浏览器收到了字节,也不代表前端已经解析并展示。Nginx 的响应缓冲、SSE 的事件边界,以及前端读取响应的方式,都需要分别检查。

本文通过一个可运行的 Java 探针和本地对照实验,把这条链路拆开验证。

一、流式输出不是一个开关,而是一条完整链路

为了方便排查,可以把数据返回过程划分成五个环节:

graph LR
    A["应用生成数据"] --> B["响应流写出"]
    B --> C["Nginx 转发"]
    C --> D["浏览器解析事件"]
    D --> E["页面更新内容"]

这里需要区分三个时刻:

数据生成时间,表示应用已经拿到内容。

数据写出时间,表示应用开始把内容交给响应流。

事件消费时间,表示客户端已经获得一个可以处理的完整事件。

这三个时间并不天然相同。排障时,应分别记录,而不是用一条“收到模型结果”的日志代表全部过程。

SSE,即服务端发送事件,使用 text/event-stream 类型的响应,内容按 UTF-8 编码。它不是把任意字符串不断写出去就算完成:事件具有明确的字段和边界,解析器遇到空行后才会处理完整事件。

因此,即使某些字节已经到达客户端,缺少事件结束空行,也可能导致回调迟迟不执行。

生成了内容、发送了字节、触发了事件,是三个不同的判断。

二、先把真实模型换成一个确定性的 Java 探针

直接拿真实 AI 接口排查,会混入模型排队、上下文长度、工具调用等变量。

更容易验证的方法,是先用一个固定节奏的服务代替模型:每秒发送一条序号数据,连续发送五条,然后结束。

下面的示例使用 JDK 自带的 HTTP Server,不依赖 Spring Boot。sendResponseHeaders(200, 0) 在这个 API 中表示使用分块传输,可以继续写入不预先确定总长度的响应体。

保存为 SseProbe.java

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.Executors;

public class SseProbe {

    public static void main(String[] args) throws IOException {
        HttpServer server = HttpServer.create(
                new InetSocketAddress("127.0.0.1", 18081), 0);

        server.createContext("/events", SseProbe::stream);
        server.setExecutor(Executors.newFixedThreadPool(4));
        server.start();

        System.out.println(
                "Listening on http://127.0.0.1:18081/events");
    }

    private static void stream(HttpExchange exchange) {
        try {
            if (!"GET".equals(exchange.getRequestMethod())) {
                exchange.getResponseHeaders().set("Allow", "GET");
                exchange.sendResponseHeaders(405, -1);
                return;
            }

            exchange.getResponseHeaders().set(
                    "Content-Type",
                    "text/event-stream; charset=utf-8");
            exchange.getResponseHeaders().set(
                    "Cache-Control", "no-store");

            exchange.sendResponseHeaders(200, 0);

            try (var out = exchange.getResponseBody()) {
                for (int i = 1; i <= 5; i++) {
                    String frame = "id: " + i + "\n"
                            + "event: token\n"
                            + "data: {\"seq\":" + i + "}\n\n";

                    out.write(frame.getBytes(StandardCharsets.UTF_8));
                    out.flush();

                    Thread.sleep(1000);
                }

                out.write("event: done\ndata: {}\n\n"
                        .getBytes(StandardCharsets.UTF_8));
                out.flush();
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } catch (IOException e) {
            System.err.println(
                    "Stream ended with I/O error: " + e.getMessage());
        } finally {
            exchange.close();
        }
    }
}

使用 JDK 17 或更高版本编译运行:

javac --release 17 SseProbe.java
java SseProbe

这个探针只用于本地链路验证,没有实现鉴权、并发准入和事件续传,不应直接作为生产服务。

另开一个终端,直接访问应用:

curl -N -sS --max-time 15 \
  http://127.0.0.1:18081/events

其中,-N 关闭的是 curl 自己的输出缓冲,并不能替服务端或代理关闭缓冲。(Curl)

观察重点不是最后有没有返回五条数据,而是它们是否按照约一秒的间隔逐条出现。

三、给流式路径单独配置 Nginx

在应用直连正常后,再增加 Nginx 这一层。

下面的 server 块放在现有配置的 http 块中,仅监听本机端口,用于探测。生产环境应将相应 location 合入既有的 HTTPS 与鉴权配置,而不是覆盖整个站点。

server {
    listen 127.0.0.1:18080;

    location = /events {
        proxy_pass http://127.0.0.1:18081;

        proxy_http_version 1.1;
        proxy_set_header Connection "";

        proxy_buffering off;
        proxy_cache off;

        proxy_read_timeout 75s;

        gzip off;
    }
}

这里的核心是:只在流式路径关闭响应缓冲,不把整个站点都改成无缓冲转发。

Nginx 的缓冲机制本身有价值:它可以在上游响应与客户端接收速度之间提供缓冲。交互式流更关注内容尽早到达,而其他响应可能更适合保留这种机制。

1. 请求缓冲和响应缓冲,不是一回事

proxy_buffering 控制上游响应的缓冲;proxy_request_buffering 控制客户端请求体的缓冲。后者不会代替前者解决回答内容滞留问题。

本例是没有请求体的 GET 探针,因此没有专门修改请求缓冲。

2. X-Accel-Buffering 要放对位置

应用也可以通过上游响应头 X-Accel-Buffering: no 告诉 Nginx 不缓冲该响应,但要检查是否存在忽略该头的配置。

不要把下面这条指令当成关闭当前 Nginx 缓冲的等价写法:

add_header X-Accel-Buffering no;

add_header 是往发给下游的响应中添加字段;它不是当前 Nginx 从上游读取到的控制头。这是两个不同的处理位置。

3. 暂时排除压缩变量,而不是永久禁止压缩

示例中的 gzip off 用于关闭当前路径的 Nginx gzip 处理,先减少排障变量。它并不意味着所有流式响应都不能压缩,也不能据此认定上游应用已经停止压缩。

检查配置并重载:

sudo nginx -t && sudo nginx -s reload

-t 用于检查配置,-s reload 用于重载。使用自定义配置文件或容器部署时,应针对实际运行的实例执行。

随后通过代理访问:

curl -N -sS --max-time 15 \
  http://127.0.0.1:18080/events

四、对照实验:关闭 Nginx 缓冲,为什么仍然没用?

本次在隔离的本地环境中,使用 JDK 21.0.11、Nginx 1.26.3 验证了上述探针,并由客户端逐行记录到达时间。

除正常逐条 flush() 外,还增加了一个对照:临时去掉循环内部的 out.flush(),保留结束时的刷新。

单次验证结果如下,时间从客户端发起请求开始计算:

测试方式 响应头到达 首条数据到达 后续数据表现
应用逐条刷新,直连应用 约 0.06 秒 约 0.07 秒 约每秒一条
应用逐条刷新,Nginx 缓冲关闭 约 0.06 秒 约 0.07 秒 约每秒一条
应用逐条刷新,Nginx 缓冲开启 约 0.07 秒 约 0.07 秒 约每秒一条
应用只在结束时刷新,Nginx 缓冲关闭 约 0.06 秒 约 5.07 秒 集中到达

这些数据是本地功能验证结果,不是性能基准,也不能用来预测真实模型的延迟。

但它们说明了两个很重要的问题。

第一,应用没有及时写出时,关闭代理缓冲也救不了它。

最后一组中,响应头很快到达,但事件仍然要等待约五秒。Nginx 无法提前转发应用尚未交付的数据。

第二,开启响应缓冲,不等于必然等整个响应结束才发送。

在本次逐条刷新的对照中,Nginx 开启缓冲仍然保持了逐条到达。因此,不能只看到 proxy_buffering on,就直接认定它是故障原因。

真正有价值的证据是:

同一个确定性探针,在哪一段链路上开始失去逐条到达的节奏。

五、字节到了,前端也可能把流重新“攒”起来

即使服务端和代理都正常,下面的前端写法仍然不会逐段处理内容:

const response = await fetch("/events");
const text = await response.text();

console.log(text);

response.text() 要等待整个响应体读取完成,才把完整文本交给调用者。要增量处理,应读取响应体的流,而不是等待完整文本。(MDN Web Docs)

对于本文的 GET 探针,可以在与 /events 同源的页面控制台中,使用原生 EventSource 验证事件到达:

(() => {
    const startedAt = performance.now();
    const source = new EventSource("/events");

    source.addEventListener("token", (event) => {
        const seconds =
            (performance.now() - startedAt) / 1000;

        console.log(
            `${seconds.toFixed(3)}s`,
            event.data
        );
    });

    source.addEventListener("done", () => {
        console.log("流式响应结束");
        source.close();
    });

    source.onerror = () => {
        console.warn("事件流发生错误,停止本次探测");
        source.close();
    };
})();

服务端发送的是命名事件 token,因此这里使用对应的事件监听器;测试结束后主动关闭连接。这个测试版出错即停止,不包含自动恢复策略。(MDN Web Docs)

实际的 POST 流式接口,可以使用 fetch() 读取 response.body,再交给 SSE 解析逻辑处理。需要注意:网络读取块不等于完整事件,不能把每次读取的块直接当成一份完整 JSON;解码和事件边界处理都必须跨读取保留状态。(MDN Web Docs)

排查到这里,可以做一个非常直接的判断:

如果事件回调已经逐条触发,但页面仍然成段更新,就应继续检查前端状态更新和渲染逻辑,而不是继续修改 Nginx。

六、心跳解决“空闲”,总超时解决“失控”

配置中的:

proxy_read_timeout 75s;

不是说整个流最多持续 75 秒,而是约束 Nginx 从上游读取响应时,相邻读取操作之间的等待时间。(nginx.org)

对于长时间没有业务内容的流,可以发送 SSE 注释作为心跳。例如:

out.write(": ping\n\n".getBytes(StandardCharsets.UTF_8));
out.flush();

以冒号开头的行是注释,不会作为普通消息交给事件处理器。HTML 标准的作者建议中,提到可大约每 15 秒发送一次注释,帮助应对部分代理的空闲断连。(HTML Living Standard)

但实现时,还应分别考虑三个边界。

心跳不能完全依赖内容生成。 如果生成过程阻塞,心跳也随之停止,就失去了意义。可以独立调度心跳,再由同一个写出通道串行发送。

每一跳都要单独检查。 应用向 Nginx 发出的心跳,并不会替模型服务向应用的 HTTP 客户端补充数据。

总任务时长仍要受限。 心跳只能说明这条连接还有数据活动,不能成为任务无限运行的理由。

更合理的设计,是分别管理连接空闲时间、首次有效内容等待时间和任务总时长,而不是用一个很大的超时覆盖全部情况。

七、别把首字节时间当成首个有效内容时间

流式排障中,一个容易误导人的现象是:监控显示首字节很快,但用户仍然长时间看不到回答。

前面的对照实验已经复现了这一点:响应头约 0.06 秒到达,第一条数据却约 5.07 秒才出现。

curl 的 time_starttransfer 衡量首次收到字节的时间,并不理解业务内容;它不能自动等同于首个有效回答片段到达的时间。(Curl)

Nginx 日志也需要按定义解释。例如:

# 放在 http 块中
log_format sse '$request_method $uri status=$status '
               'rt=$request_time '
               'uht=$upstream_header_time '
               'urt=$upstream_response_time '
               'bytes=$body_bytes_sent';

# 放在流式接口对应的 server 或 location 中
access_log /var/log/nginx/sse_access.log sse;

其中,$upstream_header_time 记录获取上游响应头的耗时,$upstream_response_time 记录上游响应耗时;它们并不知道哪一个事件才是有意义的回答内容。(Nginx)

$request_time 覆盖请求处理至日志写入前的整个阶段。一个持续输出几十秒的正常流,出现较长的请求时间,并不能单独证明它发生了卡顿。(Nginx)

建议在应用和前端额外记录:

观测点 要回答的问题
应用收到首个有效内容 上游什么时候真正开始产出?
应用首次写出有效事件 内容是否滞留在应用内部?
前端首次消费有效事件 内容是否滞留在传输或解析环节?
首次展示及后续事件间隔 用户实际等待多久,中途是否停顿?

这里的“有效内容”应由业务定义,不能把连接成功通知、空事件或心跳都算成回答开始。

八、流式恢复正常,还不等于生产设计完成

完成传输排障后,还应补上重连和资源管理边界。

原生 EventSource 在可重连的断开情形下会尝试恢复连接,并可通过 Last-Event-ID 携带之前的事件标识。但事件能否续传,仍然取决于服务端是否保存并支持回放相应内容。(HTML Living Standard)

因此,建议把“创建生成任务”和“订阅已有任务”分开设计。重新订阅不应直接等同于重新调用模型,否则一次网络波动就可能重复执行同一任务。

客户端离开后的处理也应明确:任务应该取消,还是允许继续执行?无论选择哪一种,都应同步管理上游请求、定时器、队列和连接资源,而不是只关闭下游响应。

对于接收速度慢的客户端,还应给待发送队列设置边界。持续产出但无法及时消费时,不能依赖无限堆积来维持“连接还活着”的表象。

这些问题不属于某个 Nginx 开关能够独立解决的范围,而是流式任务生命周期的一部分。

结语

遇到“AI 回答总是一整段出现”,最有效的起点,不是搜一份包含几十条参数的配置模板,而是先构造一个节奏确定的探针。

让它依次经过应用直连、反向代理、真实入口和前端消费逻辑,比较每一层的到达时间。

哪个环节第一次改变了输出节奏,就优先调查哪个环节;修改之后,再用同一个探针验证,而不是凭页面似乎变快了就结束排查。

流式传输优化的关键,不是让配置看起来更激进,而是让每一段内容在产生之后,都能沿着链路及时向前。

0 条评论
如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  0 条评论