DevFix
Spring AI已验证

Spring AI 流式输出踩坑:stream 变一次性返回、SseEmitter 挂死与 SSE 配置

@debug_master更新于 3 天前阅读 6 min0

一句话先说结论:流式输出靠 .stream() 拿到 Flux,MVC 里用 SseEmitter 订阅推送,WebFlux 里直接返回 Flux。最隐蔽的坑是“看起来是流式、实则一次性”——某些版本(2.0.0-M5/M6)的 OpenAI stream() 内部把整条流 collectList() 了再发,直接退化成非流式;另外 SseEmitter 不设超时会挂死,produces 配置漏了会导致连接提前关闭。

背景

LLM 生成一句话可能要几秒,.call() 会等你拿到完整答案才返回,用户体验是“转圈几秒后一整块文字蹦出来”。.stream() 改为把生成的每个 token 都即时吐出来,用户几百毫秒就开始读。总耗时其实没变,但“首 token 时间”大幅提前——这是整个功能的核心价值。

基本用法

流式最简一行,用 .stream() 替代 .call()

Flux<String> tokens = chatClient.prompt()
    .user("给我讲个笑话")
    .stream()
    .content();          // Flux<String>,文本增量

stream() 提供三种返回:

返回类型 内容
Flux<String> content() 文本增量(95% 的场景够用)
Flux<ChatResponse> chatResponse() 每块响应 + 元数据
Flux<ChatClientResponse> chatClientResponse() 带执行上下文,能看到 advisor/RAG 检索了啥

Spring MVC:SseEmitter

@GetMapping(path = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@RequestParam String message) {
    SseEmitter emitter = new SseEmitter(120_000L);   // 显式超时

    Flux<String> tokens = chatClient.prompt().user(message).stream().content();

    tokens.subscribe(
        token -> {
            try {
                emitter.send(token);
            } catch (IOException e) {
                emitter.completeWithError(e);   // 客户端已断开,别硬发
            }
        },
        emitter::completeWithError,
        emitter::complete);

    return emitter;
}

Spring WebFlux:直接返回 Flux

@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> stream(@RequestParam String message) {
    return chatClient.prompt().user(message).stream().content();
}

根因分析

坑 1:stream 被内部缓冲,退化成一次性返回

Spring AI 2.0.0-M5/M6OpenAiChatModel.internalStream() 一度为了判断“有没有 tool calls”,先 collectList() 把整条流收齐了再发。结果是:订阅者啥都收不到,直到模型生成完才一次性吐一批——流式名存实亡(issue #5987 / #6183,已在后续版本修复)。升级到 2.0 早期的同学很容易撞上,表现就是“加了 stream 还是整块输出”。

坑 2:SseEmitter 不设超时挂死

new SseEmitter() 默认超时很长,客户端中途断开、服务端又一直等,servlet 线程就被占住。务必显式给超时(如上 120_000L)。

坑 3:SSE 配置缺失导致连接提前关

MVC 里必须 produces = MediaType.TEXT_EVENT_STREAM_VALUE;少了它,或者返回类型/状态码不对(比如默认 204),浏览器会立刻断掉 SSE 连接。前端就会“连一下就没下文”。

坑 4:advisor 的 publishOn 无 prefetch 导致冻结

某些带 ChatModelStreamAdvisor 的场景,publishOn(Schedulers.boundedElastic()) 没设 prefetch(默认 256),流吐满 256 个元素后会因为 demand 耗尽而冻结(issue #5651)。自测时碰“流到中途突然停”,可以往这个方向查。

排查与防线

  • 前端用浏览器内建 EventSource 时注意:它只能发 GET、不能自定义 header 或 POST body。要 POST 或带 token,用 fetch + 手动解析 SSE 流。
  • EventSource 在服务端正常结束时也会触发 onerror(收到关闭事件),处理完记得 source.close(),别当成真错误。
  • 走 Nginx 要加 X-Accel-Buffering: no,否则反向代理会把流缓冲起来,又变回一次性。
  • 长回答中间留白、以及 reasoning 模型“先想再答”的首 token 延迟,容易和“流断了”混淆,必要时加心跳 Flux.interval(...) 保活。

小结

  • .stream().content()Flux<String>,MVC 用 SseEmitter、WebFlux 直接返 Flux
  • 流式“没流起来”先想两件事:是不是撞上了 2.0 早期的 stream 缓冲 bug;produces 和超时配没配。
  • SseEmitter 一定显式超时;前端 EventSource 只能 GET,要 POST 用 fetch
  • 反向代理记得关缓冲,否则前面全白做。

来源

最后更新于 2026-08-22

这篇帮到你了吗?

刚解决了一个棘手的报错?花两分钟记录下来,帮助下一个遇到同样问题的开发者。

贡献一条解法