Web

SSE流式响应:前端取消请求后,服务端还在烧Token吗?

AI 对话产品几乎已经全部用上了流式输出:Token 一个一个蹦出来,用户体验丝滑。但流式响应带来了一个很少被认真对待的资源管理问题——用户点下「停止生成」、切换了会话、或者直接关掉页面,前端的请求确实取消了,loading 也停了,但服务端背后那次 LLM 调用,很可能还在一本正经地把剩下的 Token 全部生成完

这笔钱,你照付。而且随着流量增长,这笔被悄悄烧掉的钱可能相当可观。

一个你可能每天都在亏钱的场景

典型的 AI Chat 应用链路是这样的:浏览器通过 fetch 发起 POST 请求,服务端以 text/event-stream(SSE)格式把 LLM 的输出逐块推回来。用户侧的常见行为包括:

  • 答案看了一半发现不对,点「停止生成」;
  • 等了几秒不耐烦,连续发新问题;
  • 路由跳转到别的页面,或者直接关了标签页;
  • 网络抖动,浏览器主动断开重试。

前端代码通常写得很标准:

const ac = new AbortController();

const resp = await fetch('/api/chat', {
  method: 'POST',
  body: JSON.stringify({ prompt }),
  signal: ac.signal,
});

const reader = resp.body.getReader();
// 用户点停止按钮
stopBtn.onclick = () => ac.abort(); // 或者 reader.cancel()

abort() 一调,页面上立刻风平浪静。但服务端日志里,这次 LLM 调用往往跑满了 max_tokens 才结束。用户没看到的那 80% 的输出,Token 一分钱不少地计入了你的账单。

前端取消请求时,TCP 层面发生了什么

先把底层发生的事情讲清楚。AbortController.abort()(或 EventSource.close())执行后,浏览器会做两件事:

  1. 停止从该连接读取数据;
  2. 向服务端发起 TCP 连接关闭——正常情况下发 FIN,异常情况下发 RST,然后开始四次挥手。

注意一个关键认知:HTTP 请求一旦发出去,「取消」的语义只是「我不听了」,而不是「帮我撤回」。请求体大概率已经送达服务端,服务端该开始的处理早就开始了。

更要命的是,生产环境中浏览器和你的服务之间往往隔着 Nginx、网关、CDN、Service Mesh 代理。浏览器的 FIN 最先到达的是代理,代理什么时候、以什么方式通知上游服务,完全取决于代理配置——这个窗口可能是几毫秒,也可能是几十秒。

补充一个容易踩的细节:EventSourcefetch 的取消行为并不完全一样。EventSource.close() 除了关闭连接,还会停止自动重连;但 EventSource 只支持 GET 请求、无法自定义请求头,所以现在主流的 AI 流式接口基本都用 fetch + ReadableStream 实现。无论哪种方式,底层都是 TCP 断开,服务端的感知方式没有区别。另外,移动端 App 切后台、弱网切换基站等场景下,TCP 连接可能处于「半死不活」的状态——客户端以为断了,服务端却迟迟收不到 FIN,只能靠心跳和超时来兜底,这也是为什么后面方案二的心跳机制不是可选项。

服务端能不能感知到断开?能,但「感知了也白感知」

答案是:绝大多数技术栈都能感知到客户端断开,但框架默认不会替你停止背后的 LLM 调用。

Node.js / Express:响应对象上有 close 事件。现代 Node.js(16+,aborted 事件已废弃)的标准写法是监听 res.on('close'),并通过 res.writableFinished 判断是否为正常结束后的关闭。但这个事件触发时,框架做的事情仅仅是释放这次 HTTP 请求相关的资源——它根本不知道你手里还攥着一个到 OpenAI 的流式 HTTP 请求。你的 for await 循环还在继续从 LLM 读 chunk,读完往一个已经关闭的 socket 上写(写报错算好的,某些情况下数据被静默丢弃),LLM 该生成多少照常生成多少。

Python / FastAPI(Starlette)StreamingResponse 包装的异步生成器在客户端断开时会被取消,生成器内部的 await 点会抛出 asyncio.CancelledError。听起来很自动化——但前提是你的 LLM 调用真的绑定在这个取消链路上。如果你用的是同步版 openai 客户端、把生成任务丢进了别的 asyncio.Task、或者用线程池跑,取消信号根本传不到底层 httpx 连接,上游照样活得好好的。

Java / Spring WebFlux:响应式编程模型理论上最优雅——订阅者 cancel,取消信号沿 Flux 链路一路向上游传播。但现实是,只要链路中间出现一次手动 .subscribe().block()、或者把流 hot-publish 到外部 Sink,cancel 信号就断了,WebClient 对 LLM 的调用收不到任何通知。

技术栈断开感知方式是否自动停止 LLM 调用
Node.js / Expressres.on('close') + writableFinished 判断否,必须手动 abort
Python / FastAPICancelledError / Request.is_disconnected()生成器会取消,但信号需显式传到 SDK
Spring WebFluxFlux cancel 信号沿订阅链传播链路完整才有效,断链即失效

