导读:后端接了大模型,想做成 ChatGPT 那种一个字一个字往外蹦的效果,结果前端等了几秒一次性收到整段,或者只收到第一个字,或者回答到一半断了。我在 Spring Boot 4.1.2 上写了个假模型,逐个场景量了每个 token 到达客户端的时间,找出 5 个常见原因和对应改法,顺带验证了一个流传很广但在 Tomcat 上不成立的说法:开 gzip 会让 SSE 变成一次性返回。
一、先说清楚:SSE 是什么,为什么会"一次性返回"
SSE(Server-Sent Events) 就是服务端在一个 HTTP 响应里,隔一会儿往外写一段、隔一会儿再写一段,连接不关。浏览器那边每收到一段就显示一段,看起来就是打字机效果。大模型的流式接口(OpenAI、Anthropic、DeepSeek 都一样)本身就是这么返回的,Java 后端要做的是把模型吐出来的 token 原样转给前端。
格式很简单,每条消息是 data: 开头的一行,后面跟一个空行:
text
data:Spring
data: Boot
event:done
data:[DONE]
"一次性返回"是怎么回事:链路上只要有一环把响应先攒起来、最后再发,客户端看到的就是等几秒后整段出现。这一环可能是你自己的 controller 写法、一个记录日志的过滤器、Servlet 容器、Nginx,或者网关。
不管它会怎样:功能上答案还是对的,但用户要盯着空白页等完整个回答,长回答可能要等十几秒,体验和"卡死了"差不多;有几种情况更糟,前端只收到第一个字就结束,或者 30 秒一到连接直接被掐断。
什么时候该查:前端说"流式没生效"、回答长了就断、加了某个过滤器或网关之后流式突然失效,按本文第三节的顺序挨个排查。
二、实测环境和测量方法
| 项 | 版本 / 做法 |
|---|---|
| Spring Boot | 4.1.2(spring-webmvc 7.0.10,内嵌 Tomcat 11.0.27) |
| JDK | 17.0.12 |
| 系统 | macOS 15(arm64) |
| 假模型 | 10 个 token,每个间隔 300ms,一共约 3 秒 |
| 测量 | Python 客户端逐块读响应,打印每块到达的时间和内容 |
看"是不是真流式",最直接的办法是看每个 token 到达的时间:真流式是 0.3 秒、0.6 秒、0.9 秒......逐个到;被攒起来了就是 3 秒时一次到齐。浏览器开发者工具的 Network 面板也能看,但不如时间戳直观。
客户端脚本如下,后面所有输出都是它打的:
python
# client.py:逐块读取响应,打印每块到达时间(支持 gzip)
import sys, time, urllib.request, zlib
url = sys.argv[1]
gz = len(sys.argv) > 2 and sys.argv[2] == 'gzip'
req = urllib.request.Request(url, headers={'Accept-Encoding': 'gzip'} if gz else {})
t0 = time.time()
with urllib.request.urlopen(req, timeout=120) as r:
print('status', r.status, 'Content-Type', r.headers.get('Content-Type'), 'Content-Encoding', r.headers.get('Content-Encoding'), 'Transfer-Encoding', r.headers.get('Transfer-Encoding'))
d = zlib.decompressobj(16 + zlib.MAX_WBITS) if r.headers.get('Content-Encoding') == 'gzip' else None
while True:
chunk = r.read1(65536)
if not chunk:
break
text = d.decompress(chunk) if d else chunk
lines = [l for l in text.decode('utf-8', 'replace').split('\n') if l.startswith('data:') or l.startswith('event:')]
print(f'{time.time()-t0:6.2f}s {len(chunk):4d}B {lines}')
print(f'end {time.time()-t0:.2f}s')
💡 命令行里用
curl -N也能看,-N关掉 curl 自己的输出缓冲。不加-N时 curl 也可能攒着输出,容易误判成后端的问题。
先看正确写法和它的效果
依赖只要 spring-boot-starter-webmvc(Boot 4 里 Web MVC 的 starter 改叫这个名字)。正确写法的要点只有一个:先把 SseEmitter 返回给 Spring,在另一个线程里逐个 send。
java
// ChatController.java:正确写法,emitter 先返回,另起线程逐个 send
@RestController
public class ChatController {
// 模拟大模型逐个吐出的 token
private static final List<String> TOKENS = List.of("Spring", " Boot", " 的", " SSE", " 要", " 在", " 别的", " 线程", " 里", " 发");
private final ExecutorService pool = Executors.newCachedThreadPool();
@GetMapping(path = {"/chat/stream", "/log/chat/stream"}, produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@RequestParam(defaultValue = "300") long gapMs,
@RequestParam(defaultValue = "0") int count) {
SseEmitter emitter = new SseEmitter(60_000L);
pool.execute(() -> {
try {
int n = count > 0 ? count : TOKENS.size();
for (int i = 0; i < n; i++) {
emitter.send(SseEmitter.event().data(TOKENS.get(i % TOKENS.size())));
Thread.sleep(gapMs);
}
emitter.send(SseEmitter.event().name("done").data("[DONE]"));
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter;
}
}
实测每个 token 间隔约 0.3 秒逐个到达,这是我们要的效果:
text
status 200 Content-Type text/event-stream Content-Encoding None Transfer-Encoding chunked
0.04s 13B ['data:Spring']
0.34s 12B ['data: Boot']
0.65s 11B ['data: 的']
0.95s 11B ['data: SSE']
...(中间 5 个 token 每隔约 0.3 秒到一个)
2.79s 11B ['data: 发']
3.09s 24B ['event:done', 'data:[DONE]']
end 3.10s
⚠️ 示例里的
newCachedThreadPool()只是为了演示。生产上请用有界线程池,或者直接用模型 SDK 自带的回调线程(比如流式回调里send),否则并发一高线程数会失控。
三、5 个原因,按排查顺序
原因 1:在 controller 线程里循环 send,最后才 return
这是最常见的写法错误。看起来很自然:拿到模型的流,循环里 send,循环完 return emitter。
java
// 错误写法:在 controller 线程里循环 send,最后才 return
@GetMapping(path = "/chat/blocking", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter blocking() throws Exception {
SseEmitter emitter = new SseEmitter(60_000L);
for (String t : TOKENS) {
emitter.send(SseEmitter.event().data(t));
Thread.sleep(300);
}
emitter.complete();
return emitter;
}
实测 10 个 token 在 3.07 秒时一次到齐:
text
status 200 Content-Type text/event-stream Content-Encoding None Transfer-Encoding chunked
3.07s 119B ['data:Spring', 'data: Boot', 'data: 的', 'data: SSE', 'data: 要', 'data: 在', 'data: 别的', 'data: 线程', 'data: 里', 'data: 发']
end 3.07s
为什么 :方法没 return 之前,Spring 还没拿到这个 emitter,也就没开始异步处理、没把它和响应连起来。这期间的 send 不会报错,Spring 先把消息存在 emitter 内部(ResponseBodyEmitter 里有个 earlySendAttempts 列表,我在 spring-webmvc 7.0.10 的 class 文件里确认了这个字段),等方法 return、emitter 初始化完成后一次性写出去。
改法 :像第二节那样,先 return emitter,在别的线程里 send。用 Spring AI 的话更省事,直接返回 Flux,见原因 4。
这种写法还有个副作用:整个回答期间都占着一个 Tomcat 请求线程,失去了用异步的意义。
原因 2:日志过滤器用 ContentCachingResponseWrapper 包了响应
很多项目都有一个"打印请求和响应体"的过滤器,核心是 Spring 自带的 ContentCachingResponseWrapper:先把响应体写进内存,过滤器结束时打日志,再 copyBodyToResponse() 写回去。
java
// 常见的"记录响应体"日志过滤器,这里只拦 /log/**
@Component
public class BodyLogFilter extends OncePerRequestFilter {
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().startsWith("/log/");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
ContentCachingResponseWrapper wrapper = new ContentCachingResponseWrapper(response);
try {
chain.doFilter(request, wrapper);
} finally {
System.out.println("response body bytes so far: " + wrapper.getContentSize());
wrapper.copyBodyToResponse();
}
}
}
同一个正确写法的接口,换成走过滤器的 /log/chat/stream,我原本以为结果是"攒到最后一次性返回",实测比这还糟:客户端只收到第一个 token,连接就结束了。
text
status 200 Content-Type text/event-stream Content-Encoding None Transfer-Encoding None
0.02s 13B ['data:Spring']
end 0.02s
用 curl -i 看响应头,问题一目了然,响应头里多了 Content-Length: 13,Transfer-Encoding: chunked 没了:
text
HTTP/1.1 200
Content-Type: text/event-stream
Content-Length: 13
Date: Thu, 05 Nov 2026 00:44:09 GMT
data:Spring
为什么 :SSE 是异步请求,controller 一 return,过滤器链就走完了。这时过滤器的 finally 执行,wrapper 里只有第一个 token 的 13 个字节,copyBodyToResponse() 把这 13 字节连同 Content-Length: 13 一起写出去。客户端收够 13 字节就认为响应结束。后面 9 个 token 还在往 wrapper 里写,但再也没人把它们复制出去,服务端日志里也没有任何报错 ,只有我加的那行 response body bytes so far: 13。
这个坑难查的地方就在这里:后端日志干干净净,前端说"只出了一个字"。
改法 :让这类过滤器跳过流式接口。最稳的是按路径排除,或者看请求头 Accept 里有没有 text/event-stream:
java
// 让响应体日志过滤器跳过 SSE:按路径或 Accept 头排除
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String accept = request.getHeader("Accept");
return request.getRequestURI().startsWith("/chat/")
|| (accept != null && accept.contains("text/event-stream"));
}
⚠️ 只看
Accept头不够保险:浏览器的EventSource会带Accept: text/event-stream,但前端用fetch读流时不一定带。路径排除和Accept判断最好一起用。上面这段排除逻辑我只验证了按路径排除那一半(不走过滤器的/chat/stream正常流式),Accept判断没单独测。
排查时把项目里所有 Filter、HandlerInterceptor、AOP 切面里碰响应体的地方过一遍,关键词是 ContentCachingResponseWrapper、HttpServletResponseWrapper、getOutputStream。
原因 3:没传超时,30 秒后被掐断
new SseEmitter() 不传参数时,超时用 spring.mvc.async.request-timeout;这个也没配,就用容器默认值。Tomcat 文档里 asyncTimeout 的默认值是 Servlet 规范的 30000 毫秒。
我让一个不传超时的 emitter 每秒发一个 token、一共发 40 个,结果在第 30 秒断开:
text
28.24s 10B ['data:t28']
29.24s 10B ['data:t29']
30.24s 10B ['data:t30']
end 30.45s
服务端日志里能看到超时回调触发、Spring 打了 AsyncRequestTimeoutException 的 WARN,之后后台线程再 send 就抛 IllegalStateException:
text
onTimeout fired
WARN ... DefaultHandlerExceptionResolver : Ignoring exception, response committed already: org.springframework.web.context.request.async.AsyncRequestTimeoutException
send failed: java.lang.IllegalStateException: ResponseBodyEmitter has already completed
大模型的长回答、带工具调用的 Agent 一轮跑 30 秒很常见,测试时问个短问题发现不了,上线后长问题才出现"回答到一半断了"。
我又验证了两个配置的优先级。把 spring.mvc.async.request-timeout 设成 5 秒:
| emitter 写法 | 结果 |
|---|---|
new SseEmitter() |
第 5 秒断开,onTimeout 触发 |
new SseEmitter(60_000L) |
8 秒的流完整收到,不受 5 秒配置影响 |
也就是说构造函数里显式传的超时优先于全局配置。
改法:
java
// 显式给超时,按业务最长回答时间留余量;同时注册回调,超时/出错时停掉模型调用
SseEmitter emitter = new SseEmitter(180_000L);
emitter.onTimeout(emitter::complete);
emitter.onError(e -> log.warn("sse error", e));
emitter.onCompletion(() -> log.debug("sse completed"));
或者全局配:
properties
# application.properties:异步请求的默认超时(SseEmitter() 不传参时用它)
spring.mvc.async.request-timeout=180s
💡 超时之后最好把上游的模型请求也取消掉,否则前端已经断了,后端还在继续收 token、继续计费。用
onTimeout、onError、onCompletion回调去关上游的流。
原因 4:返回 Flux 但没写 produces
用 Spring AI 的话,chatClient.prompt(...).stream().content() 返回的是 Flux<String>,Spring MVC 可以直接把它当返回值(classpath 上有 reactor-core 就行,不用引入 WebFlux)。
java
// FluxController.java:Spring MVC 里直接返回 Flux,用假模型代替 Spring AI 的流
@RestController
public class FluxController {
// Spring AI 的 chatClient.prompt(...).stream().content() 返回的就是 Flux<String>
private Flux<String> fakeModel() {
return Flux.just("Flux", " 在", " MVC", " 里", " 也能", " 流式").delayElements(Duration.ofMillis(300));
}
@GetMapping(path = "/flux/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> stream() {
return fakeModel();
}
// 没写 produces:看客户端拿到的是什么
@GetMapping(path = "/flux/no-produces")
public Flux<String> noProduces() {
return fakeModel();
}
@GetMapping(path = "/flux/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> sse() {
return fakeModel().map(t -> ServerSentEvent.builder(t).event("token").build())
.concatWith(Flux.just(ServerSentEvent.builder("[DONE]").event("done").build()));
}
}
三个接口的实测结果:
| 接口 | Content-Type | 是否逐个到达 | 客户端看到的内容 |
|---|---|---|---|
/flux/stream(写了 produces) |
text/event-stream |
是,间隔约 0.3 秒 | data:Flux、data: 在...... |
/flux/no-produces |
text/plain |
是 | 裸文本 Flux 在 MVC 里 也能 流式,没有 data: 前缀 |
/flux/sse(ServerSentEvent) |
text/event-stream |
是 | event:token + data:Flux...... |
没写 produces 时数据其实也是一块一块到的,但它是 text/plain,每个 token 之间没有分隔。前端如果用浏览器的 EventSource,HTML 规范要求响应必须是 text/event-stream,否则连接直接失败;如果用 fetch 自己解析 data: 行,也解析不出东西。前端看到的现象往往是"没反应"或"最后拼成一坨"。
改法 :流式接口一律写 produces = MediaType.TEXT_EVENT_STREAM_VALUE。需要区分事件类型(正文、工具调用、结束标记)时用 Flux<ServerSentEvent<String>>。
还有一点和原因 3 相反,是我初稿写错、实测推翻的:返回 Flux 做流式时,spring.mvc.async.request-timeout 不起作用 。我把它设成 5 秒,让 Flux 每 7 秒才吐一个元素,两个元素 14 秒照样完整收到,日志里没有超时异常。用 javap 看 spring-webmvc 7.0.10 的 ReactiveTypeHandler,流式类型用的是 STREAMING_TIMEOUT_VALUE = -1,也就是不设超时。
java
// Flux 流式默认没有超时:上游模型卡住时,自己加一个"两个 token 之间最多等多久"
@GetMapping(path = "/chat/flux", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chat(@RequestParam String q) {
return chatClient.prompt(q).stream().content()
.timeout(Duration.ofSeconds(60));
}
这样不会被 30 秒掐断,但上游模型卡住时连接也会一直挂着。上面的 .timeout() 是 Reactor 的标准操作符,表示两个元素之间超过 60 秒就报错结束;chatClient 那一行是 Spring AI 的写法,这段我没接真模型跑。
原因 5:Nginx、网关把响应缓冲了
前面四个都排除了,本地直连后端是流式的,经过 Nginx 或网关就变成一次性返回,那就是代理层缓冲了响应。这一条我本机没装 Nginx,下面的配置据 Nginx 官方文档,未实测。
Nginx 的 proxy_buffering 默认是 on,会先把上游响应读进缓冲区再发给客户端。两种关法:
nginx
# 方式一:在 Nginx 里对流式接口关掉代理缓冲
location /chat/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_read_timeout 300s;
}
方式二不用改 Nginx,后端在响应头里加 X-Accel-Buffering: no,Nginx 文档写明这个头可以按响应开关缓冲:
java
// 方式二:后端加响应头,让 Nginx 对这个响应不做缓冲
@GetMapping(path = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(HttpServletResponse response) {
response.setHeader("X-Accel-Buffering", "no");
SseEmitter emitter = new SseEmitter(180_000L);
// 另起线程 send,同第二节
return emitter;
}
proxy_read_timeout 默认 60 秒,模型两个 token 之间如果隔了很久(比如在等工具调用),也会被 Nginx 断开。其他网关(Spring Cloud Gateway、云厂商的 API 网关、CDN)各有自己的缓冲和超时设置,要分别查文档。
四、一个不成立的说法:开 gzip 会让 SSE 一次性返回
网上很多文章说"开了 server.compression.enabled 之后 SSE 就不流式了,因为 gzip 要攒够数据才压缩"。我在 Tomcat 11 上实测,这个说法不成立。
先看默认配置:Spring Boot 的 server.compression.mime-types 默认列表里没有 text/event-stream,只开 server.compression.enabled=true 时 SSE 根本不会被压缩。实测客户端带 Accept-Encoding: gzip 请求,响应头里没有 Content-Encoding,照常逐个到达。
再故意把 text/event-stream 加进去,把最小压缩大小设成 1 字节,强制压缩:
bash
# 强制对 SSE 启用 gzip
java -jar sse-demo-1.0.jar --server.compression.enabled=true \
--server.compression.mime-types=text/event-stream,application/json \
--server.compression.min-response-size=1
结果响应确实变成 gzip 了,token 依然逐个到达:
text
status 200 Content-Type text/event-stream Content-Encoding gzip Transfer-Encoding chunked
0.04s 10B []
0.04s 19B ['data:Spring']
0.33s 16B ['data: Boot']
0.63s 13B ['data: 的']
...(每隔约 0.3 秒到一个)
3.08s 26B ['event:done', 'data:[DONE]']
3.08s 10B []
end 3.08s
第一个 10 字节是 gzip 头,最后一个 10 字节是 gzip 尾,中间每个 token 单独压缩、单独发出。也就是说 Tomcat 在 send 刷新时会把 gzip 流也刷出去,不会攒着。
⚠️ 这个结论只在 Spring Boot 4.1.2 内嵌 Tomcat 11.0.27 上测过。Undertow、Jetty,以及 Nginx 的
gzip on、CDN 压缩没测,不能直接套用。真碰到"开压缩后不流式",压缩发生在哪一层要单独查。
所以排查顺序里我没有把 gzip 放进前 5 个原因,查完上面 5 个再看它。
五、速查表
| 现象 | 最可能的原因 | 怎么确认 | 改法 |
|---|---|---|---|
| 等几秒后整段一起出现,本地直连也这样 | controller 线程里 send(原因 1) | 看代码是不是循环完才 return | 先 return emitter,另起线程 send,或返回 Flux |
| 只收到第一个字就结束,后端无报错 | 响应体缓存过滤器(原因 2) | curl -i 看到 Content-Length、没有 chunked |
过滤器跳过流式接口 |
| 回答到 30 秒左右断开 | 异步默认超时(原因 3) | 日志里有 AsyncRequestTimeoutException |
new SseEmitter(超时) 或 spring.mvc.async.request-timeout |
| 数据在走,但前端没反应或拼成一坨 | 没写 produces(原因 4) | 响应头是 text/plain |
produces = TEXT_EVENT_STREAM_VALUE |
| 直连正常,过 Nginx/网关就一次性返回 | 代理缓冲(原因 5) | 绕过代理直连对比 | proxy_buffering off 或 X-Accel-Buffering: no |
排查时先用 curl -N -i 直连后端端口,把问题分成"后端自己的问题"和"中间层的问题"两半,能省掉一半时间。
bash
# 直连后端,看响应头和逐个到达的效果
curl -N -i http://localhost:8080/chat/stream
六、常见问题
Q:SseEmitter 和返回 Flux 选哪个?
接 Spring AI 的话直接返回 Flux<String> 或 Flux<ServerSentEvent<String>>,不用自己管线程,原因 1 那种错误也犯不了。手上是 OkHttp 回调、厂商 SDK 的监听器这类回调式接口,用 SseEmitter 在回调里 send 更直接。项目用的是 Spring MVC 的话,两种都不需要引入 WebFlux。
Q:前端用 EventSource 还是 fetch?
EventSource 只支持 GET、不能自定义请求头,带 token 鉴权和长 prompt 时不方便,很多项目改用 fetch 读 ReadableStream 自己解析 data: 行。用 fetch 时注意原因 2 里说的 Accept 头问题。
Q:send 一个 token 就刷一次网络,会不会太频繁?
每个 token 一个 HTTP chunk,上面的实测里一个 token 加上 SSE 格式大约十几个字节。如果担心开销,可以在后端攒几十毫秒或几个 token 再 send 一次,用户几乎感觉不到差别。
Q:客户端断开了,后端怎么知道?
下一次 send 会抛异常。我用 curl -m 2 在 2 秒时主动断开,后台线程下一次 send 抛的是 AsyncRequestNotUsableException: ServletResponse failed to flushBuffer: java.io.IOException: Broken pipe。拿到这个信号后要把上游的模型流也取消掉,否则会一直收 token、一直计费。返回 Flux 时上游会不会跟着取消,我没测,建议在 doOnCancel 里打一行日志自己确认一下。
参考链接
- Spring Framework 文档:Asynchronous Requests(SseEmitter、响应式返回值)
- Spring Boot 配置属性参考(spring.mvc.async.request-timeout、server.compression.*)
- Tomcat 11 HTTP Connector 配置(asyncTimeout 默认 30000ms)
- Nginx ngx_http_proxy_module(proxy_buffering、X-Accel-Buffering)
- HTML 规范:Server-sent events
你们项目的流式输出最后卡在哪一环:controller 写法、日志过滤器、超时,还是 Nginx/网关?评论区说说你们用的是 SseEmitter 还是 Flux,我挑典型的情况补进 FAQ。
觉得有用的话点个赞、收个藏,下次流式出问题时翻得到。