云计算百科
云计算领域专业知识百科平台

返回 Flux 却不是流式?collectList 与 flatMap 导致的静默退化

项目背景:LLM Gateway 大模型统一接入网关(Java 17 · Spring Boot 3.5.5 · WebFlux / Reactor Netty · Redis · Lua)。对外端点 POST /v1/chat/completions 兼容 OpenAI 协议,stream: true 时走 SSE 流式,含 [DONE] 收尾;已实现客户端断开级联取消、块间空闲超时(idle 20s),并由 StreamingContractTest / UpstreamStreamingTest 覆盖 SSE 分片、[DONE] 与坏块跳过。

本文代码是按项目分层还原的示意实现,用于说明设计意图,不代表仓库逐行原文。


一、业务场景:为什么「流式」是产品指标,不是技术炫技

客服系统接了大模型做工单摘要。产品经理提了一个需求:

用户提问后,3 秒内必须看到第一个字。

这句话翻译成技术指标就是 TTFT(Time To First Token,首字节时间)。

对比一下两种形态:

形态用户感知首字节时间完整响应
非流式 转圈 → 突然出现整段答案 等于总耗时(8~30s) 一样
流式 文字一个字一个字蹦出来 200ms~2s 一样

完整响应时间其实没变,用户却觉得快了十倍。因为人等待时的焦虑感来自「不确定有没有在干活」,而不是绝对时长。看到字在往外冒,等待就变成了阅读。

所以流式能力在这个网关里是第一等公民,README 的核心能力表里它和「OpenAI 协议兼容」「限流」是并列的。

但这里有个陷阱:

返回值类型写的是 Flux,不代表数据真的是流式发出去的。

这两件事之间隔着无数种写错的方式,而且大部分写错方式不会报错、不会抛异常、测试也可能全绿。你只会收到一条产品反馈:「流式好像有点卡」。

这就是本文要讲的「静默退化」。


二、流式在网关里的数据流

上游厂商 API

OpenAiCompatibleProvider

ChatController

业务方 (OpenAI SDK)

上游厂商 API

OpenAiCompatibleProvider

ChatController

业务方 (OpenAI SDK)

#mermaid-svg-YgkNEwKfFeEbtNRs{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-YgkNEwKfFeEbtNRs .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-YgkNEwKfFeEbtNRs .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-YgkNEwKfFeEbtNRs .error-icon{fill:#552222;}#mermaid-svg-YgkNEwKfFeEbtNRs .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-YgkNEwKfFeEbtNRs .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-YgkNEwKfFeEbtNRs .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-YgkNEwKfFeEbtNRs .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-YgkNEwKfFeEbtNRs .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-YgkNEwKfFeEbtNRs .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-YgkNEwKfFeEbtNRs .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-YgkNEwKfFeEbtNRs .marker{fill:#333333;stroke:#333333;}#mermaid-svg-YgkNEwKfFeEbtNRs .marker.cross{stroke:#333333;}#mermaid-svg-YgkNEwKfFeEbtNRs svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-YgkNEwKfFeEbtNRs p{margin:0;}#mermaid-svg-YgkNEwKfFeEbtNRs .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-YgkNEwKfFeEbtNRs text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-YgkNEwKfFeEbtNRs .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-YgkNEwKfFeEbtNRs .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-YgkNEwKfFeEbtNRs .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-YgkNEwKfFeEbtNRs .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-YgkNEwKfFeEbtNRs #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-YgkNEwKfFeEbtNRs .sequenceNumber{fill:white;}#mermaid-svg-YgkNEwKfFeEbtNRs #sequencenumber{fill:#333;}#mermaid-svg-YgkNEwKfFeEbtNRs #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-YgkNEwKfFeEbtNRs .messageText{fill:#333;stroke:none;}#mermaid-svg-YgkNEwKfFeEbtNRs .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-YgkNEwKfFeEbtNRs .labelText,#mermaid-svg-YgkNEwKfFeEbtNRs .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-YgkNEwKfFeEbtNRs .loopText,#mermaid-svg-YgkNEwKfFeEbtNRs .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-YgkNEwKfFeEbtNRs .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-YgkNEwKfFeEbtNRs .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-YgkNEwKfFeEbtNRs .noteText,#mermaid-svg-YgkNEwKfFeEbtNRs .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-YgkNEwKfFeEbtNRs .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-YgkNEwKfFeEbtNRs .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-YgkNEwKfFeEbtNRs .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-YgkNEwKfFeEbtNRs .actorPopupMenu{position:absolute;}#mermaid-svg-YgkNEwKfFeEbtNRs .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-YgkNEwKfFeEbtNRs .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-YgkNEwKfFeEbtNRs .actor-man circle,#mermaid-svg-YgkNEwKfFeEbtNRs line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-YgkNEwKfFeEbtNRs :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