一句话总结:断开检测是框架的事,停止 LLM 是你自己的事。

Token 是怎么被白白烧掉的

把完整链路摊开看:浏览器 → Nginx → 你的服务 → LLM API。这里面有三个互相不知道对方状态的角色。

第一,客户端断开 ≠ LLM 停止生成。 LLM 的 stream 模式,本质上是你的服务端和 LLM 之间另一条独立的 HTTP/TCP 连接。下游浏览器断开,影响的是浏览器到你服务这一段;你服务到 LLM 的连接毫不知情,模型会一直生成到自然结束或 max_tokens,输出 Token 按实计费。

第二,代理缓冲会放大浪费窗口。 Nginx 默认 proxy_buffering on,上游响应会被攒在缓冲区里再发给客户端。这意味着客户端断开的事实可能被延迟暴露给你的服务;如果再配上 proxy_ignore_client_abort on,Nginx 甚至会在客户端走后继续把上游响应整个读完——等于代理亲自帮你把 Token 全烧完。

第三,「伪流式」和后台任务让信号彻底断路。 有些封装层在服务端把 LLM 输出聚合成完整结果后再一次性返回(流式只流了个寂寞);还有些架构把生成逻辑扔进后台队列,HTTP 请求只是去查结果。这两种情况下,取消信号从架构上就没有通路,代码写得再标准也没用。

浪费量级可以简单估一下:假设 10% 的请求被提前取消,单次平均输出 800 Token,取消时平均已读 200 Token,那么每次取消浪费约 600 输出 Token。日请求 10 万的服务,每天就是 600 万 Token 的纯浪费——按主流模型价格算,一天几百到几千美元,取决于模型档位和流量。

还要厘清一个计费认知:输入 Token(prompt)在请求到达 LLM 时就已经产生费用,取消也退不回来,这部分不用纠结;真正的浪费全部在输出 Token 上——模型为断开的连接继续生成的每一个字,都按输出单价计费。输出 Token 单价通常是输入的 3~5 倍,所以这笔浪费的实际成本比 Token 数量看上去更疼。此外,别忽略隐性成本:一次没人收的长生成,会占住你服务端的连接、内存和并发额度,高取消率下可能直接拖垮整台服务的吞吐,这比 Token 账单更致命。

顺带一提 Serverless 场景(Vercel、Cloudflare Workers、函数计算等):这类平台上请求和函数实例的生命周期绑定方式各不相同,部分平台在客户端断开后会冻结甚至直接回收实例,close 事件能不能可靠触发、异步任务能不能继续跑完,都需要实测。如果你的流式服务跑在 Serverless 上,方案四的双通道取消几乎是必选项,不要把赌注押在断开事件上。

优化方案

方案一:监听中断信号,把 AbortSignal 一路传到 LLM(最核心)

这是治本的一招:每个请求创建一个 AbortController,客户端断开时 abort,并把 signal 透传给 LLM SDK。

import express from 'express';
import OpenAI from 'openai';

const app = express();
const openai = new OpenAI();

app.post('/api/chat', async (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');

  // 为本次请求创建中断控制器
  const ac = new AbortController();

  const onClose = () => {
    // writableFinished 为 false 说明是中途断开,而非正常结束
    if (!res.writableFinished) {
      console.log('[abort] 客户端断开,中止 LLM 调用');
      ac.abort(new Error('client disconnected'));
    }
  };
  res.on('close', onClose);

  // 心跳:防止代理静默断连,同时让断开更快暴露
  const heartbeat = setInterval(() => res.write(': ping\n\n'), 15000);

  try {
    const stream = await openai.chat.completions.create({
      model: 'gpt-4o',
      messages: [{ role: 'user', content: req.body.prompt }],
      stream: true,
      signal: ac.signal, // 关键:中断信号一路传到底层 fetch
    });

    for await (const chunk of stream) {
      const delta = chunk.choices[0]?.delta?.content || '';
      res.write(`data: ${JSON.stringify({ delta })}\n\n`);
    }
    res.write('data: [DONE]\n\n');
    res.end();
  } catch (err) {
    if (err.name === 'AbortError') {
      // 预期内的中断,不要当成 500 告警
      console.log('LLM 流已随客户端断开而中止');
    } else {
      res.write(`event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n`);
      res.end();
    }
  } finally {
    clearInterval(heartbeat);
    res.off('close', onClose);
  }
});

Python FastAPI 侧的等价写法——让 CancelledError 顺着异步上下文管理器关掉到 LLM 的连接,再用 is_disconnected() 做双保险:

from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from openai import AsyncOpenAI

app = FastAPI()
client = AsyncOpenAI()

