ChatGPT、Codex工程实战:Spring Boot的SSE一直在推送,为什么经过Nginx后前端才一次性显示?

一个Java开发人员最近在维护一个基于Spring Boot的大模型聊天系统。为了让用户能够像使用ChatGPT一样,看到AI逐步生成回答,团队把原来的普通HTTP接口改成了SSE流式输出。

本地测试时一切正常。

用户发送问题后,后端每隔几十毫秒就能生成一段内容,浏览器也会不断显示新文字。开发人员打开日志,能够看到每次SseEmitter.send()都正常执行。

但部署到生产环境后,问题出现了。

前端发送请求后,页面一直显示加载状态。后端日志显示AI已经生成了几百个Token,接口也没有报错,但浏览器却迟迟没有显示内容。

等到AI完整回答生成结束,前端突然一次性显示出全部文字。

开发人员最初怀疑是模型接口没有真正流式返回,又检查了Spring Boot的异步线程和SseEmitter,发现后端确实在持续推送。

随后,他们绕过Nginx,直接访问Spring Boot服务,流式输出又恢复正常。

这时候问题已经比较明确:应用正在持续发送数据,但数据经过Nginx后没有及时到达浏览器。

为什么本地正常,经过Nginx就变成了一次性输出?

问题可能出现在反向代理缓冲、响应压缩、连接超时、SSE事件格式,甚至前端读取方式上。只检查后端有没有调用send(),无法证明整个链路真正实现了流式传输。

一、先给出核心判断:SSE不是后端持续发送,前端就一定能实时显示

SSE(Server-Sent Events)是一种基于HTTP的服务器向客户端持续推送事件的机制。

在大模型应用中,常见流程是:

用户提交问题,Spring Boot调用模型接口,模型逐步返回生成内容,后端将内容封装成SSE事件,再通过HTTP连接持续发送给浏览器。

理想情况下,整个过程应该是:

模型生成一段内容 → 后端接收 → Spring Boot推送 → Nginx转发 → 浏览器读取 → 页面更新。

但这里面存在多个可能缓冲数据的环节。

例如:

  • 模型SDK可能先收集完整响应,再交给业务代码。

  • Spring Boot可能没有及时提交或刷新响应。

  • Nginx可能把上游数据暂存在缓冲区。

  • Gzip压缩可能影响数据输出时机。

  • 前端可能使用普通JSON读取方式等待完整响应。

因此,后端日志显示持续调用send(),只能证明应用尝试发送数据,不能证明浏览器已经收到并处理了这些数据。

真正的流式输出,需要整个请求链路都支持及时传输。

二、最常见原因:Nginx默认开启了代理缓冲

这是这类问题最值得优先检查的地方。

Nginx作为反向代理时,默认可以对上游响应进行缓冲。

简单理解,Spring Boot不断向Nginx发送数据,但Nginx不一定立即把每一段数据转发给浏览器。

它可能先读取上游响应,将数据放入内存缓冲区,必要时还会使用临时文件,再根据缓冲策略向客户端发送。

对于普通接口,这种机制通常有助于处理上游响应和客户端接收速度不一致的问题。

但对于SSE,它可能破坏实时输出体验。

例如:

  1. Spring Boot生成第一个Token并发送。

  2. Nginx接收到数据,但暂时保留在缓冲区。

  3. 后端继续生成内容,Nginx不断接收。

  4. 浏览器一直没有收到足够的数据。

  5. 当响应结束或缓冲条件满足时,数据才集中转发。

于是开发人员看到的现象就是:后端一直在推送,前端却最后一次性显示。

解决方向很明确:对SSE接口关闭Nginx响应缓冲。

例如:

复制代码
location /api/chat/stream {
    proxy_pass http://spring_backend;

    proxy_http_version 1.1;
    proxy_set_header Connection "";

    proxy_buffering off;
    proxy_cache off;

    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;

    gzip off;
}

这里最关键的是:

proxy_buffering off:关闭该接口的代理响应缓冲,让Nginx尽可能及时转发上游数据。

