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/

智元界将持续分享可运行的技术实战、架构设计、问题排查与企业应用案例。