一句话先说结论:流式输出靠
.stream()拿到Flux,MVC 里用SseEmitter订阅推送,WebFlux 里直接返回Flux。最隐蔽的坑是“看起来是流式、实则一次性”——某些版本(2.0.0-M5/M6)的 OpenAIstream()内部把整条流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/M6 的 OpenAiChatModel.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。- 反向代理记得关缓冲,否则前面全白做。
来源
- Building a Production AI Agent: Streaming Responses with SSE — dev.to
- How to Stream LLM Responses in Spring AI (SSE) — dev.to
- OpenAiChatModel#stream buffers the entire response — spring-ai issue #5987
- Possible deadlock during streaming — spring-ai issue #5651
- Spring AI + Flux/FluxSink + SSE 实战 — CSDN