Spring Boot SSE 流式输出变成一次性返回怎么办:大模型打字机效果失效的 5 个原因(Spring Boot 4.1.2 实测)

导读:后端接了大模型,想做成 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 里打一行日志自己确认一下。


参考链接


你们项目的流式输出最后卡在哪一环:controller 写法、日志过滤器、超时,还是 Nginx/网关?评论区说说你们用的是 SseEmitter 还是 Flux,我挑典型的情况补进 FAQ。

觉得有用的话点个赞、收个藏,下次流式出问题时翻得到。

相关推荐
Thomas.Sir3 小时前
第15课:SpringBoot连接池详解:HikariCP配置、调优、监控,4.x连接池新特性
java·spring boot
VimorphTech3 小时前
每批开 4 个线程,为什么限制不住 TTS 的总并发?
java·spring boot·spring
杨凯凡3 小时前
Spring Boot 缓存一致性怎么保证?@Cacheable、@CacheEvict 与事务提交时机实战
java·spring boot·spring cache·后端开发·缓存一致性
软件技术NINI5 小时前
基于SpringBoot和Vue的项目评审系统
javascript·vue.js·spring boot·后端·spring·毕业设计·毕设
vipxieliang5 小时前
Spring Boot 性能优化实战:从启动加速到运行时调优
java·spring boot
AI全栈学习助手6 小时前
告别“转圈等待”:Spring Boot + SSE 让大模型像 ChatGPT 一样流式输出
java·人工智能·spring boot·后端·aigc
云-缩影15 小时前
拆解 AI 图像生成应用的后端架构:ECS、OSS 和大模型 API 怎么配合
java·spring boot·后端
AI开源软件17 小时前
SpringBoot3+Vue3 财务管理系统如何设计:从业务单据到总账的模块划分
spring boot·vue3·领域建模·会计凭证·ruoyi office·业财协同·财务系统设计
Mikko71 天前
Redis 连接数满了服务端有错误信息吗?连接数不增加却命令超时?6.2 实测连接池、超时与大 key 怎么判定
java·spring boot·redis·后端