流式输出时前端关了页面,你后端怎么取消模型调用?90% 的人栽在这
原文:流式输出时前端关了页面,你后端怎么取消模型调用?90% 的人栽在这 | 作者:Fox爱分享
大模型应用 · 面试硬核解析
一、先看一个真实翻车现场
面试官问:「你们的 AI 对话是流式输出的吧?那用户聊着聊着直接把页面关了,你后端怎么知道?怎么把模型那边的调用停掉?」
候选人 A(背八股型):「前端有 beforeunload 事件,可以发请求通知后端。」
面试官追问:「那用户直接强杀进程、断网、或者手机锁屏呢?beforeunload 根本不触发,你怎么办?」
候选人 A 沉默了。
候选人 B(略懂皮毛型):「后端用 SseEmitter,客户端断开后会有异常,我 catch 住就行了。」
面试官追问:「SseEmitter 抛异常是在你往 emitter 里 send 的时候才发生的。如果模型吐字很慢,你 10 秒都没 send 一次,这 10 秒里模型在不在白白烧钱?catch 住之后你怎么把上游 LLM 的 HTTP 调用停掉?」
候选人 B 也沉默了。
这道题,恰恰是现在所有大模型后端应用面试的高频考点。为什么?因为「流式输出」已经是 AI 应用的标配,而「客户端断开后的资源治理」直接决定了你的服务会不会在高峰期裸奔烧钱。
今天 Fox 把这条链路彻底拆开给你看:感知 → 取消 → 清理,三件事,一步都不能少。
二、结论先拍脸:核心就三个字——「信号传播」
先把整条链路摊开,取消信号传播链:
- 前端关闭页面 / 断网 / 锁屏 —— 浏览器主动断开 HTTP/TCP 连接
- TCP 连接断开(RST / FIN) —— 服务端网络层(Reactor Netty)立即感知
- 后端感知:下游 Subscriber 收到 cancel —— 触发
doOnCancel回调(感知点) - cancel 沿 Flux 链向上游传播 ——
map/flatMap不会阻断(别写block/独立订阅) - WebClient 取消对 LLM 的 HTTP 请求 —— 底层 TCP 连接关闭,模型收到断开
- 模型停止生成 +
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 | return chatClient.prompt(request.message()) |
机制二:SseEmitter 的回调三兄弟(Spring MVC 传统栈)
如果是传统 Spring MVC + Tomcat,用 SseEmitter 就要手动一点了。核心是挂满三个回调:
1 | SseEmitter emitter = new SseEmitter(120_000L); // 2 分钟超时,必须设! |
插一句 insight:MVC 方案下,客户端断开不会自动传播 cancel 给你——你是在「下一次 send 失败」时才后知后觉。模型吐字慢,可能好几分钟都没机会发现。dispose() 是 MVC 方案里取消模型调用的核心动作,每个回调里都必须带上。
机制三:心跳检测(兜底,网络不稳 / 过 CDN 时必上)
前两种机制都依赖「TCP 连接异常能被立刻感知」。但如果中间隔着 Nginx/CDN/网关,断连感知会被延迟甚至吞掉。这时候需要应用层心跳兜底:
- 服务端每 15~30 秒往 SSE 里发一个注释行
:heartbeat\n\n(对端不可见,只保活); - 一旦
send心跳失败 → 判定连接死亡 → 主动取消模型调用。
1 | // 心跳保活 + 断连检测 |
四、第二层:怎么「取消」模型端的调用?
感知到了,接下来是关键动作:让 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 | // ❌ 错误:block() 是阻塞的,会卡住 cancel 传播 |
坑 2:内部产生「独立订阅」,与外部链脱节
1 | // ❌ 错误:mono.subscribe() 创建了独立订阅 |
坑 3:用了不响应取消的第三方客户端
如果你的「模型调用」不是走 Spring AI,而是自己用普通 HttpClient.send()(同步阻塞)调的,那 cancel 根本传不进去。要么换成 java.net.http.HttpClient 的 sendAsync() 返回 CompletableFuture,要么用 WebClient。同步阻塞的 HTTP 客户端 = 无法取消,记住这句话。
4.3 不是响应式?那就手动取消
如果是传统 MVC + 同步 HTTP 调用模型,取消手段就有限了:
- 方案 A:用
Future.cancel(true)(配合ExecutorService提交),中断正在执行的 HTTP 调用线程; - 方案 B:请求带
requestId,前端断开时后端记录到「取消表」,模型响应返回后发现有取消标记就直接丢弃(软取消,模型还在烧钱但结果不返回); - 方案 C:直接换成响应式/异步方案,一劳永逸。
插一句 insight:真实生产中,方案 B(软取消)反而是最常用的兜底——因为很多 LLM API 一旦开始生成就不支持中途断开,你只能选择「白烧这部分钱,但别污染业务状态」。
五、第三层:清理,别让状态烂在内存里
取消只是手段,清理才是目的。前端关页面,往往意味着这次会话是「半途而废」的,你要收拾的烂摊子包括:
| 要清理的东西 | 为什么必须清 | 不清的后果 |
|---|---|---|
| 会话上下文(ChatMemory) | 半截对话塞进历史,下轮问答会错乱 | 用户下次对话前言不搭后语 |
| 计数/统计状态(token 计数、并发计数) | 流断了但计数没减 | 并发数虚高 → 触发限流误伤 |
| 数据库/缓存里的临时状态 | 半成品记录残留 | 脏数据、下次读到过期状态 |
| 线程池/连接池资源 | 订阅未释放 | 连接泄漏 → 高峰期 OOM / 无连接可用 |
代码上就一条铁律:清理逻辑放 doFinally,而不是 doOnCancel 或 onError 里。因为 doFinally 无论流是正常完成、被取消、还是异常都会执行,保证必达。
1 | .doFinally(signal -> { |
六、完整实战:一个生产级流式接口长这样
把三层串起来,一个「能扛住前端随便关页面」的流式接口(WebFlux + Spring AI):
1 |
|
这套代码回答面试时,你可以理直气壮地说:「前端断开 → 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。
八、避坑清单(背下来,全是血泪)
- SseEmitter 必须设超时 —— 不设 = 连接永不超时 = 泄漏。
- MVC 方案每个回调里都要 dispose() —— 漏一个就是一条「烧钱流」。
- 操作链里禁止 block() —— 阻塞会卡死 cancel 传播。
- 别搞独立订阅 ——
mono.subscribe()扔在flatMap里,cancel 传不到 = 孤儿请求。 - 同步 HTTP 客户端无法取消 —— 要可取消就得用异步/响应式客户端。
- 清理逻辑放 doFinally —— 别只放
doOnCancel,异常终止时你会漏清。 - 别指望「关了连接就不扣钱」 —— 部分 LLM API 按已生成 token 计费,断开只是止损,不是免费。
- 过 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 进行许可。