一个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,它可能破坏实时输出体验。
例如:
Spring Boot生成第一个Token并发送。
Nginx接收到数据,但暂时保留在缓冲区。
后端继续生成内容,Nginx不断接收。
浏览器一直没有收到足够的数据。
当响应结束或缓冲条件满足时,数据才集中转发。
于是开发人员看到的现象就是:后端一直在推送,前端却最后一次性显示。
解决方向很明确:对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 会员订阅渠道,有需要可自取。
![[Android] ChatGPT 最新 -全球顶级AI大模型-171主机测评](https://www.171host.com/wp-content/uploads/2026/11/20261102155225-6ae8b1b96af4c-220x150.jpg)