proxy_cache off:避免该接口使用代理缓存。

proxy_read_timeout:延长等待上游数据的时间,减少长时间没有数据时连接被关闭的情况。

gzip off:避免该接口的压缩处理影响流式输出。

注意,proxy_read_timeout不是整个请求的最长执行时间,而主要控制两次上游读取操作之间允许的空闲时间。

另外,proxy_request_buffering off经常被误认为是解决SSE响应延迟的配置。它主要控制请求体缓冲,不是这里需要优先修改的响应缓冲配置。

三、为什么只修改Nginx还不够?Spring Boot也要正确设置响应头

除了Nginx配置,后端还应该明确告诉代理和客户端:这是一个持续输出的事件流。

SSE响应通常需要以下几个重要属性:

响应头 作用
Content-Type text/event-stream
Cache-Control no-cache
X-Accel-Buffering no

其中,X-Accel-Buffering: no是Nginx支持的响应控制头,可以让Nginx不对该响应进行缓冲,前提是没有通过配置忽略这个响应头。

Spring Boot可以在响应中设置:

复制代码
HttpHeaders headers = new HttpHeaders();
headers.setContentType(
    MediaType.TEXT_EVENT_STREAM
);
headers.setCacheControl("no-cache");
headers.add("X-Accel-Buffering", "no");

return new ResponseEntity<>(
    emitter, headers, HttpStatus.OK
);

这里的emitter是已经创建好的SseEmitter。

需要注意,响应头设置和Nginx配置不是互相替代的关系。

对于生产环境,我更建议在Nginx对应的SSE location中明确关闭缓冲,同时在后端设置X-Accel-Buffering: no,形成双重保障。

还要检查Nginx是否配置了proxy_ignore_headers X-Accel-Buffering。如果忽略了这个响应头,后端即使设置成功,也不一定能够发挥预期作用。

四、第三个问题:Spring Boot调用send(),为什么仍然可能没有及时输出?

如果已经关闭Nginx缓冲,仍然存在延迟,就需要回到Spring Boot本身。

以SseEmitter为例,常见实现方式是:

复制代码
SseEmitter emitter =
    new SseEmitter(300000L);

executor.execute(() -> {
    try {
        emitter.send(
            SseEmitter.event()
                .name("message")
                .data("正在生成内容")
        );
    } catch (IOException e) {
        emitter.completeWithError(e);
    }
});

这只是一个简化示例,真实项目还需要处理任务结束、异常和客户端断开连接。

这里有几个容易忽略的地方。

第一,不能把整个模型响应收集完成后再发送。

例如,调用模型接口时使用阻塞式方式获取完整字符串,再调用一次emitter.send()。

这种写法虽然使用了SseEmitter,但本质上仍然是普通请求响应。

真正的流式处理应该让模型SDK逐段返回内容,后端收到一段就发送一段。

第二,异步线程不能无限制创建。

如果每个聊天请求都创建独立线程,随着并发增加,可能出现线程数量膨胀、线程池排队和资源耗尽。

建议使用有界线程池,并设置合理的并发量和拒绝策略。

第三,注意客户端断开连接。

用户关闭页面后,后端可能仍在继续生成内容。如果没有及时取消模型调用,就会浪费计算资源和模型额度。

应该通过SseEmitter的完成、超时和错误回调,管理对应的生成任务,并在客户端断开时尽可能停止上游任务。

因此,不能只检查send()是否被调用,还要确认发送时机、执行线程和连接生命周期。

五、SSE事件格式不正确,浏览器为什么也可能一直不显示?

这是另一个经常被忽略的问题。

SSE并不是简单地通过HTTP不断发送字符串,它有自己的事件格式。

例如:

复制代码
event: message
data: 第一段内容

event: message
data: 第二段内容

每个事件需要使用空行结束。

如果后端持续发送内容,却没有正确结束事件,浏览器可能已经收到数据,但还没有将它识别为一个完整事件。

这种情况容易被误判成Nginx缓冲。

