文章摘要
Spring AI在本地通过Flux<String>和SSE可以正常逐段返回,但部署到Nginx、网关或CDN后,经常变成等待几十秒再一次性输出。根因通常不是模型没有流式返回,而是代理缓冲、响应压缩、错误的Content-Type、连接超时或业务代码中出现阻塞操作。本文给出从Spring WebFlux、SSE响应头、Nginx配置、网关链路到前端读取方式的完整排查流程。
一、先判断问题发生在哪一段
完整链路:
模型Provider → Spring AI ChatClient → Spring WebFlux → Nginx或网关 → 浏览器 → 前端渲染任何一段都可能把流式响应变成批量响应。
建议按顺序测试:
1. 直接调用模型Provider 2. 本机调用Spring Boot接口 3. 绕过Nginx调用服务实例 4. 通过Nginx调用 5. 通过最终域名和CDN调用如果第3步正常、第4步异常,问题基本在代理层。
二、Spring接口必须真正返回流
示例:
@RestController@RequestMapping("/api/ai")publicclassStreamingController{privatefinalChatClientchatClient;publicStreamingController(ChatClient.Builderbuilder){this.chatClient=builder.build();}@GetMapping(value="/stream",produces=MediaType.TEXT_EVENT_STREAM_VALUE)publicFlux<String>stream(@RequestParamStringmessage){returnchatClient.prompt().user(message).stream().content();}}关键点:
返回Flux 使用stream() Content-Type为text/event-stream错误示例:
publicStringstream(Stringmessage){returnchatClient.prompt().user(message).stream().content().collectList().block().toString();}这里已经把整个流收集并阻塞,代理配置再正确也无法逐段输出。
三、不要在流中执行阻塞操作
错误:
returnchatClient.prompt().user(message).stream().content().map(chunk->{jdbcTemplate.update("insert into log(content) values (?)",chunk);returnchunk;});JDBC调用会阻塞Netty事件线程。
推荐:
returnchatClient.prompt().user(message).stream().content().publishOn(Schedulers.boundedElastic()).doOnNext(this::writeAsyncLog);更好的做法是把日志放入异步队列,不要每个Token写一次数据库。
建议按请求聚合:
流式发送Token → 内存累积完整回答 → 流结束后异步写入一次四、Nginx默认缓冲会导致一次性返回
Nginx可能先缓存上游响应,达到一定大小后再发送给客户端。
SSE位置建议:
location /api/ai/stream { proxy_pass http://ai_backend; proxy_http_version 1.1; proxy_buffering off; proxy_cache off; proxy_read_timeout 600s; proxy_send_timeout 600s; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; add_header X-Accel-Buffering no; }最关键的是:
proxy_buffering off;同时可以由应用返回:
X-Accel-Buffering: no五、压缩也可能造成缓冲
gzip为了提高压缩率,可能等待更多数据后再输出。
如果只有SSE接口异常,可以针对该路径关闭:
gzip off;或者确保:
text/event-stream不被代理压缩。
排查时查看响应头:
curl-N-vhttps://example.com/api/ai/stream?message=hello关注:
Content-Type Content-Encoding Transfer-Encoding X-Accel-Buffering Cache-Controlcurl必须加:
-N否则curl自身也可能缓冲输出。
六、推荐返回标准SSE事件
直接返回Flux<String>虽然简单,但生产系统更适合返回事件对象:
@GetMapping(value="/stream-events",produces=MediaType.TEXT_EVENT_STREAM_VALUE)publicFlux<ServerSentEvent<AiStreamEvent>>streamEvents(@RequestParamStringmessage){AtomicLongsequence=newAtomicLong();returnchatClient.prompt().user(message).stream().content().map(content->ServerSentEvent.<AiStreamEvent>builder().id(Long.toString(sequence.incrementAndGet())).event("delta").data(newAiStreamEvent("DELTA",content)).build()).concatWithValues(ServerSentEvent.<AiStreamEvent>builder().event("done").data(newAiStreamEvent("DONE","")).build());}事件类型建议:
start delta tool_start tool_result error done七、设置正确响应头
建议:
Content-Type: text/event-stream;charset=UTF-8 Cache-Control: no-cache, no-transform Connection: keep-alive X-Accel-Buffering: noSpring示例:
@GetMapping(value="/stream",produces=MediaType.TEXT_EVENT_STREAM_VALUE)publicResponseEntity<Flux<String>>stream(...){Flux<String>body=...;returnResponseEntity.ok().header(HttpHeaders.CACHE_CONTROL,"no-cache, no-transform").header("X-Accel-Buffering","no").body(body);}不要手工设置错误的:
Content-Length流式响应长度在开始时通常未知。
八、网关也可能缓冲
链路可能是:
CDN → WAF → API Gateway → Nginx → Spring Boot只修改最后一层Nginx不一定有效。
需要逐层检查:
- 是否支持SSE;
- 最大连接时长;
- 空闲超时;
- 响应缓冲;
- 压缩;
- 最大并发连接;
- 是否改写Content-Type。
云网关常见限制:
30秒或60秒空闲超时 固定最大请求时长 不支持长连接九、心跳防止空闲连接被关闭
模型在调用工具或深度推理时,可能一段时间没有Token。
可以定期发送心跳:
: pingSSE中以冒号开头的是注释,浏览器不会当成业务事件。
Reactor示意:
Flux<ServerSentEvent<String>>heartbeat=Flux.interval(Duration.ofSeconds(15)).map(index->ServerSentEvent.<String>builder().comment("ping").build());再与业务流合并。
但需要确保业务完成后心跳也被取消,避免连接无法结束。
十、前端读取方式是否正确
EventSource
适合GET和简单鉴权:
constsource=newEventSource(`/api/ai/stream?message=${encodeURIComponent(message)}`);source.addEventListener("delta",event=>{constdata=JSON.parse(event.data);appendText(data.content);});source.addEventListener("done",()=>{source.close();});fetch流
适合POST、自定义Header和复杂请求:
constresponse=awaitfetch("/api/ai/stream",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({message}),signal:abortController.signal});constreader=response.body.getReader();constdecoder=newTextDecoder();while(true){const{value,done}=awaitreader.read();if(done)break;consttext=decoder.decode(value,{stream:true});render(text);}如果前端调用:
awaitresponse.text()它会等待全部响应结束。
十一、浏览器看起来不流式,也可能是渲染策略
前端可能收到很多小Chunk,却为了性能进行批量渲染。
例如:
每100毫秒更新一次DOM这是合理优化,但需要区分:
网络未流式与:
前端主动批量渲染在浏览器Network面板中查看响应到达时间,或直接使用curl -N验证。
十二、超时设置
至少检查:
模型客户端超时 Spring WebFlux超时 Reactor timeout Nginx proxy_read_timeout 网关空闲超时 浏览器请求取消错误做法:
.timeout(Duration.ofSeconds(30))复杂模型可能30秒仍未结束。
应区分:
首次Token超时 Token间隔超时 总任务超时例如:
首次Token:15秒 空闲间隔:30秒 总时长:5分钟十三、完整排查清单
□ ChatClient使用stream() □ Controller返回Flux □ produces为text/event-stream □ 没有collectList和block □ 没有阻塞数据库调用 □ curl -N直连服务可以逐段返回 □ Nginx proxy_buffering off □ SSE路径没有gzip缓冲 □ 没有错误Content-Length □ 网关支持长连接 □ 空闲超时足够长 □ 必要时发送心跳 □ 前端使用ReadableStream或EventSource □ 前端没有response.text()总结
Spring AI流式接口经过Nginx后一次性输出,最常见根因是:
应用层收集了整个Flux 代理层开启缓冲或压缩 网关超时 前端等待完整Body排查时应沿着模型、应用、代理和浏览器逐段验证,先确定数据在哪一层停止流动,再修改配置。