回到博客
billingengineeringai-gateway

AI 流式网关在断流时是怎么悄悄少收(或多扣)你钱的

ApiLink Team··9 分钟阅读English

大多数 AI 网关都宣称“流式安全计费”——客户端断流时把预扣的钱退还。但很少有人愿意承认:那个“退款”最朴素的实现方式是可被利用的。一种特定的断连方式能把退款变成免费推理。我们自己也踩过这个坑。这篇是事故复盘 + 修法 + 一个挑选其他网关时的实操指南。

为什么流式计费天然就难

当你带 stream: true 调用 POST /v1/chat/completions 时,网关必须在不知道最终 token 数的情况下,把响应往客户端推。这是流式计费最别扭的地方:账单要等到最后才能算出来,但字节必须从上游一开始吐就立刻往下流。

标准实现长这样:

  1. 预估并预扣。开上游连接前,先按 prompt 算输入 token,乘以每千 token 的输入价,再叠一个慷慨的输出端缓冲,预扣这笔钱。
  2. 透传流。上游 SSE chunk 一来就原样转给客户端。每个 chunk 通常带增量 token;最后一个 chunk(或者我们自己拼一个)会携带最终的 usage 对象。
  3. 结束时结算。流正常关闭后,根据最终 usage 重新算真实成本,把预扣和实际的差额退还给用户。

只要每个流都能正常关闭,这套算法是对的。但客户端一旦提前挂掉,它就崩了——钱就是从这里开始漏的。

流的三种死法

从网关视角看,一次流式调用只能以三种方式结束:

结束状态实际发生了什么结算判断
clean上游发完最后 chunk + DONE 标记。客户端拿到了全部内容。按最终 usage 结算。退还缓冲部分。
upstream_error上游中途返回 4xx/5xx,或 TCP reset。棘手——要看 usage 之前有没有先发出来。
client_disconnect客户端关连接(Ctrl-C、关浏览器标签、abort signal)。更棘手——用户可能想要退款。

对第 2、3 行的本能反应是“宽容”——用户大概没拿完整段输出,别按全额收。很多网关把这种宽容写成“如果没拿到最终 usage,就按实际发出的字符数估算”。这条字符估算路径就是漏洞的入口。

漏洞利用步骤拆解

模式如下。任何在 usage 缺失时走字符估算回退的网关都中招:

  1. 攻击者发一个很贵的请求——比如长上下文的 Claude Opus 4,预扣 $0.45。
  2. 上游打开流。多数提供商的第一个 chunk 里就会带 prompt token 数(因为它们生成前先 tokenize 过)。网关在 usage 子对象里看到 prompt_tokens: 12000,于是正确地翻起一个内部标记“usage 已发出,这条流是真的”。
  3. 攻击者读到这个 chunk,然后立即关闭连接
  4. 结算时网关看到:usage 已发出(所以“上游从没回应”那条保护逻辑不会触发),但最终统计 completion_tokens: 0。它落入字符估算分支。累积的内容几乎为空(客户端从没读过)。
  5. 字符估算算出接近零的实际成本。网关执行接近全额退款。攻击者拿到了几乎免费的推理,且可重复刷。
截至 2026 年中,这个 bug 在不止一个知名网关的生产环境里还活着。我们不点名——但如果你在运营一个,建议读完这篇之前先自查一下。

经济损失随攻击者贪心程度放大。单次损失几分钱。脚本化跑企业级套餐(按 key 限流的那种),一个月就是六位数。

我们的修法

错误的修法是“断流时一律按预扣全额计费”——这会把所有正常的客户端中止(合上笔电、手机网络抖一下)都变成全额扣款。用户立刻上 Reddit 开喷。

正确的修法是两步,必须一起做:

  1. 正常结束的 usage 可以信。流正常关闭、usage 帧里同时带了 prompt 和 completion 数,用这些数字。这是常态路径,应该产生干净的退款。
  2. 断流后绝不信部分计数。如果结算是被 client_disconnect stream_error 触发的,走字符估算回退。直接按预扣全额计费。理由:上游已经算出了这些 token——不管你的客户端看没看到,你都得给上游付钱。自己默默吃掉这笔钱只为了显得慷慨,等于免费补贴所有人。