对于SseEmitter,通常可以使用标准事件构建方式:

复制代码
emitter.send(
    SseEmitter.event()
        .name("message")
        .data(content)
);

Spring会按照SSE格式处理事件。

如果使用WebFlux的Flux、手动写HttpServletResponse,或者自己拼接SSE字符串,就需要特别检查换行符和事件结束标记。

此外,数据中包含换行、中文、多字节字符时,也要确保编码处理正确。

浏览器原生EventSource会按照SSE协议解析事件,而不是每收到一个HTTP数据块就立即触发一次事件。

六、前端为什么可能已经收到数据,却仍然一次性显示?

还有一种情况比较隐蔽:后端和Nginx都没有问题,前端却在等待完整响应。

例如,前端使用:

复制代码
const response = await fetch(url);
const result = await response.json();

这种写法会等待完整响应体才能解析JSON。

如果后端返回的是持续输出的SSE内容,就不应该使用普通JSON读取方式。

对于Fetch流式读取,需要使用ReadableStream:

复制代码
const response = await fetch(url);

const reader = response.body.getReader();
const decoder = new TextDecoder();

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

    if (done) break;

    const chunk = decoder.decode(
        value,
        { stream: true }
    );

    console.log(chunk);
}

这只是读取HTTP数据块的示例,还需要进一步按照SSE协议解析完整事件。

尤其要注意:一次reader.read()返回的数据,不一定恰好对应一个SSE事件。一个事件可能被拆成多个数据块,多个事件也可能被合并在一个数据块中。

所以需要维护解析缓冲区,按照空行分隔事件,不能直接把每个Chunk当成一条完整消息。

如果使用原生EventSource,则应通过onmessage或addEventListener处理事件,不需要自己解析HTTP数据块。

另外,EventSource主要用于GET请求。如果大模型聊天接口需要通过POST提交复杂请求体,可以使用Fetch流式读取,或者由后端提供适合的请求设计。

七、最快排查:按照这4个位置定位到底是哪一层在缓冲

遇到这种问题,不建议一开始就同时修改Nginx、Spring Boot和前端。

更高效的方式是逐层对比。

第一步:绕过Nginx,直接访问Spring Boot。

使用curl测试:

复制代码
curl -N -i \
http://127.0.0.1:8080/api/chat/stream

-N可以关闭curl自身的输出缓冲。

如果直连后端能够持续输出,说明应用至少具备基本的流式传输能力,接下来重点检查代理链路。

如果直连后端也是一次性输出,就应该先检查模型SDK、Spring Boot和SSE事件格式。

第二步:通过Nginx访问同一个接口。

复制代码
curl -N -i \
https://example.com/api/chat/stream

对比两次请求的输出时间。

如果直连正常、代理异常,优先检查proxy_buffering、响应压缩、缓存和其他代理层配置。

第三步:检查浏览器Network面板。

观察请求是否保持Pending状态,Response数据是否持续增加,事件是否按预期到达。

如果curl能够持续输出,但浏览器没有更新,重点检查前端读取逻辑、SSE事件解析和页面渲染。

第四步:检查模型接口是否真正流式返回。

不要只看Spring Boot日志中的send()调用。

还要确认模型SDK是否逐段返回内容,有没有使用collectList()、block()或者类似方式等待完整响应。

如果上游模型本身已经被收集成完整字符串,那么后面的Nginx配置再正确,也无法恢复真正的流式效果。

八、还有哪些边界问题容易被忽略?

除了代理缓冲,生产环境还需要注意几个问题。

1. Gzip和其他压缩层。

某些压缩过滤器可能积累一定数据后才输出。SSE接口可以单独关闭压缩,避免压缩处理影响响应时机。

2. CDN和多层反向代理。

请求经过Nginx后,可能还要经过负载均衡器、API网关或CDN。只关闭最前面的Nginx缓冲,不代表整个链路都没有缓冲。

3. 心跳与空闲超时。

如果模型生成过程中长时间没有新内容,代理可能认为连接空闲而关闭连接。

