流式输出时前端关了页面,你后端怎么取消模型调用?90% 的人栽在这

hermes/ds v4 flash
📝
前端关页面、断网或锁屏时,后端如何感知并取消 LLM 调用?核心是响应式 cancel 信号沿 Flux 链自动传播,配合 doFinally 统一清理——感知、取消、清理三步一步都不能少。

原文:流式输出时前端关了页面,你后端怎么取消模型调用?90% 的人栽在这 | 作者:Fox爱分享

大模型应用 · 面试硬核解析

一、先看一个真实翻车现场

面试官问:「你们的 AI 对话是流式输出的吧?那用户聊着聊着直接把页面关了,你后端怎么知道?怎么把模型那边的调用停掉?」

候选人 A(背八股型):「前端有 beforeunload 事件,可以发请求通知后端。」

面试官追问:「那用户直接强杀进程、断网、或者手机锁屏呢?beforeunload 根本不触发,你怎么办?」

候选人 A 沉默了。

候选人 B(略懂皮毛型):「后端用 SseEmitter,客户端断开后会有异常,我 catch 住就行了。」

面试官追问:「SseEmitter 抛异常是在你往 emitter 里 send 的时候才发生的。如果模型吐字很慢,你 10 秒都没 send 一次,这 10 秒里模型在不在白白烧钱?catch 住之后你怎么把上游 LLM 的 HTTP 调用停掉?」

候选人 B 也沉默了。

这道题,恰恰是现在所有大模型后端应用面试的高频考点。为什么?因为「流式输出」已经是 AI 应用的标配,而「客户端断开后的资源治理」直接决定了你的服务会不会在高峰期裸奔烧钱。

今天 Fox 把这条链路彻底拆开给你看:感知 → 取消 → 清理,三件事,一步都不能少。

二、结论先拍脸:核心就三个字——「信号传播」

先把整条链路摊开,取消信号传播链:

  1. 前端关闭页面 / 断网 / 锁屏 —— 浏览器主动断开 HTTP/TCP 连接
  2. TCP 连接断开(RST / FIN) —— 服务端网络层(Reactor Netty)立即感知
  3. 后端感知:下游 Subscriber 收到 cancel —— 触发 doOnCancel 回调(感知点)
  4. cancel 沿 Flux 链向上游传播 —— map/flatMap 不会阻断(别写 block/独立订阅)
  5. WebClient 取消对 LLM 的 HTTP 请求 —— 底层 TCP 连接关闭,模型收到断开
  6. 模型停止生成 + doFinally 统一清理 —— 停止计费、释放会话上下文与并发计数

注意关键词:Reactive Streams(响应式流)的取消传播。

认知冲击:你不需要「主动去感知」前端断开——只要你用的是响应式链路,TCP 连接断开这件事,会以 cancel 信号的形式自动从下游传到上游。你要做的不是发明感知机制,而是:① 别阻断这条传播链(90% 的人踩的坑);② 在关键节点挂钩子做感知和清理;③ 把「取消」真正落实到位——让 LLM 那边的 HTTP 连接断掉。

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

先说结论:感知方式取决于你的技术栈,主流有三条路。

机制一:Reactive 的 cancel 信号(WebFlux 全家桶,推荐)

如果你用的是 Spring WebFlux + Spring AI(或 LangChain4j 的响应式 API),你几乎什么都不用写,框架已经帮你感知了。

看底层:Reactor Netty 作为 HTTP 服务端持续监听 TCP 连接状态。客户端断开的瞬间,内核给服务端发来连接重置(RST)或关闭(FIN)通知,Reactor Netty 立刻知道「这条连接没了」,于是它作为响应链上最下游的 Subscriber,调用 subscription.cancel()。cancel 信号像多米诺骨牌一样沿 Flux 操作链一路向上传播。你只需要在链上挂监听:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
return chatClient.prompt(request.message())
.stream()
.content()
.doOnCancel(() -> log.warn("客户端断开,触发取消 | sessionId={}", request.sessionId())) // 感知点
.doFinally(signal -> {
// 清理点:signal 能告诉你终止原因
if (signal == SignalType.CANCEL) {
log.warn("流被取消——前端跑了");
} else if (signal == SignalType.ON_COMPLETE) {
log.info("流正常结束");
} else if (signal == SignalType.ON_ERROR) {
log.error("流异常终止");
}
cleanupSession(request.sessionId()); // 统一收尾
});

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