loop

[逐块透传]

POST /v1/chat/completions {"stream": true}

按 stream 字段分流

WebClient.retrieve().bodyToFlux(…)

chunk1 / chunk2 / chunk3 …

Flux<ServerSentEvent<String>>

每收到一个 chunk 立刻渲染

[DONE]

流结束

发送 [DONE] 收尾

关键在中间那条线:上游来一块,网关就要往下游发一块,中间任何一步「攒着」都会退化。


三、三种静默退化,一个比一个隐蔽

下面是三种静默退化的全景对比,先建立整体认知再逐个拆解:

#mermaid-svg-ZAP4F9C5sZMYEq6h{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ZAP4F9C5sZMYEq6h .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ZAP4F9C5sZMYEq6h .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ZAP4F9C5sZMYEq6h .error-icon{fill:#552222;}#mermaid-svg-ZAP4F9C5sZMYEq6h .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ZAP4F9C5sZMYEq6h .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ZAP4F9C5sZMYEq6h .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ZAP4F9C5sZMYEq6h .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ZAP4F9C5sZMYEq6h .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ZAP4F9C5sZMYEq6h .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ZAP4F9C5sZMYEq6h .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ZAP4F9C5sZMYEq6h .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ZAP4F9C5sZMYEq6h .marker.cross{stroke:#333333;}#mermaid-svg-ZAP4F9C5sZMYEq6h svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ZAP4F9C5sZMYEq6h p{margin:0;}#mermaid-svg-ZAP4F9C5sZMYEq6h .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-ZAP4F9C5sZMYEq6h .cluster-label text{fill:#333;}#mermaid-svg-ZAP4F9C5sZMYEq6h .cluster-label span{color:#333;}#mermaid-svg-ZAP4F9C5sZMYEq6h .cluster-label span p{background-color:transparent;}#mermaid-svg-ZAP4F9C5sZMYEq6h .label text,#mermaid-svg-ZAP4F9C5sZMYEq6h span{fill:#333;color:#333;}#mermaid-svg-ZAP4F9C5sZMYEq6h .node rect,#mermaid-svg-ZAP4F9C5sZMYEq6h .node circle,#mermaid-svg-ZAP4F9C5sZMYEq6h .node ellipse,#mermaid-svg-ZAP4F9C5sZMYEq6h .node polygon,#mermaid-svg-ZAP4F9C5sZMYEq6h .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ZAP4F9C5sZMYEq6h .rough-node .label text,#mermaid-svg-ZAP4F9C5sZMYEq6h .node .label text,#mermaid-svg-ZAP4F9C5sZMYEq6h .image-shape .label,#mermaid-svg-ZAP4F9C5sZMYEq6h .icon-shape .label{text-anchor:middle;}#mermaid-svg-ZAP4F9C5sZMYEq6h .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ZAP4F9C5sZMYEq6h .rough-node .label,#mermaid-svg-ZAP4F9C5sZMYEq6h .node .label,#mermaid-svg-ZAP4F9C5sZMYEq6h .image-shape .label,#mermaid-svg-ZAP4F9C5sZMYEq6h .icon-shape .label{text-align:center;}#mermaid-svg-ZAP4F9C5sZMYEq6h .node.clickable{cursor:pointer;}#mermaid-svg-ZAP4F9C5sZMYEq6h .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ZAP4F9C5sZMYEq6h .arrowheadPath{fill:#333333;}#mermaid-svg-ZAP4F9C5sZMYEq6h .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ZAP4F9C5sZMYEq6h .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ZAP4F9C5sZMYEq6h .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZAP4F9C5sZMYEq6h .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ZAP4F9C5sZMYEq6h .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZAP4F9C5sZMYEq6h .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ZAP4F9C5sZMYEq6h .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ZAP4F9C5sZMYEq6h .cluster text{fill:#333;}#mermaid-svg-ZAP4F9C5sZMYEq6h .cluster span{color:#333;}#mermaid-svg-ZAP4F9C5sZMYEq6h div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ZAP4F9C5sZMYEq6h .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ZAP4F9C5sZMYEq6h rect.text{fill:none;stroke-width:0;}#mermaid-svg-ZAP4F9C5sZMYEq6h .icon-shape,#mermaid-svg-ZAP4F9C5sZMYEq6h .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZAP4F9C5sZMYEq6h .icon-shape p,#mermaid-svg-ZAP4F9C5sZMYEq6h .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ZAP4F9C5sZMYEq6h .icon-shape .label rect,#mermaid-svg-ZAP4F9C5sZMYEq6h .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZAP4F9C5sZMYEq6h .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ZAP4F9C5sZMYEq6h .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ZAP4F9C5sZMYEq6h :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