可以定期发送SSE注释心跳,例如:

复制代码
: keepalive

心跳间隔应小于链路中的空闲超时时间。对于Spring Boot,还要合理设置SseEmitter超时,避免长任务被提前结束。

4. 客户端断开与任务取消。

用户离开页面后,后端应该能够感知连接异常,并尽可能取消模型生成任务。否则即使前端已经不需要结果,后端仍可能持续消耗资源。

这些问题不一定表现为一次性输出,但都属于生产环境SSE必须考虑的工程边界。

九、修复后怎样验证?

不能只看页面是否能够逐字显示,还需要验证整个链路的性能和稳定性。

建议重点记录三个时间:

  • T1:模型返回第一段内容的时间。

  • T2:Spring Boot发送第一段SSE事件的时间。

  • T3:浏览器收到并处理第一段事件的时间。

如果T1正常、T2正常,但T3明显延迟,就应该检查Spring Boot之后的代理链路。

如果T1本身就很晚,问题可能发生在模型调用或上游SDK。

如果T3已经及时到达,但页面很久才显示,则需要检查前端事件解析和渲染。

可以把首段内容到达时间作为主要观察指标,再结合持续输出间隔、连接中断率和完整响应时间判断。

例如,后端第一段内容在200毫秒时发送,浏览器却要等到完整回答结束才收到,那么就不能认为这条SSE链路已经实现了实时输出。

真正的流式验证,要看数据何时到达客户端,而不是后端何时调用send()。

十、Plus和Pro怎么判断?

如果主要使用ChatGPT、Codex开发单个Spring Boot SSE接口,排查Nginx缓冲、SseEmitter、Fetch流式读取和响应超时,Plus通常够用。

如果需要跨多个服务、网关和代理层分析AI流式调用,还要持续优化异步任务管理、并发控制、断开取消和压力测试,Pro更适合高频、长任务的工程工作流。

套餐选择仍然要结合实际开发任务和使用频率,不能替代真实部署环境下的链路测试。

最后

Spring Boot的SSE一直在推送,为什么经过Nginx后前端才一次性显示?

因为后端持续发送,只是整个流式链路的第一步。Nginx响应缓冲、压缩处理、SSE事件格式和前端读取方式,都可能让数据无法及时展示。

正确的排查顺序是:

确认模型是否流式返回 → 检查Spring Boot发送时机 → 关闭Nginx响应缓冲 → 检查压缩与超时 → 验证SSE事件格式 → 检查前端流式解析 → 进行完整链路测试。

ChatGPT、Codex能够帮助开发人员快速生成SSE接口、Nginx配置和异步处理代码,但真正影响生产效果的,是这些组件之间的协作方式。

流式输出不是后端把数据分成很多段发送就结束了,而是每一段内容都应该尽可能及时地到达客户端。

持续分享 Codex、大模型开发与 AI 编程实战内容

也整理了稳定的 Plus/Pro 会员订阅渠道,有需要可自取。

相关推荐
孤狼GPT2 小时前
ChatGPT、Codex性能实战:MySQL升级到8.0后,SQL没改,为什么接口P99反而翻倍?
spring boot·chatgpt·sql优化·codex·mysql8.0
她说..2 小时前
常见设计模式-观察者模式
java·spring boot·spring·观察者模式·设计模式
vipxieliang2 小时前
Actuator 与 Micrometer:生产级监控体系搭建
java·spring boot
代码不停2 小时前
MyBatis进阶
spring boot·mybatis
captain37612 小时前
MyBatis(入门5)其他查询操作
java·数据库·spring boot·mybatis
学长毕设20 小时前
基于SpringBoot的烘焙商品商品系统-附源码
java·spring boot·后端
howard200521 小时前
英中 / 中英对照制作程序:操作指南
spring boot·bootstrap·thymeleaf·操作指南·对照制作程序
CoderYanger1 天前
Java EE 进阶:5. Spring IoC&DI(第三弹):Spring DI
java·开发语言·spring boot·spring·面试·java-ee·mvc