Spring AI流式接口经过Nginx后不再逐字输出?缓冲、压缩与超时完整排查
文章摘要
Spring AI在本地通过Flux和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")
public class StreamingController {
private final ChatClient chatClient;
public StreamingController(
ChatClient.Builder builder
) {
this.chatClient = builder.build();
}
@GetMapping(
value = "/stream",
produces = MediaType.TEXT_EVENT_STREAM_VALUE
)
public Flux stream(
@RequestParam String message
) {
return chatClient.prompt()
.user(message)
.stream()
.content();
}
}
关键点:
返回Flux
使用stream()
Content-Type为text/event-stream
错误示例:
public String stream(String message) {
return chatClient.prompt()
.user(message)
.stream()
.content()
.collectList()
.block()
.toString();
}
这里已经把整个流收集并阻塞,代理配置再正确也无法逐段输出。
三、不要在流中执行阻塞操作
错误:
return chatClient.prompt()
.user(message)
.stream()
.content()
.map(chunk -> {
jdbcTemplate.update(
"insert into log(content) values (?)",
chunk
);
return chunk;
});
JDBC调用会阻塞Netty事件线程。
推荐:
return chatClient.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 -v https://example.com/api/ai/stream?message=hello
关注:
Content-Type
Content-Encoding
Transfer-Encoding
X-Accel-Buffering
Cache-Control
curl必须加:
-N
否则curl自身也可能缓冲输出。
六、推荐返回标准SSE事件
直接返回Flux虽然简单,但生产系统更适合返回事件对象:
@GetMapping(
value = "/stream-events",
produces = MediaType.TEXT_EVENT_STREAM_VALUE
)
public Flux> streamEvents(
@RequestParam String message
) {
AtomicLong sequence = new AtomicLong();
return chatClient.prompt()
.user(message)
.stream()
.content()
.map(content ->
ServerSentEvent.builder()
.id(
Long.toString(
sequence.incrementAndGet()
)
)
.event("delta")
.data(
new AiStreamEvent(
"DELTA",
content
)
)
.build()
)
.concatWithValues(
ServerSentEvent.builder()
.event("done")
.data(
new AiStreamEvent(
"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: no
Spring示例:
@GetMapping(
value = "/stream",
produces = MediaType.TEXT_EVENT_STREAM_VALUE
)
public ResponseEntity> stream(...) {
Flux body = ...;
return ResponseEntity.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。
可以定期发送心跳:
: ping
SSE中以冒号开头的是注释,浏览器不会当成业务事件。
Reactor示意:
Flux> heartbeat =
Flux.interval(Duration.ofSeconds(15))
.map(index ->
ServerSentEvent.builder()
.comment("ping")
.build()
);
再与业务流合并。
但需要确保业务完成后心跳也被取消,避免连接无法结束。
十、前端读取方式是否正确
EventSource
适合GET和简单鉴权:
const source = new EventSource(
`/api/ai/stream?message=${encodeURIComponent(message)}`
);
source.addEventListener("delta", event => {
const data = JSON.parse(event.data);
appendText(data.content);
});
source.addEventListener("done", () => {
source.close();
});
fetch流
适合POST、自定义Header和复杂请求:
const response = await fetch("/api/ai/stream", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ message }),
signal: abortController.signal
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
const text = decoder.decode(value, {
stream: true
});
render(text);
}
如果前端调用:
await response.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
排查时应沿着模型、应用、代理和浏览器逐段验证,先确定数据在哪一层停止流动,再修改配置。
延伸阅读
如果你正在关注企业级 AI 应用、Agent、RAG、MCP 与大模型工程化落地,欢迎访问 智元界:
https://www.zyentor.com/
智元界将持续分享可运行的技术实战、架构设计、问题排查与企业应用案例。