用户点了停止生成,上游还在烧 token 吗?取消传播链路拆解

项目背景: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(),或者关掉页面。

但站在网关的角度,这一刻同时发生了几件事:

  1. 客户端到网关的 TCP 连接被关闭;
  2. 网关应该立刻停止向上游要数据;
  3. 上游应该停止生成,不再消耗 token;
  4. 这次请求的计费、指标、日志要正确收尾。

如果第 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(...));
}

问题在于:

  1. Controller 方法立刻返回,Spring 认为这个请求已经处理完了;
  2. 真正订阅上游流的是你自己,和 HTTP 响应之间没有订阅关系;
  3. 客户端断开时,Spring 没有东西可取消;
  4. 上游那条流会继续跑完,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:用户中途放弃,上游可能已经生成了大量内容。

如果把两者混成一句「流结束」:

  1. 「用户中途放弃率」这个产品指标永远算不出来;
  2. 无法判断是上游太慢导致用户没耐心,还是回答本身有问题;
  3. 计费逻辑也失去判断依据。

日志里带上 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();
}

这个测试有三个设计要点:

  1. 真的走 HTTP,不直接调 client------必须让「客户端断开」这个真实事件发生;
  2. 先确认收到了至少 3 块,否则「取消成功」可能只是因为流根本没开始;
  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 请求,上游仍会继续生成。

可行的补救方式:

  1. 换异步客户端(WebClient / OkHttp 的异步调用 / Jetty HttpClient),让请求本身可取消;
  2. 如果必须用阻塞客户端,把调用放到独立线程池,并在取消时中断线程------但这依赖底层 IO 是否响应中断,很多时候并不;
  3. 更现实的做法是设置足够短的读超时和 max_tokens,用「限制最坏情况」代替「精确取消」。

一句话:取消能力是客户端选型时就要考虑的,不是事后补的。


小结

「停止生成」按钮的完整语义,可以拆成四件事:

  1. 客户端断开:浏览器/SDK 关闭连接;
  2. 网关取消订阅 :框架自动完成,前提是别自己 subscribe;
  3. 上游停止生成:取消信号传到 WebClient,连接关闭;
  4. 账目正确收尾 :doFinally 覆盖三种终态,已交付按预扣结算,未交付退款。

验证方式只有一条:在上游边界留下可断言的证据,并且同时测试「取消会发生」和「正常完成不会误报」。

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

推荐标签:WebFlux、Reactor、取消传播、流式输出、LLM网关

相关推荐
昇腾CANN44 分钟前
10月23日直播丨CANN挑战赛决赛赛题解析
人工智能·昇腾·cann·cann开源
长不大的杨瑞杰1 小时前
给 OpenCode v2 接入大模型:Provider、模型选择与 API Key 安全配置实战
人工智能·安全·ai·语言模型
初心未改HD1 小时前
DSH Skill开发实战:SKILL.md是给LLM看的操作手册,不是给人看的README,踩了4个坑
java·大数据·开发语言
aibo8603081 小时前
基于 Spring Boot 的茶铺管理系统设计与实现
java·spring boot·后端
虎鲸不是鱼1 小时前
AI 自动化逆向(九)AI 会记住错的公共常量
人工智能·自动化·大模型·逆向·ai逆向
飞猫的边缘AI1 小时前
芯片设计会不会全自动化?什么时候到来?
人工智能·ic设计·芯片设计·智能体·边缘ai·ai岗位·ai职业前景
Wang's Blog1 小时前
Java 项目实战: 交友项目 - SpringData-Mongo 环境搭建和 CRUD
java·开发语言·交友
class心平气和1 小时前
软件测试-AI提示词库
人工智能
阿里云大数据AI技术1 小时前
云栖实录|Hologres 5.0全新发布:Agent Native 的全模态实时数仓
大数据·人工智能·agent