API账单对不上,这事我太有发言权了。之前负责一个接入多家大模型厂商的业务系统,每个月对账日都是煎熬:产品说用了这么多,财务说账单金额对不上,后端说日志里请求量就这么多,三方各执一词。更气人的是,明明线上跑得好好的,一到月底成本核算就多出一大截,问就是"token消耗异常",再问就是"重试导致重复计费",最后全成了开发背锅。
后来我实在受不了,把整个计费链路彻底捋了一遍,沉淀出一套三层对账法。核心思路不复杂:先管好请求级流水,再做聚合比对,最后落到业务归因。这篇文章就把这套方法和实操细节完整讲清楚,尤其是token、重试、缓存这三块最容易出问题的环节,看完你也能自己搭一套对账体系。
1. 账单怎么"悄悄变多"的:先搞清楚钱花在哪三个地方
想要对账,第一步不是写脚本,而是弄明白厂商账单上的每一笔钱对应到你系统里的哪次操作。很多人上来就比总额,比完发现对不上,也不知道差在哪,本质上是没搞清楚计费的最小单元。
1.1 token:不同模型、不同口径,单价不是一回事
先看token。这是最基础的计费单位,但也最容易产生口径分歧。
同一个模型,输入token和输出token单价往往不同。比如某厂商的定价是输入0.002元/千token,输出0.006元/千token。你请求一次,假设发送了2000 token的上下文,模型返回800 token,那这次调用的费用应该是:
输入费用 = 2000 / 1000 × 0.002 = 0.004元
输出费用 = 800 / 1000 × 0.006 = 0.0048元
单次总费用 = 0.0088元
这个账看着简单,但实际跑起来就有几个坑:
第一,系统提示词。很多应用会在请求里自动拼接一大段系统提示词,这部分token也是要花钱的。如果你们在代码里拼了3000 token的提示词,用户每发一句话,背后就多出3000 token的输入消耗。我之前排查过一个项目,日志里看用户请求只有几十token,但账单上输入token量特别高,最后发现是平台上统一加的"行业知识前缀"太大了。
第二,厂商的tokenizer和你们本地估算的不一样。不同tokenizer对同一段中文的分词结果不同,有些按字,有些按词,误差能从百分之几到百分之几十。所以"你觉得消耗了1000 token",厂商账单可能记了1200。任何基于本地估算的对账,都必须预留tokenizer差异的容忍区间。
第三,多轮对话的上下文累积。对话类的应用,每轮都要把历史消息重新发送一遍,上下文越长,输入token增长越快。如果没做上下文裁剪,十轮对话后单次请求可能携带上万token,这个成本增长是线性的,甚至是指数的。
1.2 重试:你以为的"一次请求",厂商可能算了两次钱
重试是成本黑洞,而且是最容易让开发觉得"冤枉"的地方。原因在于:很多重试是SDK或HTTP客户端自动触发的,你根本没在业务日志里看到异常,但请求确实发出去了,也计费了。
常见的重试场景有几种:
- 网络超时:请求发出去了,服务端其实已经处理完了,但响应在半路丢了或超时,客户端自动重试,服务端实际上被调用了两次,计费两次。
- 限流触发:厂商返回429限流,SDK自动等待重试,如果第一次已经部分计费,重试又计一次。
- 5xx错误重试:服务端报错,但错误可能发生在生成完成之后,导致你收到报错的同时,那次生成已经消耗了token。
更隐蔽的是没有幂等标记的重试。有些厂商的API支持幂等键,你传相同的幂等键,服务端就会识别并合并重复请求。但很多业务代码压根没传幂等键,或者只是简单地在header里放了请求ID,厂商根本不认。这种情况下每次重试都是全新计费。
我之前统计过一个线上应用,某天异常波动期间,重试请求占总请求量的17%,但重试产生的token消耗占了总消耗的29%。因为重试往往发生在超时场景,超时之前模型已经生成了部分内容,所以重试请求的token量往往比正常请求更大。
1.3 缓存:本应省钱的功能,最容易让人误判
缓存是用来省钱的,但如果对账时不单独区分缓存命中与未命中,你根本算不清实际成本。
比如某厂商的缓存计费规则是:命中的输入token按标准价的10%计费,未命中的按全价计费。假设你发了5000 token的请求,其中4000 token命中缓存,1000 token未命中,那么输入费用是:
4000 × 0.002 × 10% + 1000 × 0.002 = 0.0008 + 0.002 = 0.0028元
如果全部未命中,则是5000 × 0.002 = 0.01元
差距接近4倍。但问题在于,缓存是否命中,你们的日志里通常根本没有记录。你们看到的只是"发了一次请求,返回了结果",但厂商账单里缓存命中和未命中的单价是分开的。没有请求级命中标记,你永远做不出精确对账,只能估算。
更麻烦的是,缓存命中有时效窗口。比如某厂商的缓存有效期只有5分钟。用户A和用户B如果在5分钟内使用了完全相同的前缀,可以命中缓存,但如果超过窗口期,同样的前缀就要重新计费。这意味着缓存命中和你的流量分布强相关,峰值期的命中率和平峰期完全不同,想用"平均命中率"去估算,误差会很大。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三层对账法设计思路:从单点核对到完整闭环
说清楚了钱花在哪,就可以设计对账方案了。三层对账法的核心思路是:不同粒度对账解决不同维度的问题。只看总额会漏掉细节,只看单请求又效率太低,三层叠加才能形成完整闭环。
2.1 第一层:请求级流水核对,守住原始证据
第一层是请求级流水核对,也就是把你系统里每一次API调用的记录,和厂商账单明细逐条对齐。
这一层要做的事情很纯粹:
- 记录每一次请求的request_id、时间戳、模型名称、输入token、输出token、缓存命中状态。
- 从厂商账单明细里解析出同样的维度。
- 用request_id做关联,逐条比对token量和金额。
这一层对账的成本最高,不可能每次都对全量数据,但它有一个不可替代的作用:建立原始证据链。一旦聚合层面出现差异,最终都要落回到请求级去定位具体是哪一批请求出了问题。
实操中,第一层对账通常采用抽样策略。按小时抽样10%的请求做全字段比对,如果差异率在容忍范围内,说明聚合对账的结果是可信的;如果差异率超标,就扩大抽样比例,甚至做全量比对。抽样是手段,建立可信度才是目的。
2.2 第二层:聚合级比对,用汇总数据快速锁偏差
第二层是把双方的流水按维度聚合,然后比对汇总值。常用的聚合维度有:
- 按天、按小时聚合
- 按模型聚合
- 按接口或功能模块聚合
- 按缓存命中/未命中聚合
聚合层对账的核心价值是快速定位偏差范围。比如某天账单总额比系统估算多了800块,你按模型拆分后,发现多出来的钱主要集中在某个模型上,再按小时拆分,发现集中在某几个小时,再按接口拆分,就能快速缩小到具体是哪个功能在什么时间段出了问题。
这一层要对得非常细。不同维度交叉比对,才能把问题范围压到足够小。我的经验是,聚合维度建得越多,后续排查越省力。至少要有模型、时间、接口、缓存状态四个维度,有条件的话加上用户或业务线维度。
2.3 第三层:业务级归因,把成本分摊到功能模块
第三层是业务级归因。账单最终是要分摊到各个业务方头上的。如果你们公司内部还要做成本核算,那第三层对账就是财务层面的"分账"。
这一层的做法是:在请求流水里加上业务维度的标签,比如来源端(App、小程序、Web)、功能模块(聊天、摘要、翻译)、用户层级(免费用户、会员用户)等。然后按这些标签做成本聚合。
值得关注的是,第三层对账往往能发现"每笔都正确,但总额严重超标"的问题。比如某个功能的调用量只占全系统的5%,但token消耗占了30%,说明这个功能存在严重的上下文膨胀或者无效重试。这是从成本管控角度做的对账,比起逐笔核对更能暴露设计层面的问题。
三层之间的关系是逐层深入、逐层定位:第一层管证据,第二层管目标,第三层管归因。每次对账先跑第二层,发现差异后落到第一层定位,最后用第三层明确责任归属。
3. 实操落地:字段设计、流水存储与对账脚本
框架搭好了,落地就是具体工程问题。我按实际开发顺序梳理一遍,从埋点字段设计到对账脚本实现,每一步都给出可直接照抄的方案。
3.1 请求埋点:发起调用时就该记录的8个关键字段
很多团队做对账时才发现日志里缺字段,这已经晚了。埋点字段在设计阶段就要定好,我建议至少包含以下内容:
| 字段 | 说明 | 示例 |
|---|---|---|
| request_id | 全局唯一请求ID,和厂商返回的ID对应 | req_20250601_001234 |
| provider_request_id | 厂商侧的请求ID,响应里返回 | chatcmpl-abcdef123456 |
| timestamp | 发起请求的本地时间戳(毫秒) | 1717212345678 |
| model_name | 模型名称 | gpt-4o-mini |
| prompt_tokens | 输入token数(来自厂商响应) | 3200 |
| completion_tokens | 输出token数(来自厂商响应) | 850 |
| cache_hit | 是否命中缓存:-1未知,0未命中,1命中 | 0 |
| biz_tag | 业务标签,第三层归因用 | chat/translate/summary |
这里有一个容易忽略的地方:prompt_tokens和completion_tokens必须从厂商的响应结果里取,不能在本地估算。因为厂商的tokenizer和你本地不同,估算得再准也有偏差,对账时就会产生大量误报差异。
还有一点建议:请求结束后,把厂商返回的usage字段完整记录到日志里,保留原始JSON,方便后续排查。具体的做法是把usage字段序列化后落到独立的日志字段里,而不是只取几个数字。
3.2 日志采集与流水表设计:从埋点到可查可算
光有日志还不够,得把日志采集起来,落到能查询、能统计的地方。我用的方案是:日志打到文件或消息队列,由采集任务写入ClickHouse或PostgreSQL,然后按天建分区表。
流水表的设计上,我的建议是采用"明细表+汇总表"的双表结构:
明细表存每一笔请求的完整信息,按request_id去重。这张表只增不改,便于追溯。
sql复制CREATE TABLE api_request_log (
request_id String,
provider_request_id String,
ts DateTime64(3),
model_name String,
prompt_tokens UInt32,
completion_tokens UInt32,
cache_hit Int8,
biz_tag String,
raw_usage String,
PRIMARY KEY request_id
) ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(ts)
汇总表按业务需要的维度做预聚合,每天跑一次,对账时直接查汇总表,不用每次都扫全表明细。
sql复制CREATE TABLE api_request_agg_daily (
biz_date Date,
model_name String,
cache_hit Int8,
biz_tag String,
total_prompt_tokens UInt64,
total_completion_tokens UInt64,
request_count UInt64,
PRIMARY KEY (biz_date, model_name, cache_hit, biz_tag)
)
这套设计的关键在于:明细表能回答"具体是哪一笔"的问题,汇总表能回答"某天某个维度花了多少"的问题。两层各司其职,对账效率高很多。
3.3 对账脚本核心逻辑:批量匹配、差额计算、差异标记
对账脚本的核心逻辑可以拆成四个步骤:拉取账单、匹配请求、计算差额、标记差异。
第一步,拉取厂商账单明细。各家厂商都提供了账单导出功能,把明细下载下来,解析成结构化数据。
第二步,按request_id或provider_request_id和我们自己的流水做关联匹配。这一步要处理几种情况:
- 完全匹配:双方都有这条请求,token量一致,无差异。
- 我方有、厂商无:请求发出去了但厂商没计费?这种情况要重点关注。有可能是异步计费延迟,也可能是请求根本没到达服务端。多数情况下等账单同步后会自动匹配上。
- 厂商有、我方无:这个要大写特写。这种情况十有八九是重试或自动补发——厂商侧计费了,但我们自己的日志里找不到对应记录。常见原因是SDK在后台自动重试,但业务代码没记录重试日志,导致我们根本不知道这次请求发生过。
- request_id一致但token量不一致:如果厂商返回的usage字段我们有留存,直接把本地的和厂商的对上就能找到差值。
第三步,计算差额。对于匹配上的请求,分别计算token差额和金额差额。金额差额需要知道单价,这里建议在配置表里维护一套"模型单价表",包括输入单价、输出单价、缓存折扣价。
第四步,把有差异的请求打上标记,写入差异表,方便后续人工排查。
python复制for row in provider_bill:
local = local_log.find(row.request_id)
if local is None:
diff = DiffRecord(request_id=row.request_id, type="provider_only", amount=row.amount)
diff.save()
continue
token_diff = (row.prompt_tokens - local.prompt_tokens) + (row.completion_tokens - local.completion_tokens)
if abs(token_diff) > threshold:
diff = DiffRecord(request_id=row.request_id, type="token_mismatch", diff=token_diff)
diff.save()
这段代码省略了厂商账单解析和单价表的实现,但核心流程就是这个意思。差异表建好后,每天对账跑一遍,把差异记录拉出来处理,三个月下来,账单金额基本就在掌控范围内了。
实际操作中还有两个细节要注意:
一是时间窗口对齐。厂商账单往往是按"计费时间"(请求完成时间)统计的,而我们日志里记录的是"请求发起时间"。跨天请求会导致双方在日期归集上不同,对账时要把时间窗口多留出几个小时,或者全部按厂商账单的计费日期来聚合。
二是单价调整。模型降价、缓存折扣调整都可能导致历史账单和当前价格不匹配。建议单价表按版本管理,记录价格生效日期,这样对账脚本回算历史时用的是当时的价格,不会因为调价导致系统性差异。
4. 常见对账偏差与排查思路实录
三层对账法跑起来之后,你会遇到各种奇奇怪怪的偏差。我把实际踩过的坑整理成速查表,碰到类似问题可以直接对照排查。
4.1 金额差异快查表
| 现象 | 可能原因 | 排查路径 |
|---|---|---|
| 账单比我方统计多,多集中在某几个小时 | 异常流量触发大量重试,或限流导致自动补发 | 按小时拆分聚合,看该时段请求量和token量是否异常 |
| 单笔金额高于预期 | 输入token中包含大量系统提示词或上下文膨胀 | 查看该请求的prompt_tokens与上下文长度,检查是否有对话历史无限累积 |
| 缓存相关费用异常高 | 缓存命中率极低,或缓存参数被改写导致全部未命中 | 按缓存状态聚合,计算命中率,检查请求里是否每次都携带了不同的动态前缀 |
| 我方有请求但厂商账单没有 | 异步计费延迟 | 等待1-3天再比对,如果仍然不存在,联系厂商查单 |
| 厂商账单有但我们没有 | SDK隐藏重试 | 检查SDK配置,开启debug日志,确认是否自动重试及重试生成的request_id |
4.2 缓存命中意外失效的排查路径
缓存命中的对账,最常见的问题是你认为该命中的请求实际没有命中。
我总结了一套排查路径:
第一步,先确认缓存字段是否设置正确。有些厂商的缓存要求传入特定的参数,比如cache_key或prompt_prefix,如果你没有传,就永远不命中。
第二步,检查请求里是否混入了动态内容。常见的坑是:请求里放了当前时间戳、随机数或者用户ID作为"系统提示词"的一部分,导致前缀每次都不同,缓存完全失效。
第三步,检查上下文裁剪策略。如果每次请求都只发送最近N轮对话,那前缀在每轮之间都可能变化,缓存生效的概率大打折扣。建议把固定知识、系统提示词单独作为前缀,把动态对话部分放在后面,最大化缓存命中范围。
第四步,检查缓存窗口时间和业务流量分布。比如缓存有效期为5分钟,高峰期每个用户请求间隔超过5分钟,命中率自然很低。这种情况需要考虑前缀共享策略,或者用更长的缓存窗口。
4.3 重试引起的重复计费定位技巧
重试是最难排查的,因为它可能发生在SDK内部,业务代码完全感知不到。就我的经验来看,定位重试问题主要靠两个手段。
第一个是参数比对。在SDK启动时把重试相关的参数打开,比如最大重试次数、重试条件、重试间隔,这些参数决定了重试行为。很多默认配置其实非常激进:超时1秒就重试,最多重试3次,加上指数退避,一旦网络抖动,瞬间就是4倍请求量。
第二个是找特征。厂商账单里,如果出现多个request_id指向同一个业务请求ID,那就说明发生了多次计费。可以在自己的请求日志里加一个retry_group_id,把同一次业务请求的所有重试归到一个组里,这样就能看到每次业务请求实际被调用了多少次。
举一个实际案例:某模拟项目X在接入某厂商后,账单持续超标约20%,查了一个星期没找到原因。后来加了retry_group_id,发现线上有大量请求是SDK在连接超时后自动重发的,每次重发都带新的request_id,但业务层只记录了第一次的日志。定位后把SDK重试次数改为1,并把超时时间从1秒调到5秒,账单立刻降了15%。
4.4 时间口径不一致导致的对账偏差
还有一个容易被忽略的问题:时间口径。厂商账单统计时间和你本地日志统计时间如果不一致,哪怕代码完全正确,对账也会飘。
厂商账单通常是按请求结束时间计费,我们日志记的是请求开始时间。如果单次请求耗时较长,比如十几秒,或者跨小时、跨天,双方归集到的时间窗口就会错位。
解决办法是在对账脚本里做偏移处理:统计某小时的数据时,把请求开始时间往前推一个最大耗时窗口,或者干脆用厂商账单的"计费时间"字段作为统一切片维度。我建议后者,因为厂商账单是最终结算依据,以它为基准最不容易扯皮。
另外,如果你们是自建日志系统,建议统一用UTC存储时间戳,展示时再转北京时间或其他时区。多时区混用,排查到具体请求时会非常痛苦。
4.5 抽样对账的批次策略
最后说一个对账工程上的细节:全量对账太慢,批量对账又怕漏检,所以需要设计一个合理的抽样策略。
我的做法是按天分三层抽样:
- 全量总额比对:每天都跑,只看总额差异率,控制在正负1%以内就算过。
- 10%请求级明细比对:每天随机抽样10%的请求做全字段比对,记录token差异和金额差异。
- 全量异常召回:把金额大于某个阈值(比如单笔超过10元)、token量超过某个阈值(比如单次超过5万token)的请求全部拎出来单独核对。
这套策略既能保证效率,又能抓到主要矛盾。小额差异允许存在,大额差异必须查清。对账的本质不是追求100%精确,而是把误差控制在一个可解释、可接受的范围内。
5. 从对账到成本治理:顺手还能做的三件优化事
对账体系跑通之后,你会发现手里多了一大笔数据资产。这些数据不只是用来"平账"的,还能直接反哺成本治理。有几个动作我强烈建议做一下,投入产出比极高。
5.1 建立token单价的版本化配置
各家模型的价格经常调整,如果不做版本管理,回算历史账单时价格就乱了。
建议维护一张price_config表:
| 生效日期 | 模型名 | 输入单价(元/千token) | 输出单价(元/千token) | 缓存命中价(元/千token) |
|---|---|---|---|---|
| 2025-01-01 | model-a | 0.002 | 0.006 | 0.0002 |
| 2025-03-01 | model-a | 0.0015 | 0.005 | 0.00015 |
对账脚本里统一从这张表取价,历史账单回算时按生效日期匹配对应价格。别小看这个动作,我见过不止一个团队因为厂商悄悄调了价,导致当月账单"莫名"多了一笔,排查半天才发现是价格变了。
5.2 按业务线分摊成本,账目大白于天下
成本分摊这种事,越早做越省心。每个请求打上业务标签后,就能按周、按月自动生成成本报表:哪个功能模块烧钱最多、哪个用户的token消耗增长最快、哪个模型该优化了,一眼可见。
我之前在一个团队推行过成本周报,效果立竿见影。之前没人关心token消耗,周报出来之后,负责某个功能的同事主动跑过来问,说他的功能怎么占了30%的成本,结果一查是上下文无限累积导致的,优化之后成本降了40%。
5.3 建立异常消耗预警机制
对账做到后面,更关键的是"提前发现",而不是"事后解释"。可以设定告警:如果连续5分钟内的token消耗速率超过历史均值的2倍,就触发告警。这个阈值要根据你们自己的业务特性去调,核心是抓住异常膨胀。
异常消耗最常见的几种触发原因:
- 线上事故导致重试风暴
- 某用户或某脚本的误操作,比如循环调用忘记退出
- 新版本代码的bug,比如缓存逻辑写错,导致每次请求都未命中
预警机制不需要很复杂,一条定时任务+一个webhook就够了,但它能帮你把问题发现在账单出来之前。
写在最后的实话
三层对账法用下来,最大的感受是:对账这事,方法论只占三成,剩下七成都是数据规范的功夫。你埋点字段全不全,时间口径统不统一,价格版本管没管好,这些都决定了对账能不能自动跑起来。
我最开始做的时候也走过弯路,上来就写了个对账脚本,结果天天报差异,根本分不清是厂商的问题还是自己数据的问题。后来回过头来把字段标准化、补全缓存状态标记、加上retry_group_id,再跑对账,一周内就把积累了三个月的账单差异全部消灭了。
另外想提醒一句:如果发现差异率持续稳定在某个固定比例,比如每天都是2%,不要急着怀疑厂商,先查自己的日志是不是有固定的丢失场景——我遇到过最隐蔽的一次,是因为负载均衡偶尔把请求转发到旧版本的实例上,那个实例的日志采集早就停了,导致每天固定有一小部分账单对不上。
对账这件事,跟做工程一样,没有银弹。先把数据基础夯实,再谈三层对账,才能做到既不背锅,也确实把成本管住了。如果你正在被API账单折磨,不妨从今天开始,先在日志里加上那几个关键字段,跑一周看看,你会回来感谢自己的。
