前端关了页面,后端怎么取消模型调用?

流式输出已是 AI 应用标配,而「客户端断开后的资源治理」直接决定你的服务会不会在高峰期裸奔烧钱。本文从底层(TCP / 响应式取消传播)讲清原理,并给出 Go / PHP / Python / Java 四种语言的可运行实现。

Go · 原生 context 传播 PHP · SSE + 连接检测 Python · asyncio 取消 Java · WebFlux / SseEmitter
底层原理:核心就三个字——信号传播

你不需要「主动去感知」前端断开。只要链路不阻断,TCP 断开会以 cancel 信号自动从下游传到上游。

把整条链路摊开,取消信号是这样传播的:

浏览器 关闭/断网/锁屏 TCP 断开 RST / FIN 网络层感知 Reactor Netty Subscriber 收到 cancel Flux 链传播 map/flatMap 不阻断 WebClient 取消 关闭 LLM 的 HTTP ← cancel 信号沿链路向上游反向传播(多米诺骨牌) 模型收到断开 → 停止生成
正常请求向下游流动 cancel 信号向上游传播
认知冲击:你不是去「发明感知机制」,而是做三件事——① 别阻断传播链(90% 的人踩的坑);② 在关键节点 挂钩子做感知和清理;③ 把「取消」落实到位,让 LLM 的 HTTP 连接真正断掉。

1感知(Perceive)

后端怎么知道前端走了?靠 TCP 连接断开(RST/FIN)被服务端网络层立即捕获,而不是靠前端发「我要走了」的通知。强杀、断网、锁屏都不会触发 beforeunload,但一定会断 TCP。

2取消(Cancel)

把断开变成「取消信号」,一路传到最上游——真正发起对 LLM 的 HTTP 请求的那一层,把那条连接关掉,让模型停止生成,停止烧钱。

3清理(Cleanup)

前端关页面往往意味着会话「半途而废」。会话上下文、并发计数、临时状态、连接池资源都要在 统一收尾点清掉,否则下轮对话错乱、并发数虚高触发误限流、连接泄漏最终 OOM。

👁
第一层:后端怎么「感知」前端断开?

感知方式取决于技术栈,主流有三条路。关键词:别信 beforeunload。

机制一:响应式 cancel 信号(WebFlux 全家桶,最推荐)

Reactor Netty 监听 TCP 状态,客户端断开瞬间内核发来 RST/FIN → 最下游 Subscriber 调用 subscription.cancel() → cancel 沿 Flux 链一路向上传播。你只在链上挂监听即可。

// Spring WebFlux + Spring AI
return chatClient.prompt(request.message())
    .stream().content()
    .doOnCancel(() -> log.warn("客户端断开,触发取消 | session={}", sessionId)) // 感知点
    .doFinally(signal -> {
        if (signal == SignalType.CANCEL) log.warn("流被取消——前端跑了");
        else if (signal == SignalType.ON_COMPLETE) log.info("流正常结束");
        else if (signal == SignalType.ON_ERROR) log.error("流异常终止");
        cleanupSession(sessionId); // 统一收尾
    });

机制二:SseEmitter 的「回调三兄弟」(Spring MVC 传统栈)

MVC 下客户端断开不会自动传播 cancel——你是在「下一次 send 失败」时才后知后觉。dispose() 是手动取消模型订阅的核心动作,每个回调都必须带上。

SseEmitter emitter = new SseEmitter(120_000L); // 必须设超时!
Disposable sub = chatClient.prompt(req.message()).stream().content()
    .subscribe(
        chunk -> { try { emitter.send(SseEmitter.event().data(chunk)); }
                   catch (IOException e) { log.warn("send 失败,客户端已断开"); sub.dispose(); } },
        emitter::completeWithError, emitter::complete);

emitter.onCompletion(() -> { log.info("SSE 关闭"); sub.dispose(); });
emitter.onTimeout(() -> { log.warn("SSE 超时,强制取消"); sub.dispose(); emitter.complete(); });
emitter.onError(e -> { log.warn("SSE 异常: {}", e.getMessage()); sub.dispose(); });
关键坑:MVC 方案下若模型吐字慢、好几分钟没 send 一次,你就好几分钟发现不了断开,模型一直在烧钱。dispose() 漏掉任何一个回调 = 一条「烧钱流」。

机制三:应用层心跳检测(过 Nginx / CDN 时必上)

若中间隔着代理,TCP 断连感知会被延迟甚至吞掉。服务端每 15~30s 发一个 SSE 注释行 :heartbeat\n\n,一旦 send 心跳失败即判定连接死亡,主动取消模型调用。

Flux<ServerSentEvent<String>> heartbeat =
    Flux.interval(Duration.ofSeconds(15))
        .map(i -> ServerSentEvent.<String>builder().comment("keep-alive").build());
