合集

Claude Agent SDK 实战

已更新 27 章
阅读进度 70%

第 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):

text
subtype      success
turns        6
cost_usd     0.037716
input        3259
output       643
cache_read   7040
cache_create 0

拆开看:

python
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_tokens3,259这次请求里没命中缓存的输入
cache_read_input_tokens7,040命中缓存的前缀
cache_creation_input_tokens0这次写入缓存的部分
output_tokens643模型生成的 token
total_cost_usd0.037716CLI 估算,不是账单
num_turns6轮数

两个反直觉的点:

  • 只读任务也不便宜。 它要读 README、找 CLAUDE.md、确认测试文件;6 轮下来,不是“几分钱”。
  • 缓存读取占了大头。 7,040 个缓存 token 比 3,259 个非缓存输入还多;缓存不是“可有可无的优化”,是账单结构的一部分。

18.2 钱花在哪:输入、输出、缓存

一次请求的成本,大致由三块组成:

text
成本 ≈ 非缓存输入 × 输入单价
     + 缓存读取 × 缓存单价(通常更低)
     + 输出 × 输出单价

用这次的真实 token 数,做一个单价假设(只为看比例,不是端点报价):

python
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
    )

代进去:

text
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 章 baseline 7 轮,$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。轮数是你能直接控制的第一杠杆。

把成本写成粗略公式:

text
每轮输入 ≈ 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 不是“代码整洁”,是成本控制。更好的做法是工具内聚合:

python
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 走了缓存价”。

缓存的前缀结构

把一次请求想成一条从头到尾的序列:

text
[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 里怎么最大化命中

按“从稳定到易变”排:

  1. 工具面固定。 tools / allowed_tools / agents / mcp_servers 在一个会话里不要变;列表顺序也固定,别用 set 拼。
  2. system prompt 固定。 用 SystemPromptPreset + append,把项目约定放在 append;不要把日期、随机 ID、当前时间放进去。
  3. 项目设置固定。 setting_sources 显式写,并在会话内保持一致;skills / 插件不要每轮切。
  4. CLAUDE.md 短而稳定。 动态进度写 progress.md,不要写进 CLAUDE.md;否则第一条 user 消息每轮都变。
  5. 历史只追加。 不要编辑前面的消息;压缩放在里程碑,不要每轮压。
  6. 多轮用 ClaudeSDKClient。 一个进程、一个前缀;不要每轮新建 query()。
  7. 模型固定。 中途换模型会换缓存;要换就开新会话。
  8. 用 exclude_dynamic_sections=True。 如果系统提示里有日期/cwd/git 状态等动态段,考虑排除。
  9. 测量,不猜。 看 cache_read_input_tokens / cache_creation_input_tokens,不要看“感觉”。

把这几条落成代码,大概是这样:

python
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):

text
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 里放时间

python
system_prompt=f"You are a coding agent. Today is {datetime.now()}."

每次请求都变。它可能只影响 system 段的后缀,也可能让整段失效,取决于断点;不要靠猜。把日期放到 UserPromptSubmit 的 additionalContext 或任务尾部,system 保持稳定。

反例 2:每轮重建工具列表

python
tools=list({"Read", "Edit", "Bash"})   # 顺序不稳定

set 的顺序在不同进程/版本可能不同;工具段一变,后面的缓存都受影响。用固定列表:

python
tools=["Read", "Glob", "Grep", "Edit", "Bash"]

反例 3:每轮切 setting_sources

python
# 第一轮 ["project"],第二轮 [],第三轮 ["project"]

技能/CLAUDE.md/项目设置会变,前缀跟着变。一个会话内固定;要换,开新会话。

反例 4:每轮压缩/重写历史

压缩会改变消息段;每轮压缩等于每轮重建缓存。把压缩放在里程碑(第 7 章),不要每轮做。

正例:稳定前缀 + 追加历史

python
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 字,以为能省一大笔。

结果成本几乎没变。

因为系统提示只占输入的一小部分;真正的大头是历史重发和工具结果。压缩提示词,模型可能还要多跑几轮来补上下文,反而更贵。

省钱的顺序应该是:

  1. 减轮数:工具面、提示、Stop 条件,别让它反复试;
  2. 减工具:少一个工具,每轮少一份 schema;
  3. 减结果:工具输出截断/聚合,别把大文件塞回历史;
  4. 稳前缀:缓存命中,比“压缩提示词”更直接;
  5. 换模型:最后才考虑;能力下降可能让轮数反弹。

提示词当然要写清楚,但别把“短”当“省”。


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 估算,不是兼容端点的真实账单;缓存/计价口径以端点为准。