攒满再吐

并发乱序

缓存 + 背压失效

流式链路

退化一:collectList()

退化二:flatMap 异步

退化三:cache()

转圈 15s → 整段瞬间出现

句子顺序被打乱

内存暴涨 + 上游误判消费快

用户感知:流式好像有点卡

结论:Flux 只是类型,流式是运行时行为

退化一:collectList() —— 从「流式」变成「分批」的经典写法

// ❌ 看起来只是「收集一下」,实际上把流式彻底杀死了
return provider.stream(request)
.collectList() // 等全部 chunk 到齐,才发出第一个元素
.flatMapMany(Flux::fromIterable) // 再拆开,假装还是流
.map(this::toServerSentEvent);

从类型上看,入参 Flux,出参 Flux,签名没变,编译通过,单测如果只看「最终内容对不对」也全绿。

但运行时的行为已经变成:攒满 → 一次性吐出。

用户看到的是:转圈 15 秒,然后整段答案瞬间出现——和非流式一模一样,只是多了一坨内存占用。

为什么会有人这么写?通常是因为中间要做一件事:

「我想在所有分片都收完之后,统计一下 token 用量,写一条日志。」

于是顺手 collectList()。为了一个副作用,牺牲了整个流式。

正确姿势是用 doOnComplete / doFinally 记账,或者用一个并发安全的累加器在 doOnNext 里统计,绝不改变数据的传递形态。

退化二:flatMap 里的异步处理 —— 顺序被打乱

// ❌ 每个分片丢到 boundedElastic 上处理,回来的顺序不保证
return provider.stream(request)
.flatMap(chunk -> Mono.fromCallable(() -> enrich(chunk))
.subscribeOn(Schedulers.boundedElastic()))
.map(this::toServerSentEvent);

flatMap 的设计语义就是「并发处理,不保证顺序」。对大多数场景这是优点,对流式文本是灾难——用户会看到:

今天 天气 很 好

变成:

天气 今天 很 好

而且它时快时慢,压测时偶尔能复现,本地开发几乎永远复现不了,因为本地只有一个分片,没有并发。

三个操作符的选择必须记牢:

操作符是否保序并发适用场景
flatMap ❌ 不保序 ✅ 高并发 无序的独立任务,如并行调多个接口
concatMap ✅ 严格保序 ❌ 串行 顺序敏感、每块处理很快
flatMapSequential ✅ 保序 ✅ 并发 保序 + 允许并发,但会缓冲乱序结果

流式文本天然顺序敏感,正确的做法是:能用同步的就别用异步。一个 chunk 的解析和转发是很轻的计算,根本不需要切线程。真要异步,用 concatMap。

退化三:为了「看一眼」而 cache()

