第 18 章
成本:把 token 花在刀刃上
10 轮 $0.17,6 轮 $0.04:钱花在哪?
先看三组真实数字。
第 10 章修 days_left:10 轮,cost=0.170644。
第 17 章评测里的 baseline:7 轮 $0.0492,9 轮 $0.0612;workflow 9 轮 $0.0740,11 轮 $0.0636。
第 18 章只读问答:6 轮,$0.037716。
同一个模型、同一个仓库,成本能差四倍。这一章不算“怎么让 token 变少”的玄学,只算清楚:钱花在哪,哪些地方值得省。
18.1 一次真实账单
ch18_cost.py 让 Agent 做一件只读的事:读 README,回答测试命令,不许改文件。
实际输出(2026-10-22,SDK 0.2.168 + CLI 2.1.296 + deepseek-flash):
subtype success
turns 6
cost_usd 0.037716
input 3259
output 643
cache_read 7040
cache_create 0拆开看:
if isinstance(msg, ResultMessage):
u = msg.usage or {}
print("turns", msg.num_turns, "cost", msg.total_cost_usd)
print("input", u.get("input_tokens"))
print("cache_read", u.get("cache_read_input_tokens"))
print("cache_create", u.get("cache_creation_input_tokens"))
print("output", u.get("output_tokens"))这段代码比“看账单”有用:它把一次运行拆成四个可比较的字段。cost 只有一个数,出问题时你不知道是输入、缓存还是输出;四个字段能告诉你钱花在哪。
| 字段 | 数值 | 含义 |
|---|---|---|
input_tokens | 3,259 | 这次请求里没命中缓存的输入 |
cache_read_input_tokens | 7,040 | 命中缓存的前缀 |
cache_creation_input_tokens | 0 | 这次写入缓存的部分 |
output_tokens | 643 | 模型生成的 token |
total_cost_usd | 0.037716 | CLI 估算,不是账单 |
num_turns | 6 | 轮数 |
两个反直觉的点:
- 只读任务也不便宜。 它要读 README、找
CLAUDE.md、确认测试文件;6 轮下来,不是“几分钱”。 - 缓存读取占了大头。 7,040 个缓存 token 比 3,259 个非缓存输入还多;缓存不是“可有可无的优化”,是账单结构的一部分。
18.2 钱花在哪:输入、输出、缓存
一次请求的成本,大致由三块组成:
成本 ≈ 非缓存输入 × 输入单价
+ 缓存读取 × 缓存单价(通常更低)
+ 输出 × 输出单价用这次的真实 token 数,做一个单价假设(只为看比例,不是端点报价):
def weighted(usage: dict) -> float:
# 假设:非缓存输入 1x,缓存读取 0.1x,输出 5x
return (
usage["input_tokens"] * 1
+ usage["cache_read_input_tokens"] * 0.1
+ usage["output_tokens"] * 5
)代进去:
3,259×1 + 7,040×0.1 + 643×5
= 3,259 + 704 + 3,215
= 7,178输出虽然只有 643 token,但单价高,权重和非缓存输入差不多;缓存读取量最大,但因为便宜,只占约 10%。
真实单价以端点为准。这个计算只说明一件事:不要只看 token 数量,要看“数量 × 单价”。
18.3 轮数是乘数
第 10 章那次:
- 10 轮,$0.1706;
- 第 17 章
baseline7 轮,$0.0492;9 轮,$0.0612。
同样是修测试,10 轮比 7 轮贵三倍多。为什么?
因为每一轮都要重发前面的历史。第 1 轮只有任务;第 10 轮有任务 + 9 轮工具结果 + 9 轮 assistant。轮数不是加法,是乘法。
第 17 章的两个配置更直观:
baseline 平均 8.0 轮、$0.0552;workflow 平均 10.0 轮、$0.0688。成功率一样,workflow 多 2 轮,成本高约 25%。
注意“成本/成功”这个指标:失败也要花钱,但分母只算成功。如果一个配置 2 次成功、2 次失败,成本/成功会翻倍。第 17 章没出现失败,所以这个指标这次没拉开差距。
先看轮数,再看 token。轮数是你能直接控制的第一杠杆。
把成本写成粗略公式:
每轮输入 ≈ system + tools + 历史
总输入 ≈ Σ(每轮输入) ≈ 轮数 × (固定前缀 + 平均历史)历史不是常数,它随轮数增长。7 轮和 10 轮的历史总量,不是 7:10,而是接近 28:55(1+2+…+7 vs 1+…+10)。再叠加工具结果,10 轮比 7 轮贵三倍并不奇怪。
所以“多跑两轮试试”不是小开销;它可能是账单翻倍的原因。
18.4 工具 schema 与系统提示
第 6 章算过:六个工具的 schema 是 1,680 字符,占那次请求的 80%。
这笔钱不是“用一次”,是每一轮都付。你跑 10 轮,就是 10 次。
工具结果更隐蔽。假设一个工具返回 8,000 字符的测试日志:
- 第 1 轮:8,000 字符进历史;
- 第 2 轮:再发一次;
- 第 10 轮:这份日志已经被重发 10 次。
所以第 4 章的 MAX_RESULT=8000 不是“代码整洁”,是成本控制。更好的做法是工具内聚合:
def run_tests(args):
r = subprocess.run(["python3", "-m", "unittest", "discover", "-s", "tests"], ...)
lines = (r.stdout + r.stderr).strip().splitlines()
# 只回最后 20 行 + 失败测试名,不回整段日志
return "\n".join(lines[-20:])工具返回的是“结论”,不是“原始日志”。模型不需要看完整堆栈,除非它要查。
算一笔“复利”:8,000 字符的日志,10 轮累计约 80,000 字符,按 4 字符/token 约 20,000 token;换成 1,000 字符摘要,10 轮只有 10,000 字符、约 2,500 token。
20,000 个输入 token,按每次重发算,足够让一个便宜任务变贵。工具内聚合不是“美观”,是“少发 9 次同样的日志”。
18.5 缓存:命中什么,为什么没命中
缓存不是一个“开关”,是前缀匹配。
这次只读问答 cache_read=7040,说明有 7,040 个 token 是从缓存读的;但它不代表“命中率 70%”,只代表“这些 token 走了缓存价”。
缓存的前缀结构
把一次请求想成一条从头到尾的序列:
[tools] → [system] → [messages...] → [当前任务]缓存按前缀匹配:前面的部分完全一样,后面的部分可以变。常见的断点在:
- 工具定义之后;
- system prompt 之后;
- 一段消息之后。
Anthropic 的 prompt caching 有几个关键事实(具体以文档/端点为准):
- 缓存有 TTL,通常几分钟;过期后要重新写;
- 前缀太短可能低于最小可缓存长度;
- 缓存按模型/端点隔离;换模型,缓存不通用;
- 缓存读取比普通输入便宜,但不是免费;写入也可能有额外成本。
Claude Agent SDK 不直接暴露 cache_control;断点由 CLI 管理。你能控制的是“喂给它的前缀稳不稳”。
什么会让缓存失效
先是工具段:tools 增删、顺序变化;然后是系统段:system prompt 里的时间戳、模型名、随机 ID;再往后是项目设置:setting_sources、skills、MCP 变化;CLAUDE.md 变化会影响第一条 user 消息;历史被编辑/压缩会影响消息段;换模型/端点则整个缓存不通用。
注意“影响哪段”不等于“整个缓存归零”。CLI 可能在多个位置打断点;你改了 system prompt 的尾部,前面的工具段可能仍然命中。
在 SDK 里怎么最大化命中
按“从稳定到易变”排:
- 工具面固定。
tools/allowed_tools/agents/mcp_servers在一个会话里不要变;列表顺序也固定,别用set拼。 - system prompt 固定。 用
SystemPromptPreset+append,把项目约定放在 append;不要把日期、随机 ID、当前时间放进去。 - 项目设置固定。
setting_sources显式写,并在会话内保持一致;skills/ 插件不要每轮切。 CLAUDE.md短而稳定。 动态进度写progress.md,不要写进CLAUDE.md;否则第一条 user 消息每轮都变。- 历史只追加。 不要编辑前面的消息;压缩放在里程碑,不要每轮压。
- 多轮用
ClaudeSDKClient。 一个进程、一个前缀;不要每轮新建query()。 - 模型固定。 中途换模型会换缓存;要换就开新会话。
- 用
exclude_dynamic_sections=True。 如果系统提示里有日期/cwd/git 状态等动态段,考虑排除。 - 测量,不猜。 看
cache_read_input_tokens/cache_creation_input_tokens,不要看“感觉”。
把这几条落成代码,大概是这样:
options = ClaudeAgentOptions(
setting_sources=["project"], # 一个会话内固定
system_prompt={ # preset + append,稳定
"type": "preset",
"preset": "claude_code",
"append": "This repo uses unittest; do not edit tests.",
},
exclude_dynamic_sections=True, # 去掉日期/cwd/git 状态
tools=["Read", "Glob", "Grep"], # 固定列表,不用 set
allowed_tools=["Read", "Glob", "Grep"],
)这不是“缓存开关”,是把前缀里容易变的东西挪走/固定住。剩下的交给 CLI 的断点。
一次对照:同会话 vs 改 system prompt
ch18_cache.py 做了两组:
stable:一个ClaudeSDKClient,同一个 system/tools,连续两轮;dynamic:每轮新建query(),system prompt 里带time.time_ns()。
实际输出(2026-10-22,SDK 0.2.168 + CLI 2.1.296 + deepseek-flash):
stable1 turns=6 cost=0.028381 in=1205 cache_read=8960 cache_create=0 out=595
stable2 turns=1 cost=0.036312 in=214 cache_read=3072 cache_create=0 out=213
dynamic1 turns=5 cost=0.031572 in=2903 cache_read=6912 cache_create=0 out=445
dynamic2 turns=6 cost=0.040595 in=3242 cache_read=7168 cache_create=0 out=712先别急着下结论。
反直觉的是:dynamic 也命中了缓存。 我本来想用时间戳制造“全 miss”,结果 cache_read 仍然是 6,912/7,168。
原因:CLI 的 base 前缀(工具/基础 system)在更前面,时间戳只改了 system prompt 的一小段后缀;前面的断点仍然命中。也就是说,缓存是分段/部分的,不是“改一个字就全废”。
这组数据也不是严格 A/B:轮数、查询内容不同,成本不能直接比。它只说明一件事:
不要用“时间戳一定让缓存全 miss”这种口诀做优化;看
cache_read/cache_create,看断点在哪。
四个实际例子
反例 1:system prompt 里放时间
system_prompt=f"You are a coding agent. Today is {datetime.now()}."每次请求都变。它可能只影响 system 段的后缀,也可能让整段失效,取决于断点;不要靠猜。把日期放到 UserPromptSubmit 的 additionalContext 或任务尾部,system 保持稳定。
反例 2:每轮重建工具列表
tools=list({"Read", "Edit", "Bash"}) # 顺序不稳定set 的顺序在不同进程/版本可能不同;工具段一变,后面的缓存都受影响。用固定列表:
tools=["Read", "Glob", "Grep", "Edit", "Bash"]反例 3:每轮切 setting_sources
# 第一轮 ["project"],第二轮 [],第三轮 ["project"]技能/CLAUDE.md/项目设置会变,前缀跟着变。一个会话内固定;要换,开新会话。
反例 4:每轮压缩/重写历史
压缩会改变消息段;每轮压缩等于每轮重建缓存。把压缩放在里程碑(第 7 章),不要每轮做。
正例:稳定前缀 + 追加历史
async def main():
client = ClaudeSDKClient(options=options) # 一个进程
await client.connect()
try:
await client.query("第一轮")
...
await client.query("第二轮") # 前缀不变,历史追加
...
finally:
await client.disconnect()工具/system/设置不变,历史只追加;第二轮的 cache_read 才有机会上来。
缓存不是“免费”,也不是目标
缓存读取更便宜,但不是零;缓存写入可能更贵;TTL 过期后要重写。
所以“最大化命中”不是目标,**最小化“成本/成功”**才是。一个缓存命中率高但多跑三轮的方案,可能更贵。
18.6 total_cost_usd 不是账单
ResultMessage.total_cost_usd 是 CLI 的估算值。
接兼容端点时,它可能:
- 按 Claude 的价目表估算,不是端点真实计费;
- 对缓存/推理 token 的计价口径不同;
costBasis标成unknown。
做同一端点、同一天的 A/B,可以只看相对差值;算“每成功任务成本”,可以,但要注明是估算;做财务账单/对账,不行,以端点账单为准。
第 17 章的 pilot 总成本约 $0.248;这是估算,不是账单。
18.7 预算刹车
max_budget_usd 是循环刹车,但它不是“实时熔断”:
- 它在响应后检查,不是每次 token 生成时检查;
- 一次超预算的响应可能已经发生;
- 要留余量,不要卡着预算跑。
我的默认:
- 单个 run:
max_budget_usd=0.3,留 50% 余量; - 整批评测:总预算 = 单 run 上限 × 运行数 × 1.5;
- 长任务:
max_turns和max_budget_usd同时设,别只设一个。
一个具体例子:你有 10 个任务 × 2 个配置 × 2 次 = 40 次运行。单次上限 $0.3,不代表总预算 $12;按实际平均 $0.06 估算,总预算约 $2.4,再留 50% 就是 $3.6。别用“单次上限 × 次数”做预算,那会高估。
预算不是“省钱开关”,是“防止跑飞的安全带”。
18.8 一次失败的省钱尝试
我曾经把系统提示从 400 字压到 100 字,以为能省一大笔。
结果成本几乎没变。
因为系统提示只占输入的一小部分;真正的大头是历史重发和工具结果。压缩提示词,模型可能还要多跑几轮来补上下文,反而更贵。
省钱的顺序应该是:
- 减轮数:工具面、提示、Stop 条件,别让它反复试;
- 减工具:少一个工具,每轮少一份 schema;
- 减结果:工具输出截断/聚合,别把大文件塞回历史;
- 稳前缀:缓存命中,比“压缩提示词”更直接;
- 换模型:最后才考虑;能力下降可能让轮数反弹。
提示词当然要写清楚,但别把“短”当“省”。
18.9 成本/成功,不是成本/任务
比较配置时,别只看“每次任务多少钱”,要看“每次成功多少钱”。
假设总成本都是 $0.20:成功率 100% 时,成本/成功 $0.20;50% 时 $0.40;25% 时 $0.80。
一个便宜但经常失败的配置,单次成本低,成本/成功可能更高。
第 17 章的 pilot 两个配置都是 2/2,所以成本/成功和平均成本一样;如果 workflow 是 2/3,它的成本/成功会更高。
报告时两个都给:成本/任务(用户每次要付多少)和成本/成功(完成一件事要付多少)。只看前者,会鼓励“便宜但失败”的配置。
18.10 一张默认成本配置
我的默认是:
max_turns=12–20:防跑飞;修测试一般 7–10 轮;max_budget_usd=0.3/run:留 50% 余量;tools只留只读三件套 + 必要写工具:每轮省 schema;- 工具结果只回最后 20 行/摘要:别把日志塞回历史;
- 子 Agent 只用于调研:隔离长过程;
- 缓存前缀 system/tools 稳定:提高命中。
这不是“最省”,是“不容易跑飞,也不至于为了省而牺牲成功率”。
18.11 Harness 视角:成本是控制面的结果
这一章的每个旋钮,都是 Harness 对上下文/工具/循环的控制:工具面决定每轮 schema,上下文裁剪决定历史大小,轮数/预算决定循环何时停,缓存决定前缀是否复用。
所以“省钱”不是换一个便宜模型,是调 Harness:少给工具、少塞结果、少跑轮次、稳住前缀。第 22–24 章的项目,会把这些旋钮落到具体配置。
18.12 下一章:时延
成本告诉你“花了多少钱”,时延告诉你“用户等了多久”。
第 19 章拆端到端时延:进程启动、首字、模型轮次、工具执行、审批等待;以及为什么“平均 10 秒”可能藏着 30 秒的长尾。
本章代码 成本测量:https://github.com/kevinkelin/claude-agent-sdk-book/blob/master/code/sdkagent/ch18_cost.py 实测环境:SDK 0.2.168 + CLI 2.1.296 +
deepseek-flash;结果为一次运行,不是统计。 说明:total_cost_usd是 CLI 估算,不是兼容端点的真实账单;缓存/计价口径以端点为准。