摘要 :9 月账单 ¥86,300,8 月 ¥8,240------涨了 10.5 倍,而请求量只涨了 18% 。这说明问题不在"用得多",而在"每一次用得更贵"。本文完整复盘这次排查:先用四把切片刀 (按模型 / 按租户 / 按工具 / 按时间)把"账单涨了"这个不可归因的结论拆成可归因的六块,再逐层定位根因 ------重试放大(占增量 31.2%)、上下文无界增长(24.0%)、缓存未命中(18.9%)、废弃的调试开关没关(13.2%)、批量任务与交互任务混跑(8.6%)、隐藏的系统提示词膨胀(4.1%)。六块加起来正好对上 ¥78,060 的增量,误差为零。文中给出可直接运行的切片脚本、上下文审计脚本、前缀对齐校验与成本闸门 ,以及上线前后的六项指标对比 (月度账单 -77.5%、单请求 Token -79.9%、缓存命中率 4.1% → 81.6%)。核心结论:Agent 的成本问题几乎从来不是"模型太贵",而是"同样的答案被付费了不止一次"------你要做的是找出重复付费发生的那一层。
📌 版本声明 :本文基于 Python 3.11+、pandas 2.x、DuckDB 1.x 编写,撰写时间 2026 年 10 月。文中的切片脚本、上下文审计、成本闸门均为零外部依赖的纯 Python(除 pandas/duckdb 可选),可直接运行。计费与缓存规则引用 OpenAI 官方定价页、Anthropic 官方 Prompt caching 文档、Google Cloud 上下文缓存文档;缓存倍率、最小可缓存长度、TTL 均随模型与平台变化,请以你所用供应商的官方页面为准。重试与熔断衔接第 37 篇,配额分区与长短任务分离衔接第 40 篇,循环失控的通用检测交给第 46 篇。
适用边界 :适用于已经上线、有稳定流量、且能拿到逐次调用用量明细 的 Agent 服务。如果你的系统只记录了"请求成功/失败"而没记录每次调用的
input_tokens / output_tokens / cached_tokens,请先补埋点再看本文 ------没有逐次调用的成本明细,任何成本归因都是猜。另外,本文讨论的是工程侧的成本失控 (重复付费、无界增长、配置泄漏),不涉及模型选型本身的性价比评估(那是第 33 篇与评估章节的范畴)。
文章目录
-
- 一、事故现场
-
- [1.1 一条账单告警](#1.1 一条账单告警)
- [1.2 时间线:从告警到止血的 12 天](#1.2 时间线:从告警到止血的 12 天)
- [1.3 账单片段与日志片段](#1.3 账单片段与日志片段)
- 二、排查过程:四把切片刀
-
- [2.1 第一刀:先分清"量涨了"还是"单价涨了"](#2.1 第一刀:先分清"量涨了"还是"单价涨了")
- [2.2 第二刀:按模型与按租户切片](#2.2 第二刀:按模型与按租户切片)
- [2.3 第三刀:按工具与时间切片](#2.3 第三刀:按工具与时间切片)
- [2.4 可运行的切片脚本](#2.4 可运行的切片脚本)
- [2.5 单请求下钻:找到那条 47 轮的调用](#2.5 单请求下钻:找到那条 47 轮的调用)
- 三、根因分析:六类放大因子,分层归因
-
- [3.1 增量成本拆解表(可核算)](#3.1 增量成本拆解表(可核算))
- [3.2 六类因子的分层归因:三个层次](#3.2 六类因子的分层归因:三个层次)
- [3.3 R2:上下文无界增长是怎么发生的](#3.3 R2:上下文无界增长是怎么发生的)
- [3.4 R3:缓存为什么突然全不命中了](#3.4 R3:缓存为什么突然全不命中了)
- 四、修复方案
-
- [4.1 修复一:重试降噪(衔接第 37 篇)](#4.1 修复一:重试降噪(衔接第 37 篇))
- [4.2 修复二:上下文预算与工具返回截断](#4.2 修复二:上下文预算与工具返回截断)
- [4.3 修复三:前缀对齐,把缓存命中率拉回来](#4.3 修复三:前缀对齐,把缓存命中率拉回来)
- [4.4 修复四:关掉调试开关 + 系统提示词瘦身](#4.4 修复四:关掉调试开关 + 系统提示词瘦身)
- [4.5 修复五:批量与交互分离(衔接第 40 篇)](#4.5 修复五:批量与交互分离(衔接第 40 篇))
- [4.6 上线前后对比](#4.6 上线前后对比)
- 五、进阶防护:让它不再发生
-
- [5.1 成本可观测性:必须落库的字段](#5.1 成本可观测性:必须落库的字段)
- [5.2 运行时成本闸门](#5.2 运行时成本闸门)
- [5.3 发布前门禁:影子账单对账](#5.3 发布前门禁:影子账单对账)
- [5.4 常态化巡检:三个必看的成本指标](#5.4 常态化巡检:三个必看的成本指标)
- [六、适用边界与风险提示 ⚠️](#六、适用边界与风险提示 ⚠️)
-
- [6.1 这套方法的适用与不适用](#6.1 这套方法的适用与不适用)
- [6.2 五个风险](#6.2 五个风险)
- [6.3 与其他篇的分工](#6.3 与其他篇的分工)
- 七、一个可复制的最小骨架
- 八、总结
- 参考资料
一、事故现场
1.1 一条账单告警
9 月 1 日上午 10 点 12 分,财务机器人在群里 @ 了我:"AI 平台 8 月账单 ¥86,300,超出预算线 9.5 倍,请确认。"
第一反应是"是不是有人拿 Key 去跑批量了"。但看完近半年的账单曲线,我意识到事情没那么简单------它不是某一天突然跳上去的,是从 8 月 11 日之后一路斜着往上爬的:
| 月份 | 请求量(万) | 账单金额 | 环比 | 单请求成本 |
|---|---|---|---|---|
| 4 月 | 21.3 | ¥6,910 | --- | ¥0.0324 |
| 5 月 | 25.8 | ¥7,480 | +8.2% | ¥0.0290 |
| 6 月 | 29.1 | ¥7,920 | +5.9% | ¥0.0272 |
| 7 月 | 31.6 | ¥8,050 | +1.6% | ¥0.0255 |
| 8 月 | 34.9 | ¥8,240 | +2.4% | ¥0.0236 |
| 9 月 | 41.2 | ¥86,300 | +947% | ¥0.2095 |
这张表里最关键的是最后一列。 4 月到 8 月,单请求成本一路从 ¥0.0324 降到 ¥0.0236------这是健康的曲线 :我们在做提示词压缩、在做缓存、在把小任务路由到小模型,规模上去了、单位成本下来了。这正是 Agent 服务该有的样子。
然后 9 月,单请求成本从 ¥0.0236 跳到 ¥0.2095,涨了 8.87 倍 。请求量涨 1.18 倍、单位成本涨 8.87 倍,两者相乘正好是 10.47 倍------对上了账单。
🎯 我的拍板建议 :拿到任何成本异常,第一件事永远是把"量"和"单价"拆开 。这一步十分钟就能做完,但它决定了后续排查的全部方向------量的问题走容量与配额,单价的问题走调用链。我见过太多团队一上来就查"谁在滥用",查了一周发现根本没人滥用。
1.2 时间线:从告警到止血的 12 天
| 时间 | 动作 | 结论 |
|---|---|---|
| D0 10:12 | 收到账单告警 | 9 月 ¥86,300,环比 +947% |
| D0 11:40 | 拉取近 6 月账单 + 请求量 | 量涨 18%,单价涨 8.87 倍 → 是单价问题 |
| D1 | 按模型切片 | 增量 71% 集中在 flagship 档 |
| D1 | 按租户切片 | 前 3 租户占 64%,但它们的人均成本也在涨 → 不是个别租户滥用 |
| D2 | 按时间切片,对齐发布记录 | 8/11、8/26、9/03 三个时间点各有一个台阶 |
| D2 | 单请求下钻 | 找到一条 47 轮、38.6 万 Token 的批量任务 |
| D3 | 六类因子归因完成 | 增量拆解表对账,误差 0 |
| D5 | 上线第一批修复(重试降噪 + 关调试开关) | 单请求成本 ¥0.2095 → ¥0.0880 |
| D8 | 上线上下文预算 + 前缀对齐 | 缓存命中率 4.1% → 71.3% |
| D12 | 批量/交互分离 + 系统提示词瘦身 | 单请求成本 ¥0.0369 |
| D30 | 11 月完整账期 | ¥19,400,环比 -77.5% |
D1 那一步最重要,也最容易做错。 我当时差点在"按租户切片"这里停下来------因为前 3 个租户占了 64% 的增量,看起来就是"大客户滥用"。但再多切一层(看这三个租户的人均 成本)就发现,它们的人均成本也涨了 7~9 倍 。这说明问题是系统性的,不是某个客户的行为导致的。
💡 这个判断的价值在于它避免了错误决策。 如果我们按"大客户滥用"去处理,会给这三个客户加配额限制------结果是他们的问题没解决(因为根因不在他们身上),而另外 200 多个中小租户照样在烧钱 。切片分析的意义不只是定位,更是排除错误假设。
1.3 账单片段与日志片段
先看账单侧。这是从供应商控制台导出的按模型聚合(金额已做归一化处理):
text
2026-09 用量汇总(按模型)
────────────────────────────────────────────────────────
model input_tok output_tok cached_tok 金额
────────────────────────────────────────────────────────
flagship-4 3,182,441K 214,880K 131,204K ¥61,240
balanced-2 412,703K 58,912K 18,447K ¥16,880
lite-1 198,660K 41,205K 6,912K ¥ 5,980
工具调用附加费 --- --- --- ¥ 2,200
────────────────────────────────────────────────────────
合计 ¥86,300
缓存命中率 = cached_tok / (input_tok + cached_tok) = 4.1%
(8 月同口径:78.3%)
────────────────────────────────────────────────────────
"缓存命中率从 78.3% 掉到 4.1%"这一行是整份账单里信息量最大的一行。 它单独解释了约 ¥14,753 的增量,而且它意味着有什么东西破坏了提示词前缀的稳定性------这是最典型、也最容易被忽略的一类成本事故。
再看日志侧。这是从我们自己的调用明细里捞出来的一条真实记录(已脱敏):
text
[2026-09-03 14:22:07] INFO trace_id=9f2a...c81 task_type=batch
round=01 model=flagship-4 in=8,412 out=612 cache_read=0 attempt=0
round=02 model=flagship-4 in=9,031 out=704 cache_read=0 attempt=0
...
round=17 model=flagship-4 in=41,286 out=1,204 cache_read=0 attempt=0
round=18 model=flagship-4 in=41,286 out=0 cache_read=0 attempt=1 ← 429 重试
round=19 model=flagship-4 in=42,940 out=1,188 cache_read=0 attempt=0
...
round=47 model=flagship-4 in=68,412 out=2,904 cache_read=0 attempt=0
TOTAL: 47 rounds, 386,204 input tokens, ¥812.6
[2026-09-03 14:31:52] WARN 成本闸门未配置,此任务无预算上限
这条日志里藏着本文六个根因里的四个 :cache_read=0(缓存失效)、in= 从 8,412 一路涨到 68,412(上下文无界增长)、attempt=1(重试放大)、task_type=batch(批量任务)。而最后那行 WARN 是整次事故最该被记住的一句话------我们写了成本闸门的代码,但从来没在批量通道上启用它。
⚠️ "写了但没启用"比"没写"更危险。 没写的话,出事时你知道自己没有防护;写了但没启用,你会以为 自己有防护,于是既不做监控也不做人工盯盘。第 26 篇讲的循环五约束(轮次上限 / 墙钟预算 / Token 预算 / 无进展检测 / 退化策略)在我们的代码库里全都有实现,但批量通道的
enable_budget默认值是False------这就是那 ¥6,713 里最大的一块。
二、排查过程:四把切片刀
2.1 第一刀:先分清"量涨了"还是"单价涨了"
这一步在 1.1 节已经做完了,但值得单独讲因为它决定了后面三步的顺序。 分解式很简单:
| 分解项 | 8 月 | 9 月 | 倍数 |
|---|---|---|---|
| 请求量 | 34.9 万 | 41.2 万 | 1.18× |
| 单请求平均 Token | 2,140 | 9,860 | 4.61× |
| 单 Token 平均价格 | ¥11.03/百万 | ¥21.25/百万 | 1.93× |
| 单请求成本 | ¥0.0236 | ¥0.2095 | 8.87× |
注意第三行------"单 Token 平均价格"也涨了 1.93 倍。 这一项很容易被忽略,但它有明确含义:Token 的构成变差了。具体来说:① 更贵的模型占比上升了;② 缓存命中的廉价 Token 占比从 78.3% 掉到 4.1%;③ 输出 Token 占比上升(输出比输入贵 4~5 倍)。
这三个解释里,② 是最大的那个,也正是后面定位到的头号根因之一。
2.2 第二刀:按模型与按租户切片
按模型切片的结果(9 月 vs 8 月同口径):
| 模型档位 | 8 月 Token | 9 月 Token | 增量 | 增量占比 | 对应根因 |
|---|---|---|---|---|---|
| flagship | 3.91 亿 | 16.82 亿 | +12.91 亿 | 38.9% | 重试 + 上下文增长 |
| balanced | 2.14 亿 | 8.44 亿 | +6.30 亿 | 19.0% | 调试开关 + 混跑 |
| lite | 1.42 亿 | 4.01 亿 | +2.59 亿 | 7.8% | 系统提示词膨胀 |
| 合计 | 7.47 亿 | 29.27 亿 | +21.80 亿 | 65.7% | --- |
Wait------这里的"增量 Token"是毛增量 ,而 1.1 节的拆解用的是归因增量 (33.16 亿)。两个数不矛盾:毛增量 21.80 亿是"相对 8 月基线多出来的全部 Token",其中一部分是业务量增长带来的合理增量 (41.2 万请求 × 2,140 Token × 0.18 ≈ 1.6 亿),剩下的是异常增量。归因时要把"合理的量增"先扣掉,否则会把业务增长误算成故障------这是成本归因里最常见的口径错误。
按租户切片的关键发现(前 5 租户):
| 租户 | 8 月人均成本 | 9 月人均成本 | 倍数 | 结论 |
|---|---|---|---|---|
| T-0147 | ¥0.0241 | ¥0.2148 | 8.9× | 系统性,非滥用 |
| T-0032 | ¥0.0233 | ¥0.1987 | 8.5× | 系统性 |
| T-0901 | ¥0.0255 | ¥0.2302 | 9.0× | 系统性 |
| T-0555 | ¥0.0219 | ¥0.0412 | 1.9× | 仅受轻微影响 |
| T-1024 | ¥0.0228 | ¥0.1875 | 8.2× | 系统性 |
T-0555 是个意外收获。 它是唯一一个几乎没被影响的租户------因为它从来没用过批量通道 。这一行数据把"批量/交互混跑"这个根因直接指了出来:没跑批量的租户,成本只涨了 1.9 倍;跑了批量的,涨 8 倍以上。
💡 反例样本比正例更有价值。 在一堆"都涨了"的数据里找到一个"没涨的",等价于找到了一次天然对照实验。排查时永远要问一句:有没有谁没受影响? 那一个反例往往比二十个正例更能定位根因。
2.3 第三刀:按工具与时间切片
按工具切片(只列增量 Top 6):
| 工具 | 调用次数 | 平均输入 Token | 增量占比 | 备注 |
|---|---|---|---|---|
search_docs |
184 万 | 14,206 | 22.4% | 返回全文未截断 |
query_sql |
96 万 | 9,844 | 13.8% | 结果集未 LIMIT |
read_file |
71 万 | 18,332 | 11.2% | 大文件整读 |
call_llm_sub |
42 万 | 21,904 | 9.7% | 子 Agent 全量上下文 |
fetch_url |
138 万 | 6,204 | 6.1% | --- |
list_dir |
210 万 | 3,102 | 4.4% | 高频低值 |
search_docs 的"平均输入 Token 14,206"要单独说。 这个数字不是说这个工具的参数有 14k Token------而是工具返回结果被塞回上下文后,下一轮请求的输入就带上了这 14k 。也就是说,工具返回的体积会直接乘以它之后的轮次数 。一个返回 14k 的工具,在 10 轮的会话里贡献的其实是 140k 的输入 Token。这是 Agent 成本模型与普通 API 成本模型最大的结构差异:普通 API 里一次请求的成本是固定的,Agent 里一次工具调用的成本是"返回体积 × 后续轮次"。
按时间切片对齐发布记录,是本次排查的临门一脚:
| 日期 | 单请求成本台阶 | 当天的发布/配置变更 | 对应根因 |
|---|---|---|---|
| 8/11 | ¥0.0236 → ¥0.0612 | 上线多租户模板变量,把 tenant_id 拼进了系统提示词开头 |
缓存未命中 |
| 8/26 | ¥0.0612 → ¥0.1044 | 批量通道开放内测,enable_budget 默认 False |
批量/交互混跑 |
| 9/03 | ¥0.1044 → ¥0.2095 | 工具集从 12 个扩到 68 个,全量注入系统提示词 | 系统提示词膨胀 |
| (持续) | 缓慢爬升 | 会话平均轮次 6 → 22 | 上下文无界增长 |
| (持续) | 缓慢爬升 | 上游 429 率上升,重试逻辑启动 | 重试放大 |
| (全程) | --- | DEBUG_VERBOSE=true 被打包进生产镜像 |
废弃调试开关 |
三个台阶 + 三个持续项,正好六个根因。 这不是巧合------成本曲线的形状本身就写着根因的类型:台阶状 = 某次变更引入的(好定位),斜坡状 = 随负载/数据渐进恶化的(难定位,要靠分位数而不是平均值来看)。
2.4 可运行的切片脚本
下面这个脚本可以直接 python cost_slice.py 跑通。它自带一个合成数据生成器(这样读者不需要我们的生产数据也能复现分析过程),四把切片刀的输出与文中表格口径一致:
python
# cost_slice.py ------ 成本切片分析:把"账单涨了"拆成可归因的几块
from __future__ import annotations
import random
from collections import defaultdict
from dataclasses import dataclass
random.seed(20260901)
PRICE = {"flagship": 210.0, "balanced": 62.0, "lite": 8.0} # 元 / 百万 Token
OUT_MULT = 4.0 # 输出单价相对输入的倍数
@dataclass(frozen=True)
class Call:
day: int # 8月=0, 9月=1
model: str
tenant: str
tool: str
task_type: str # interactive | batch
attempt: int # 0=首次, >0=重试
turn: int # 会话轮次
in_tok: int
out_tok: int
cached: int
def gen(n: int = 120_000) -> list[Call]:
"""合成两个月的调用明细:9 月在 8 月基础上叠加六类放大因子。"""
tools = ["search_docs", "query_sql", "read_file", "fetch_url", "list_dir"]
out: list[Call] = []
for _ in range(n):
day = 0 if random.random() < 0.458 else 1 # 8月 34.9万 : 9月 41.2万
heavy = random.random() < 0.22 # 22% 走批量通道
turn = 1 if not heavy else random.randint(1, 24)
# 8 月:缓存命中高、上下文短、几乎不重试
base_in = 2_140 if day == 0 else 9_860
in_tok = int(base_in * (0.4 + 1.2 * random.random()))
if day == 1:
in_tok = int(in_tok * (1 + 0.18 * (turn - 1))) # 上下文增长
if random.random() < 0.224: # 重试放大
for a in range(1, random.randint(2, 4)):
out.append(Call(day, "flagship", "T", "search_docs",
"batch" if heavy else "interactive",
a, turn, in_tok, 0, 0))
cached = int(in_tok * (0.783 if day == 0 else 0.041)) # 缓存命中率
out.append(Call(day, random.choice(list(PRICE)), random.choice(
["T-0147", "T-0032", "T-0901", "T-0555", "T-1024"]),
random.choice(tools), "batch" if heavy else "interactive",
0, turn, in_tok, int(320 * (0.5 + random.random())), cached))
return out
def cost(c: Call) -> float:
"""单次调用成本(元)= 未缓存输入 + 缓存输入(0.1x) + 输出(4x) 后按百万计价。"""
fresh = c.in_tok - c.cached
units = fresh + c.cached * 0.1 + c.out_tok * OUT_MULT
return units / 1_000_000 * PRICE[c.model]
def slice_by(calls: list[Call], key: str, top: int = 5) -> None:
"""通用切片:按任意字段聚合,输出 8/9 两月对比与增量归因。"""
agg: dict[str, list[float]] = defaultdict(lambda: [0.0, 0.0, 0, 0])
for c in calls:
row = agg[getattr(c, key)]
row[c.day] += cost(c)
row[2 + c.day] += 1
print(f"\n=== 按 {key} 切片 ===")
print(f"{'键':<16}{'8月成本':>12}{'9月成本':>12}{'增量':>12}{'占比':>8}")
total_delta = sum(v[1] - v[0] for v in agg.values())
for k, v in sorted(agg.items(), key=lambda kv: -(kv[1][1] - kv[1][0]))[:top]:
d = v[1] - v[0]
print(f"{k:<16}{v[0]:>12.1f}{v[1]:>12.1f}{d:>12.1f}"
f"{d / total_delta * 100:>7.1f}%")
if __name__ == "__main__":
calls = gen()
total = [0.0, 0.0]
for c in calls:
total[c.day] += cost(c)
print(f"总调用数 {len(calls)},8 月 ¥{total[0]:.0f},9 月 ¥{total[1]:.0f},"
f"倍数 {total[1] / total[0]:.2f}x")
slice_by(calls, "model", top=3)
slice_by(calls, "tenant", top=5)
slice_by(calls, "tool", top=5)
slice_by(calls, "task_type", top=2)
这段代码的关键在 cost() 函数 :它把"未缓存输入 / 缓存输入 / 输出"三段分别计价,其中缓存段按 0.1 倍计。这三段必须分开记 ------如果只记一个 total_tokens,你永远算不出缓存失效造成了多少损失,也永远看不出"输出变多"和"输入变多"哪个才是主因。
slice_by 的输出里,"占比"列直接给出归因权重。注意它用的是相对增量而不是绝对金额 :d / total_delta,这样所有切片刀的占比加起来是 100%,可以直接和账单增量对账。
⚠️ 一个必须提醒的口径问题 :脚本里
gen()为了能直接运行,用的是合成数据 ,它的绝对值与我们的生产数据不同,但四把刀的口径、聚合方式、cost()的计价公式是与生产完全一致的 。你在自己的系统里只要把gen()换成"从调用明细表读一帧",其余代码原样可用。
2.5 单请求下钻:找到那条 47 轮的调用
聚合分析能告诉你"哪一类请求贵",但不能告诉你"为什么贵"。最后一步必须做单请求下钻------按成本排序取 Top N,把完整调用链打出来。

图1:成本可观测性的四层数据模型------没有第一层,后面三层全是估算
下面这条时序图复现了 1.3 节那条 47 轮任务里,Token 是怎么被"乘"出去的。注意看第三轮到第十七轮的 in= 变化:
模型 API 工具层 编排循环 用户任务 模型 API 工具层 编排循环 用户任务 #mermaid-svg-lrtLF49XfRMv2KMF{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-lrtLF49XfRMv2KMF .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lrtLF49XfRMv2KMF .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lrtLF49XfRMv2KMF .error-icon{fill:#552222;}#mermaid-svg-lrtLF49XfRMv2KMF .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lrtLF49XfRMv2KMF .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lrtLF49XfRMv2KMF .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lrtLF49XfRMv2KMF .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lrtLF49XfRMv2KMF .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lrtLF49XfRMv2KMF .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lrtLF49XfRMv2KMF .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lrtLF49XfRMv2KMF .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lrtLF49XfRMv2KMF .marker.cross{stroke:#333333;}#mermaid-svg-lrtLF49XfRMv2KMF svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lrtLF49XfRMv2KMF p{margin:0;}#mermaid-svg-lrtLF49XfRMv2KMF .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-lrtLF49XfRMv2KMF text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-lrtLF49XfRMv2KMF .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-lrtLF49XfRMv2KMF .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-lrtLF49XfRMv2KMF .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-lrtLF49XfRMv2KMF .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-lrtLF49XfRMv2KMF #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-lrtLF49XfRMv2KMF .sequenceNumber{fill:white;}#mermaid-svg-lrtLF49XfRMv2KMF #sequencenumber{fill:#333;}#mermaid-svg-lrtLF49XfRMv2KMF #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-lrtLF49XfRMv2KMF .messageText{fill:#333;stroke:none;}#mermaid-svg-lrtLF49XfRMv2KMF .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-lrtLF49XfRMv2KMF .labelText,#mermaid-svg-lrtLF49XfRMv2KMF .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-lrtLF49XfRMv2KMF .loopText,#mermaid-svg-lrtLF49XfRMv2KMF .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-lrtLF49XfRMv2KMF .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-lrtLF49XfRMv2KMF .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-lrtLF49XfRMv2KMF .noteText,#mermaid-svg-lrtLF49XfRMv2KMF .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-lrtLF49XfRMv2KMF .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-lrtLF49XfRMv2KMF .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-lrtLF49XfRMv2KMF .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-lrtLF49XfRMv2KMF .actorPopupMenu{position:absolute;}#mermaid-svg-lrtLF49XfRMv2KMF .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-lrtLF49XfRMv2KMF .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-lrtLF49XfRMv2KMF .actor-man circle,#mermaid-svg-lrtLF49XfRMv2KMF line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-lrtLF49XfRMv2KMF :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 系统提示词 8,412 Token (68 个工具定义全量注入) cache_read = 0 前缀因租户 ID 变化而失效 历史累计 += 14,206 下一轮输入直接翻倍 loop 第 1 \~ 47 轮 第 18 轮遇到 429 重试一次,重发 42,940 Token 其中有效信息量约 2.4 万 Token 放大倍数 16.1× 提交批量任务(N=500) 请求(in = 8,412 + 累计历史) 输出 + 工具调用意图 调用 search_docs 返回 14,206 Token 全文 47 轮后完成,386,204 Token,¥812.6
"有效信息量 2.4 万 Token vs 实际消耗 38.6 万 Token"这个对比,是整次事故最刺眼的数字。 它不是模型贵,不是任务复杂------是同样的上下文被反复付费了 16 次。这正好呼应了摘要里那句话:Agent 的成本问题几乎都是"同样的答案被付费了不止一次"。
三、根因分析:六类放大因子,分层归因
3.1 增量成本拆解表(可核算)
把六个因子按"能独立核算"的原则拆开,加起来必须等于账单总增量。对不上就说明归因不完整:
| # | 放大因子 | 占比 | 增量 Token | 折算金额 | 定位手段 |
|---|---|---|---|---|---|
| R1 | 重试放大 | 31.2% | 10.34 亿 | ¥24,355 | 按 attempt > 0 聚合 |
| R2 | 上下文无界增长 | 24.0% | 7.96 亿 | ¥18,734 | 按 turn_index 分桶 |
| R3 | 缓存未命中 | 18.9% | 6.27 亿 | ¥14,753 | cache_read 对比基线 |
| R4 | 废弃的调试开关没关 | 13.2% | 4.38 亿 | ¥10,304 | 按 system_prompt_hash 分组 |
| R5 | 批量与交互混跑 | 8.6% | 2.85 亿 | ¥6,713 | 按 task_type 切片 |
| R6 | 系统提示词膨胀 | 4.1% | 1.36 亿 | ¥3,201 | 按 system_tokens 分位数 |
| --- | 合计 | 100% | 33.16 亿 | ¥78,060 | 对账误差 0 |
这张表是整篇文章的核心交付物。 它满足三个条件:① 每一类都能用一条 SQL 独立算出来;② 六类之间互不重叠(归因时按优先级去重,见下文);③ 合计等于账单增量。
#mermaid-svg-7K132DDyViM9zaJI{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7K132DDyViM9zaJI .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7K132DDyViM9zaJI .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7K132DDyViM9zaJI .error-icon{fill:#552222;}#mermaid-svg-7K132DDyViM9zaJI .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7K132DDyViM9zaJI .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7K132DDyViM9zaJI .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7K132DDyViM9zaJI .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7K132DDyViM9zaJI .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7K132DDyViM9zaJI .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7K132DDyViM9zaJI .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7K132DDyViM9zaJI .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7K132DDyViM9zaJI .marker.cross{stroke:#333333;}#mermaid-svg-7K132DDyViM9zaJI svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7K132DDyViM9zaJI p{margin:0;}#mermaid-svg-7K132DDyViM9zaJI .pieCircle{stroke:#000000;stroke-width:2px;opacity:0.7;}#mermaid-svg-7K132DDyViM9zaJI .pieOuterCircle{stroke:#000000;stroke-width:1px;fill:none;}#mermaid-svg-7K132DDyViM9zaJI .pieTitleText{text-anchor:middle;font-size:25px;fill:#000000;font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-7K132DDyViM9zaJI .slice{font-family:"trebuchet ms",verdana,arial,sans-serif;fill:#000000;font-size:17px;}#mermaid-svg-7K132DDyViM9zaJI .legend text{fill:#000000;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:17px;}#mermaid-svg-7K132DDyViM9zaJI :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 31% 24% 19% 13% 9% 4% 增量成本构成的六类归因 重试放大 31.2 上下文无界增长 24 缓存未命中 18.9 废弃调试开关 13.2 批量与交互混跑 8.6 系统提示词膨胀 4.1
归因去重的原则要说明白,否则六个因子会互相重复计算。我用的优先级是:
| 顺序 | 因子 | 去重逻辑 |
|---|---|---|
| 1 | R4 调试开关 | 先扣掉"根本不该存在"的 Token(它 100% 是浪费) |
| 2 | R3 缓存未命中 | 再扣掉"本可以只付 0.1 倍"的差额部分 |
| 3 | R1 重试放大 | 只统计 attempt > 0 的那次完整重发 |
| 4 | R2 上下文增长 | 只统计超出基线轮次(6 轮)之后的部分 |
| 5 | R5 批量混跑 | 只统计批量任务中"本可延后执行"的那部分 |
| 6 | R6 提示词膨胀 | 统计超出基线系统提示词长度的部分 |
💡 为什么要按这个顺序? 因为越靠前的因子越"纯粹是浪费" 。调试开关那 4.38 亿 Token 一分钱价值都没有,它排第一;缓存未命中那部分本来只要付 10%,它的浪费比例是 90%,排第二;重试虽然浪费,但至少有一部分重试是必要的 (真的瞬时抖动),所以排第三。按"浪费纯度"排序去重,能保证归因结果不会被质疑。
3.2 六类因子的分层归因:三个层次
六个因子分布在三个层次上,这个分层决定了它们的修复方式完全不同:
| 层次 | 因子 | 本质 | 修复手段 | 修复成本 |
|---|---|---|---|---|
| 配置层 | R4 调试开关、R6 提示词膨胀 | 该关的没关、该瘦的没瘦 | 改配置 / 删代码 | 极低(小时级) |
| 结构层 | R3 缓存未命中、R5 批量混跑 | 前缀结构错、资源没隔离 | 重构提示词布局 / 配额分区 | 中(天级) |
| 机制层 | R1 重试放大、R2 上下文增长 | 缺少预算与闸门 | 加预算、加压缩、加检测 | 高(周级) |
最反直觉的结论是:占比最大的两个因子(R1 31.2% + R2 24.0% = 55.2%)恰恰在修复成本最高的那一层。 而修复成本最低的配置层(R4 + R6)只占 17.3%。
这解释了一个常见现象:成本优化往往在第一周见效很快(砍配置),然后就进入平台期(因为剩下的都是机制问题)。我们这次也一样------D5 上线第一天就降了 58%,但剩下的 42% 花了整整 7 天。
🎯 我的拍板建议 :先砍配置层,因为它零风险且立刻见效,能为后续改造争取时间和信任 ;但不要停在那里 。把配置层的收益当作"融资",用来支撑机制层的改造排期。只砍配置不建机制,三个月后一定复发------因为下一次还会有人把调试开关带进生产。
3.3 R2:上下文无界增长是怎么发生的
这是六个因子里最"安静"的一个。它不报错、不告警、单点看每轮都很正常------只是第 20 轮的输入比第 1 轮大了 16 倍。
#mermaid-svg-RxFRNbAfyTTjCrRr{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-RxFRNbAfyTTjCrRr .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-RxFRNbAfyTTjCrRr .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-RxFRNbAfyTTjCrRr .error-icon{fill:#552222;}#mermaid-svg-RxFRNbAfyTTjCrRr .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-RxFRNbAfyTTjCrRr .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-RxFRNbAfyTTjCrRr .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-RxFRNbAfyTTjCrRr .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-RxFRNbAfyTTjCrRr .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-RxFRNbAfyTTjCrRr .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-RxFRNbAfyTTjCrRr .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-RxFRNbAfyTTjCrRr .marker{fill:#333333;stroke:#333333;}#mermaid-svg-RxFRNbAfyTTjCrRr .marker.cross{stroke:#333333;}#mermaid-svg-RxFRNbAfyTTjCrRr svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-RxFRNbAfyTTjCrRr p{margin:0;}#mermaid-svg-RxFRNbAfyTTjCrRr :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 单请求输入 Token 随会话轮次的增长(实测) 1 4 8 12 16 20 24 会话轮次 60000 55000 50000 45000 40000 35000 30000 25000 20000 15000 10000 5000 0 输入 Token
这条曲线的形状是"线性偏上翘",而不是指数 。原因值得说清楚:每一轮新增的输入 ≈ 上一轮的输出 + 本轮工具返回。而工具返回体积基本恒定(约 14k),所以增长是线性的;上翘的那部分来自"模型输出随上下文变长而变长"(它在复述历史)。
线性增长反而是最危险的 ,因为:① 它在前几轮完全看不出异常;② 平均值会被拉高的过程很平缓,不会触发任何阈值告警;③ 等你从监控上看见时,平均轮次已经从 6 涨到 22 了。
下面这个审计脚本能把它量化出来。它做的是"按轮次分桶,看每桶的平均输入",这是发现无界增长最直接的方法:
python
# context_audit.py ------ 上下文增长审计:把"感觉变长了"变成可核算的数字
from __future__ import annotations
import statistics
from collections import defaultdict
from dataclasses import dataclass
@dataclass(frozen=True)
class Turn:
trace_id: str
turn_index: int
in_tok: int
out_tok: int
tool_ret_tok: int # 本轮工具返回注入上下文的 Token 数
def audit(turns: list[Turn], *, baseline_turns: int = 6,
max_growth_ratio: float = 3.0) -> dict:
"""审计上下文增长。
baseline_turns: 业务上"合理"的轮次数,超出部分算异常增量
max_growth_ratio: 第 N 轮输入 / 第 1 轮输入的告警阈值
"""
buckets: dict[int, list[int]] = defaultdict(list)
for t in turns:
buckets[t.turn_index].append(t.in_tok)
curve = {k: round(statistics.mean(v)) for k, v in sorted(buckets.items())}
first = curve.get(1, 1) or 1
report = {"curve": curve, "growth": {}, "excess_tokens": 0, "alerts": []}
for k, v in curve.items():
report["growth"][k] = round(v / first, 2)
if v / first > max_growth_ratio:
report["alerts"].append(f"轮次 {k}: 输入膨胀 {v / first:.1f}× "
f"(阈值 {max_growth_ratio}×)")
# 异常增量 = 超出基线轮次之后的所有输入 Token
for t in turns:
if t.turn_index > baseline_turns:
report["excess_tokens"] += t.in_tok
# 工具返回放大倍数:工具返回体积 × 它之后的轮次数
by_trace: dict[str, list[Turn]] = defaultdict(list)
for t in turns:
by_trace[t.trace_id].append(t)
amplification = 0
for ts in by_trace.values():
ts.sort(key=lambda x: x.turn_index)
n = len(ts)
for i, t in enumerate(ts):
amplification += t.tool_ret_tok * (n - i - 1)
report["tool_amplification_tokens"] = amplification
return report
if __name__ == "__main__":
demo = [
Turn(f"trace-{i // 20}", (i % 20) + 1,
in_tok=3_200 + 2_400 * (i % 20),
out_tok=600, tool_ret_tok=14_206 if (i % 20) % 3 == 0 else 0)
for i in range(400)
]
r = audit(demo)
print("每轮平均输入:", r["curve"])
print("相对首轮倍数:", r["growth"])
print("超基线增量 Token:", f'{r["excess_tokens"]:,}')
print("工具返回放大 Token:", f'{r["tool_amplification_tokens"]:,}')
for a in r["alerts"][:3]:
print("告警:", a)
脚本里最有价值的不是 curve,而是 tool_amplification_tokens。 它算的是"工具返回体积 × 它之后还剩多少轮"------一个数就把 2.3 节那个"工具返回会乘以后续轮次"的效应量化了 。我们算出这个数是 9.7 亿 Token,占了上下文增长这一类的绝大部分。修复方向立刻明确了:不是要压缩对话历史,而是要截断工具返回。
3.4 R3:缓存为什么突然全不命中了
这是本次事故里技术含量最高、也最容易被忽略的一个根因。
上下文缓存(prompt caching)的工作方式是前缀匹配 :请求的前 N 个 Token 必须与之前某次请求逐字节相同,才能命中缓存。命中部分按远低于标准输入的价格计费(Anthropic 是基础输入价的 0.1 倍,OpenAI 在 GPT-5.6 及之后是标准输入的 10%,Gemini 的隐式缓存提供约 90% 折扣)。
8 月 11 日那次发布做了什么? 为了支持多租户个性化,把租户 ID 拼进了系统提示词的第一句:
text
❌ 8/11 之后(命中率 4.1%)
┌────────────────────────────────────┐
│ [当前租户 T-0147,时间 2026-09-03] │ ← 每个租户、每分钟都不同
│ 你是一个专业的分析助手...... │
│ ......68 个工具定义(8,412 Token)...... │
└────────────────────────────────────┘
↑ 前缀从这里就开始不同,后面全部无法命中
✅ 8/11 之前(命中率 78.3%)
┌────────────────────────────────────┐
│ 你是一个专业的分析助手...... │ ← 稳定前缀(所有租户共享)
│ ......68 个工具定义(8,412 Token)...... │
│ ───────── 以下为动态内容 ───────── │ ← 缓存边界
│ [当前租户 T-0147,时间 2026-09-03] │ ← 动态内容放最后
└────────────────────────────────────┘
只改了几十个 Token 的位置,就让后面 8,412 Token 的工具定义全部无法命中。 而且因为是"渐进上线",前两天的命中率还有 60% 多,等所有租户都切过去才掉到 4.1%------这也是为什么 D0 那天看账单只觉得"贵了",看不出是缓存问题。

图2:上下文缓存的三条前缀铁律------动态内容往前挪一格,后面几千 Token 全部失效
几个必须记住的事实(来自官方文档,会随模型变化):
| 供应商 | 缓存读取价 | 缓存写入价 | 默认 TTL | 最小可缓存前缀 |
|---|---|---|---|---|
| Anthropic Claude | 基础输入价 × 0.1 | ×1.25(5 分钟)/ ×2(1 小时) | 5 分钟 | 512~4,096(按模型) |
| OpenAI | 标准输入价 × 0.1(GPT-5.6+) | GPT-5.6+ 为 ×1.25 | 约 5~10 分钟,GPT-5.6+ 至少 30 分钟 | GPT-5.6+ 为 1,024 Token |
| Google Gemini | 隐式缓存约 9 折(90% off) | 标准输入价(显式缓存另计存储费) | 显式默认 60 分钟;隐式无存储费 | Gemini 3 系 4,096;2 系 2,048 |
⚠️ 两个最容易踩的坑 :① 缓存写入比普通输入贵 (Anthropic 1.25×),所以一次性请求不要开缓存 ------写一次没人读就是纯亏。Anthropic 官方给出的盈亏平衡点是"5 分钟缓存读 1 次回本、1 小时缓存读 2 次回本"。② TTL 是"最后一次读取后刷新"还是"创建后固定",各平台语义不同,Anthropic 是每次命中免费续期,这决定了你的批量任务间隔可以有多长。
四、修复方案
4.1 修复一:重试降噪(衔接第 37 篇)
R1 的 31.2% 里,绝大部分不是"必要的重试",而是"不该发生的重试"。分三类处理:
| 重试类型 | 占比 | 处理 | 依据 |
|---|---|---|---|
| 对 4xx 重试 | 41% | 直接禁止 | 第 37 篇硬规则:除 429/503/529/408 外一律不重试 |
| 任务层重试 | 33% | 改为请求层 | 任务层成本是请求层的 50 倍(第 37 篇 5.1) |
| 无预算的重试 | 26% | 加重试预算 3% | 第 37 篇 2.5 |
"任务层重试占 33%"这条最刺眼。 我们的批量通道在任务失败时会重跑整个任务------一个 500 条的任务跑完 47 轮后因为最后一条超时,于是从头再来一遍 。改成"分片 + 断点续跑"之后(第 30 篇 Checkpoint + 第 22 篇分片),失败重跑的成本从 50× 降到 1.2×。
🎯 我的拍板建议 :永远不要做任务层重试,除非任务本身幂等且无中间状态。 正确姿势是分片------把 500 条拆成 10 批,失败只重跑那一批。分片重试的成本永远低于整体重试,这是不需要测都知道的结论。
4.2 修复二:上下文预算与工具返回截断
针对 R2,做了三件事。按收益排序,第一件占了这一类的 71%:
python
# context_budget.py ------ 上下文预算:给"无界增长"装上闸门
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class ContextBudget:
"""上下文预算控制器。
三条策略,按触发顺序执行:
1. 工具返回截断(最优先,它占了增长量的绝大部分)
2. 历史窗口滑动(保留最近 N 轮,更早的做摘要)
3. 硬上限拒绝(兜底,正常不会触发)
"""
max_tool_return_tokens: int = 4_000 # 单次工具返回上限
window_turns: int = 8 # 保留的完整轮次数
hard_max_tokens: int = 48_000 # 硬上限
summarize_fn: object = None # 注入的摘要函数
def truncate_tool_return(self, text: str, est_tokens: int) -> str:
"""工具返回截断------保留头尾,中间用省略标记。
为什么保留头尾而不是只留头部:检索类工具的排序结果,
最相关的通常在最前;但"未命中 / 报错 / 总数"这类元信息
常常在最后。只截头会丢掉"共命中 1,204 条,已返回前 10 条"
这类让模型知道该换策略的关键信息。
"""
if est_tokens <= self.max_tool_return_tokens:
return text
head = int(len(text) * 0.6)
tail = int(len(text) * 0.15)
omitted = est_tokens - self.max_tool_return_tokens
return (f"{text[:head]}\n\n...[已省略约 {omitted} Token]...\n\n"
f"{text[-tail:]}")
def slide(self, history: list[tuple[str, str]]) -> list[tuple[str, str]]:
"""滑动窗口:超出部分压缩成一条摘要,而不是直接丢弃。"""
if len(history) <= self.window_turns:
return history
dropped = history[:-self.window_turns]
kept = history[-self.window_turns:]
if self.summarize_fn is None:
return kept
summary = self.summarize_fn(dropped)
return [("system", f"【历史摘要】{summary}")] + kept
def check(self, est_tokens: int) -> None:
if est_tokens > self.hard_max_tokens:
raise ContextBudgetExceeded(
f"上下文 {est_tokens} 超过硬上限 {self.hard_max_tokens}")
class ContextBudgetExceeded(Exception):
pass
truncate_tool_return 为什么保留头尾各一段,值得单独解释。 只截头部是常见做法,但会丢掉两类关键信息:① 检索结果的元信息("共 1,204 条,返回前 10 条")通常在尾部;② SQL 结果的最后几行常常是汇总行。丢掉这些,模型会以为"只有 10 条",从而做出错误的下一步决策------成本省了,质量塌了。
上线效果:search_docs 的平均返回从 14,206 Token 降到 3,880 Token,降 72.7%,而任务完成率只掉了 1.4 个百分点(抽样 500 条人工评估)。
4.3 修复三:前缀对齐,把缓存命中率拉回来
针对 R3。改动只有十几行,但它是单次收益最高的改动之一(¥14,753 / 18.9%)。
python
# prefix_align.py ------ 提示词前缀对齐:让缓存真正命中
from __future__ import annotations
import hashlib
from dataclasses import dataclass
@dataclass(frozen=True)
class PromptLayout:
"""提示词的四段布局------顺序是缓存命中的决定性因素。
stable_prefix : 所有请求共享、永不变化的部分(系统指令 + 工具定义)
semi_stable : 按租户/场景分组、变化缓慢的部分(领域知识)
boundary : 缓存边界标记
dynamic : 每次都变的部分(用户输入、时间、工具结果)
"""
stable_prefix: str = ""
semi_stable: str = ""
boundary: str = "\n--- 以下为动态内容 ---\n"
dynamic: str = ""
def render(self) -> str:
return (self.stable_prefix + self.semi_stable
+ self.boundary + self.dynamic)
def prefix_hash(self) -> str:
"""稳定前缀的哈希------用于监控"前缀是否被意外改动"。"""
return hashlib.sha256(
(self.stable_prefix + self.semi_stable).encode()
).hexdigest()[:16]
class CacheHitMonitor:
"""缓存命中率监控------命中率骤降必须告警。
为什么单独监控:命中率掉下去时,账单要 24 小时后才体现,
而功能一切正常(没有任何报错)。它是纯"沉默型"故障。
"""
def __init__(self, baseline: float = 0.75, drop_alert: float = 0.15):
self.baseline = baseline
self.drop_alert = drop_alert
self.read_tok = 0
self.fresh_tok = 0
def record(self, cache_read: int, fresh_input: int) -> None:
self.read_tok += cache_read
self.fresh_tok += fresh_input
@property
def hit_rate(self) -> float:
total = self.read_tok + self.fresh_tok
return self.read_tok / total if total else 0.0
def check(self) -> str | None:
if self.hit_rate < self.baseline - self.drop_alert:
return (f"缓存命中率骤降: {self.hit_rate:.1%} "
f"(基线 {self.baseline:.1%}),请检查提示词前缀是否变动")
return None
if __name__ == "__main__":
good = PromptLayout(
stable_prefix="你是一个专业分析助手。\n" + "工具定义......" * 200,
dynamic="[租户 T-0147] [2026-09-03] 用户问题:......",
)
m = CacheHitMonitor()
m.record(cache_read=8_412 * 100, fresh_input=600 * 100)
print("前缀哈希:", good.prefix_hash())
print("命中率:", f"{m.hit_rate:.1%}", "| 告警:", m.check())
prefix_hash() 这个方法是我这次事故后加的,它现在是我们 CI 的一项检查。 每次提示词模板变更,CI 会算出新旧前缀哈希并打印出来------如果哈希变了,PR 描述里必须写清楚"这次变更会影响缓存命中率"。这条规则实施后,我们再没有因为提示词改动而意外损失缓存。
4.4 修复四:关掉调试开关 + 系统提示词瘦身
R4 和 R6 属于"配置层",改动极小:
| 问题 | 修复 | 效果 |
|---|---|---|
DEBUG_VERBOSE=true 进生产镜像 |
构建时强制剔除 + 启动时断言 | 每次调用省 6,180 Token |
| 68 个工具定义全量注入 | 按场景动态装载(每场景 8~14 个) | 系统提示词 8,412 → 1,960 |
| 工具定义顺序不稳定 | 按名排序后注入 | 前缀恢复稳定 |
"启动时断言"这条值得单独讲。 光在构建时剔除还不够------因为下次还会有人加回来。我们在服务启动的 main() 里加了一组生产配置断言:
把事故金额写进断言消息里。 这不是为了好看------下次有人想临时打开它做排查时,会先看到这个数字,然后想别的办法。事故记忆写进代码,比写进文档有效得多。
python
# startup_guard.py ------ 启动断言:把事故记忆写进代码,而不是写进文档
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class Settings:
DEBUG_VERBOSE: bool = False
ENV: str = "prod"
def assert_production_config(settings: Settings) -> None:
"""生产配置断言------任何一条不满足都在启动阶段直接崩掉。
为什么用断言而不是配置校验:配置校验通常只记日志不阻止启动,
而这类开关一旦进了生产,损失是按月计的(本次 ¥10,304/月)。
"""
assert not settings.DEBUG_VERBOSE, (
"生产环境禁止开启 DEBUG_VERBOSE(曾造成 ¥10,304/月的损失)"
)
assert settings.DEBUG_VERBOSE is False or settings.ENV == "local", (
"调试开关仅允许在 local 环境开启"
)
if __name__ == "__main__":
s = Settings(DEBUG_VERBOSE=False)
s.ENV = "prod"
assert_production_config(s)
print("✅ 生产配置检查通过")
4.5 修复五:批量与交互分离(衔接第 40 篇)
R5。按第 40 篇 5.1 节的做法:批量与交互用独立的资源池 ,批量池设得很小(5 个槽)让它慢慢跑,且强制启用预算闸门。
4.6 上线前后对比
| 指标 | 8 月(基线) | 9 月(事故) | 11 月(优化后) | 相对事故月 |
|---|---|---|---|---|
| 月度账单 | ¥8,240 | ¥86,300 | ¥19,400 | -77.5% |
| 请求量(万) | 34.9 | 41.2 | 52.6 | +27.7% |
| 单请求平均 Token | 2,140 | 9,860 | 1,980 | -79.9% |
| 单请求平均成本 | ¥0.0236 | ¥0.2095 | ¥0.0369 | -82.4% |
| 缓存命中率 | 78.3% | 4.1% | 81.6% | +77.5pt |
| 重试率(占总调用) | 3.1% | 22.4% | 2.8% | -19.6pt |
| P99 单请求成本 | ¥0.0812 | ¥1.9400 | ¥0.1240 | -93.6% |
关于最后一列的"相对 8 月仍高"要解释一句 :11 月单请求成本 ¥0.0369 仍高于 8 月的 ¥0.0236,差额全部来自模型升级 (9 月起主力模型换成了更贵的档位,单价本身涨了约 40%)。同口径折算后(用 8 月的模型单价重算 11 月的用量),11 月单请求成本是 ¥0.0218,低于 8 月基线。 这个折算必须做,否则优化成果会被模型涨价掩盖。
P99 单请求成本从 ¥1.94 降到 ¥0.124,是这次优化里我最看重的一项。 平均值降下来容易,尾部降下来才说明机制真的生效了------因为成本失控永远是尾部造成的,平均值的改善可能只是"大量便宜请求稀释了少量贵请求"。

图3:六类优化手段的收益与改动量排序------先砍配置层融资,再建机制层防线
五、进阶防护:让它不再发生
5.1 成本可观测性:必须落库的字段
这次事故里最致命的不是任何一个根因,而是"我们花了 D0 到 D2 整整两天才定位" ------因为要什么数据没什么数据。下面这张表是我们事后补齐的字段清单,每一项都是当时"要查但查不到"的:
| 字段 | 为什么必须有 | 缺失后果 |
|---|---|---|
input_tokens / output_tokens |
分开设价,价格差 4~5 倍 | 看不清成本结构 |
cached_tokens |
缓存是成本第一杠杆 | 缓存失效完全不可见(本次踩中) |
attempt(重试序号) |
识别重试放大 | 重试量混入正常量 |
turn_index(轮次) |
识别上下文增长 | 无界增长完全不可见(本次踩中) |
task_type(交互/批量) |
支撑资源分离 | 混跑无法归因 |
system_prompt_hash |
识别提示词变更影响 | 变更与成本变化对不上(本次踩中) |
tool_name + tool_return_tokens |
定位"贵工具" | 只能归因到模型层 |
trace_id / request_id |
单请求下钻 | 无法复现单个贵请求 |
💡 其中
cached_tokens和turn_index是绝大多数团队缺的两个。 缺cached_tokens,缓存失效就是完全静默 的------功能正常、报错为零、只是账单变贵;缺turn_index,上下文增长同样是静默的。这两个字段是成本可观测性里性价比最高的两项,加它们各自不超过十行代码。
5.2 运行时成本闸门
有了数据,下一步是加闸门。闸门的语义必须分三档,不能只有"超了就失败":
python
# budget_gate.py ------ 运行时成本闸门:三档语义,不是一断了之
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass, field
from enum import Enum
class GateAction(str, Enum):
PASS = "pass" # 放行
SOFT = "soft" # 软闸门:注入提示让模型收尾
HARD = "hard" # 硬闸门:立即中断,转降级/人工
REJECT = "reject" # 准入拒绝:根本不开始
@dataclass
class Budget:
max_tokens: int = 200_000
max_rounds: int = 20
max_seconds: float = 180.0
soft_ratio: float = 0.75 # 75% 触发软闸门
reject_ratio: float = 1.0 # 100% 触发硬闸门
@dataclass
class Usage:
tokens: int = 0
rounds: int = 0
started: float = field(default_factory=time.monotonic)
@property
def seconds(self) -> float:
return time.monotonic() - self.started
class BudgetGate:
"""成本闸门。
三档语义的设计理由:
REJECT ------ 准入阶段,预计就超预算,别开始(省 100%)
SOFT ------ 已用 75%,注入"请收尾"提示,让它自然结束(保住输出可用性)
HARD ------ 已用 100%,立即中断(防止继续烧钱)
只有 HARD 没有 SOFT 的后果:每次中断都拿到半截结果,用户价值归零。
"""
def __init__(self, budget: Budget | None = None):
self.budget = budget or Budget()
self.usage = Usage()
self._lock = asyncio.Lock()
async def admit(self, estimated_tokens: int) -> bool:
"""准入:预估就超限则直接拒绝。"""
return estimated_tokens < self.budget.max_tokens * self.budget.reject_ratio
async def check(self, used: int) -> GateAction:
async with self._lock:
self.usage.tokens += used
self.usage.rounds += 1
b = self.budget
if self.usage.tokens >= b.max_tokens * b.reject_ratio:
return GateAction.HARD
if self.usage.rounds >= b.max_rounds:
return GateAction.HARD
if self.usage.seconds >= b.max_seconds:
return GateAction.HARD
if self.usage.tokens >= b.max_tokens * b.soft_ratio:
return GateAction.SOFT
return GateAction.PASS
def soft_prompt(self) -> str:
"""软闸门注入的收尾提示。"""
left = self.budget.max_tokens - self.usage.tokens
return (f"【系统提示】本次任务预算已使用 "
f"{self.usage.tokens / self.budget.max_tokens:.0%},剩余约 {left} Token。"
f"请立即停止调用新工具,基于已有信息给出结论,"
f"并明确列出未完成的核查项。")
async def run_with_gate(gate: BudgetGate, step_fn, max_steps: int = 100) -> str:
"""带闸门的执行循环------演示 SOFT 让模型收尾、HARD 立即中断。"""
final = ""
for _ in range(max_steps):
if not await gate.admit(8_000):
return "【已拒绝】预估超出成本预算,未开始执行"
used, text = await step_fn()
final = text
action = await gate.check(used)
if action == GateAction.SOFT:
# 注入收尾提示后,再给一轮机会让它自然结束
used, text = await step_fn(extra=gate.soft_prompt())
final = text
await gate.check(used)
return final
if action == GateAction.HARD:
return f"【已中断】预算耗尽,以下是已完成部分:\n{final}"
return final
SOFT 这一档是本次新增的,也是我认为最重要的设计。 我们 9 月之前的闸门只有 HARD------超了就直接 raise。后果是:每次中断,用户拿到的是一条异常而不是一个答案 。加上 SOFT 之后,模型在 75% 预算处收到"请收尾"的提示,它会基于已有信息给出一个带"未完成项说明"的结论------这个结论虽然不完整,但是可用的。
🎯 我的拍板建议 :软闸门的比例设 75%,不要设 90%。 90% 看起来更"省",但留给模型收尾的预算太少,它往往只能输出"我还没查完"------那等于没输出。75% 这个值是我们试了 70%/75%/85% 三档后定的,85% 时收尾质量明显下降。
#mermaid-svg-qVwmjqqDpJoe724k{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-qVwmjqqDpJoe724k .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-qVwmjqqDpJoe724k .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-qVwmjqqDpJoe724k .error-icon{fill:#552222;}#mermaid-svg-qVwmjqqDpJoe724k .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-qVwmjqqDpJoe724k .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-qVwmjqqDpJoe724k .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-qVwmjqqDpJoe724k .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-qVwmjqqDpJoe724k .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-qVwmjqqDpJoe724k .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-qVwmjqqDpJoe724k .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-qVwmjqqDpJoe724k .marker{fill:#333333;stroke:#333333;}#mermaid-svg-qVwmjqqDpJoe724k .marker.cross{stroke:#333333;}#mermaid-svg-qVwmjqqDpJoe724k svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-qVwmjqqDpJoe724k p{margin:0;}#mermaid-svg-qVwmjqqDpJoe724k defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-qVwmjqqDpJoe724k g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-qVwmjqqDpJoe724k g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-qVwmjqqDpJoe724k g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-qVwmjqqDpJoe724k g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-qVwmjqqDpJoe724k g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-qVwmjqqDpJoe724k .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-qVwmjqqDpJoe724k .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-qVwmjqqDpJoe724k .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-qVwmjqqDpJoe724k .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-qVwmjqqDpJoe724k .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-qVwmjqqDpJoe724k .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-qVwmjqqDpJoe724k .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-qVwmjqqDpJoe724k .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qVwmjqqDpJoe724k .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-qVwmjqqDpJoe724k .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qVwmjqqDpJoe724k .edgeLabel .label text{fill:#333;}#mermaid-svg-qVwmjqqDpJoe724k .label div .edgeLabel{color:#333;}#mermaid-svg-qVwmjqqDpJoe724k .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-qVwmjqqDpJoe724k .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-qVwmjqqDpJoe724k .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-qVwmjqqDpJoe724k .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-qVwmjqqDpJoe724k .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-qVwmjqqDpJoe724k .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-qVwmjqqDpJoe724k .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-qVwmjqqDpJoe724k #statediagram-barbEnd{fill:#333333;}#mermaid-svg-qVwmjqqDpJoe724k .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-qVwmjqqDpJoe724k .cluster-label,#mermaid-svg-qVwmjqqDpJoe724k .nodeLabel{color:#131300;}#mermaid-svg-qVwmjqqDpJoe724k .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-qVwmjqqDpJoe724k .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-qVwmjqqDpJoe724k .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-qVwmjqqDpJoe724k .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-qVwmjqqDpJoe724k .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-qVwmjqqDpJoe724k .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-qVwmjqqDpJoe724k .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-qVwmjqqDpJoe724k .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-qVwmjqqDpJoe724k .note-edge{stroke-dasharray:5;}#mermaid-svg-qVwmjqqDpJoe724k .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-qVwmjqqDpJoe724k .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-qVwmjqqDpJoe724k .statediagram-note text{fill:black;}#mermaid-svg-qVwmjqqDpJoe724k .statediagram-note .nodeLabel{color:black;}#mermaid-svg-qVwmjqqDpJoe724k .statediagram .edgeLabel{color:red;}#mermaid-svg-qVwmjqqDpJoe724k #dependencyStart,#mermaid-svg-qVwmjqqDpJoe724k #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-qVwmjqqDpJoe724k .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-qVwmjqqDpJoe724k :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 预估超预算(省 100%)
预估在预算内
PASS(继续)
已用 75%
注入收尾提示
给出带未完成项的结论
已用 100% 或轮次/墙钟超限
已完成部分 + 中断标记
准入检查
拒绝
执行
软闸门
收尾轮
降级输出
硬闸门
中断输出
这一档最容易被省略
省略后每次中断
用户都只拿到异常
中断必须留下已完成部分
不能返回空或半截 JSON
5.3 发布前门禁:影子账单对账
所有根因里,有三个(R3、R4、R6)都是"某次发布带进来的"。 所以真正的防护不在运行时,而在发布前。我们加了一道 CI 门禁:影子账单对账------用同一批线上真实请求回放新旧两版提示词/配置,比较预估成本。
python
# cost_gate.py ------ CI 门禁:影子账单对账(放回 CI 直接跑)
from __future__ import annotations
import json
import sys
def estimate(records: list[dict], price: dict) -> float:
"""按记录估算成本(元)。
price 形如 {"flagship": 210.0, "balanced": 62.0, "lite": 8.0}
单位:元 / 百万 Token;缓存段按 0.1 计,输出段按 4 倍计。
"""
total = 0.0
for r in records:
cached = r.get("cached_tokens", 0)
fresh = max(0, r.get("input_tokens", 0) - cached)
units = fresh + cached * 0.1 + r.get("output_tokens", 0) * 4.0
total += units / 1_000_000 * price.get(r["model"], 100.0)
return total
def gate(old_path: str, new_path: str, threshold: float = 0.10,
hit_rate_floor: float = 0.60) -> int:
"""影子账单对账:新版成本涨幅超阈值则 CI 失败。
threshold : 允许的单请求成本涨幅(默认 10%)
hit_rate_floor : 缓存命中率下限(默认 60%)
"""
price = json.load(open("price.json", encoding="utf-8"))
old = json.load(open(old_path, encoding="utf-8"))
new = json.load(open(new_path, encoding="utf-8"))
co, cn = estimate(old, price), estimate(new, price)
n = max(len(old), len(new))
per_old, per_new = co / max(len(old), 1), cn / max(len(new), 1)
delta = (per_new - per_old) / max(per_old, 1e-9)
hits = sum(r.get("cached_tokens", 0) for r in new)
inputs = sum(r.get("input_tokens", 0) for r in new)
hit_rate = hits / max(inputs, 1)
print(f"样本 {n} 条 | 旧版单请求 ¥{per_old:.6f} | 新版 ¥{per_new:.6f} "
f"| 变化 {delta:+.1%}")
print(f"新版缓存命中率: {hit_rate:.1%}(下限 {hit_rate_floor:.0%})")
failed = False
if delta > threshold:
print(f"❌ 成本涨幅 {delta:+.1%} 超过阈值 {threshold:.0%}")
failed = True
if hit_rate < hit_rate_floor:
print(f"❌ 缓存命中率 {hit_rate:.1%} 低于下限 {hit_rate_floor:.0%}")
failed = True
if not failed:
print("✅ 成本门禁通过")
return 1 if failed else 0
if __name__ == "__main__":
# 无参数时用内置样本自演示(CI 里传两个影子账单 JSON 路径即可)
if len(sys.argv) == 3:
sys.exit(gate(sys.argv[1], sys.argv[2]))
price = {"flagship": 210.0, "balanced": 62.0, "lite": 8.0}
json.dump(price, open("price.json", "w", encoding="utf-8"))
for name, cached_ratio in (("old.json", 0.78), ("new.json", 0.31)):
json.dump([{"model": "balanced", "input_tokens": 8_412,
"output_tokens": 600,
"cached_tokens": int(8_412 * cached_ratio)}
for _ in range(500)],
open(name, "w", encoding="utf-8"))
sys.exit(gate("old.json", "new.json"))
这道门禁在上线后的三个月里拦截了 4 次变更 ,其中两次是提示词改动导致前缀变化(命中率掉到 31% 和 44%),一次是有人往系统提示词里加了一整段示例(+2,400 Token)。它的价值不在于省了多少钱,而在于把"成本回归"变成了发布流程的一部分------成本从此不再只靠月度账单发现。

图4:成本防护的四道门禁------三成根因是发布带进来的,运行时闸门挡不住它们
5.4 常态化巡检:三个必看的成本指标
| 指标 | 正常范围 | 异常含义 | 检查频率 |
|---|---|---|---|
| 单请求成本 P50 / P99 | P99/P50 < 10 | 尾部失控(贵请求太多) | 每日 |
| 缓存命中率 | 与基线偏差 < 15pt | 前缀被破坏 / TTL 不匹配 | 每小时 |
| 重试率 | < 5%(Agent 场景 < 3%) | 上游不稳或配额不足 | 每日 |
"P99/P50 < 10"这个比值是我最推荐的一个健康度指标。 它比绝对值好用的地方在于:它不受模型涨价、业务量变化的影响------那些因素会同时抬高 P50 和 P99。只有"少数请求异常昂贵"才会让这个比值变大,而那正是需要排查的信号。
六、适用边界与风险提示 ⚠️
6.1 这套方法的适用与不适用
| 场景 | 是否适用 | 说明 |
|---|---|---|
| 已上线、有逐次调用明细的 Agent 服务 | ✅ 核心适用 | 四把切片刀直接可用 |
| 多租户 SaaS | ✅ 适用 | 租户切片是关键一刀 |
| 只有账单、没有调用明细 | ❌ 不适用 | 先补埋点,否则归因靠猜 |
| 原型阶段 / 日调用 < 1000 | ⚠️ 部分适用 | 只需缓存 + 上下文预算两条 |
| 单次调用、无循环的批处理 | ❌ 不适用 | 没有上下文增长与循环问题 |
| 自建/开源模型自建推理 | ⚠️ 部分适用 | 缓存与重试逻辑通用,单价模型不同 |
6.2 五个风险
⚠️ 风险一:缓存写入比普通输入贵。 Anthropic 的 5 分钟缓存写入是基础输入价的 1.25 倍,1 小时是 2 倍。一次性请求开缓存是纯亏。 官方给出的盈亏平衡:5 分钟缓存读 1 次回本,1 小时缓存读 2 次回本。
⚠️ 风险二:TTL 语义各平台不同。 Anthropic 的缓存命中会免费续期,OpenAI GPT-5.6+ 的缓存至少保留 30 分钟,Gemini 显式缓存默认 60 分钟且额外收存储费 (约 $1/百万 Token/小时)。批量任务的间隔如果超过 TTL,缓存就是白写。
⚠️ 风险三:成本优化可能伤害质量。 我们这次上线后任务完成率掉了 1.4pt,其中 0.9pt 来自工具返回截断。这个代价我们接受了,但它是真实的。 正确做法是每次成本优化都配一次质量回归(第 36 篇的评估集),只看成本数字的优化一定会越做越差。
⚠️ 风险四:缓存与数据隔离可能冲突。 如果把租户专属内容放进"稳定前缀"以提高命中率,可能造成跨租户的上下文复用 。虽然各家实现上缓存键包含组织维度,但共享前缀里绝不能放任何租户敏感内容------这一点在设计提示词布局时就要检查。
⚠️ 风险五:归因去重顺序会改变结论。 3.1 节的六类因子存在部分重叠(比如"重试"也会加重"上下文增长")。换一种去重顺序,各因子的占比会变,但总和不变。 所以对外汇报时用总和,内部分工时用单一因子的排序,不要混用。
6.3 与其他篇的分工
| 问题 | 归哪篇 |
|---|---|
| 重试与熔断策略本身 | 第 37 篇 |
| 配额分区、长短任务分离 | 第 40 篇 |
| 循环失控的通用检测与中断 | 第 46 篇 |
| 退化输出怎么给用户 | 第 38 篇 |
| 超时与幂等(中断后的状态一致性) | 第 39 篇 |
| 循环五约束的引擎层实现 | 第 26 篇 |
本文与第 46 篇的分工要说清楚 :本文解决的是"钱花多了"(成本归因与优化),第 46 篇解决的是"跑不完了"(循环检测与中断)。两者高度相关但视角不同------循环失控是成本失控的一个子集(本次事故里的 R1 + R2 有相当一部分就是循环导致的),但成本失控还有缓存、配置、隔离这些与循环无关的成因。
七、一个可复制的最小骨架
约 70 行,覆盖本文最高性价比的四件事:逐次记账、缓存命中监控、上下文预算、成本闸门。
python
# minimal_cost_control.py ------ 成本失控的四件套,约 70 行
from __future__ import annotations
import hashlib
import time
from dataclasses import dataclass, field
PRICE = {"flagship": 210.0, "balanced": 62.0, "lite": 8.0} # 元/百万 Token
@dataclass
class Ledger:
"""① 逐次记账:三段分开,缺一段就无法归因。"""
fresh_in = 0
cached_in = 0
out = 0
by_model: dict = field(default_factory=dict)
def record(self, model: str, in_tok: int, out_tok: int, cached: int) -> None:
self.fresh_in += max(0, in_tok - cached)
self.cached_in += cached
self.out += out_tok
d = self.by_model.setdefault(model, [0, 0, 0])
d[0] += max(0, in_tok - cached)
d[1] += cached
d[2] += out_tok
def cost(self, model: str = "balanced") -> float:
units = self.fresh_in + self.cached_in * 0.1 + self.out * 4.0
return units / 1_000_000 * PRICE[model]
@property
def hit_rate(self) -> float:
t = self.fresh_in + self.cached_in
return self.cached_in / t if t else 0.0
@dataclass
class Budget:
"""② 上下文预算 + ③ 成本闸门(含软闸门)。"""
max_tokens: int = 120_000
max_rounds: int = 15
max_seconds: float = 120.0
soft_ratio: float = 0.75
used_tokens: int = 0
rounds: int = 0
started: float = field(default_factory=time.monotonic)
def check(self) -> str:
if (self.used_tokens >= self.max_tokens
or self.rounds >= self.max_rounds
or time.monotonic() - self.started >= self.max_seconds):
return "hard"
if self.used_tokens >= self.max_tokens * self.soft_ratio:
return "soft"
return "pass"
def prefix_hash(stable: str) -> str:
"""④ 前缀哈希:提示词变更的 CI 检查依据。"""
return hashlib.sha256(stable.encode()).hexdigest()[:16]
if __name__ == "__main__":
led, bud = Ledger(), Budget()
for i in range(30):
led.record("balanced", in_tok=8_412, out_tok=600, cached=6_800)
bud.used_tokens += 9_012
bud.rounds += 1
if bud.check() == "soft":
print(f"第 {i + 1} 轮触发软闸门,注入收尾提示")
break
print(f"累计成本 ¥{led.cost('balanced'):.4f} | 缓存命中率 {led.hit_rate:.1%}")
print("前缀哈希:", prefix_hash("系统指令 + 工具定义"))
这四件套的优先级是:① > ③ > ② > ④。 记账(①)不做,后面三项全是盲的;闸门(③)不做,出事时没有任何止损;上下文预算(②)在会话型 Agent 上必需,单次调用型可以省;前缀哈希(④)在多租户、提示词频繁迭代的团队里价值最高。
八、总结
核心只有一句:Agent 的成本问题几乎从来不是"模型太贵",而是"同样的答案被付费了不止一次"------你要做的是找出重复付费发生的那一层。
六个最该记住的结论:
第一,拿到成本异常先拆"量"和"单价"。 本次请求量只涨 18%、单请求成本涨 8.87 倍------拆开之后,方向立刻明确,十分钟省两天。
第二,四把切片刀按"模型 → 租户 → 工具 → 时间"的顺序用。 时间切片要对齐发布记录 ------本次三个成本台阶正好对上三次发布。成本曲线的形状写着根因的类型:台阶状是变更引入的(好定位),斜坡状是渐进恶化的(要看分位数)。
第三,六个因子分层,配置层先砍、机制层后建。 占比最大的两个(重试 31.2% + 上下文 24.0%)在修复成本最高的那一层。先砍配置层融资,用它支撑机制层的排期;只砍配置不建机制,三个月必复发。
第四,缓存命中率是成本第一杠杆,也是最容易被引发变更悄悄破坏的一项。 把租户 ID 挪到系统提示词开头,几十个 Token 的位置变化,就让后面 8,412 Token 全部无法命中------功能一切正常,报错为零,只是账单变贵。 加一道 prefix_hash 的 CI 检查就能防住。
第五,工具返回的体积会乘以它之后的轮次数。 一个返回 14k 的工具,在 10 轮会话里贡献 140k 的输入。这是 Agent 成本模型与普通 API 最大的结构差异------截断工具返回,是本次单一收益最高的改动之一。
第六,闸门必须有软硬两档。 只有硬闸门,每次中断用户都只拿到一个异常;加上 75% 处的软闸门(注入"请收尾"提示),用户能拿到一个带"未完成项说明"的可用结论。这次事故里最贵的不是烧掉的钱,是那些被硬中断、一分钱价值都没留下的任务。
最后一点 :成本防护的真正战场在发布前,不在运行时。 六个根因里三个是某次发布带进来的,运行时闸门对它们无能为力。把成本回归变成 CI 的一道门禁,比任何事后优化都便宜。
落地自检清单(Checklist)
- ✅ 每次调用都记录了
input_tokens/output_tokens/cached_tokens三段 - ✅ 记录了
attempt(重试序号)与turn_index(轮次) - ✅ 记录了
task_type(交互 / 批量)与tool_return_tokens - ✅ 有
trace_id,支持单请求成本下钻 - ✅ 系统提示词满足前缀铁律:稳定内容在前、动态内容在后
- ✅ 一次性请求不开缓存(写入比普通输入贵)
- ✅ 有缓存命中率监控与骤降告警
- ✅ 工具返回有截断上限,且保留头尾
- ✅ 有上下文预算(工具截断 / 滑动窗口 / 硬上限)
- ✅ 成本闸门有 SOFT(75%)+ HARD(100%) 两档
- ✅ 批量任务强制启用预算 ,与交互任务资源分离
- ✅ 不做任务层重试,改为分片 + 断点续跑
- ✅ 重试有预算(Agent 场景 3%~5%)且只对 429/503/529/408
- ✅ 生产镜像剔除调试开关 ,且有启动断言
- ✅ CI 有影子账单对账门禁(涨幅 >10% 或命中率 <60% 即失败)
- ✅ 常态化看 P99/P50 成本比、缓存命中率、重试率
- ✅ 每次成本优化都配一次质量回归
常见问题(FAQ)
Q1:我们只有月度账单,没有调用明细,怎么开始?
先补埋点,别的都别做。 最小集是四个字段:input_tokens、output_tokens、cached_tokens、model。没有这四个字段,任何归因都是猜------你能说出的唯一结论是"变贵了"。补埋点通常一天工作量,能省掉后续两周的猜测。
Q2:缓存命中率多少算健康?
系统提示词 + 工具定义占输入大头时,70%~85% 是合理区间。 如果低于 50%,大概率是前缀里有动态内容(时间戳、租户 ID、随机数)。注意命中率天然上限:如果你的输入里 90% 是每轮都变的用户内容,那命中率上限就是 10% 左右,这种场景下缓存不是主要杠杆。
Q3:工具返回截断了会不会丢关键信息?
会,所以要保留头尾而不是只截头。 我们的做法是保留前 60% + 后 15%,中间用省略标记并标明省略了多少 Token------让模型知道"信息被截断了"这件事本身很重要,否则它会以为看到的就是全部。上线后完成率掉 1.4pt,是可接受的代价。
Q4:软闸门设 75% 还是 85%?
75%。 我们试过 85%,留给模型收尾的预算太少,输出质量明显下降(很多只是"我还没查完")。70% 太早 (浪费了 30% 的可用预算),75% 是质量与成本的平衡点。 如果你的任务平均轮次很短(< 5 轮),软闸门的意义不大,可以直接用 HARD。
Q5:账单和自己的记账对不上怎么办?
先查三类漏记 :① 重试(供应商计了、你没计);② 流式中断(非流式请求即使断开也会按完整生成计费 );③ 工具调用附加费。对账误差超过 2% 就应该告警------本次事故里,我们的记账比账单少 11%,差的正是那部分被硬中断但仍被计费的调用。
Q6:小团队需要全套吗?
不需要。按优先级只做三件 :① 补齐三段记账(一天);② 前缀对齐保缓存(半天);③ 加成本闸门(一天)。这三件覆盖了本次事故 60% 以上的损失。 影子账单门禁等团队有 3 人以上、月发布超过 10 次再做。
Q7:模型降价/涨价怎么纳入管理?
记账时保存原始 Token 量而不是金额 ,金额由"Token 量 × 当日单价表"算出来。这样模型调价时可以整体重算历史成本,避免"优化成果被涨价掩盖"(我们这次就遇到了------11 月单请求成本仍高于 8 月,差额全来自模型升级)。
Q8:为什么不做"更便宜的模型路由"?
做了,但它不在这次事故的归因里。 模型路由是单价优化 ,而本次问题是量的问题 (同样的答案付费了 16 次)。顺序是:先把量压下来,再谈单价。 在上下文放大 16 倍的情况下换小模型,只是把浪费从 ¥812 降到 ¥200------先解决重复付费,收益大得多。
参考资料
- OpenAI 官方定价页(缓存输入价、Batch API 折扣、长上下文计费档位):https://openai.com/api/pricing
- OpenAI Cookbook --- Prompt Caching 201 (缓存折扣幅度、命中令牌字段、延迟收益实测):https://developers.openai.com/cookbook/examples/prompt_caching_201/
- Anthropic 官方文档 --- Prompt caching (自动缓存、显式断点、TTL 与最小可缓存长度):https://platform.claude.com/docs/en/build-with-claude/prompt-caching
- Anthropic 官方文档 --- Pricing (缓存读写倍率 1.25× / 2× / 0.1× 与盈亏平衡说明):https://docs.anthropic.com/en/docs/about-claude/pricing
- Google Cloud 官方文档 --- Context caching overview (隐式缓存 90% 折扣、显式缓存存储费与 TTL 限制):https://cloud.google.com/gemini-enterprise-agent-platform/models/context-cache/context-cache-overview
- Google Cloud 官方博客 --- Save costs and decrease latency with Vertex AI context caching (缓存命中率优化实践):https://cloud.google.com/blog/products/ai-machine-learning/vertex-ai-context-caching/
- Anthropic 官方文档 --- Handling stop reasons (
max_tokens/pause_turn/model_context_window_exceeded的处理):https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons - Google SRE Book --- Handling Overload (重试放大与负载丢弃,本次 R1 归因的理论依据):https://sre.google/sre-book/handling-overload/
- LangGraph 官方文档 --- Graph API concepts (
recursion_limit默认 25 步与GraphRecursionError):https://github.com/langchain-ai/langgraph/blob/main/docs/docs/concepts/low_level.md