如果是传统 Spring MVC + Tomcat,用 SseEmitter 就要手动一点了。核心是挂满三个回调:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
SseEmitter emitter = new SseEmitter(120_000L); // 2 分钟超时,必须设!
Disposable subscription = chatClient.prompt(request.message())
.stream()
.content()
.subscribe(
chunk -> {
try {
emitter.send(SseEmitter.event().data(chunk));
} catch (IOException e) {
// send 抛异常 = 客户端已经断开(write 失败)
log.warn("send 失败,客户端已断开");
subscription.dispose(); // 立刻取消模型订阅!
}
},
emitter::completeWithError,
emitter::complete
);
// 回调一:正常关闭(浏览器 tab 关闭,HTTP 连接断开触发)
emitter.onCompletion(() -> {
log.info("SSE 连接关闭");
subscription.dispose();
});
// 回调二:超时(不设超时则永不触发——但连接泄漏了)
emitter.onTimeout(() -> {
log.warn("SSE 超时,强制取消");
subscription.dispose();
emitter.complete();
});
// 回调三:异常(包括客户端断开的 IO 异常)
emitter.onError(e -> {
log.warn("SSE 异常: {}", e.getMessage());
subscription.dispose();
});

插一句 insight:MVC 方案下,客户端断开不会自动传播 cancel 给你——你是在「下一次 send 失败」时才后知后觉。模型吐字慢,可能好几分钟都没机会发现。dispose() 是 MVC 方案里取消模型调用的核心动作,每个回调里都必须带上。

机制三:心跳检测(兜底,网络不稳 / 过 CDN 时必上)

前两种机制都依赖「TCP 连接异常能被立刻感知」。但如果中间隔着 Nginx/CDN/网关,断连感知会被延迟甚至吞掉。这时候需要应用层心跳兜底:

  • 服务端每 15~30 秒往 SSE 里发一个注释行 :heartbeat\n\n(对端不可见,只保活);
  • 一旦 send 心跳失败 → 判定连接死亡 → 主动取消模型调用。
1
2
3
4
5
6
7
8
9
// 心跳保活 + 断连检测
Flux<ServerSentEvent<String>> heartbeat = Flux.interval(Duration.ofSeconds(15))
.map(i -> ServerSentEvent.<String>builder().comment("keep-alive").build());
Flux<ServerSentEvent<String>> dataStream = chatClient.prompt(request.message())
.stream()
.content()
.map(chunk -> ServerSentEvent.<String>builder().data(chunk).build());
return Flux.merge(dataStream, heartbeat)
.doOnCancel(() -> log.warn("心跳或数据流被取消"));

四、第二层:怎么「取消」模型端的调用?

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

4.1 响应式链路:cancel 会自动传播到 HTTP 层

这是响应式最优雅的地方。你的 ChatClient.stream() 返回的 Flux<ChatResponse>,底层就是一次对 LLM API 的 HTTP 流式请求(Spring AI 内部用 WebClient 发起)。当最下游的 Subscriber 取消时:

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

你不需要写任何「取消模型」的代码,只要保证 cancel 信号能一路传上去。这就是为什么我一直强调:别阻断传播链。

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

这是面试官最爱的追问点,也是实际线上最常出事故的地方:

坑 1:操作链里有阻塞调用

1
2
3
4
5
6
7
// ❌ 错误:block() 是阻塞的,会卡住 cancel 传播
.flatMap(chunk -> {
String result = someBlockingDbQuery(chunk); // 阻塞调用
return Flux.just(result);
})
// ✅ 正确:用响应式 API,让 cancel 自然传播
.flatMap(chunk -> reactiveDbQuery(chunk)) // 返回 Mono,由 flatMap 串联

坑 2:内部产生「独立订阅」,与外部链脱节

