跳到主要内容

你发出的流式中止信号,供应商照样收了费:账单中隐藏的 14% 差额

· 阅读需 11 分钟
Tian Pan
Software Engineer

你的财务团队发起了一项申诉并失败了。该账单项是“输出 token”,它比你交付 token 总数的统计指标高出了 14%。供应商的支持工程师以“流式传输取消下的预期行为”为由关闭了工单,并附上了一份文档链接,上面写着“取消操作将在最后一个交付的 token 处停止计费”。这两句话都是事实,而它们之间的差距,正是你尚未编写的那行代码。

你阅读的合同是一回事,推理调度器的实际操作是另一回事。这种不匹配既不是 bug,也不是计费错误,更不是恶意欺诈——它是一个分层系统,取消信号必须穿越三个边界(浏览器、边缘节点、GPU),而计费表位于第三个边界,但你的“停止生成”按钮却位于第一个边界。缩小这一差距是一个由财务负责人发起的工程项目。

这篇文章将讨论这种差距的具体表现形式,为什么文档约定的合同与实现的行为会产生背离,以及四种模式——服务端取消、单次请求 token 上限、对账仪表盘和合同补充协议——它们能将争议从“预期行为”转变为退款或重构。

你的中断信号穿越的三个层级

AbortController.abort() 在浏览器中只做一件事:在 fetch promise 上触发 DOMException,并取消任何读取器对正文流的订阅。这就是整个客户端的契约。下游是否感知到这一点,取决于每一个中间层,而这些层级都没有义务转发该信号。

第一层是浏览器与你的边缘节点之间的 TCP 连接。当 abort() 触发时,浏览器会关闭其 socket。根据 SSE 代理配置的不同,边缘节点可能会迅速察觉(TCP RST 或 FIN 传播到上游 socket),也可能反应缓慢(代理保持上游连接开启,仅在尝试写入下一个分块时才发现客户端已断开)。大多数反向代理默认属于第二类。例如,Cloudflare 只有在尝试向已关闭的 socket 写入时才会转发客户端断开信号——这意味着上游连接会一直存活到下一个 SSE 事件发生,在慢速流中,这可能是几百毫秒之后。

第二层是从你的边缘节点到供应商的连接。假设边缘节点最终关闭了该 socket,供应商的 API 网关会收到一个 TCP FIN。网关现在处于和你之前的边缘节点相同的境地:它必须决定如何处理上游的推理请求。在开源案例(vLLM, TGI, TensorRT-LLM)中,答案是“取决于请求中间件是否被配置为在断开连接时调用 abort(request_id)”。vLLM 在 2025 年底仍有未解决的问题,记录了即使客户端挂断后 request.is_disconnected() 仍返回 False 的情况,因为用户安装的中间件破坏了 Starlette 的断开信号传播。取消信号从未到达引擎。

第三层是 GPU。即使中断信号到达,解码循环也在一个以连续批处理 (continuous batching) 方式处理请求的 pod 上运行。调度器在迭代边界(通常是每几十毫秒)拣选新请求并丢弃已完成的请求,但该边界与你的取消信号并不同步。更糟糕的是:在高批次占用率下,调度器没有动力立即驱逐一个进行中的请求,因为这样做会浪费它已经分配的 KV cache。一些推理栈会在一次迭代内响应取消;另一些则延迟到下一个 prefill 窗口;还有一些干脆让请求运行到 max_tokens 上限并全额计费。

供应商的文档描述了其中一个层级的合同,而账单反映了另一个层级发生的情况。

14% 差距背后的数字

14% 这个数字并非杜撰。一个运行中等规模聊天助手的团队对其客户端渲染的 token 计数器进行了监测,并将其与供应商提供的 usage 数据块进行比对,发现每月的分歧在 9% 到 18% 之间,中位数约为 14%。具体分解如下:

  • 约 4 个百分点来自直接放弃:用户关闭标签页、代理进程挂掉、手机进入后台。连接在没有显式中断的情况下关闭,供应商将请求运行至结束。
  • 约 6 个百分点来自显式的“停止生成”点击。中断被触发,连接关闭,供应商对每个已取消请求的平均计费为中断后又生成了 180 个 token。在以约 50ms 迭代运行的批处理边界调度器中,这与取消信号在约三到四个调度步骤后才传播开来的情况是一致的。
  • 约 4 个百分点来自长尾边缘情况:供应商响应队列中缓存的、客户端从未读取但计费表仍计入的 SSE 事件;发生瞬时错误时的重试,其中失败尝试的 token 已被计费;以及一小部分调度器似乎完全忽略了取消信号并运行至 max_tokens 的请求。

在每个类别中,供应商的行为都符合对文档的某种解读。这 14% 不是一个单一的 bug,它是每一个合同模棱两可且实现选择了有利于计费表的情况的总和。

该团队的财务申诉失败,并不是因为数字不对,而是因为没有任何一个单独的类别超过了支持团队视为计费错误的阈值。“预期行为”是一个能够经受住对任何单个取消事件进行孤立审查的措辞。只有当你成为汇总数据的人时,聚合成本才变得清晰可见。

为什么“按最后交付的 token 停止计费”是一个并不完全真实的契约