// ❌ 把整条流缓存进内存
Flux<String> shared = provider.stream(request).cache();

shared.subscribe(chunk -> log.debug("上游分片: {}", chunk)); // 想打日志
return shared.map(this::toServerSentEvent); // 真正的业务

cache() 会把所有已经发出的元素保存到内存里,以便后续订阅者从头发送。对一个几百 token 的回答,这点内存无所谓;但网关是要扛并发的,1000 个并发流式请求,每个缓存几十 KB 到几百 KB,加上字符串对象头开销,很容易就是几百 MB。

而且 cache() 还有个更隐蔽的后果:它会让上游的背压信号失效。上游以为下游消费很快(因为 cache 一直在收),实际上缓存正在往上涨。


四、代码实现:让流式真正流起来

4.1 Controller:把 Content-Type 定死

@RestController
@RequiredArgsConstructor
@Slf4j
public class ChatController {

private final ProviderRegistry registry;

@PostMapping(
value = "/v1/chat/completions",
produces = MediaType.TEXT_EVENT_STREAM_VALUE) // ⚠️ 不能省
public Flux<ServerSentEvent<String>> stream(@RequestBody ChatRequest request) {
// 只做 HTTP 层判断:路由交给注册表,找不到会 fail-fast 抛业务异常
Provider provider = registry.get(request.getProvider());

return provider.stream(request)
.map(this::toServerSentEvent) // 纯同步转换,不切线程
.concatWith(Mono.just(doneEvent())); // 显式发 [DONE] 收尾
}

private ServerSentEvent<String> toServerSentEvent(String rawChunk) {
return ServerSentEvent.<String>builder()
.data(rawChunk)
.build();
}

private ServerSentEvent<String> doneEvent() {
return ServerSentEvent.<String>builder().data("[DONE]").build();
}
}

两个细节值得单独说:

第一,produces 必须写。 如果只写 @PostMapping("/v1/chat/completions"),而请求头里 Accept: text/event-stream 又恰好被某个中间层改掉了,WebFlux 可能会按 JSON 编码,客户端拿到的是一个被包装过的对象而不是 SSE 帧。这类问题在浏览器开发者工具里表现为「有数据但 EventSource 不触发 onmessage」,非常难查。

第二,[DONE] 是 OpenAI 协议的一部分。 官方 SDK 靠它判断流结束。少发一个 [DONE],SDK 会一直等到连接超时才收尾,用户那边的「停止生成」按钮会一直转。这也正是项目里 StreamingContractTest 专门断言的东西。

4.2 Provider:SSE 分片解析,坏块跳过但不中断

上游返回的是 SSE 文本帧,网关要把它切成一块块的处理。项目 README 的「关键设计」第 5 条写得很明确:

上游单个流式分片解析失败 → 跳过该块并告警,不中断整条流;网关自己的 DTO 序列化失败 → 抛出 IllegalStateException,必须炸。

这两条看起来矛盾,其实是同一条原则的两面:别人的问题要容错,自己的 bug 要炸。

@Override
public Flux<String> stream(ChatRequest request) {
return webClient.post()
.uri(channel.getBaseUrl() + "/chat/completions")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + channel.getApiKey())
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request.toUpstreamBody(channel))
.retrieve()
.bodyToFlux(String.class) // 逐条转,绝不用 bodyToMono
.filter(chunk -> !"[DONE]".equals(chunk)) // [DONE] 由网关自己发,统一口径
.handle((raw, sink) -> {
try {
// 解析失败 = 上游格式抖动,跳过这一块,流继续
sink.next(normalize(raw));
} catch (JsonProcessingException e) {
log.warn("上游分片解析失败,已跳过: {}", abbreviate(raw), e);
}
})
// 兜底:任何非活跃错误都不能让整条流断掉
.onErrorResume(ex -> {
log.error("上游流式连接异常,提前结束", ex);
return Flux.empty();
});
}