// 与数据流 merge,任一被取消都能感知
return Flux.merge(dataStream, heartbeat)
    .doOnCancel(() -> log.warn("心跳或数据流被取消"));
第二层:怎么「取消」模型端的调用?

感知到了,关键动作是让 LLM 那边的 HTTP 请求真正断掉。

响应式:cancel 自动传播到 HTTP 层(最优雅)

下游 cancel → ChatClient 的 Flux 被取消 → WebClient 响应 Flux 被取消 → Reactor Netty 客户端关闭与 LLM 的 TCP 连接 → 模型端收到断开停止生成。你不用写任何「取消模型」的代码,只要保证信号能传上去。

下游 cancel
  ↓  ChatClient 的 Flux 被取消
  ↓  WebClient 的响应 Flux 被取消
  ↓  Reactor Netty 关闭与 LLM 的 TCP 连接
  ↓  模型端收到连接关闭 → 停止生成

哪些操作会「阻断 cancel 传播」?(重点避坑)

坑 1:操作链里有阻塞调用。block() 会卡死传播链,cancel 传不上来。要用响应式 API(返回 Mono,由 flatMap 串联)。
// ❌ 错误
.flatMap(chunk -> { String r = someBlockingDbQuery(chunk); return Flux.just(r); })
// ✅ 正确
.flatMap(chunk -> reactiveDbQuery(chunk))
坑 2:内部产生「独立订阅」。mono.subscribe() 在 flatMap 里创建了与外部链脱节的订阅,cancel 传不到,模型调用变「孤儿请求」。应返回链内对象由框架串联。
// ❌ 错误:外部 cancel 传不到
.flatMap(chunk -> { someAsyncCall(chunk).subscribe(); return Flux.just(chunk); })
// ✅ 正确
.flatMap(chunk -> someAsyncCall(chunk))
坑 3:用了不响应取消的同步 HTTP 客户端。普通 HttpClient.send()(同步阻塞)调模型,cancel 根本传不进去。要么换成 sendAsync() / WebClient,要么手动取消。

不是响应式?那就手动取消(传统栈 / 同步调用)

  • 方案 A · Future.cancel(true):配合 ExecutorService 提交,中断正在执行 HTTP 调用的线程。
  • 方案 B · 软取消(生产最常用兜底):请求带 requestId,前端断开时后端写「取消表」;模型响应返回后发现有取消标记就直接丢弃结果。模型可能仍在烧钱,但不污染业务状态。
  • 方案 C · 换响应式/异步:一劳永逸,让取消自动传播。
Insight:很多 LLM API 一旦开始生成就不支持中途断开,你只能「白烧这部分钱,但别污染业务状态」。所以软取消反而是最务实的兜底。
🧹
第三层:清理,别让状态烂在内存里

取消只是手段,清理才是目的。

