Token 账单翻了 10 倍:Agent 成本失控的排查过程与优化实录

摘要 :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------先解决重复付费,收益大得多。


参考资料

  1. OpenAI 官方定价页(缓存输入价、Batch API 折扣、长上下文计费档位):https://openai.com/api/pricing
  2. OpenAI Cookbook --- Prompt Caching 201 (缓存折扣幅度、命中令牌字段、延迟收益实测):https://developers.openai.com/cookbook/examples/prompt_caching_201/
  3. Anthropic 官方文档 --- Prompt caching (自动缓存、显式断点、TTL 与最小可缓存长度):https://platform.claude.com/docs/en/build-with-claude/prompt-caching
  4. Anthropic 官方文档 --- Pricing (缓存读写倍率 1.25× / 2× / 0.1× 与盈亏平衡说明):https://docs.anthropic.com/en/docs/about-claude/pricing
  5. Google Cloud 官方文档 --- Context caching overview (隐式缓存 90% 折扣、显式缓存存储费与 TTL 限制):https://cloud.google.com/gemini-enterprise-agent-platform/models/context-cache/context-cache-overview
  6. 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/
  7. Anthropic 官方文档 --- Handling stop reasons (max_tokens / pause_turn / model_context_window_exceeded 的处理):https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons
  8. Google SRE Book --- Handling Overload (重试放大与负载丢弃,本次 R1 归因的理论依据):https://sre.google/sre-book/handling-overload/
  9. LangGraph 官方文档 --- Graph API concepts (recursion_limit 默认 25 步与 GraphRecursionError):https://github.com/langchain-ai/langgraph/blob/main/docs/docs/concepts/low_level.md
相关推荐
泯泷43 分钟前
为什么 RL 比 SFT 更会"举一反三"?
人工智能·llm·agent
民乐团扒谱机1 小时前
Agent-Skills 新手入门与实战部署指南
人工智能·ai·agent·skill
XLYcmy3 小时前
ASR 识别任务:两篇论文阅读分析与系统方案技术报告 当前报告不足
论文阅读·论文笔记·token·cuda·asr·论文分享·tokenizer
深圳雨林凯AI3 小时前
商品详情长图的自动生成:版块模板、要素抽取与版式约束的实现思路
人工智能·ai
泯泷3 小时前
模型"缺课"了:为什么该补底座,而不是急着上 RL
llm·agent·强化学习
小林ixn3 小时前
别再全量塞历史了:用 Redis 给 Agent 装上「短期记忆」
redis·agent
初学AI的小高3 小时前
Agent 工具太多怎么办:BM25 检索、依赖补全与按需加载实战
后端·agent
发现你走远了4 小时前
05-权限不是开关:工具调用如何在放行、拦截与追问之间做决定
人工智能·agent·claude
泯泷4 小时前
RL 训练真正难的不是算法:环境、奖励与样本效率
llm·agent