项目背景:LLM Gateway 大模型统一接入网关,Java 17 + Spring Boot 3.5.5 + WebFlux(Reactor Netty)+ Redis + Lua,48 个主类约 6300 行、19 个测试类 65 个测试,对外提供 OpenAI 兼容的
POST /v1/chat/completions,支持非流式与 SSE 流式。本文讨论的取消传播、断流计费和终态统计都在仓库里落地,对应
CancelPropagationTest、GatewayChatService与OpenAiCompatibleProvider;文中标注「未落地」的部分只作为方案讨论。
一、业务场景:那个「停止生成」按钮背后发生了什么
几乎每个接了大模型的产品都有这个按钮:回答太长、答偏了、用户不想等了,点一下「停止生成」。
前端做的事情很简单:AbortController.abort(),或者关掉页面。
但站在网关的角度,这一刻同时发生了几件事:
- 客户端到网关的 TCP 连接被关闭;
- 网关应该立刻停止向上游要数据;
- 上游应该停止生成,不再消耗 token;
- 这次请求的计费、指标、日志要正确收尾。
如果第 2 步没做好,会发生什么?
用户已经走了,网关还在替他把整段回答读完。 一条流式回答可能持续几分钟、生成几千个 token。用户点了停止,你的账单却照付。并发高的时候,这些「无人接收的流」还会持续占用连接和上游配额。
更糟的是,这类问题不会报错:
- 接口成功率 100%;
- 延迟指标正常;
- 日志里只有正常的请求记录;
- 唯一的症状是月底账单偏高。
这就是为什么取消传播必须当成一条功能来做,而不是「框架应该会处理吧」。
二、一条取消信号是怎么传上去的
text
浏览器 / SDK
│ 用户点「停止」→ abort() → TCP 连接关闭
▼
Reactor Netty(网关出口)
│ 写响应失败 → 订阅被取消
▼
Spring WebFlux
│ 取消信号沿算子链向上游传播
▼
concatMap → filter → doFinally → WebClient
│
▼
上游厂商的 HTTP 连接被关闭
│ 上游停止生成,停止计费
▼
网关 doFinally(CANCEL)
├─ 记录 [cancel] 日志
├─ 指标 requestCancelled++
└─ 计费:已吐字按预扣结算,未吐字全额退款
上面这条链路用流程图表示更清楚:
#mermaid-svg-eptzsuphboFsWRGR{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-eptzsuphboFsWRGR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-eptzsuphboFsWRGR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-eptzsuphboFsWRGR .error-icon{fill:#552222;}#mermaid-svg-eptzsuphboFsWRGR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-eptzsuphboFsWRGR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-eptzsuphboFsWRGR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-eptzsuphboFsWRGR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-eptzsuphboFsWRGR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-eptzsuphboFsWRGR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-eptzsuphboFsWRGR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-eptzsuphboFsWRGR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-eptzsuphboFsWRGR .marker.cross{stroke:#333333;}#mermaid-svg-eptzsuphboFsWRGR svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-eptzsuphboFsWRGR p{margin:0;}#mermaid-svg-eptzsuphboFsWRGR .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-eptzsuphboFsWRGR .cluster-label text{fill:#333;}#mermaid-svg-eptzsuphboFsWRGR .cluster-label span{color:#333;}#mermaid-svg-eptzsuphboFsWRGR .cluster-label span p{background-color:transparent;}#mermaid-svg-eptzsuphboFsWRGR .label text,#mermaid-svg-eptzsuphboFsWRGR span{fill:#333;color:#333;}#mermaid-svg-eptzsuphboFsWRGR .node rect,#mermaid-svg-eptzsuphboFsWRGR .node circle,#mermaid-svg-eptzsuphboFsWRGR .node ellipse,#mermaid-svg-eptzsuphboFsWRGR .node polygon,#mermaid-svg-eptzsuphboFsWRGR .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-eptzsuphboFsWRGR .rough-node .label text,#mermaid-svg-eptzsuphboFsWRGR .node .label text,#mermaid-svg-eptzsuphboFsWRGR .image-shape .label,#mermaid-svg-eptzsuphboFsWRGR .icon-shape .label{text-anchor:middle;}#mermaid-svg-eptzsuphboFsWRGR .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-eptzsuphboFsWRGR .rough-node .label,#mermaid-svg-eptzsuphboFsWRGR .node .label,#mermaid-svg-eptzsuphboFsWRGR .image-shape .label,#mermaid-svg-eptzsuphboFsWRGR .icon-shape .label{text-align:center;}#mermaid-svg-eptzsuphboFsWRGR .node.clickable{cursor:pointer;}#mermaid-svg-eptzsuphboFsWRGR .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-eptzsuphboFsWRGR .arrowheadPath{fill:#333333;}#mermaid-svg-eptzsuphboFsWRGR .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-eptzsuphboFsWRGR .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-eptzsuphboFsWRGR .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eptzsuphboFsWRGR .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-eptzsuphboFsWRGR .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eptzsuphboFsWRGR .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-eptzsuphboFsWRGR .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-eptzsuphboFsWRGR .cluster text{fill:#333;}#mermaid-svg-eptzsuphboFsWRGR .cluster span{color:#333;}#mermaid-svg-eptzsuphboFsWRGR 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-eptzsuphboFsWRGR .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-eptzsuphboFsWRGR rect.text{fill:none;stroke-width:0;}#mermaid-svg-eptzsuphboFsWRGR .icon-shape,#mermaid-svg-eptzsuphboFsWRGR .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eptzsuphboFsWRGR .icon-shape p,#mermaid-svg-eptzsuphboFsWRGR .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-eptzsuphboFsWRGR .icon-shape .label rect,#mermaid-svg-eptzsuphboFsWRGR .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eptzsuphboFsWRGR .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-eptzsuphboFsWRGR .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-eptzsuphboFsWRGR :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 浏览器 / SDK
用户点停止 → abort()
TCP 连接关闭
Reactor Netty 网关出口
写响应失败
Spring WebFlux
取消对 Flux 的订阅
算子链向上游传播
concatMap → filter → doFinally
WebClient 上游连接关闭
上游停止生成、停止计费
网关 doFinally(CANCEL)
日志 / 指标 / 计费收尾
这条链路有一个非常重要的性质:
取消是「自下而上」传播的,从最终订阅者一路传回数据源。
谁订阅了这条流,谁就握着取消的开关。客户端断开 → Spring 取消对 Controller 返回的 Flux 的订阅 → 取消信号穿过所有中间算子 → 最终到达 WebClient 的响应体 → Reactor Netty 关闭上游连接。
这一切是框架送的,前提是你别把订阅关系弄丢。
三、最关键的代码:Controller 必须「原样返回」Flux
项目的流式出口在 ChatController:
java
if (Boolean.TRUE.equals(request.stream())) {
// 钉死 Content-Type,不依赖客户端 Accept 的内容协商
exchange.getResponse().getHeaders().setContentType(MediaType.TEXT_EVENT_STREAM);
exchange.getResponse().getHeaders().set(HttpHeaders.CACHE_CONTROL, "no-cache");
return streamToClient(clientId, request);
}
java
private Flux<String> streamToClient(String clientId, ChatCompletionRequest request) {
Flux<String> chunks = chatService.stream(clientId, request)
.map(this::toJson);
// [DONE] 是协议尾巴,由出口补上
return chunks.concatWith(Mono.just(ChatCompletionChunk.DONE_SENTINEL));
}
注意这里没有任何 subscribe()。Controller 只是组装并返回 一个 Flux,真正订阅它的是 Spring WebFlux。
3.1 为什么不能自己 subscribe()
写错的版本大概是这样:
java
// ❌ 错误示范
@PostMapping("/v1/chat/completions")
public void completions(...) {
chatService.stream(clientId, request)
.subscribe(chunk -> response.writeWith(...));
}
问题在于:
- Controller 方法立刻返回,Spring 认为这个请求已经处理完了;
- 真正订阅上游流的是你自己,和 HTTP 响应之间没有订阅关系;
- 客户端断开时,Spring 没有东西可取消;
- 上游那条流会继续跑完,token 继续烧。
一句话总结:
「返回 Flux」和「自己 subscribe 一个 Flux」是生与死的区别。
前者把生命周期交给框架,后者等于把取消开关扔了。
两种写法的订阅关系差异,可以这样对比:
#mermaid-svg-wJ9HsKxnAJr8j5DB{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-wJ9HsKxnAJr8j5DB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-wJ9HsKxnAJr8j5DB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-wJ9HsKxnAJr8j5DB .error-icon{fill:#552222;}#mermaid-svg-wJ9HsKxnAJr8j5DB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-wJ9HsKxnAJr8j5DB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-wJ9HsKxnAJr8j5DB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-wJ9HsKxnAJr8j5DB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-wJ9HsKxnAJr8j5DB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-wJ9HsKxnAJr8j5DB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-wJ9HsKxnAJr8j5DB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-wJ9HsKxnAJr8j5DB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-wJ9HsKxnAJr8j5DB .marker.cross{stroke:#333333;}#mermaid-svg-wJ9HsKxnAJr8j5DB svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-wJ9HsKxnAJr8j5DB p{margin:0;}#mermaid-svg-wJ9HsKxnAJr8j5DB .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-wJ9HsKxnAJr8j5DB .cluster-label text{fill:#333;}#mermaid-svg-wJ9HsKxnAJr8j5DB .cluster-label span{color:#333;}#mermaid-svg-wJ9HsKxnAJr8j5DB .cluster-label span p{background-color:transparent;}#mermaid-svg-wJ9HsKxnAJr8j5DB .label text,#mermaid-svg-wJ9HsKxnAJr8j5DB span{fill:#333;color:#333;}#mermaid-svg-wJ9HsKxnAJr8j5DB .node rect,#mermaid-svg-wJ9HsKxnAJr8j5DB .node circle,#mermaid-svg-wJ9HsKxnAJr8j5DB .node ellipse,#mermaid-svg-wJ9HsKxnAJr8j5DB .node polygon,#mermaid-svg-wJ9HsKxnAJr8j5DB .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-wJ9HsKxnAJr8j5DB .rough-node .label text,#mermaid-svg-wJ9HsKxnAJr8j5DB .node .label text,#mermaid-svg-wJ9HsKxnAJr8j5DB .image-shape .label,#mermaid-svg-wJ9HsKxnAJr8j5DB .icon-shape .label{text-anchor:middle;}#mermaid-svg-wJ9HsKxnAJr8j5DB .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-wJ9HsKxnAJr8j5DB .rough-node .label,#mermaid-svg-wJ9HsKxnAJr8j5DB .node .label,#mermaid-svg-wJ9HsKxnAJr8j5DB .image-shape .label,#mermaid-svg-wJ9HsKxnAJr8j5DB .icon-shape .label{text-align:center;}#mermaid-svg-wJ9HsKxnAJr8j5DB .node.clickable{cursor:pointer;}#mermaid-svg-wJ9HsKxnAJr8j5DB .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-wJ9HsKxnAJr8j5DB .arrowheadPath{fill:#333333;}#mermaid-svg-wJ9HsKxnAJr8j5DB .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-wJ9HsKxnAJr8j5DB .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-wJ9HsKxnAJr8j5DB .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wJ9HsKxnAJr8j5DB .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-wJ9HsKxnAJr8j5DB .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wJ9HsKxnAJr8j5DB .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-wJ9HsKxnAJr8j5DB .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-wJ9HsKxnAJr8j5DB .cluster text{fill:#333;}#mermaid-svg-wJ9HsKxnAJr8j5DB .cluster span{color:#333;}#mermaid-svg-wJ9HsKxnAJr8j5DB 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-wJ9HsKxnAJr8j5DB .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-wJ9HsKxnAJr8j5DB rect.text{fill:none;stroke-width:0;}#mermaid-svg-wJ9HsKxnAJr8j5DB .icon-shape,#mermaid-svg-wJ9HsKxnAJr8j5DB .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wJ9HsKxnAJr8j5DB .icon-shape p,#mermaid-svg-wJ9HsKxnAJr8j5DB .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-wJ9HsKxnAJr8j5DB .icon-shape .label rect,#mermaid-svg-wJ9HsKxnAJr8j5DB .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wJ9HsKxnAJr8j5DB .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-wJ9HsKxnAJr8j5DB .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-wJ9HsKxnAJr8j5DB :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ❌ 自己 subscribe:订阅关系断裂
Controller 内 subscribe()
方法立刻返回
客户端断开 → 无人可取消
上游继续跑完、继续烧 token
✅ 返回 Flux:框架持有订阅
Controller 返回 Flux
Spring WebFlux 订阅
客户端断开 → cancel()
取消传到上游
3.2 哪些常见写法会「弄丢」取消信号
| 写法 | 后果 |
|---|---|
flux.subscribe(...) 后直接返回 |
订阅关系断裂,客户端断开无法取消上游 |
flux.collectList().block() |
阻塞等待全部完成,取消无从谈起,还会占住事件循环 |
flux.cache() |
缓存会让上游流脱离下游生命周期,取消不再向下传 |
把流交给线程池后 fire-and-forget |
同样失去订阅关系 |
用 Spring MVC 的 SseEmitter |
需要手动处理 onCompletion/onTimeout/onError,框架不会自动取消上游 |
这些写法有一个共同点:看起来都能把数据发出去。所以它们能通过「功能测试」,只会在账单和并发上出问题。
四、怎么证明取消真的传到了上游
「客户端断开后上游连接被关闭」这件事,光看代码推理不够,必须有可观测的证据。
项目在 OpenAiCompatibleProvider 的最贴近网络边界 的位置放了一个 doFinally:
java
.bodyToFlux(String.class)
// 刻意放在最靠近上游的位置:
// 取消信号能传到这里,证明它已经穿过了 filter / concatMap / timeout 所有算子
.doFinally(signal -> {
if (signal == SignalType.CANCEL) {
log.info("[cancel] channel={} 客户端断开,取消信号已传到上游 ------ 上游连接将被关闭", name);
} else {
log.info("[stream-end] channel={} 上游流结束,signal={}", name, signal);
}
})
.timeout(Duration.ofMillis(idleTimeoutMs))
.onErrorMap(TimeoutException.class,
e -> new UpstreamIdleTimeoutException(idleTimeoutMs, e));
4.1 为什么放在这里,而不是链路末尾
放的位置决定了证明力。
- 放在链尾:只能说明「信号进了这条链」,不代表它能传到 HTTP 客户端;
- 放在
bodyToFlux之后、最贴近网线的地方 :能收到CANCEL,说明信号已经穿过了filter、concatMap、timeout等所有算子,到达了 Reactor Netty 的连接边界。
这就像查水管漏没漏:要在最末端看出不出水,而不是在水泵旁边听声音。
4.2 为什么要区分 CANCEL 和 COMPLETE
因为这两件事的商业含义完全相反:
COMPLETE:正常交付完成,用户看完了;CANCEL:用户中途放弃,上游可能已经生成了大量内容。
如果把两者混成一句「流结束」:
- 「用户中途放弃率」这个产品指标永远算不出来;
- 无法判断是上游太慢导致用户没耐心,还是回答本身有问题;
- 计费逻辑也失去判断依据。
日志里带上 channel 名字,是因为多家渠道并存后,「哪个渠道的用户最没耐心」是个真问题------不带名字就永远查不出来。
五、取消时的计费:最容易被漏掉的一段
用户断开的时候,上游可能已经生成了很多 token。这段钱谁付?
项目的处理在 GatewayChatService.withBilling:
java
return stream
.doOnNext(chunk -> {
delivered.set(true);
if (chunk.usage() != null) {
usage.set(chunk.usage());
}
String content = firstContent(chunk);
if (content != null) {
generated.append(content);
}
})
.doFinally(signal -> {
if (!finalized.compareAndSet(false, true)) {
return;
}
Mono<Void> settlement;
switch (signal) {
case ON_COMPLETE -> settlement = settleStream(
reservation, request, usage.get(), generated);
case CANCEL -> settlement = delivered.get()
? billing.settleAtReservation(reservation, "客户端中断")
: billing.refund(reservation, "客户端在输出前中断");
case ON_ERROR -> settlement = delivered.get()
? billing.settleAtReservation(reservation, "上游中断")
: billing.refund(reservation, "上游在输出前失败");
default -> settlement = Mono.empty();
}
// doFinally 的回调签名是 void,主链路已经终止。
// 这里必须显式 subscribe,否则 Redis 命令永远不会发出,而且不会报错。
settlement.subscribe(
ignored -> {
},
error -> log.error("[计费-终态处理失败] client={} reservation={} signal={}",
reservation.clientId(), reservation.id(), signal, error));
});
5.1 为什么用 doFinally 而不是 doOnComplete / doOnError
流式有三种结束方式,而前两个回调只能覆盖两种:
| 终态 | 回调 | 用户看到的现象 |
|---|---|---|
| 正常完成 | doOnComplete |
回答结束 |
| 上游失败 | doOnError |
流突然断了 |
| 客户端断开 | 只有 doFinally 能看到 CANCEL |
用户自己点了停止 |
如果只在 doOnError 里结算,关掉页面的用户就永远不用付钱------而那一刻上游往往已经生成了大量 token。
doFinally 是唯一能同时看到 ON_COMPLETE、ON_ERROR、CANCEL 三种终态的地方。
三种终态与计费分支的关系如下:
#mermaid-svg-R0q6Ch80tBpfg3gL{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-R0q6Ch80tBpfg3gL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-R0q6Ch80tBpfg3gL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-R0q6Ch80tBpfg3gL .error-icon{fill:#552222;}#mermaid-svg-R0q6Ch80tBpfg3gL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-R0q6Ch80tBpfg3gL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-R0q6Ch80tBpfg3gL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-R0q6Ch80tBpfg3gL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-R0q6Ch80tBpfg3gL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-R0q6Ch80tBpfg3gL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-R0q6Ch80tBpfg3gL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-R0q6Ch80tBpfg3gL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-R0q6Ch80tBpfg3gL .marker.cross{stroke:#333333;}#mermaid-svg-R0q6Ch80tBpfg3gL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-R0q6Ch80tBpfg3gL p{margin:0;}#mermaid-svg-R0q6Ch80tBpfg3gL .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-R0q6Ch80tBpfg3gL .cluster-label text{fill:#333;}#mermaid-svg-R0q6Ch80tBpfg3gL .cluster-label span{color:#333;}#mermaid-svg-R0q6Ch80tBpfg3gL .cluster-label span p{background-color:transparent;}#mermaid-svg-R0q6Ch80tBpfg3gL .label text,#mermaid-svg-R0q6Ch80tBpfg3gL span{fill:#333;color:#333;}#mermaid-svg-R0q6Ch80tBpfg3gL .node rect,#mermaid-svg-R0q6Ch80tBpfg3gL .node circle,#mermaid-svg-R0q6Ch80tBpfg3gL .node ellipse,#mermaid-svg-R0q6Ch80tBpfg3gL .node polygon,#mermaid-svg-R0q6Ch80tBpfg3gL .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-R0q6Ch80tBpfg3gL .rough-node .label text,#mermaid-svg-R0q6Ch80tBpfg3gL .node .label text,#mermaid-svg-R0q6Ch80tBpfg3gL .image-shape .label,#mermaid-svg-R0q6Ch80tBpfg3gL .icon-shape .label{text-anchor:middle;}#mermaid-svg-R0q6Ch80tBpfg3gL .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-R0q6Ch80tBpfg3gL .rough-node .label,#mermaid-svg-R0q6Ch80tBpfg3gL .node .label,#mermaid-svg-R0q6Ch80tBpfg3gL .image-shape .label,#mermaid-svg-R0q6Ch80tBpfg3gL .icon-shape .label{text-align:center;}#mermaid-svg-R0q6Ch80tBpfg3gL .node.clickable{cursor:pointer;}#mermaid-svg-R0q6Ch80tBpfg3gL .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-R0q6Ch80tBpfg3gL .arrowheadPath{fill:#333333;}#mermaid-svg-R0q6Ch80tBpfg3gL .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-R0q6Ch80tBpfg3gL .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-R0q6Ch80tBpfg3gL .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-R0q6Ch80tBpfg3gL .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-R0q6Ch80tBpfg3gL .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-R0q6Ch80tBpfg3gL .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-R0q6Ch80tBpfg3gL .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-R0q6Ch80tBpfg3gL .cluster text{fill:#333;}#mermaid-svg-R0q6Ch80tBpfg3gL .cluster span{color:#333;}#mermaid-svg-R0q6Ch80tBpfg3gL 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-R0q6Ch80tBpfg3gL .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-R0q6Ch80tBpfg3gL rect.text{fill:none;stroke-width:0;}#mermaid-svg-R0q6Ch80tBpfg3gL .icon-shape,#mermaid-svg-R0q6Ch80tBpfg3gL .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-R0q6Ch80tBpfg3gL .icon-shape p,#mermaid-svg-R0q6Ch80tBpfg3gL .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-R0q6Ch80tBpfg3gL .icon-shape .label rect,#mermaid-svg-R0q6Ch80tBpfg3gL .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-R0q6Ch80tBpfg3gL .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-R0q6Ch80tBpfg3gL .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-R0q6Ch80tBpfg3gL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ON_COMPLETE
ON_ERROR
CANCEL
是
否
是
否
流式响应结束
doFinally 收到的 signal
正常完成
按实际 usage 结算
是否已交付内容
是否已交付内容
按预扣额度结算
全额退款
按预扣额度结算
全额退款
AtomicBoolean finalized
保证只结算一次
5.2 为什么断流时按预扣额度收费
java
case CANCEL -> delivered.get()
? billing.settleAtReservation(reservation, "客户端中断") // 已经吐过字
: billing.refund(reservation, "客户端在输出前中断"); // 一个字没吐
如果按「网关实际收到的字数」收费,会系统性少收:
- 客户端断开后,上游可能已经生成了远超网关收到的内容;
- 这些内容还在 TCP 缓冲、事件循环队列里,没来得及交付;
- 但上游的账单是按它生成的 token 算的。
所以规则是:只要已经向客户端交付过内容,就按预扣额度结算;一个字都没交付,才全额退款。
5.3 为什么还要 AtomicBoolean finalized 兜底
Reactor 规范下 doFinally 每个订阅只触发一次,按说不需要额外的幂等保护。
但项目仍然用 CAS 兜底,理由不是「不相信 Reactor」,而是:
计费代码将来可能被重构到别的地方,「只结算一次」这件事不能依赖调用方的自觉。
即便 CAS 失效,Lua 脚本里「预扣凭据不存在就跳过」是最后一道防线。
六、取消的测试:负向断言比正向更重要
CancelPropagationTest 里有两条测试,缺一不可。
6.1 第一条:客户端断开,取消必须传到上游
java
@Test
@DisplayName("客户端断开 → 取消信号确实传到上游(不是只断了自己这头)")
void clientDisconnectPropagatesCancelToUpstream() throws Exception {
stubDribblingUpstream(); // 上游慢慢地一块一块吐
ListAppender<ILoggingEvent> appender = attachToClientLogger();
Flux<String> responseBody = openStream();
// ① 先确认流真的在动
CountDownLatch receivedThree = new CountDownLatch(3);
Disposable subscription = responseBody.subscribe(chunk -> receivedThree.countDown());
assertThat(receivedThree.await(15, TimeUnit.SECONDS))
.as("取消之前必须真的收到至少 3 块,否则'取消成功'什么都证明不了")
.isTrue();
// ② 真的断开 ------ 取消订阅,底层 TCP 连接随之关闭
subscription.dispose();
// ③ 断言上游边界观察到了 CANCEL
assertThat(awaitLogMarker(appender, "[cancel]", 15))
.as("没有 [cancel],说明上游还在继续吐字烧钱")
.isTrue();
}
这个测试有三个设计要点:
- 真的走 HTTP,不直接调 client------必须让「客户端断开」这个真实事件发生;
- 先确认收到了至少 3 块,否则「取消成功」可能只是因为流根本没开始;
- 断言的是上游边界的日志,不是网关自己的某个变量。
两条测试的职责边界可以这样理解:
#mermaid-svg-eaHoVqwDItqJLOQ2{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-eaHoVqwDItqJLOQ2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-eaHoVqwDItqJLOQ2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-eaHoVqwDItqJLOQ2 .error-icon{fill:#552222;}#mermaid-svg-eaHoVqwDItqJLOQ2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-eaHoVqwDItqJLOQ2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-eaHoVqwDItqJLOQ2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-eaHoVqwDItqJLOQ2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-eaHoVqwDItqJLOQ2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-eaHoVqwDItqJLOQ2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-eaHoVqwDItqJLOQ2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-eaHoVqwDItqJLOQ2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-eaHoVqwDItqJLOQ2 .marker.cross{stroke:#333333;}#mermaid-svg-eaHoVqwDItqJLOQ2 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-eaHoVqwDItqJLOQ2 p{margin:0;}#mermaid-svg-eaHoVqwDItqJLOQ2 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-eaHoVqwDItqJLOQ2 .cluster-label text{fill:#333;}#mermaid-svg-eaHoVqwDItqJLOQ2 .cluster-label span{color:#333;}#mermaid-svg-eaHoVqwDItqJLOQ2 .cluster-label span p{background-color:transparent;}#mermaid-svg-eaHoVqwDItqJLOQ2 .label text,#mermaid-svg-eaHoVqwDItqJLOQ2 span{fill:#333;color:#333;}#mermaid-svg-eaHoVqwDItqJLOQ2 .node rect,#mermaid-svg-eaHoVqwDItqJLOQ2 .node circle,#mermaid-svg-eaHoVqwDItqJLOQ2 .node ellipse,#mermaid-svg-eaHoVqwDItqJLOQ2 .node polygon,#mermaid-svg-eaHoVqwDItqJLOQ2 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-eaHoVqwDItqJLOQ2 .rough-node .label text,#mermaid-svg-eaHoVqwDItqJLOQ2 .node .label text,#mermaid-svg-eaHoVqwDItqJLOQ2 .image-shape .label,#mermaid-svg-eaHoVqwDItqJLOQ2 .icon-shape .label{text-anchor:middle;}#mermaid-svg-eaHoVqwDItqJLOQ2 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-eaHoVqwDItqJLOQ2 .rough-node .label,#mermaid-svg-eaHoVqwDItqJLOQ2 .node .label,#mermaid-svg-eaHoVqwDItqJLOQ2 .image-shape .label,#mermaid-svg-eaHoVqwDItqJLOQ2 .icon-shape .label{text-align:center;}#mermaid-svg-eaHoVqwDItqJLOQ2 .node.clickable{cursor:pointer;}#mermaid-svg-eaHoVqwDItqJLOQ2 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-eaHoVqwDItqJLOQ2 .arrowheadPath{fill:#333333;}#mermaid-svg-eaHoVqwDItqJLOQ2 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-eaHoVqwDItqJLOQ2 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-eaHoVqwDItqJLOQ2 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eaHoVqwDItqJLOQ2 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-eaHoVqwDItqJLOQ2 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eaHoVqwDItqJLOQ2 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-eaHoVqwDItqJLOQ2 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-eaHoVqwDItqJLOQ2 .cluster text{fill:#333;}#mermaid-svg-eaHoVqwDItqJLOQ2 .cluster span{color:#333;}#mermaid-svg-eaHoVqwDItqJLOQ2 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-eaHoVqwDItqJLOQ2 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-eaHoVqwDItqJLOQ2 rect.text{fill:none;stroke-width:0;}#mermaid-svg-eaHoVqwDItqJLOQ2 .icon-shape,#mermaid-svg-eaHoVqwDItqJLOQ2 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eaHoVqwDItqJLOQ2 .icon-shape p,#mermaid-svg-eaHoVqwDItqJLOQ2 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-eaHoVqwDItqJLOQ2 .icon-shape .label rect,#mermaid-svg-eaHoVqwDItqJLOQ2 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eaHoVqwDItqJLOQ2 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-eaHoVqwDItqJLOQ2 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-eaHoVqwDItqJLOQ2 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 测试一:客户端断开
先确认收到 ≥3 块
dispose() 断开订阅
断言上游边界出现 cancel
测试二:正常读完
上游痛快吐完 20 块 + DONE
断言块数正确
断言没有 cancel、有 stream-end
取消能力可信
6.2 第二条:正常读完不能误报取消
java
@Test
@DisplayName("流正常读完时不得误报取消")
void normalCompletionIsNotReportedAsCancel() throws Exception {
// 上游 300ms 内痛快吐完
// ...collectList().block()
assertThat(events).hasSize(UPSTREAM_CHUNK_COUNT + 1); // 20 块 + [DONE]
assertThat(hasLogMarker(appender, "[cancel]"))
.as("正常读完的流绝不能出现取消日志")
.isFalse();
assertThat(hasLogMarker(appender, "[stream-end]")).isTrue();
}
这条「负向断言」比第一条更重要。
如果代码里无脑把每次流结束都记成取消:
- 第一条测试照样会绿(确实出现了
[cancel]); - 但日志彻底失去意义,统计出来的「用户中途放弃率」全是假的。
只测「该发生的发生了」,不测「不该发生的没发生」,测试就是假绿的。
七、踩坑清单
坑 1:以为「关闭页面」就结束了
浏览器关闭连接只意味着客户端这边不再接收。网关如果还持有上游订阅,上游会继续生成。
必须用「上游边界是否收到取消」来验证,而不是「用户是否还能看到内容」。
坑 2:用 collectList 或 block 处理流
这两个操作都会让整条流变成「等全部完成」,取消语义随之消失。而且 block() 在 WebFlux 事件循环线程上调用会直接抛异常或拖死事件循环。
坑 3:在 doFinally 里返回 Mono 但不订阅
doFinally 的回调签名是 void。在里面构造一个 Mono 却不 subscribe,Redis 命令永远不会发出------而且不会有任何报错,账目就这样静默地漏了。
坑 4:把取消当成失败
取消和失败在业务上完全不同:
- 失败要告警、要计入错误率;
- 取消是正常的用户行为,只应计入单独指标。
混在一起,错误率告警会被大量「用户点了停止」污染,最后没人再看这个告警。
坑 5:上游不支持取消
有些上游厂商收到连接关闭后仍会把剩余 token 生成完再丢弃。这种情况下网关能做的只有:尽快断开连接、按已交付内容计费、在渠道评估里记录该厂商的取消响应情况。
八、能写进简历的部分
LLM Gateway 统一接入网关(Java 17 / Spring Boot 3.5 / WebFlux)
- 实现客户端断开 → 上游连接取消的完整传播链路,避免用户停止生成后仍持续消耗上游 token;
- 在流式链路的上游边界记录
CANCEL/COMPLETE终态,区分「用户主动放弃」与「正常完成」,为渠道质量评估提供数据;- 使用
doFinally覆盖正常完成、上游异常、客户端取消三种终态,断流时按预扣额度结算、未输出时全额退款;CancelPropagationTest覆盖「取消确实到达上游」与「正常完成不误报取消」两个方向。
这条素材的价值在于:它不是一个 CRUD,而是一个只有在真实长连接场景里才会暴露的问题。面试官问「你怎么知道取消传到上游了」,你有日志断言和测试可以现场解释。
Commit Message 参考:
text
feat(stream): 客户端断开时向上游传播取消并完成计费收尾
- Controller 原样返回 Flux,由 Spring 管理订阅生命周期
- Provider 在上游边界记录 CANCEL / COMPLETE 终态日志
- 计费挂在最终交付的 Flux 上,doFinally 覆盖三种终态
- 新增 CancelPropagationTest:取消到达上游 + 正常完成不误报
九、八股文 / 场景题暴击
场景题 1:Reactor 的取消信号是怎么传播的?为什么 subscribe 的位置这么重要?
Reactor 采用发布者-订阅者 + 反向压力 模型。订阅时,下游持有一个 Subscription;调用 cancel() 时,这个信号会沿着算子链从下游向上游逐层传递,每一层算子都要负责把取消继续往上传。
所以:
- 如果 Controller 返回
Flux,Spring 是最终订阅者,客户端断开时它调用cancel(),信号自然到达 WebClient; - 如果 Controller 自己
subscribe(),最终订阅者变成了你的 lambda,Spring 手里没有Subscription,取消无从发起。
这也是为什么 doFinally 能看到 CANCEL:它是算子链的一部分,取消信号经过它时会被通知。
追问点:doOnCancel 和 doFinally(CANCEL) 有什么区别? doOnCancel 只在取消时触发;doFinally 在完成、异常、取消三种终态都会触发,且保证每个订阅最多一次。收尾逻辑(计费、释放资源)应该放 doFinally,单纯统计取消可以放 doOnCancel。
场景题 2:如果上游是同步阻塞客户端(比如 RestTemplate),还能做到取消传播吗?
很难,而且通常不值得。
RestTemplate 的调用是阻塞的:线程进入 execute() 后一直等到响应读完。客户端断开时,你最多能做到「把结果丢弃」,但无法中断已经发出的 HTTP 请求,上游仍会继续生成。
可行的补救方式:
- 换异步客户端(WebClient / OkHttp 的异步调用 / Jetty HttpClient),让请求本身可取消;
- 如果必须用阻塞客户端,把调用放到独立线程池,并在取消时中断线程------但这依赖底层 IO 是否响应中断,很多时候并不;
- 更现实的做法是设置足够短的读超时和
max_tokens,用「限制最坏情况」代替「精确取消」。
一句话:取消能力是客户端选型时就要考虑的,不是事后补的。
小结
「停止生成」按钮的完整语义,可以拆成四件事:
- 客户端断开:浏览器/SDK 关闭连接;
- 网关取消订阅 :框架自动完成,前提是别自己
subscribe; - 上游停止生成:取消信号传到 WebClient,连接关闭;
- 账目正确收尾 :
doFinally覆盖三种终态,已交付按预扣结算,未交付退款。
验证方式只有一条:在上游边界留下可断言的证据,并且同时测试「取消会发生」和「正常完成不会误报」。
项目地址:https://github.com/maixiaowen111/llm-gateway
推荐标签:WebFlux、Reactor、取消传播、流式输出、LLM网关