streaming-client-cancel-cleanup · ZH · 2026-10-14

中途取消流:连接、token 与账单各会发生什么

解释在 LLM API 聚合站上使用 SSE 流式输出时,中途取消请求在连接层、token 生成层和账单层分别会发生什么:为什么取消不等于模型立刻停止、已产出 token 通常仍计费、以及如何通过 AbortController、正确关闭响应体和避免连接泄漏来干净地中断。

为什么“取消”不是一个瞬间动作

流式响应看起来像一条实时管道:模型每生成一个 token,服务端就通过 SSE(Server-Sent Events)推给你一行 data:。但当你在客户端点下停止、或调用 abort,实际发生的是三件互不相同的事,它们的完成时间并不一致:

  • 客户端连接被中断:你这一侧的 socket 关闭,不再读取后续字节。
  • 中间层停止转发:聚合站或反代察觉连接断开,停止向你写入。
  • 上游模型停止生成:上游推理进程需要收到取消信号,才会结束当前这一轮生成。

这三步是逐级传递的,所以“你不再收到 token”往往早于“模型不再计算 token”。理解这个时间差,是理解计费与连接管理的前提。

token 层面:已经产出的 token 会怎样

关键认知:取消影响的是“未来”的 token,而不是“已经生成”的 token。

  • 在断开之前,模型已经产生并推送给你的 token,通常已经进入了上游的用量统计。
  • 取消信号到达上游后,尚未生成的部分不再计算,这部分一般不产生费用。
  • 因此“取消”不是“这次请求免费”,而是“从取消那一刻起停止计费”。

有一个细节值得注意:流式响应经常是分批产出的,上游可能一次计算多个 token 再推送。这意味着你看到的最后一个 token 和模型实际停止的位置之间,可能存在一小段“已经算完但你没收到”的区间。这段是否计入,取决于上游的计费口径,聚合站只能转述上游的用量结果。

账单层面:聚合站如何结算

在本站,计费遵循一个稳定规则:

  • 使用方按官方价 ×1.3 扣费。
  • 贡献 key 的一方按官方价 ×1.1 返还 USDC,质量优质时 ×1.2。

取消流的场景下,这条规则不变:结算依据是上游回报的实际用量,而不是你主观上“用了多少”。所以:

  • 你取消得越早,被计费的 token 越少,成本越接近一次性调用的起步开销。
  • 你取消只是因为 UI 上不想要这段输出,但请求已经跑了很久——那部分 token 依然按上述倍率结算。

如果你的目标是控制成本,尽早取消和缩短 max tokens 是两件互补的事:前者砍掉不必要的尾部,后者给单次请求装上硬上限。

怎么干净地关闭连接

“干净”意味着三件事:让服务端尽早知道你不想要了、不留下半开的 socket、不让 SDK 的读取循环卡在已废弃的响应上。

用 AbortController 而不是直接丢弃对象

const controller = new AbortController();

const resp = await fetch(endpoint, {
  method: "POST",
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
  body: JSON.stringify(payload),
  signal: controller.signal,
});

// 需要中断时
controller.abort();

直接不管返回对象、任其被 GC,并不会及时通知服务端;主动 abort 才会触发底层 socket 的关闭。

手动读流时记得释放 reader

如果你用 resp.body.getReader() 逐块解析 SSE,取消时要同时取消 reader:

const reader = resp.body.getReader();
// ...
await reader.cancel();

只 abort fetch 而不 cancel reader,在某些运行时下会留下悬挂的读取。

服务端侧的信号传递

客户端断开后,聚合站需要把取消继续传给上游,否则上游仍会跑完整个生成过程——对用户来说账单照算,对贡献者来说算力白费。一个可靠的实现会在写入响应失败时立即终止上游请求,而不是继续读完再丢弃。

常见误区

  • “取消 = 不计费”。 不对。已生成并推送的 token 会被计入。
  • “关闭页面就等于取消”。 浏览器卸载时未必能发出可靠的取消信号,服务端通常靠写失败来推断断开,存在延迟。
  • “重复 abort 无害,所以随便调”。 幂等调用本身没问题,但如果你在 abort 之后继续往同一个 reader 读数据,会拿到已关闭的错误。
  • “重试会覆盖上一次的费用”。 每次请求都是独立结算,重试意味着两份用量。

实用建议

  • 把取消绑定到明确的用户动作或超时,而不是依赖页面卸载。
  • 给流式请求设一个自己的上限(超时或 max tokens),作为取消机制之外的兜底。
  • 在日志里区分“正常完成”“用户取消”“上游中断”,这三者对成本和对账的意义不同。
  • 如果你的用量对账出现偏差,先确认取消发生的时间点,再看对应时段的用量记录。

取消流不是“撤回”,而是“从此刻起不再继续”。把连接关闭做干净,既能让服务端尽早止损,也能让你的账单更接近你真正消费的那部分。