AI 流式网关在断流时是怎么悄悄少收(或多扣)你钱的
大多数 AI 网关都宣称“流式安全计费”——客户端断流时把预扣的钱退还。但很少有人愿意承认:那个“退款”最朴素的实现方式是可被利用的。一种特定的断连方式能把退款变成免费推理。我们自己也踩过这个坑。这篇是事故复盘 + 修法 + 一个挑选其他网关时的实操指南。
为什么流式计费天然就难
当你带 stream: true 调用 POST /v1/chat/completions 时,网关必须在不知道最终 token 数的情况下,把响应往客户端推。这是流式计费最别扭的地方:账单要等到最后才能算出来,但字节必须从上游一开始吐就立刻往下流。
标准实现长这样:
- 预估并预扣。开上游连接前,先按 prompt 算输入 token,乘以每千 token 的输入价,再叠一个慷慨的输出端缓冲,预扣这笔钱。
- 透传流。上游 SSE chunk 一来就原样转给客户端。每个 chunk 通常带增量 token;最后一个 chunk(或者我们自己拼一个)会携带最终的
usage对象。 - 结束时结算。流正常关闭后,根据最终
usage重新算真实成本,把预扣和实际的差额退还给用户。
只要每个流都能正常关闭,这套算法是对的。但客户端一旦提前挂掉,它就崩了——钱就是从这里开始漏的。
流的三种死法
从网关视角看,一次流式调用只能以三种方式结束:
| 结束状态 | 实际发生了什么 | 结算判断 |
|---|---|---|
| clean | 上游发完最后 chunk + DONE 标记。客户端拿到了全部内容。 | 按最终 usage 结算。退还缓冲部分。 |
| upstream_error | 上游中途返回 4xx/5xx,或 TCP reset。 | 棘手——要看 usage 之前有没有先发出来。 |
| client_disconnect | 客户端关连接(Ctrl-C、关浏览器标签、abort signal)。 | 更棘手——用户可能想要退款。 |
对第 2、3 行的本能反应是“宽容”——用户大概没拿完整段输出,别按全额收。很多网关把这种宽容写成“如果没拿到最终 usage,就按实际发出的字符数估算”。这条字符估算路径就是漏洞的入口。
漏洞利用步骤拆解
模式如下。任何在 usage 缺失时走字符估算回退的网关都中招:
- 攻击者发一个很贵的请求——比如长上下文的 Claude Opus 4,预扣 $0.45。
- 上游打开流。多数提供商的第一个 chunk 里就会带 prompt token 数(因为它们生成前先 tokenize 过)。网关在
usage子对象里看到prompt_tokens: 12000,于是正确地翻起一个内部标记“usage 已发出,这条流是真的”。 - 攻击者读到这个 chunk,然后立即关闭连接。
- 结算时网关看到:usage 已发出(所以“上游从没回应”那条保护逻辑不会触发),但最终统计
completion_tokens: 0。它落入字符估算分支。累积的内容几乎为空(客户端从没读过)。 - 字符估算算出接近零的实际成本。网关执行接近全额退款。攻击者拿到了几乎免费的推理,且可重复刷。
经济损失随攻击者贪心程度放大。单次损失几分钱。脚本化跑企业级套餐(按 key 限流的那种),一个月就是六位数。
我们的修法
错误的修法是“断流时一律按预扣全额计费”——这会把所有正常的客户端中止(合上笔电、手机网络抖一下)都变成全额扣款。用户立刻上 Reddit 开喷。
正确的修法是两步,必须一起做:
- 正常结束的 usage 可以信。流正常关闭、usage 帧里同时带了 prompt 和 completion 数,用这些数字。这是常态路径,应该产生干净的退款。
- 断流后绝不信部分计数。如果结算是被
client_disconnect或stream_error触发的,不走字符估算回退。直接按预扣全额计费。理由:上游已经算出了这些 token——不管你的客户端看没看到,你都得给上游付钱。自己默默吃掉这笔钱只为了显得慷慨,等于免费补贴所有人。
我们网关里的 diff 很小,但很有针对性:
效果:正常流照样退款准确。断流按预扣全额收,刚好≈我们欠上游的钱。漏洞面消失,正常中止也不会被超额扣款(因为预扣本身就是上限)。
那正常的客户端中止呢?
有个合理的反驳:“我用户其实是因为第 3 个 chunk 给的答案已经够了,所以关掉了页面。现在你按 50 个 chunk 全收?”
我们认为这是正确行为,理由有两个。
第一,上游已经生成了 token。OpenAI、Anthropic、Google——没有一家会因为你的终端用户关了浏览器就退你钱。算力已经消耗,按消耗计费。任何“断流退款”都直接从网关的毛利里掏。
第二,用户在前置阶段已经预付了。从他的视角看,没有任何“额外收费”——余额减少的金额就是预算金额。断流退缓冲是 nice-to-have,不是合同义务。在文档里把这点写清楚,比默默吃亏装慷慨要诚实得多。
挑网关时怎么自检
如果你正在选网关、想知道对方有没有这个问题,下面三个动作可以亲手跑:
- 翻文档。搜对方文档里的“disconnect”、“abort”、“partial usage”。认真想过这事的网关会有专门一段;没想过的要么只字不提,要么含糊其辞。
- 跑断流测试。发一个长流式请求。在第一个 SSE chunk 到达后立刻 kill 连接(fetch 里就是
signal.abort())。等 30 秒查余额。如果扣款接近 0、而上游肯定生成了 token,那就是这个 bug。 - 跑 Anthropic 缓存测试。同样的实验,但让 prompt 触发 Anthropic 缓存。缓存写入 token 上游会贵 25%。在断流时“忘记”为这部分计费的网关,每次调用的损失更大。这个我们另外写了一篇。
收尾
流式计费是那种 README 看起来都长一个样、真到线上跑差异巨大的领域。亲手测断流路径。如果你的供应商能扛住连续 5 次中断流而不漏毛利,那他们功课做到家了。如果你被全额退款,要么你撞上了一个免费推理 bug(他们早晚会堵)、要么这家公司在默默承担它根本承担不起的亏损。两种都不是好的长期下注对象。
我们上面这套修法上周已经在自家网关上线了。如果你也在做网关,想交流细节,footer 里有我们的邮箱。