
文章摘要Spring AI在本地通过FluxString和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接口必须真正返回流示例RestControllerRequestMapping(/api/ai)publicclassStreamingController{privatefinalChatClientchatClient;publicStreamingController(ChatClient.Builderbuilder){this.chatClientbuilder.build();}GetMapping(value/stream,producesMediaType.TEXT_EVENT_STREAM_VALUE)publicFluxStringstream(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?messagehello关注Content-Type Content-Encoding Transfer-Encoding X-Accel-Buffering Cache-Controlcurl必须加-N否则curl自身也可能缓冲输出。六、推荐返回标准SSE事件直接返回FluxString虽然简单但生产系统更适合返回事件对象GetMapping(value/stream-events,producesMediaType.TEXT_EVENT_STREAM_VALUE)publicFluxServerSentEventAiStreamEventstreamEvents(RequestParamStringmessage){AtomicLongsequencenewAtomicLong();returnchatClient.prompt().user(message).stream().content().map(content-ServerSentEvent.AiStreamEventbuilder().id(Long.toString(sequence.incrementAndGet())).event(delta).data(newAiStreamEvent(DELTA,content)).build()).concatWithValues(ServerSentEvent.AiStreamEventbuilder().event(done).data(newAiStreamEvent(DONE,)).build());}事件类型建议start delta tool_start tool_result error done七、设置正确响应头建议Content-Type: text/event-stream;charsetUTF-8 Cache-Control: no-cache, no-transform Connection: keep-alive X-Accel-Buffering: noSpring示例GetMapping(value/stream,producesMediaType.TEXT_EVENT_STREAM_VALUE)publicResponseEntityFluxStringstream(...){FluxStringbody...;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示意FluxServerSentEventStringheartbeatFlux.interval(Duration.ofSeconds(15)).map(index-ServerSentEvent.Stringbuilder().comment(ping).build());再与业务流合并。但需要确保业务完成后心跳也被取消避免连接无法结束。十、前端读取方式是否正确EventSource适合GET和简单鉴权constsourcenewEventSource(/api/ai/stream?message${encodeURIComponent(message)});source.addEventListener(delta,event{constdataJSON.parse(event.data);appendText(data.content);});source.addEventListener(done,(){source.close();});fetch流适合POST、自定义Header和复杂请求constresponseawaitfetch(/api/ai/stream,{method:POST,headers:{Content-Type:application/json},body:JSON.stringify({message}),signal:abortController.signal});constreaderresponse.body.getReader();constdecodernewTextDecoder();while(true){const{value,done}awaitreader.read();if(done)break;consttextdecoder.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间隔超时 总任务超时例如首次Token15秒 空闲间隔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排查时应沿着模型、应用、代理和浏览器逐段验证先确定数据在哪一层停止流动再修改配置。