@app.post("/api/chat")
async def chat(req: Request):
    body = await req.json()

    async def gen():
        try:
            # async with 保证取消时 httpx 连接被正确关闭
            async with await client.chat.completions.create(
                model="gpt-4o",
                messages=[{"role": "user", "content": body["prompt"]}],
                stream=True,
            ) as stream:
                async for chunk in stream:
                    if await req.is_disconnected():  # 双保险
                        break
                    delta = chunk.choices[0].delta.content or ""
                    yield f"data: {delta}\n\n"
            yield "data: [DONE]\n\n"
        except asyncio.CancelledError:
            # 客户端断开,Starlette 把取消抛进生成器
            print("[abort] client disconnected, LLM stream cancelled")
            raise  # 必须重新抛出,别吞掉

    return StreamingResponse(gen(), media_type="text/event-stream")

方案二:合理的超时与心跳

心跳不只是保活:定时 res.write(': ping\n\n')(SSE 注释帧,客户端 EventSource 会自动忽略)能让死掉的连接在第一次写失败时就触发 close,把断开检测窗口从「等 TCP 超时」压缩到一个心跳周期。建议间隔 15~30 秒,且必须小于各级代理的空闲超时。

同时设置两层超时:一是 LLM SDK 的请求超时(如 60 秒收不到新 chunk 即判定异常);二是整体生成时长上限,避免极端情况下的长尾请求无限烧钱。

方案三:Nginx / 网关层配置

SSE 场景的 Nginx 有几个必改项:

location /api/ {
    proxy_pass http://backend;
    proxy_http_version 1.1;
    proxy_set_header Connection "";

    # SSE 必须关缓冲:否则数据被攒着,断开信号也被攒着
    proxy_buffering off;
    proxy_cache off;

    # 客户端断开时立即断开上游(默认就是 off,千万别手贱改成 on)
    proxy_ignore_client_abort off;

    # 略大于心跳间隔,避免代理提前掐断
    proxy_read_timeout 120s;
    proxy_send_timeout 120s;
}

proxy_buffering off 对 SSE 是刚需——它同时保证了数据实时性和断开信号的即时传导。

方案四:前端取消时主动发取消事件(双通道方案)

在 Serverless 平台(断开事件不可靠)、多层代理、WebSocket 网关等场景下,单靠 TCP 断开检测不够稳。这时上双通道:前端停止时,除了本地 abort(),再主动调一个取消接口。

// 服务端维护 requestId -> AbortController 的映射
const running = new Map();

app.post('/api/cancel/:id', (req, res) => {
  running.get(req.params.id)?.abort();
  running.delete(req.params.id);
  res.json({ ok: true });
});
// 前端:停止按钮先通知服务端,再断本地连接
stopBtn.onclick = async () => {
  await fetch(`/api/cancel/${requestId}`, { method: 'POST' });
  ac.abort();
};

注意两点:取消接口必须做鉴权,用户只能取消自己的请求;AbortController 用完即从 Map 删除,避免内存泄漏。

方案五:确认 LLM SDK 层面的取消真的生效

OpenAI 的 Node / Python SDK 底层分别基于 fetch 和 httpx,传入 AbortSignal 后,abort 会直接销毁到 LLM 的上游 socket,服务商侧检测到断连即停止生成,输出 Token 计到断连时刻为止——这是我们要的效果。

但要警惕几种信号传不进去的情况:第三方聚合 SDK 自己包了重试/队列;用子进程调本地推理服务(如 llama.cpp server);在 worker thread / 线程池里跑同步调用。务必做一次真实验证:本地起服务,发一个长输出请求,中途取消,观察服务端日志中 LLM 调用是否在 1 秒内结束、usage 是否停在取消点附近。没验证过的取消逻辑,等于没写。

生产环境建议

埋点指标:取消率(中断请求数 / 总请求数)、取消时平均已生成 Token 数、「客户端断开 → 上游中止」的延迟、Token 浪费率。其中取消率是个被低估的产品体验指标——它异常升高,往往意味着答案质量差、响应慢或者前端有 bug。

日志规范:每次中断记录 requestId、用户 ID、模型名、已生成 Token 数、已耗时。这些数据是成本核算和问题排查的基础。

成本估算:浪费 Token ≈ Σ(取消请求的完整输出 Token − 取消时已输出 Token)。拿不到完整输出长度时,可用同模型同场景的平均输出长度估算,量级足够指导决策。

混沌测试:上线前压测一轮「批量请求 + 随机中途断开」,确认上游 LLM 调用能及时终止、连接和定时器没有泄漏。

总结

三句话收尾:

  1. 前端取消只是「我不听了」,不是「让 LLM 闭嘴」。 下游 TCP 断开和上游 LLM 生成是两条独立的连接,互不通知。
  2. 取消信号必须显式地一路传到 LLM SDK。 框架能检测断开,但不会替你中止业务调用——signal: ac.signal 这一行,很多线上服务就是没写。
  3. 代理配置、心跳超时、双通道取消、监控埋点,一个都不能少。 省下来的 Token,全是纯利润。

下次代码评审看到流式接口,不妨多问一句:用户点了停止,我们的 LLM 真的停了吗?

更早的文章

DDD领域驱动设计:从战略到战术的全面指南

欢迎在评论区留下您的见解~