省 token 不等于省钱:给 agent 成本做一次 cache 感知核算
团队里最容易踩的坑,是把 token 数当成本指标。有团队为了省钱挂了钩子压缩工具输出,压掉了 38.4% 的工具输出 token,账单反而涨了 6.8%;103 个任务逐条配对比较,token 减少和成本减少的相关性只有 r = 0.15,置信区间还跨过零。[1] 更贵的一课在正确性上:SWE-bench 的 Go 子集里,激进压缩把补丁可用率从 27/40 打到 15/40,原因是 SEARCH/REPLACE 的锚点被改写,模型以为自己在改已经不存在的旧代码。[1] 省 token 省到锚点上,等于把任务本身弄坏。
先看清账单的四个格子#
Anthropic 的计费口径把一次请求拆成四份:断点之后没进缓存的新输入按 1.0 倍算,写入缓存的按 1.25 倍算(1 小时 TTL 是 2 倍),从缓存读出来的按 0.1 倍算,输出照常计价。[3] 这里有个反直觉的地方:响应里的 input_tokens 只统计最后一个断点之后的内容,不是你以为的”总输入”。一个 10 万 token 的缓存前缀加一句 50 token 的提问,usage 里就是 read=100000、input=50。[3] 拿 input_tokens 乘单价估成本,会低得离谱。
四格中最沉的是缓存流量。那份论文复算了 2908 次按账单核对的运行,缓存创建加缓存读取占了四组件成本的约 87%,这也解释了为什么砍工具输出几乎不动账单。[1] 论文自己还留了个尾巴:按美元加权的残差有 8.7% 归因不了,在 Haiku 上这部分还随思考强度浮动。[1] 所以四组件能解释大头,但不是全部。你的账对不上时,先怀疑没被记录的思考流量,别急着推翻整个口径。还有一层容易忽略:agent 是闭环,模型发现证据被压掉了会重新检索、重读文件、多跑几轮诊断,这些新增轮次会把整个前缀再传一遍,把省下来的 token 又买回来。[1]
漏点常常是空闲,不是 token#
缓存有生存时间。Anthropic 默认 5 分钟,每次命中会免费刷新这 5 分钟,1 小时档要付 2 倍写入价。[3] 而 agent 的工作节奏天然打穿它:发一次请求,然后跑几十秒测试、等一次人工审批,等下一轮发出去时前缀已经被驱逐,于是按全价重新 prefill 一遍。[2] 各家驱逐点差得很远:Anthropic 在 300 到 360 秒之间直接掉崖(空闲基线 0/48 命中),DeepSeek 大约 10 分钟,OpenAI 要到 30 分钟左右才真正冷掉,Google 则始终不收敛,命中更像路由抽奖。[2]
对策很朴素:客户端保活。在空闲期按固定间隔重放一遍前缀,就能把命中率稳定在中位数 98% 以上,把等待之后那次请求的成本压到十分之一量级,最多差 12.5 倍。[2] 但间隔别抄那个 30 秒的惯例。保活成本随间隔单调下降,该论文实测给出的答案是取 TTL 之下最安全的最大值,5 分钟 TTL 就用大约 4 分钟;再往外,空闲超过约 46 分钟就跌破收益边界,保活不如让它冷掉重算。[2]
快速上手:三步把成本口径换成 cache 感知#
第一步先量,别先优化。下面这个脚本读 agent 的 JSONL transcript,按四组件算钱,顺手找出所有超过 TTL 的空闲断点:
#!/usr/bin/env python3
"""audit_agent_cost.py — 四组件口径核算 agent 成本
用法: python3 audit_agent_cost.py <transcript...> --rate-in 3 --rate-out 15 --ttl 300
"""
import argparse, glob, json
from datetime import datetime
def usage_of(rec):
if isinstance(rec.get("usage"), dict):
return rec["usage"]
msg = rec.get("message")
if isinstance(msg, dict) and isinstance(msg.get("usage"), dict):
return msg["usage"]
return None
def ts_of(rec):
t = rec.get("timestamp")
if not isinstance(t, str):
return None
try:
return datetime.fromisoformat(t.replace("Z", "+00:00")).timestamp()
except ValueError:
return None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("transcripts", nargs="+")
ap.add_argument("--rate-in", type=float, default=3.0) # $/百万 input token
ap.add_argument("--rate-out", type=float, default=15.0) # $/百万 output token
ap.add_argument("--ttl", type=int, default=300) # 缓存 TTL(秒)
ap.add_argument("--write-mult", type=float, default=1.25)
ap.add_argument("--read-mult", type=float, default=0.10)
a = ap.parse_args()
totals = dict(uncached=0, write=0, read=0, out=0)
turns, last, gaps, paths = 0, None, [], []
for pat in a.transcripts:
paths.extend(glob.glob(pat) or [pat])
for path in paths:
with open(path, encoding="utf-8", errors="ignore") as fh:
for line in fh:
try:
rec = json.loads(line)
except json.JSONDecodeError:
continue
u = usage_of(rec)
if not u:
continue
turns += 1
totals["uncached"] += u.get("input_tokens", 0) or 0
totals["write"] += u.get("cache_creation_input_tokens", 0) or 0
totals["read"] += u.get("cache_read_input_tokens", 0) or 0
totals["out"] += u.get("output_tokens", 0) or 0
t = ts_of(rec)
if t and last and t - last > a.ttl:
gaps.append(t - last)
if t:
last = t
unit = a.rate_in / 1e6
costs = {
"uncached": totals["uncached"] * unit,
"write": totals["write"] * unit * a.write_mult,
"read": totals["read"] * unit * a.read_mult,
"out": totals["out"] * (a.rate_out / 1e6),
}
total = sum(costs.values()) or 1e-9
print(f"turns={turns} files={len(paths)}")
for k, v in costs.items():
print(f" {k:<9} ${v:8.4f} {v / total:6.1%}")
print(f"total=${total:.4f}")
seen = totals["uncached"] + totals["write"] + totals["read"]
if seen:
print(f"cache_read_share_of_prompt={totals['read'] / seen:.1%}")
print(f"idle_gaps_over_ttl={len(gaps)} max={max(gaps) if gaps else 0:.0f}s")
if __name__ == "__main__":
main()
python3 audit_agent_cost.py ~/.claude/projects/<项目>/*.jsonl --rate-in 3 --rate-out 15 --ttl 300
第二步看结果做决定。如果 write 占大头,而 idle_gaps_over_ttl 不为零,要修的是节奏,不是提示词:把审批缓冲、长测试、人工等待收进同一轮,或者给这段空闲上保活。官方文档给的正规做法是 pre-warm:断点放在系统提示或工具定义的最后一块,启动时或按计划发一个占位请求,占位消息不会被回答,只付一次缓存写入,输出计 0。[3] 空闲经常超过 5 分钟就别硬撑 5 分钟档,换 1 小时 TTL,写入贵一倍,但省掉每次冷启动的重复写。[3]
第三步守住前缀的字节稳定,这一步最便宜也最容易被破坏。缓存键是断点之前内容的字节级匹配:改工具定义、开关 web search 或 citations、调整 thinking 或 effort 配置,都会让对应层及之后的缓存整体失效。[3] 所以系统提示里别塞时间戳、随机 ID、每次都变的仓库状态,同一条会话里保持工具集稳定。压缩也要按内容类型分流:回溯栈、测试输出、需要逐字节匹配的代码块默认保留,冗余的重复检索才压,交给其他程序消费的流不要碰。[1]
最后换掉考核指标。既然 token 减少不能预测成本减少,就该盯 cost per successful task:一次任务的总账单除以真正通过验收的次数,再配一个缓存命中率。[1] 保活还有一层要留个心眼:它对单个人是理性的,但所有人都这么做的时候,按读取计费的老机制会让整层缓存退化,论文判断 provider 迟早会改成按驻留时长收钱。[2] 到那天保活的价值要重算,在那之前,先把口径和节奏理清楚,这一轮的账单就能看懂了。
Sources#
[1] https://arxiv.org/html/2607.12161v3 — Token Reduction Is Not Cost Reduction (arXiv:2607.12161v3) [2] https://arxiv.org/html/2607.19214v2 — Keeping the Cache Warm Pays: Keepalive Economics for Agentic Workloads (arXiv:2607.19214v2) [3] https://platform.claude.com/docs/en/build-with-claude/prompt-caching — Prompt caching - Claude Platform Docs