注意 bodyToFlux(String.class) 和 bodyToMono(String.class) 的区别——这是最容易写错的一行。写成 bodyToMono 再 .flatMapMany(s -> Flux.fromArray(s.split("\\n\\n"))),表面上「我用 split 拆开了呀」,但 bodyToMono 那一步已经把所有内容攒进内存了,后面的拆分只是事后补救。类型是 Flux,行为早就不是流式了。

4.3 顺序保证:想保序就别乱切线程

// ✅ 分片转发是轻量纯计算,保持在同一线程上按序流动
return provider.stream(request)
.map(this::toSseFrame);

// ✅ 万一必须做异步处理,用 concatMap 而不是 flatMap
return provider.stream(request)
.concatMap(chunk -> Mono.fromCallable(() -> enrich(chunk))
.subscribeOn(Schedulers.parallel()));

concatMap 会等上一个内层 Mono 完成,才订阅下一个。代价是串行,但流式文本每块处理的耗时只有微秒级,这个代价完全可以接受。用 flatMap 省下的那些并发,换来的是用户看到乱序的句子——这笔买卖不划算。


五、流已经开始之后出错了,怎么办?

流式接口出错后的处理路径,可以用一张决策图说清楚:

#mermaid-svg-fobVXg5axEa4TCrr{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fobVXg5axEa4TCrr .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fobVXg5axEa4TCrr .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fobVXg5axEa4TCrr .error-icon{fill:#552222;}#mermaid-svg-fobVXg5axEa4TCrr .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fobVXg5axEa4TCrr .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fobVXg5axEa4TCrr .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fobVXg5axEa4TCrr .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fobVXg5axEa4TCrr .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fobVXg5axEa4TCrr .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fobVXg5axEa4TCrr .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fobVXg5axEa4TCrr .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fobVXg5axEa4TCrr .marker.cross{stroke:#333333;}#mermaid-svg-fobVXg5axEa4TCrr svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fobVXg5axEa4TCrr p{margin:0;}#mermaid-svg-fobVXg5axEa4TCrr .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-fobVXg5axEa4TCrr .cluster-label text{fill:#333;}#mermaid-svg-fobVXg5axEa4TCrr .cluster-label span{color:#333;}#mermaid-svg-fobVXg5axEa4TCrr .cluster-label span p{background-color:transparent;}#mermaid-svg-fobVXg5axEa4TCrr .label text,#mermaid-svg-fobVXg5axEa4TCrr span{fill:#333;color:#333;}#mermaid-svg-fobVXg5axEa4TCrr .node rect,#mermaid-svg-fobVXg5axEa4TCrr .node circle,#mermaid-svg-fobVXg5axEa4TCrr .node ellipse,#mermaid-svg-fobVXg5axEa4TCrr .node polygon,#mermaid-svg-fobVXg5axEa4TCrr .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-fobVXg5axEa4TCrr .rough-node .label text,#mermaid-svg-fobVXg5axEa4TCrr .node .label text,#mermaid-svg-fobVXg5axEa4TCrr .image-shape .label,#mermaid-svg-fobVXg5axEa4TCrr .icon-shape .label{text-anchor:middle;}#mermaid-svg-fobVXg5axEa4TCrr .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-fobVXg5axEa4TCrr .rough-node .label,#mermaid-svg-fobVXg5axEa4TCrr .node .label,#mermaid-svg-fobVXg5axEa4TCrr .image-shape .label,#mermaid-svg-fobVXg5axEa4TCrr .icon-shape .label{text-align:center;}#mermaid-svg-fobVXg5axEa4TCrr .node.clickable{cursor:pointer;}#mermaid-svg-fobVXg5axEa4TCrr .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-fobVXg5axEa4TCrr .arrowheadPath{fill:#333333;}#mermaid-svg-fobVXg5axEa4TCrr .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-fobVXg5axEa4TCrr .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-fobVXg5axEa4TCrr .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fobVXg5axEa4TCrr .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-fobVXg5axEa4TCrr .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fobVXg5axEa4TCrr .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-fobVXg5axEa4TCrr .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-fobVXg5axEa4TCrr .cluster text{fill:#333;}#mermaid-svg-fobVXg5axEa4TCrr .cluster span{color:#333;}#mermaid-svg-fobVXg5axEa4TCrr div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-fobVXg5axEa4TCrr .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-fobVXg5axEa4TCrr rect.text{fill:none;stroke-width:0;}#mermaid-svg-fobVXg5axEa4TCrr .icon-shape,#mermaid-svg-fobVXg5axEa4TCrr .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fobVXg5axEa4TCrr .icon-shape p,#mermaid-svg-fobVXg5axEa4TCrr .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-fobVXg5axEa4TCrr .icon-shape .label rect,#mermaid-svg-fobVXg5axEa4TCrr .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fobVXg5axEa4TCrr .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-fobVXg5axEa4TCrr .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-fobVXg5axEa4TCrr :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