要清理的东西为什么必须清不清的后果
会话上下文(ChatMemory)半截对话塞进历史会错乱用户下次对话前言不搭后语
计数 / 并发状态流断了计数没减并发数虚高 → 触发限流误伤
DB / 缓存临时状态半成品记录残留脏数据、下次读到过期状态
线程池 / 连接池订阅未释放连接泄漏 → 高峰期 OOM / 无连接
铁律:清理逻辑放 doFinally,而不是 doOnCancelonError因为 doFinally 无论流是正常完成、被取消、还是异常都会执行,保证必达
.doFinally(signal -> {
    chatMemory.clear(sessionId);        // 清会话
    concurrency.decrementAndGet();      // 减并发计数
    releaseResources(sessionId);          // 释放连接
});
{'
四语言实现:Go / PHP / Python / Java

点击下方语言切换。每个实现都覆盖:感知(连接断开)→ 取消(传给上游 HTTP)→ 清理(收尾)。

Go
PHP
Python
Java

Go:context.Context 是唯一真相

Go 的 context.Context 就是 cancel 信号的载体。HTTP/2 流(SSE / 流式)在客户端断开时,r.Context().Done() 会被关闭。只要把这个 ctx 一路透传到上游 LLM 的 HTTP 请求,模型调用会被自动取消——原理和响应式 cancel 传播一模一样,只是用 context 表达。

func streamChat(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context() // 客户端断开 → ctx 自动 Done
    flusher := w.(http.Flusher)
    w.Header().Set("Content-Type", "text/event-stream")

    // 1) 向上游 LLM 发起流式请求,携带同一个 ctx
    req, _ := http.NewRequestWithContext(ctx, "POST", llmURL, body)
    resp, err := httpClient.Do(req)
    if err != nil { return }
    defer resp.Body.Close()

    // 2) 监听 ctx.Done(),及时收尾
    go func() {
        <-ctx.Done()
        log.Println("客户端断开,context 取消,上游 LLM 流将关闭")
    }()

    // 3) 边读上游边写给客户端;客户端断开时 resp.Body.Read 立即报错
    buf := make([]byte, 4096)
    for {
        select {
        case <-ctx.Done():   // 客户端断开
            cleanupSession(r)  // 清理
            return
        default:
            n, err := resp.Body.Read(buf)
            if n > 0 {
                w.Write(buf[:n]); flusher.Flush()
            }
            if err != nil { // 上游结束或 ctx 取消导致读失败
                cleanupSession(r); return
            }
        }
    }
}
Go 的优雅之处:httpClient.Do(req) 用的是带 ctx 的请求,ctx 被取消时,底层的 HTTP/2 连接会被关闭,LLM 端停止生成。你不需要手动去「取消模型」——和 WebFlux 的 cancel 传播同构。defer resp.Body.Close() 在 ctx 取消时也会释放连接,正好对应「清理层」。

PHP:SSE + 连接检测(注意 PHP 的坑)

PHP(常驻进程,如 Swoole / FrankenPHP / Workerman)才能真正感知断开;传统 FPM 模式下脚本随请求结束,无法在客户端断开后继续「取消上游」。现代方案用 Swoole 的 Server->close 事件或 connection_aborted() + 心跳检测。

// 伪代码:基于 Swoole 常驻 worker 的 SSE 流式
use Swoole\Http\Response;
function streamChat(Request $req, Response $resp) {
    $resp->header('Content-Type', 'text/event-stream');
    $resp->header('Cache-Control', 'no-cache');
    $fd = $req->fd;

    // 1) 用 cURL(CURLOPT_TIMEOUT_MS)或 Swoole Coroutine\Http\Client 发起 LLM 流式请求
    $client = new Co\Http\Client($host, 443, true);
    $client->set(['timeout' => 120]);
    $client->post('/v1/chat/stream', $body);

    // 2) 边读上游边写给客户端;每次写入检测客户端是否还在
    while ($chunk = $client->recv()) {
        // 关键检测:客户端连接是否已断开
        if (!Server::exist($fd) || Server::getClientInfo($fd) === false) {
            log("客户端断开,关闭上游 LLM 连接");
            $client->close();          // 取消模型调用
            cleanupSession($sessionId); // 清理
            return;
        }
        $resp->write("data: $chunk\n\n");
        $resp->flush();
    }
    $client->close();
    cleanupSession($sessionId);
}
PHP 的两个大坑:FPM 模式不支持——脚本随 HTTP 请求结束,无法在客户端断开后继续取消上游,必须上常驻进程(Swoole / FrankenPHP / RoadRunner)。② connection_aborted() 只在有输出 flush 后才灵敏,所以必须频繁 flush + 心跳,否则要等下次写才发现断开。传统栈下这就是「感知延迟」问题,和 MVC 的 SseEmitter 一样。

Python:asyncio 的 Task 取消

FastAPI / Starlette 的 Request.is_disconnected() 会在客户端断开后置为 True;配合 asynciotask.cancel()httpx.AsyncClient 流式读取时客户端断开会抛异常,从而取消上游调用。

from fastapi import Request
import httpx, asyncio

async def stream_chat(request: Request):
    async with httpx.AsyncClient(timeout=120) as client:
        # 1) 向上游 LLM 发起流式请求(异步,可被取消)
        async with client.stream("POST", llm_url, json=body) as resp:
            async def gen():
                async for chunk in resp.aiter_text():
                    # 2) 每次产出前检测客户端是否断开
                    if await request.is_disconnected():
                        break   # 退出生成器 → resp 被关闭 → 上游 LLM 停止
                    yield f"data: {chunk}\n\n"
                # 3) 生成器退出即清理
                cleanup_session(session_id)

            return StreamingResponse(gen(), media_type="text/event-stream")
更彻底的取消:把上游请求包进 asyncio.create_task(),客户端断开时 task.cancel(),asyncio 会在最近的 await 点抛出 CancelledErrorhttpx 随即关闭与 LLM 的 TCP 连接——模型停止生成。这和响应式 cancel 传播等价,只是用协程取消表达。注意:is_disconnected() 在没网络写入时不主动触发,建议加心跳或依赖底层读异常

Java:WebFlux(响应式)vs SseEmitter(MVC)

Java 是参考文章的主场。两个栈做法完全不同:响应式靠 cancel 自动传播,MVC 靠手动 dispose。下面给出两版完整接口。

① WebFlux(响应式,推荐)

// 取消信号自动从下游传到上游 WebClient,无需手动取消 LLM
@PostMapping(value = "/stream", produces = TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> stream(@RequestBody ChatRequest req) {
    concurrency.incrementAndGet();
    return chatClient.prompt(req.message()).stream().content()
        .map(c -> ServerSentEvent.<String>builder().data(c).build())
        .doOnCancel(() -> log.warn("前端断开,取消模型 | {}", req.sessionId()))
        .doFinally(sig -> {
            concurrency.decrementAndGet();
            if (sig == SignalType.CANCEL) {
                chatMemory.clear(req.sessionId()); // 清理半截会话
                log.warn("清理会话 | {}", req.sessionId());
            }
        });
}

② MVC + SseEmitter(手动 dispose)

SseEmitter emitter = new SseEmitter(120_000L); // 必须设超时
Disposable sub = chatClient.prompt(req.message()).stream().content()
    .subscribe(
        c -> { try { emitter.send(SseEmitter.event().data(c)); }
               catch (IOException e) { sub.dispose(); } },   // send 失败=断开
        emitter::completeWithError, emitter::complete);
emitter.onCompletion(() -> sub.dispose());
emitter.onTimeout(() -> { sub.dispose(); emitter.complete(); });
emitter.onError(e -> sub.dispose());
Java 避坑:① SseEmitter 不设超时 = 连接永不超时 = 泄漏;② 每个回调都要 dispose(),漏一个就是一条烧钱流;③ 操作链里禁止 block()、禁止 mono.subscribe() 独立订阅,否则 cancel 传播被阻断。
终极对比 & 选型

四种语言 / 栈的取消机制对照

感知断开的方式取消上游的机制清理收尾点
Go (net/http)r.Context().Done() 自动关闭ctx 透传到 http.NewRequestWithContext,底层连接关闭defer + ctx.Done 监听
PHP (Swoole)连接存在性检测 / 心跳手动 $client->close()检测分支里清理
Python (asyncio)request.is_disconnected() / 读异常退出生成器 / task.cancel()生成器退出 / finally
Java (WebFlux)TCP 断开 → cancel 自动传播自动(WebClient 取消)doFinally
Java (MVC)下一次 send 失败 / 回调手动 subscription.dispose()各回调 + dispose
共同点:无论哪种语言,「取消」的底层都是关闭那一条通往 LLM 的 TCP/HTTP 连接。区别只是「谁负责关」——响应式/context 栈由运行时自动关,MVC/PHP 常驻栈由你手动关。

SSE vs WebSocket(面试官常追问)

维度SSEWebSocket
方向服务端→客户端 单向双向
协议基于 HTTP,兼容好独立协议,需握手升级
自动重连浏览器原生支持需自己实现
断连感知TCP 层即可需心跳 ping/pong
大模型流式✅ 首选(单向推送够用)需双向时才用
结论:大模型对话用 SSE 就够了,别为了「听起来高级」上 WebSocket。SSE 基于 HTTP、断连感知天然、代码更简单。
避坑清单(全是血泪)
  1. SseEmitter 必须设超时——不设 = 连接永不超时 = 泄漏。
  2. MVC 每个回调都要 dispose()——漏一个就是一条烧钱流。
  3. 操作链里禁止 block()——阻塞会卡死 cancel 传播。
  4. 别搞独立订阅——mono.subscribe() 扔在 flatMap 里,cancel 传不到 = 孤儿请求。
  5. 同步 HTTP 客户端无法取消——要可取消就得用异步/响应式客户端(Java WebClient / Go ctx / Py httpx async / PHP Swoole client)。
  6. 清理逻辑放 doFinally——别只放 doOnCancel,异常终止时你会漏清。
  7. 别指望「关了连接就不扣钱」——部分 LLM 按已生成 token 计费,断开只是止损,不是免费。
  8. 过 CDN/Nginx 一定要心跳兜底——否则断连感知会被代理层吞掉。
  9. PHP 别用 FPM 模式做这个——必须常驻进程(Swoole / FrankenPHP),否则脚本随请求结束无法继续取消上游。
  10. Python 别只用 is_disconnected 轮询——没网络写入时不触发,要配合底层读异常 / 心跳。

🎯 面试 30 秒模板(直接背)

结论:核心是响应式链路下的取消传播——TCP 断开会以 cancel 信号自动从下游传到上游,我不用主动感知,但要保证链路不被阻断,并在关键节点做感知和清理。

机制:WebFlux 下 Reactor Netty 检测到连接断开触发下游 cancel,信号沿 Flux 链传播到 WebClient,自动关闭与 LLM 的 HTTP 连接,模型停止生成。我在 doOnCancel 感知,在 doFinally 统一清理会话与并发计数。

兜底:若是 MVC + SseEmitter,则通过三兄弟回调手动 dispose();若是同步调用,用取消表做软取消。加分项:清理放 doFinally 保证必达、心跳应对 CDN、断开只是止损。

一句话本质:这道题表面考「流式输出」,本质考三件事——对网络协议的理解(TCP 断开)、对取消传播机制的掌握(Reactive / context / asyncio cancel)、对生产成本的敬畏(资源治理)。真正专业的答案是:我不是靠前端告诉我的,而是靠连接断开自己告诉我的。