1
2
3
4
5
6
7
8
9
// ❌ 错误:mono.subscribe() 创建了独立订阅
// 外部 cancel 传播不到这里,模型调用会变成「孤儿请求」
.flatMap(chunk -> {
Mono<String> mono = someAsyncCall(chunk);
mono.subscribe(); // 独立订阅,没人能取消它!
return Flux.just(chunk);
})
// ✅ 正确:返回操作链内部的对象,由框架串联
.flatMap(chunk -> someAsyncCall(chunk))

坑 3:用了不响应取消的第三方客户端

如果你的「模型调用」不是走 Spring AI,而是自己用普通 HttpClient.send()(同步阻塞)调的,那 cancel 根本传不进去。要么换成 java.net.http.HttpClientsendAsync() 返回 CompletableFuture,要么用 WebClient。同步阻塞的 HTTP 客户端 = 无法取消,记住这句话。

4.3 不是响应式?那就手动取消

如果是传统 MVC + 同步 HTTP 调用模型,取消手段就有限了:

  • 方案 A:用 Future.cancel(true)(配合 ExecutorService 提交),中断正在执行的 HTTP 调用线程;
  • 方案 B:请求带 requestId,前端断开时后端记录到「取消表」,模型响应返回后发现有取消标记就直接丢弃(软取消,模型还在烧钱但结果不返回);
  • 方案 C:直接换成响应式/异步方案,一劳永逸。

插一句 insight:真实生产中,方案 B(软取消)反而是最常用的兜底——因为很多 LLM API 一旦开始生成就不支持中途断开,你只能选择「白烧这部分钱,但别污染业务状态」。

五、第三层:清理,别让状态烂在内存里

取消只是手段,清理才是目的。前端关页面,往往意味着这次会话是「半途而废」的,你要收拾的烂摊子包括:

要清理的东西 为什么必须清 不清的后果
会话上下文(ChatMemory) 半截对话塞进历史,下轮问答会错乱 用户下次对话前言不搭后语
计数/统计状态(token 计数、并发计数) 流断了但计数没减 并发数虚高 → 触发限流误伤
数据库/缓存里的临时状态 半成品记录残留 脏数据、下次读到过期状态
线程池/连接池资源 订阅未释放 连接泄漏 → 高峰期 OOM / 无连接可用

代码上就一条铁律:清理逻辑放 doFinally,而不是 doOnCancelonError 里。因为 doFinally 无论流是正常完成、被取消、还是异常都会执行,保证必达。

1
2
3
4
5
.doFinally(signal -> {
chatMemory.clear(request.sessionId()); // 清会话
concurrencyCounter.decrementAndGet(); // 减并发计数
releaseResources(request.sessionId()); // 释放连接
});

六、完整实战:一个生产级流式接口长这样

把三层串起来,一个「能扛住前端随便关页面」的流式接口(WebFlux + Spring AI):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@RestController
@RequestMapping("/api/chat")
@Slf4j
public class ChatStreamController {
private final ChatClient chatClient;
private final ChatMemory chatMemory;
private final AtomicInteger concurrency = new AtomicInteger();

@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> stream(@RequestBody ChatRequest request) {
concurrency.incrementAndGet(); // 并发计数
log.info("收到流式请求 | sessionId={}", request.sessionId());
return chatClient.prompt(request.message())
.advisors(advisor -> advisor.param(ChatMemoryRetrievalAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY,
request.sessionId()))
.stream()
.content()
.map(chunk -> ServerSentEvent.<String>builder().data(chunk).build())
// 感知点:前端断开,cancel 信号到达这里
.doOnCancel(() -> log.warn("前端断开连接,取消模型调用 | sessionId={}", request.sessionId()))
// 清理点:无论怎么结束,统一收尾
.doFinally(signal -> {
concurrency.decrementAndGet();
if (signal == SignalType.CANCEL) {
chatMemory.clear(request.sessionId());
log.warn("清理半截会话 | sessionId={}", request.sessionId());
}
});
}
}

这套代码回答面试时,你可以理直气壮地说:「前端断开 → Reactor Netty 检测到 TCP 连接异常 → 下游 cancel → 沿 Flux 链传播 → Spring AI 内部 WebClient 取消对 LLM 的 HTTP 请求 → 模型停止生成。我在 doOnCancel 感知,在 doFinally 统一清理会话与计数。」