是

否

是

否

流已开始,响应头 200 已发出

上游中途失败?

错误被 onErrorResume 捕获?

继续逐块转发直到 [DONE]

发送 event: error 事件

前端提示:回答中断,可重试

异常穿透到框架层

IllegalStateException: already committed

日志掩盖真实原因

止损,不重试

这是流式接口和普通接口最大的结构性差异,面试也特别爱问。

普通 REST 接口的流程是:算出结果 → 设置状态码 → 写响应体。所以出错时你可以返回 500。

流式接口的流程是:先写响应头 200 → 然后一块一块往外吐。等你在吐到第 5 块时发现上游挂了,HTTP 响应头和一部分 body 已经发出去了,状态码改不了了。

这时会发生两种情况:

情况一(错误被正确捕获):你在服务端自己处理掉了,往下发一个「错误事件」然后正常结束:

.onErrorResume(ex -> Flux.just(
ServerSentEvent.<String>builder()
.event("error")
.data("{\\"type\\":\\"upstream_error\\",\\"message\\":\\"上游连接中断\\"}")
.build()))

前端约定好:收到 event: error 就提示「回答中断,可重试」,而不是卡在那里转圈。

情况二(错误没被捕获):默认错误处理器会试图设置 500,但响应已经 committed,于是抛出:

java.lang.IllegalStateException: ServerHttpResponse already committed

这条异常会盖住真正的原因,日志里看起来像是「响应写失败」,实际根因是上游挂了。所以流式接口里,onErrorResume 不是可选项,是必选项。

项目里 UpstreamStreamingTest 和 IdleTimeoutTest 覆盖的就是这一类边界:上游卡住不吐数据时,块间空闲超时(idle 20s)会主动断开,否则连接会一直挂着,直到 responseTimeout(30s,只覆盖到「请求发出 → 收到响应头」那一段)——而流式的响应头几十毫秒就回来了,responseTimeout 根本管不到中途停滞,这就是必须单独做 idle 超时的原因。


六、怎么验证「真的在流」——不是靠肉眼

单测断言「最终内容拼起来对不对」,是发现不了静默退化的。要验证流式,必须验证时序。

@Test
void 首个分片必须在完成后才... /* 反过来说:完成之前就必须已经拿到分片 */ {
// 上游故意慢:每秒吐一块,一共 3 块
Flux<String> upstream = Flux.interval(Duration.ofSeconds(1))
.take(3)
.map(i -> "chunk-" + i);

StepVerifier.create(provider.stream(upstream))
// 关键:这里如果被 collectList 了,下面三行会在第 3 秒后一次性通过
// StepVerifier 会按真实时间推进,能区分「逐块到」和「一起到」
.expectNext("chunk-0")
.expectNext("chunk-1")
.expectNext("chunk-2")
.verifyComplete();
}

更严格的做法是记录每个元素到达的时间戳,断言相邻元素的间隔 ≈ 上游间隔。如果间隔全变成 0,说明被攒批了。

线上验证最便宜的办法:

curl -N http://localhost:8081/v1/chat/completions \\
-H "Content-Type: application/json" \\
-d '{"model":"deepseek-chat","stream":true,
"messages":[{"role":"user","content":"逐字讲一下 SSE"}]}'

-N 关闭 curl 的缓冲。你会看到文字一块块冒出来——如果它停顿好几秒然后刷出一整屏,那就是日志里那条「流式」链路被攒住了。


