账单突然暴涨:怎么从调用日志一层层定位到哪一步在烧
数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。
账单暴涨这件事,多数人第一反应是”密钥泄露了”或者”团队最近用得多”,然后花半天去查安全、去问同事,最后什么也没查出来。按我处理过的情况看,绝大多数突发暴涨的真实成因是三类结构性问题:单次请求的输入被反复放大(每一轮都把整段历史重新发一遍)、失败被重试机制放大(一次业务操作背后其实打了好几次全价请求)、以及缓存本来该命中却没命中(同样的内容按新内容计费)。这三类都不是”用得多”,而是”同一件事被算了很多遍”,所以你要找的不是谁在用,而是哪一步在重复。
先说清这篇和站内几篇相邻内容的分工:API 成本监控 讲的是稳态下怎么把记账、预算闸门和日频对账搭起来(没暴涨时就该做的事),token 成本优化 讲的是账目正常但你想再省一截时的手法,AI 工具支出审计 面向的是团队席位和采购口径。本篇只管一件事:钱已经烧起来了,怎么在最短时间内从日志里指到那一行。
一、先把”涨”拆成三条曲线,不要盯总额
总额只告诉你”涨了”,拆开才告诉你”怎么涨的”。在动手翻代码之前,先用现有日志或平台侧的用量视图,把同一个时间段的三个数分别画出来:
- 请求条数:单位时间内打出去的请求有多少条。
- 单条平均输入量:总输入 token 除以请求条数。
- 单条平均输出量:总输出 token 除以请求条数。
这三条曲线的组合形状,基本就能定性:
条数陡增、单条输入基本没变,说明是调用频次问题——要么某个循环没有出口,要么重试在放大,要么有个定时任务被部署了两份。 条数没怎么变、单条输入陡增,说明是上下文膨胀——每轮都在重发历史,或者某处把整个文件、整份检索结果无节制地塞进了输入。 条数和单条输入一起涨,通常是agent 类链路失控:它在多轮里反复调工具,每一轮又把之前所有工具返回值原样带上,两个乘数叠在一起,曲线会呈现出比线性更陡的形状。 输出量单独异常增大,往往是没设输出上限或者模型陷入了自我重复,这类相对好发现,也相对少见。
如果你连这三条曲线都拿不出来,那第一步不是排查,是先把记账补上——最低限度是把每次调用的 usage 字段落一条结构化日志。日志字段的口径直接决定了后面所有动作能不能做,缺字段就先补字段。
二、现象到成因的判别表
下面这张表是我实际排查时的第一步,用来把眼前的现象快速对到候选成因上,避免一上来就猜。
| 现象 | 大概率成因 | 怎么验证 | 处置动作 |
|---|---|---|---|
| 请求条数陡增,单条输入平稳 | 重试风暴:上游超时或 429 触发了重试,重试本身又全价计费 | 按请求的状态码分组统计条数,看 429/500/ETIMEDOUT 的占比是否同步抬头;再看同一业务 ID 对应几条请求记录 | 给重试加上限和指数退避,429 一律按响应指示等待而不是立刻重打,业务层做幂等去重 |
| 单条输入陡增,条数平稳 | 上下文重复发送:每轮都把完整历史或整份文档重新塞进输入 | 取每条请求输入的前若干字符做哈希,统计重复次数;再看输入 token 随对话轮次是否单调上升 | 改成只带必要片段,历史做滚动摘要,长文档改为按需检索而不是整份携带 |
| 条数与单条输入同时陡增,且集中在少数会话 | agent 循环调工具:工具返回值不断累积进上下文,还可能在两个工具之间来回打转 | 按会话 ID 统计单会话的请求条数分布,看尾部有没有异常长的会话;再看工具调用序列有没有重复模式 | 设单会话最大轮次和最大工具调用次数的硬上限,超限直接终止并落一条告警 |
| 用量涨了但业务量没涨,缓存命中数近乎为零 | 缓存没命中:输入的稳定前缀被改动,或超出了缓存有效期 | 对比日志里缓存命中相关的 token 字段与总输入的比例,看是从某个部署时间点开始掉的 | 把变动内容(时间戳、随机 ID、用户昵称)从稳定前缀里挪到后面,确认改动上线时间与掉点时间是否吻合 |
| 用量集中在非工作时间、来源 IP 或调用方标识陌生 | 密钥外泄或被误用 | 按调用方标识和来源分组看用量,检查密钥是否进过代码仓库历史 | 立刻轮换密钥并收紧权限,同时排查密钥的分发范围 |
| 单条输出异常大,内容里有明显重复段落 | 输出没有上限,或生成陷入重复 | 抽查最长的若干条输出 | 设输出长度上限,并在业务侧对超长输出做截断与告警 |
表里最容易被跳过的是倒数第三行。缓存没命中是一种”隐形涨价”:请求条数没变、单条输入也没变,只是原本可以按低价计的那部分变成了按全价计。它不会在任何一条粗粒度曲线上表现出异常形状,只能靠命中比例这一个指标发现。
三、逐层验证:从聚合到单条
判别表给的是方向,下面是把方向落到证据上的顺序。这个顺序不要跳,跳了大概率会在错的地方挖很久。
第一层,按维度聚合,找出承担了大头的那个维度。 假设你的调用日志是每行一条 JSON(包含调用方、模型、输入输出 token、状态码、会话 ID),先做一次分组:
# 按调用方聚合当日输入/输出 token 与请求条数,按输入量降序
jq -s 'group_by(.caller)
| map({caller: .[0].caller,
n: length,
in_tok: (map(.input_tokens // 0) | add),
out_tok: (map(.output_tokens // 0) | add)})
| sort_by(-.in_tok)' calls.jsonl
-s 是把每行一条的 JSON 先收成一个数组,// 0 是给缺字段的记录兜底(不加这个,只要有一条记录没落上 token 字段,整组的 add 就会被污染成 null,那一行会莫名沉到最底下,很容易被误读成”这个调用方没花钱”)。字段名按你自己的日志改,别照抄。
把 .caller 换成 .model、.status、.session_id 各跑一遍。多数情况下,跑到第三遍你就能看到一个非常不均匀的分布——某一个调用方、某一个会话吃掉了绝大部分用量。成本问题几乎总是长尾的:不是所有人都多花了一点,而是极少数路径花掉了绝大多数。
第二层,在这个维度内部看时间形状。 锁定嫌疑维度后,按小时或分钟切片再看一次。突变点比绝对值重要得多。如果曲线是在某个具体时刻阶跃上去的,那就去对这个时刻的部署记录、配置变更、定时任务上线:
# 看那个时间点前后仓库里发生了什么
git log --since='2026-07-26 00:00' --until='2026-07-28 00:00' --oneline --name-only
阶跃意味着有人改了什么;缓慢爬升意味着某个会带状态的东西在累积(历史越来越长、检索结果越来越多、缓存越来越难命中)。这两种形状对应完全不同的排查方向。
第三层,抽单条请求看输入的真实构成。 到这一步才该看具体内容。取那个嫌疑会话里最贵的几条,把输入按段落切开,逐段看谁占了体积。常见结果是:一段本该只带摘要的历史,实际上带了全文;一段检索结果里塞了十几个几乎重复的片段;一个工具返回了整个目录树。
重复发送这件事可以用哈希粗验,不用读完内容。前提是日志里得有可比对的输入指纹——上面假设的那几个字段(调用方、模型、token 数、状态码、会话 ID)都不够,你还需要在发请求的地方多落一个字段:输入开头一段的哈希。注意是哈希而不是原文,原文落盘等于把用户输入抄进了日志,既涨存储又多一份数据合规负担;哈希只能比较”是否相同”,恰好是这里唯一需要的能力。
如果你落的是明文片段(字段名下面按 input_head 写),可以这样数:
import json, hashlib, collections
prefix_count = collections.Counter()
skipped = 0
with open('calls.jsonl', encoding='utf-8') as f:
for line in f:
rec = json.loads(line)
head = (rec.get('input_head') or '')[:1000] # 输入开头一段,缺字段/为 null 时是空串
if not head:
skipped += 1 # 没落上这个字段的记录不参与统计
continue
prefix_count[hashlib.sha256(head.encode('utf-8')).hexdigest()[:12]] += 1
for h, n in prefix_count.most_common(10):
print(h, n)
print('skipped:', skipped, 'counted:', sum(prefix_count.values()))
最后一行的 skipped 不是凑数的:如果它远大于 counted,说明你统计的其实是一小撮样本,这时候的结论不能当证据用,得先去把字段补全。空串必须显式跳过,否则所有缺字段的记录会共享同一个哈希,堆成一个假的”重复冠军”,把你引到不存在的问题上。
同一个前缀哈希出现几十上百次,就是”同一段内容被反复全价发送”的直接证据。这条证据比任何推测都好用,因为它可以直接换算成”这部分钱是白花的”。
第四层,确认重试链路。 重试风暴的特点是”一次业务操作对应多条请求记录”。如果你的日志里有业务侧的请求 ID,直接按它分组数条数就行。如果没有,退一步看状态码分布:429 明显抬头说明你在被限流后还在硬打,ETIMEDOUT 或 ECONNRESET 抬头说明网络层在丢,而丢的那次很可能服务端已经生成完并且已经计费了。限流这条线上的处置细节,DeepSeek API 429 限流 那篇讲得更细,判断逻辑对其他厂商同样适用。
顺带提一个容易被忽略的点:如果你把请求打到了自建网关或者代理上,网关自己也可能配了重试。业务侧重试若干次、网关再各重试若干次,实际打出去的请求数是两者相乘。排查时一定要把整条链路上每一跳的重试配置都翻出来,而不是只看应用代码。
四、什么情况下别再折腾了
排查是有成本的,而钱在你排查的时候还在烧。下面几条是我的止损判断依据,早点做决定比查得漂亮重要。
先止损,再定位。 如果用量还在往上走,第一动作永远是掐掉水龙头,不是找原因。把嫌疑调用方的并发降下来、把定时任务停掉、在平台侧把预算闸门收紧到能接受的水平。多数平台侧的额度控制不会替你”自动降级到便宜模型”,它到点就是直接拒绝——这个特性此刻对你是好事,但也意味着你要提前想清楚被拒绝之后业务怎么表现。
给自己一个明确的时间盒。 从开始查算起,如果一个时间盒内(我一般给自己一到两小时)还没能把用量集中到某个具体的调用方或会话上,说明你缺的是数据而不是分析能力。这时候正确的动作是停下排查,去把日志字段补齐,然后等下一次复现——盲猜代码的期望收益极低。
有回滚点就先回滚。 如果曲线是阶跃上去的,而那个时刻附近有一次部署,直接回滚,把定位放到回滚之后做。回滚是最便宜的验证手段:滚回去用量降了,成因就锁定在那次变更里;滚回去没降,你也排除了一大片可能性。别为了”想搞清楚原理”而让线上继续烧。
分不清就换条路。 如果排查发现是某个 agent 链路在多轮里失控,而这个链路本身的收益你也说不清楚,那更划算的决定是把它先改成人确认一步、机器执行一步的半自动形态,而不是继续调参数试图让它自己收敛。失控的自动化不只是贵,它还会在你不知情的时候产生一堆需要人回头收拾的结果。
别在这时候动模型选型。 暴涨当天临时换模型、换厂商,会把两个变量搅在一起,之后你既说不清是换模型省下来的,也说不清是修了 bug 省下来的。选型该在稳态下做。
五、避坑清单
只按总额告警,不按增速告警。 为什么会踩:阈值是拍脑袋定的,业务本身在长,总额慢慢接近阈值,告警变成日常噪音,真出事那天它和平时长得一样。怎么避:同时监控”单位时间用量的变化率”和”单条平均输入量”,后者的物理含义很干净——它变了,就是有人改了输入的构造方式。
重试没有上限,或者上限藏在多处。 为什么会踩:SDK 默认带重试,你的业务代码又包了一层重试,网关可能还有一层,三处配置分别看都不算激进,乘起来就很可怕。怎么避:把整条链路的重试收敛到一个地方,其余全部显式关掉,并且在日志里标记”这是第几次重试”,让重试在统计里现形。
把变动的东西放在输入的最前面。 为什么会踩:为了让内容看起来更”新鲜”,很多人习惯把当前时间、随机会话 ID、用户信息写在最开头,而缓存通常依赖稳定前缀,前缀一变,缓存就整段失效。怎么避:稳定的部分(角色设定、规则、参考资料)放前面且严格逐字不变,变动的部分统一挪到后面。这条改起来只要十分钟,是我见过投入产出比最高的一项。
用完整历史当”上下文”。 为什么会踩:把全部对话塞回去是最省事的实现,短期效果也确实更好,于是没人回头改,直到轮次长起来。怎么避:从第一版就写死”历史超过若干轮做摘要”的逻辑,而不是等出事再加。这一块的取舍见 Agent 上下文管理。
给 agent 加工具时不设调用次数上限。 为什么会踩:工具越多,模型在其中来回试探的空间越大,两个语义相近的工具很容易造成来回打转,而每一轮都是一次全价请求。怎么避:单会话设最大轮次和最大工具调用数的硬上限,触顶就终止并告警;工具的职责边界要能一句话说清,说不清就合并或者删掉。
只查自己的代码,不查工具侧的用量。 为什么会踩:团队里的 AI 编程工具(IDE 插件、命令行工具)也在消耗同一份预算,而它们的用量不经过你的网关,你的日志里根本看不见。怎么避:把工具侧的用量单独作为一条曲线,和 API 侧分开看,别把两笔账混成一笔去解释。
误判为”密钥被盗”而错过真因。 为什么会踩:这是最容易想到、也最有故事性的解释,一旦认定就会把时间全花在安全排查上。怎么避:密钥外泄的用量形状很有特征——来源和调用方陌生、时间分布与团队作息无关。这个形状不成立时,先按结构性成因查,别把它当默认答案。同时该做的密钥轮换纪律照做,两件事不冲突。
对海外工具的接入方式想当然。 为什么会踩:一部分海外 AI 工具与模型服务对中国大陆存在区域限制、官方不支持直连,团队为了能用会引入各种中转,而中转层的超时、重试与计费口径都不透明,出问题时你连日志都拿不全。怎么避:明确记录每条链路的实际出口和责任方,说不清责任方的链路不要承载核心业务;官方在你所在区域不提供服务的产品,就按官方渠道和本地合规要求评估还能不能用、由谁签约,而不是想办法绕开限制——绕过去的那条路既没有 SLA 也没有可对账的账单,出了成本问题你连找谁核对都不知道。
六、收尾
账单排查的核心思路其实只有一句:成本异常几乎都是重复,找重复比找用量更快。 重复的历史、重复的重试、重复的工具往返、本该复用却没复用的缓存,四者都会在日志里留下可数的痕迹,前提是你的日志能数。
下次曲线不对的时候,按这个顺序过一遍:
- 掐水龙头——降并发、停任务、收紧预算闸门,先让用量停止增长。
- 拆三条曲线——请求条数、单条平均输入、单条平均输出,看是谁在涨。
- 按调用方 / 模型 / 会话各聚合一次,找到吃掉大头的那一个。
- 在它内部按时间看形状,阶跃就去对部署记录,爬升就去查累积项。
- 抽最贵的几条请求,用前缀哈希验证有没有重复发送。
- 把整条链路每一跳的重试配置翻出来,确认实际请求数不是几个上限相乘的结果。
- 查缓存命中比例是从哪天开始掉的,对上那天的变更。
- 定位不了就补日志字段,等复现,不要盲改代码。
这套顺序不依赖任何特定厂商的控制台,它依赖的只有一件事:你的每次调用都留下了一条带 usage 的结构化日志。如果今天读完只做一件事,就做这个。