供应商文档倾向于将取消操作描述得仿佛是一个瞬间发生的事件。“取消操作将在最后交付的 token 处停止计费。”“关闭连接将终止生成。”这种表述借用了同步心智模型——请求进入,响应退出——这与流式推理实际的调度方式并不匹配。

文档通常的意思是:在推理引擎察觉到取消请求的那一刻,它会停止发出更多的 token,计费表也随之冻结。但它没有说明的是,取消信号从你发出信号的那一层传播到引擎察觉到的那一层需要多长时间。“按最后交付的 token 停止计费”这句话读起来像是对你发票的保证;实际上,它是对取消信号到达后引擎内部会计处理的保证。传播延迟才是核心问题,而文档并未提及它。

这与分布式存储中持久性(durability)与可用性(availability)之间的差距如出一辙:一个声称“在副本故障时停止接受写入”的系统,是对故障被检测到之后的时刻做出保证,而不是对故障发生的时刻做出保证。那个有趣的数字——保证失效的窗口期——正是没人写下来的那个。

解决办法是将传播延迟作为一个契约条款,而不是隐藏的实现细节。一个有用的补充条款可以这样写:“取消操作将在客户端发出信号后的 N 毫秒内停止计费(在 API 边界处测量)。在该窗口期之后生成的 token 不予计费,并作为对账额度显示在发票上。”这句话迫使供应商承诺一个数字,并为客户提供了一个可以据理力争的指标。

缩小差距的四种模式

如果团队将停止按钮仅与 TCP 关闭挂钩,那么他们就是将用户意图挂钩到了一个供应商并不承诺的指标上。通过同时应用以下四种模式,可以将契约从隐含转为强制执行。

尽可能使用显式取消 API。 OpenAI 为后台响应暴露了 responses.cancel,vLLM 暴露了 abort(request_id),OpenRouter 为支持的上游供应商转发中止信号。OpenAI 主要 API 上的同步流式响应完全没有取消端点,而 Anthropic 的 Messages API 也没有提供服务端取消原语——这意味着对于这些路径,TCP 关闭是唯一的可用信号,而传播延迟则取决于供应商的网关和调度器。在存在真正的取消端点时,请从你的服务器调用它,而不是从浏览器调用;依赖客户端的 TCP 拆除(teardown)跨越三个层级是造成大部分差距的根源。

限制每个请求的 max_tokens 这是成本最低且最常被忽视的模式。如果取消信号没有成功传播且请求运行到了上限,你希望这个上限尽可能低。如果一个典型的聊天响应是 400 个 token,而 max_tokens 却保持在模型默认的 4096,那么对于每一次未能成功送达的取消操作,你都要支付十倍的超额费用。将 max_tokens 设置为你交付响应长度第 95 百分位数的几倍即可;将实际长度与上限之间的差距视为你的最坏情况取消税。

建立一个从已计费中减去已交付的财务仪表盘。 你的观测平台几乎肯定会报告 usage 数据块。那告诉你供应商计费了多少,但没告诉你用户收到了多少。添加第二个指标——客户端实际渲染的 token,或下游流程实际消耗的 token——并将其作为计数器上报。两者之间的比率是财务团队需要的指标:它将取消差距从原始流量增长中分离出来,使其在每个功能上都清晰可见,并在差距超过约定阈值时为你提供与供应商交涉的数字依据。

在需要之前协商补充协议。 在发票开出后提出的财务争议显得底气不足;而在合同续约前作为合同修正案提出的相同争议则更有筹码。补充协议应明确指明传播延迟预算(例如“从 API 边界到停止计费为 200 毫秒”),要求供应商每季度进行认证,并将超额部分定义为自动冲抵而非协商退款。供应商会拒绝给出具体数字,而这种拒绝本身就告诉了你答案——这种阻力就是数据。

架构层面的认知究竟能换回什么

所有这一切背后的架构认知是,“停止生成”是一个供应商可能会按其自身进度处理的请求。你 UI 里的按钮看起来像个开关,实际上它是一个穿越三个层级的封包,其中任何一层都可能延迟或丢弃它,而计费表位于离你最远的那一层。

这虽然不浪漫,但有一个实用的结论:取消差距是一个已知的、有界的、可测量的东西,而不是一个谜。它不是模型脾气或调度魔力的函数。它是取消请求中 (token 生成速率 × 传播延迟) 的积分。你可以对其进行监测,可以用 max_tokens 进行限制,可以协商延迟预算,并针对提供显式取消端点的供应商绕过这个问题。如果团队将其视为固有的业务成本,那么就有 5%–15% 的推理支出被白白浪费了。而将其视为一个配有财务负责人的、可解决的工程问题的团队,则能拿回这笔钱。

更深层的教训在 LLM 服务中反复出现:你读到的契约是在你触及不到的层级编写的,你支付的账单是在同一层级计算的,而你关心的用户意图则存在于四个跳转之外的层级。闭合用户意图与计费之间的闭环不是单一的功能,而是一种纪律——将每一个跳转都视为一个契约界面,拥有其自身的延迟预算、对账指标和争议路径。停止按钮就是这些界面之一。未来还会有更多。

References:Let's stay in touch and Follow me for more thoughts and updates