项目背景: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 了,你的网关怎么处理?
标准答法分三层:
追问点:为什么不能在网关层重试? 因为请求已经发出、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
网硕互联帮助中心





评论前必须登录!
注册