我们网关里的 diff 很小,但很有针对性:

typescript
// 修改前:任何"usage 已发出"但最终 token 数为 0 的流
// 都会落入字符估算退款分支。第一个 chunk 后断连的请求
// 全部命中这条分支,几乎全额退款。

// 修改后:如果结算是因为断流或流错误触发的,
// 按预扣全额计费。完全跳过退款路径。

const isAbortedOrErrored =
  statusOverride === "client_disconnect" ||
  statusOverride === "stream_error";

if (!tokens.usageEverEmitted || isAbortedOrErrored) {
  billingStatus = statusOverride ?? "estimated_no_usage";
  const actualCost = estimatedCost; // 按预扣兜底,不退款
  // ... 写日志 + 给商户结算 + 跳过 settle()
  return;
}

效果:正常流照样退款准确。断流按预扣全额收,刚好≈我们欠上游的钱。漏洞面消失,正常中止也不会被超额扣款(因为预扣本身就是上限)。

那正常的客户端中止呢?

有个合理的反驳:“我用户其实是因为第 3 个 chunk 给的答案已经够了,所以关掉了页面。现在你按 50 个 chunk 全收?”

我们认为这是正确行为,理由有两个。

第一,上游已经生成了 token。OpenAI、Anthropic、Google——没有一家会因为你的终端用户关了浏览器就退你钱。算力已经消耗,按消耗计费。任何“断流退款”都直接从网关的毛利里掏。

第二,用户在前置阶段已经预付了。从他的视角看,没有任何“额外收费”——余额减少的金额就是预算金额。断流退缓冲是 nice-to-have,不是合同义务。在文档里把这点写清楚,比默默吃亏装慷慨要诚实得多。

挑网关时怎么自检

如果你正在选网关、想知道对方有没有这个问题,下面三个动作可以亲手跑:

  1. 翻文档。搜对方文档里的“disconnect”、“abort”、“partial usage”。认真想过这事的网关会有专门一段;没想过的要么只字不提,要么含糊其辞。
  2. 跑断流测试。发一个长流式请求。在第一个 SSE chunk 到达后立刻 kill 连接(fetch 里就是 signal.abort())。等 30 秒查余额。如果扣款接近 0、而上游肯定生成了 token,那就是这个 bug。
  3. 跑 Anthropic 缓存测试。同样的实验,但让 prompt 触发 Anthropic 缓存。缓存写入 token 上游会贵 25%。在断流时“忘记”为这部分计费的网关,每次调用的损失更大。这个我们另外写了一篇。

收尾

流式计费是那种 README 看起来都长一个样、真到线上跑差异巨大的领域。亲手测断流路径。如果你的供应商能扛住连续 5 次中断流而不漏毛利,那他们功课做到家了。如果你被全额退款,要么你撞上了一个免费推理 bug(他们早晚会堵)、要么这家公司在默默承担它根本承担不起的亏损。两种都不是好的长期下注对象。

我们上面这套修法上周已经在自家网关上线了。如果你也在做网关,想交流细节,footer 里有我们的邮箱。

关于 ApiLink
ApiLink 是面向中文开发者的合规 AI API 网关:一把 Key 调通 GPT / Claude / Gemini / DeepSeek,支持人民币与增值税发票。
了解更多 →
继续阅读
ApiLink vs OpenRouter vs ZenMux:一次诚实的网关横评
三个 AI 网关并排对比。各自赢在哪、输在哪,以及同时用多个的诚实答案。
把 OpenAI Codex CLI 指向第三方网关
两个环境变量,Codex 就改调 Claude、Gemini 或 DeepSeek,不再走 GPT。Cursor、Aider、Cline 同理。
中国开发者用 Claude/GPT/Gemini 的合规清单
付款、开票、外汇、数据驻留——中国团队用 OpenAI 或 Anthropic 一个季度后会撞到的每一堵墙,附具体清单。