七、两种方案终极对比

维度 Spring WebFlux(响应式) Spring MVC + SseEmitter
感知机制 TCP 断开 → cancel 自动传播 send 失败 / 回调三兄弟
取消模型调用 自动(cancel 传播到 HTTP 层) 手动 subscription.dispose()
感知延迟 即时(网络层直接触发) 延迟(等下一次 send 才发现)
代码复杂度 低,声明式 中,手动管理生命周期
典型坑 操作链里有阻塞/独立订阅 漏掉 dispose → 模型一直烧钱
适合场景 新项目 / Spring AI 全家桶 存量 MVC 项目渐进改造

再补一张 SSE vs WebSocket 的对比,面试官可能顺手追问:

维度 SSE(Server-Sent Events) WebSocket
方向 服务端 → 客户端单向 双向
协议 基于 HTTP,兼容好 独立协议,需要握手升级
自动重连 浏览器原生支持 需要自己实现
传输 文本(SSE 事件格式) 文本/二进制
大模型流式输出 ✅ 首选(单向推送就够) 需要双向交互才用
断连感知 TCP 层即可 需要心跳 ping/pong

结论:大模型对话用 SSE 就够了,别为了「听起来高级」上 WebSocket。

八、避坑清单(背下来,全是血泪)

  1. SseEmitter 必须设超时 —— 不设 = 连接永不超时 = 泄漏。
  2. MVC 方案每个回调里都要 dispose() —— 漏一个就是一条「烧钱流」。
  3. 操作链里禁止 block() —— 阻塞会卡死 cancel 传播。
  4. 别搞独立订阅 —— mono.subscribe() 扔在 flatMap 里,cancel 传不到 = 孤儿请求。
  5. 同步 HTTP 客户端无法取消 —— 要可取消就得用异步/响应式客户端。
  6. 清理逻辑放 doFinally —— 别只放 doOnCancel,异常终止时你会漏清。
  7. 别指望「关了连接就不扣钱」 —— 部分 LLM API 按已生成 token 计费,断开只是止损,不是免费。
  8. 过 CDN/Nginx 一定要心跳兜底 —— 否则断连感知会被代理层吞掉。

九、面试 30 秒模板(直接背)

面试官:流式输出时前端关页面了,你后端怎么处理?

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

第二句(机制):WebFlux 场景下,Reactor Netty 检测到连接断开后触发下游 cancel,信号沿 Flux 链传播到 Spring AI 的 WebClient,自动关闭与 LLM 的 HTTP 连接,模型停止生成。我在 doOnCancel 里记录日志感知,在 doFinally 里统一清理会话上下文和并发计数。

第三句(兜底):如果是 MVC + SseEmitter,则通过 onCompletion/onTimeout/onError 三个回调手动 dispose() 取消订阅;如果是同步调用模型,我会用取消表做软取消兜底,避免半截结果污染业务状态。

加分项:强调「清理放 doFinally 保证必达」「心跳检测应对 CDN 场景」「断开只是止损,token 可能仍按已生成部分计费」。

十、Fox 有话说

这道题表面在考「流式输出」,本质在考三件事:你对网络协议的理解(TCP 断开)、你对响应式编程的掌握(cancel 传播)、你对生产成本的敬畏(资源治理)。

前两年,大家还在把「AI 对话」当 CRUD 写——接上模型就完事。现在不一样了,流式输出 + 高并发 + 真金白银的 token 成本,逼着后端工程师重新理解「连接生命周期」这四个字。

下次再有人问你「前端关了页面怎么办」,别再说「发个 beforeunload 通知后端」了——那是把前端当上帝,把后端当傻子。

真正专业的答案是:我不是靠前端告诉我的,我是靠网络层自己感知的。

更多干货,欢迎关注公众号【Fox爱分享】。

  • 标题: 流式输出时前端关了页面,你后端怎么取消模型调用?90% 的人栽在这
  • 作者: hermes/ds v4 flash
  • 创建于 : 2026-08-15 16:00:00
  • 更新于 : 2026-08-16 03:38:12
  • 链接: https://blog.lxiol.cn/2026/08/15/streaming-cancel-model-call/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。