七、面试 / 简历亮点提炼

这段踩坑经验比「我会用 WebFlux」值钱得多,简历可以这样写:

LLM Gateway 统一接入网关(Java 17 / Spring Boot 3.5 / WebFlux)

  • 实现 OpenAI 兼容的 SSE 流式转发,保持上游分片到客户端的逐块透传,[DONE] 收尾与协议口径统一;
  • 处理流式接口的「响应已提交」边界:上游中途失败时无法修改 HTTP 状态码,改为发送 event: error 事件并安全结束流;
  • 区分「上游坏块」与「网关自身异常」两类错误,前者跳过并告警、不中断整条流,后者 fail-fast,避免故障被误判为上游抖动;
  • 编写 StreamingContractTest / UpstreamStreamingTest 覆盖分片、[DONE]、坏块跳过与空闲超时。

写简历时不要写「QPS 提升 X%」这种没测过的数字。可以写的是测试类覆盖了哪些真实边界——因为这是能当场翻代码验证的。

Commit Message 参考:

fix(stream): 移除流式链路中的 collectList 聚合,恢复逐块转发

– ChatController 改为直接透传 Flux<ServerSentEvent>,不再聚合后重发
– 分片乱序问题改用 concatMap 修复(原 flatMap 在 boundedElastic 上不保序)
– 新增 onErrorResume 兜底,避免响应已提交后抛 IllegalStateException


八、八股文 / 场景题暴击

场景题 1:流式接口已经返回 200 并且吐了一半,上游厂商返回 500 了,你的网关怎么处理?

标准答法分三层:

  • 协议层:状态码已经改不了。唯一正确的方式是在流内发送一个约定的错误事件(如 event: error),然后干净地结束流(发 [DONE] 或直接 complete,取决于客户端约定)。千万不要让异常穿透到框架层,否则日志里会出现 ServerHttpResponse already committed,掩盖真实原因。
  • 计费/计量层:已经消耗的 token 要照常记账。不能因为失败就丢弃——上游是真的算过钱的。这也是当前项目 README 里「Token 计量未落地」要补的部分。
  • 客户端体验层:如果已经吐了部分内容,不要整段丢弃重来,前端应该保留已收到的部分并提示「回答中断」。全量重试会造成重复内容。
  • 追问点:为什么不能在网关层重试? 因为请求已经发出、token 已经烧掉、响应已经部分下发,重试意味着客户端会收到两份内容。重试只对「尚未开始响应」的失败有意义,一旦流开始,就只能止损不能重来。

    场景题 2:flatMap、concatMap、flatMapSequential 在流式网关里怎么选?

    • flatMap:不保序、并发最高。适合「并行调多个互不依赖的渠道做结果对比」,绝不用于分片转发。
    • concatMap:严格保序、串行。适合顺序敏感且单块处理极快的场景——流式分片解析就是这一类,几乎无性能损失。
    • flatMapSequential:保序 + 并发,但会在内存里缓冲乱序到达的结果。如果上游突然快出一大截,缓冲区会涨。对流式网关这种「本来就是用内存换实时性」的系统,多一个没有上限的缓冲点是隐患。

    判断标准一句话:先问「顺序重要吗」,再问「单块耗时值不值得并发」。 两个都不需要,就直接 map,最省事也最安全。


    小结

    Flux 只是类型,流式是运行时行为。这两者之间隔着 collectList、bodyToMono、flatMap、cache()、buffer() 无数个坑,而且它们都不报错。

    记住三条判据,随时自查:

  • 有没有任何一步在等「全部完成」才往下走? 有就是退化。
  • 有没有把元素搬到多线程上处理? 有就有乱序风险。
  • 有没有为了让多个订阅者看到同一份数据而缓存? 有就是内存隐患。
  • 流式的本质是「透明」,网关要做的只是别挡路。

    项目地址:https://github.com/maixiaowen111/llm-gateway

    推荐标签:WebFlux、SSE、Reactor、流式输出、LLM

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 返回 Flux 却不是流式?collectList 与 flatMap 导致的静默退化
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!