Harness Engineering

第20章 成本控制与性能优化:让 Agent 经济上可持续

作者 杨艺韬 · 12,279 字

第20章 成本控制与性能优化:让 Agent 经济上可持续

“Premature optimization is the root of all evil. But ignoring cost at scale is the root of all bankruptcies.” — 每一家 AI 创业公司 CFO 的心声

本章要点

  • 理解 Agent 作为”每次调用都花钱”的软件类别,它的成本结构和传统软件的根本差异
  • 拆解单次任务的成本组成:LLM 输入 / LLM 输出 / 缓存写入 / 缓存读取 / 外部 API(工具内)
  • 掌握 Token 级优化三大手法:System Prompt 精简、工具定义精简、工具结果截断
  • 学会分级模型路由(Cascade Routing):按任务风险、可恢复性和质量门槛选模型
  • 深入 Prompt Caching:缓存读写、TTL、层级命中和前缀稳定性
  • 对比 Anthropic vs OpenAI Prompt Caching 的协议差异
  • 搭建三级缓存架构:Prompt Cache / 工具结果缓存 / 语义缓存
  • 掌握三大延迟优化:流式输出、并行工具调用、预取
  • 学会构建多级熔断器:per-task / per-user / per-tenant / per-day 预算上限
  • 读懂 Cost-Quality-Latency Pareto 曲线,不同业务阶段有不同的最优决策点
  • 通过测算模板学习:如何把月度账单拆到可优化的工程动作

20.1 Agent 作为一类新型软件:成本结构的根本变化

在 Agent 出现之前,许多软件接口的边际成本接近于零

  • 1 个用户调用 API:主要花服务器、网络和存储资源
  • 1 万个用户调用:通常可以通过水平扩容摊薄单位成本
  • 100 万个用户:成本增长往往低于请求量增长

**“规模越大单位成本越低”**是 SaaS 行业的根基。

Agent 推翻了这个模型:

  • 1 个任务:按输入、输出、缓存、外部工具调用计费
  • 1 万个任务:如果不做缓存和路由,账单近似按任务数线性增长
  • 100 万个任务:任何小的 per-task 浪费都会被放大成显著开销

规模与成本更接近线性,不像传统 SaaS 那样天然获得规模红利。这对 Agent 产品的商业模式是根本性挑战。

20.1.1 单次任务成本解剖

拆解一次 Coding Agent 修 bug 任务,先不要急着填美元数,而是先把 token 账本拆清:

用户:"帮我修复 auth.ts 里的登录 bug"
────────────────────────────────────────────────────────────
LLM Call #1: 理解需求             input + output
Tool: Read auth.ts                本地工具,不直接向模型商付费
Tool: Read auth.test.ts           本地工具,不直接向模型商付费
Tool: Grep "login"                本地工具,不直接向模型商付费
LLM Call #2: 带文件内容分析       input 明显增大
Tool: Edit auth.ts                本地工具,不直接向模型商付费
LLM Call #3: 运行测试决策         input 继续携带历史
Tool: Bash "npm test"             本地工具,不直接向模型商付费
LLM Call #4: 测试失败分析修复     工具输出进入下一轮 input
Tool: Edit auth.ts                本地工具,不直接向模型商付费
LLM Call #5: 再次验证             input 包含前面轨迹
Tool: Bash "npm test"             本地工具,不直接向模型商付费
LLM Call #6: 总结回复             output 较小但仍计费
────────────────────────────────────────────────────────────
总成本 = input_tokens × input_price + output_tokens × output_price
       + cache_write_tokens × cache_write_price
       + cache_read_tokens × cache_read_price
       + server_tool_use × tool_price

几个观察:

1. 输入 token 经常是大头。因为每一步都要把完整对话历史、工具定义和工具结果带回 context。

2. 工具是”免费的诱饵”。本地执行 Read/Edit/Bash 本身零成本,但每次结果要塞进下一轮 LLM 的 input——实际上是前端”免费”、后端”加价”

3. 循环成本放大。测试失败触发新的分析轮次;如果没有 max_turns 和预算熔断,账单会跟着循环次数膨胀。

所以成本评估不要写成故事,要落到日志字段:每轮记录 modelinput_tokensoutput_tokenscache_read_input_tokenscache_creation_input_tokensweb_search_requeststool_round

20.1.2 成本优化的空间在哪里

graph TB
    Cost[单任务成本]
    Cost --> Input[Input tokens]
    Cost --> Output[Output tokens]

    Input --> SP[System Prompt<br/>重复发送 6 次]
    Input --> H[对话历史<br/>越来越长]
    Input --> TR[工具结果<br/>文件内容最占空间]

    SP --> Opt1[优化: Prompt Caching<br/>节省 90% SP 成本]
    H --> Opt2[优化: Context Compaction<br/>见 ch03]
    TR --> Opt3[优化: 工具结果截断<br/>见 ch07]

    Output --> Model[优化: 模型降级<br/>Opus→Sonnet 5× 便宜]

    style Opt1 fill:#10b981,color:#fff,stroke:none
    style Opt2 fill:#10b981,color:#fff,stroke:none
    style Opt3 fill:#10b981,color:#fff,stroke:none
    style Model fill:#3b82f6,color:#fff,stroke:none

本章就是把这四条优化路径讲透。先从最简单的开始:Token 级优化。

20.1.3 成本公式:直接看 Claude Code 源码

与其背文档、不如直接看 Claude Code(src/utils/modelCost.ts:131-142)如何在每个请求结束后算钱

function tokensToUSDCost(modelCosts: ModelCosts, usage: Usage): number {
  return (
    (usage.input_tokens / 1_000_000) * modelCosts.inputTokens +
    (usage.output_tokens / 1_000_000) * modelCosts.outputTokens +
    ((usage.cache_read_input_tokens ?? 0) / 1_000_000) * modelCosts.promptCacheReadTokens +
    ((usage.cache_creation_input_tokens ?? 0) / 1_000_000) * modelCosts.promptCacheWriteTokens +
    (usage.server_tool_use?.web_search_requests ?? 0) * modelCosts.webSearchRequests
  )
}

五项相加——input / output / cache_read / cache_write / web_search——没有第六项。Anthropic API 返回的 Usage 对象就这五个维度、SDK 把它们乘上单价求和——没有任何隐藏开销

把这 7 行代码刻在脑子里——它就是你的成本仪表盘底层。任何”Agent 月烧 $20k”的故事、拆解到最细粒度、都是这五项的累加。

20.1.4 Usage 字段从哪里来

Anthropic SDK 的 Messages.create() 响应里带 usage 字段(@anthropic-ai/sdk v0.30+)——每次请求都必送、不需要额外开关。

Claude Code 的 src/cost-tracker.ts:164-220 逐字段累加、按模型分桶(ModelUsage)、每个 session 聚合一次。这个数据结构Record<modelName, ModelUsage>就是你设计成本仪表盘表结构的参考蓝图——每行一个 model,五列一个 usage 维度、第六列 costUSD

20.2 Token 级优化:三把刀

20.2.1 第一刀:System Prompt 精简

System Prompt 在每次 LLM call 都会被重新发送。一个 5000 token 的 SP × 20 次 call = 100k token 的 SP 开销

精简 SP 的经验:

原则 1:去除”礼貌”冗余

❌ "You are a helpful, harmless, and honest assistant. Your goal is to assist
    the user with their coding tasks in the most efficient and friendly manner.
    Please be polite and professional at all times."

✅ 去掉全部。模型默认就是 polite & professional。

原则 2:合并同类约束

❌ "- Don't delete files without confirmation.
     - Don't run rm -rf commands.
     - Don't modify .git directory.
     - Don't push to main branch without approval."

✅ "- 任何破坏性或远程操作都需要用户确认。"

原则 3:用结构化提纲代替散文

❌ 200 字的段落描述 Read 工具的用法、注意事项、边界情况...

✅ Read(path, offset?, limit?): 读文件;大文件用 offset/limit

把冗长 system prompt 改成结构化提纲后,收益要用两条线同时验证:system_prompt_tokens 是否下降,golden set 成功率是否保持。只有这两条同时成立,prompt 精简才是真优化;否则只是把成本转嫁成质量风险。

20.2.2 第二刀:工具定义精简

Agent 的 tools 列表也会被全量发送给 LLM。每个 tool 有 name + description + parameter schema。40 个工具每个定义占 80-200 token,合计 3-8k token 会跟着每次 LLM call 发送。

// ❌ 冗长:120 tokens
{
  "name": "Read",
  "description": "This tool reads a file from the local filesystem. You can access any file directly by using this tool. It reads up to 2000 lines starting from the beginning of the file. When you already know which part of the file you need, only read that part. This can be important for larger files. Results are returned using cat -n format, with line numbers starting at 1."
}

// ✅ 精简:40 tokens
{
  "name": "Read",
  "description": "读文件。默认前 2000 行,大文件用 offset/limit。返回带行号。"
}

40 工具 × 省 80 token = 省 3.2k token / call

20.2.3 第三刀:工具结果的智能截断

工具结果(特别是 Read 大文件、Bash 命令的长输出)最容易吃 token。

// ❌ 不截断:返回一个 10000 行日志文件
async function Bash(command: string): Promise<string> {
  const { stdout } = await exec(command)
  return stdout  // 可能是 200k token
}

// ✅ 智能截断:只保留头尾
async function Bash(command: string): Promise<string> {
  const { stdout } = await exec(command)
  if (stdout.length > 20000) {
    const head = stdout.slice(0, 8000)
    const tail = stdout.slice(-8000)
    return `${head}\n\n... [truncated ${stdout.length - 16000} chars] ...\n\n${tail}`
  }
  return stdout
}

工具结果截断这一刀,在长任务里能砍掉 30-50% 的 input token。详细策略看 ch07 工具编排。

20.2.4 第四刀(隐性):让 reasoning 模型闭嘴

推理模型的隐藏 reasoning / thinking 也会进入计费口径,即使用户看不到完整推理文本。一次复杂调试如果放开 reasoning 预算,output 侧成本会明显上升;具体金额必须从 provider usage 字段和当前价格表计算。

对策——

  • 简单任务禁用 thinking(SDK 参数 thinking: { type: 'disabled' }
  • 复杂任务设 budget_tokens 上限(2000-5000)、不让模型跑飞
  • calculateUSDCostthinking_tokens 单独统计——没它你根本不知道钱花哪了

Claude Code 实测——src/cost-tracker.ts:268-269 的累加逻辑里、cache_read 和 cache_write 被单独记账、thinking 走的是标准 output_tokens 通道——合并计费——所以 output 异常高时、第一嫌疑人就是 reasoning 跑飞。

20.3 分级模型路由(Cascade Routing)

不是每个 LLM call 都需要最强模型。路由的前提不是”便宜模型总够用”,而是把任务按风险、可恢复性和验证方式分层:可自动验证的简单任务可以下探,安全关键和不可恢复任务不能冒险。

20.3.1 模型分层

以 Claude Code 本地快照中的 Claude 家族价目表为例:

下表数据直接取自 Claude Code 源码 ../claude-code-main/src/utils/modelCost.ts:35-87——非官方宣传口径、是 CLI 跑生产流量时使用的本地价目表:

模型Input $/MtokOutput $/MtokCache WriteCache Read能力层级
Haiku 4.5$1.00$5.00$1.25$0.10快速、简单
Sonnet 4.6$3.00$15.00$3.75$0.30均衡、主力
Opus 4.5/4.6$5.00$25.00$6.25$0.50复杂推理
Opus 4.6 Fast$30.00$150.00$37.50$3.00Fast Mode 加价 6×
Opus 4/4.1 (旧)$15.00$75.00$18.75$1.50历史遗留

关键观察——

  • Haiku 4.5 比 Opus 4.6 便宜 (输入 1vs1 vs 5)——不是传说中的 60×、杠杆没那么夸张
  • Opus 4.6 比 Opus 4/4.1 便宜 3×——Anthropic 把旗舰模型降价、拉低了”全 Opus 方案”的门槛
  • Cache Read 统一是输入的 10%Cache Write 是输入的 1.25×——这两个系数就是 Prompt Caching 的数学底层
  • Fast Mode 加价 ——不是”快一点多花一点钱”、是真的 6 倍——用户须明确开启(isFastModeEnabled() 判定、源码 src/utils/fastMode.ts

20.3.2 三种路由策略

策略 A:任务类型路由

def route_by_task_type(task: Task) -> str:
    if task.type in ['code_review', 'architecture_design', 'complex_debug']:
        return 'claude-opus-4-7'
    elif task.type in ['code_edit', 'bug_fix', 'testing']:
        return 'claude-sonnet-4-6'
    else:  # 'format', 'summarize', 'classify'
        return 'claude-haiku-4-5'

简单粗暴,但需要提前定义 task.type。适合任务场景有限的垂直 Agent(如纯 coding agent)。

策略 B:长度路由

def route_by_context_size(messages: list, max_tokens: int) -> str:
    total_tokens = count_tokens(messages)
    if total_tokens > 100_000:
        # 长 context 只有部分模型支持
        return 'claude-sonnet-4-6'
    elif total_tokens > 20_000 or max_tokens > 4_000:
        return 'claude-sonnet-4-6'  # 复杂任务
    else:
        return 'claude-haiku-4-5'   # 短任务

适合不知道任务类型但能看到 context 规模的场景。

策略 C:LLM 判定路由(Meta-Router)

async def route_with_meta_llm(task_description: str) -> str:
    # 用 Haiku 做路由判断(成本极低)
    routing_prompt = f"""
    任务: {task_description}

    评估这个任务的复杂度:
    - simple: 格式化、翻译、单文件小改动
    - medium: 多步骤、需要工具调用、bug 修复
    - complex: 架构设计、多文件重构、深度推理

    只回答一个词: simple/medium/complex
    """
    response = await haiku.complete(routing_prompt, max_tokens=5)
    complexity = response.strip().lower()

    return {
        'simple': 'haiku',
        'medium': 'sonnet',
        'complex': 'opus',
    }[complexity]

Router 自身也有调用成本。只有当”被正确降级节省的成本”长期大于”router 调用 + 误判补救 + 质量回归”时,Meta-Router 才成立。

20.3.3 路由策略的 Pareto 对比

graph TB
    subgraph "质量-成本 Pareto 前沿"
        All_Opus[全强模型<br/>成本最高<br/>质量上限]
        Task_Route[任务类型路由<br/>低风险降级<br/>质量接近]
        Size_Route[Size 路由<br/>规则简单<br/>容易误判]
        Meta_Route[Meta-Router<br/>动态判断<br/>需监控]
        All_Sonnet[全中档模型<br/>成本低<br/>复杂任务风险高]
    end

    style All_Opus fill:#ef4444,color:#fff,stroke:none
    style Task_Route fill:#10b981,color:#fff,stroke:none
    style Meta_Route fill:#10b981,color:#fff,stroke:none
    style All_Sonnet fill:#f59e0b,color:#fff,stroke:none
  • “全 Opus” 是质量天花板但成本爆炸
  • “全 Sonnet” 便宜但质量明显下降
  • Meta-Router 通常是 Pareto 最优——用最小代价实现最接近 Opus 的质量

这张图只是 Pareto 形状,不代表固定收益。真实项目要把每个点替换成自己的 cost_per_tasksuccess_rateescalation_rate

20.3.4 Fallback 机制

路由选错了怎么办?加 Escalation

async def execute_with_escalation(task, initial_model='sonnet'):
    response = await call_llm(task, model=initial_model)

    # 如果 Sonnet 做不出来(response 说"我不确定" / "需要更多信息"),升级到 Opus
    if response.confidence < 0.6 or "uncertain" in response.text.lower():
        response = await call_llm(task, model='opus')

    return response

代价:偶尔会付两次钱。但总期望成本仍然比全 Opus 低。

20.4 Prompt Caching:90% 折扣的魔法

这是 Agent 场景下杠杆最大的优化。Anthropic 和 OpenAI 都提供,原理类似但协议细节有差异。

20.4.1 Anthropic Prompt Caching

机制:用户显式标记一段内容可被缓存。第一次发送时付写入成本(1.25×),后续 5 分钟内再次发相同前缀 → 命中缓存 → 只付 0.1× 成本

response = await client.messages.create(
    model="claude-sonnet-4-6",
    system=[{
        "type": "text",
        "text": STATIC_SYSTEM_PROMPT,  # 3000 tokens
        "cache_control": {"type": "ephemeral"}  # 标记为可缓存
    }],
    messages=[{
        "role": "user",
        "content": TOOLS_DEFINITION + "\n\n" + user_message
    }]
)

第一次调用:3000 SP × 3/M×1.25=3/M × 1.25 = **0.01125**(写入溢价) 第二次调用:3000 SP × 3/M×0.1=3/M × 0.1 = **0.0009**(命中折扣) 无缓存时:3000 SP × 3/M=3/M = 0.009

一次 20 轮对话节省:

无缓存: 20 × 3000 × $3/M = $0.18
有缓存: $0.01125(第 1 次写) + 19 × $0.0009(后 19 次读) = $0.0284
节省 84%!

20.4.2 Prompt Caching 的层级命中规则

缓存是按前缀层级匹配的。举例:

SP + Tools + Msg1 + Resp1 + Msg2 + Resp2 + Msg3
↑     ↑       ↑       ↑
cache  cache   cache   cache    ← 四个可能的缓存断点

Anthropic 允许你标 最多 4 个 cache_control 断点。如果 Msg1 之前的部分没变,第 1 个断点就命中;Msg2 之前也一致就再命中第 2 个;以此类推。

最佳实践:在每个对话轮次之后打一个 cache_control 标记。这样长对话里每一轮的查询都能最大化利用之前的缓存。

20.4.3 OpenAI Prompt Caching

OpenAI 的机制稍有不同:

  • 不用显式标记 — 系统自动缓存长 prompt 的前缀
  • 前缀必须 ≥ 1024 tokens
  • TTL 更短(5-10 分钟),且按系统负载动态调整
  • 折扣率 50%(不如 Anthropic 的 90%)
  • 不需要付写入溢价

Anthropic 更适合”长 SP + 短用户输入”场景(编程 Agent 典型),OpenAI 更适合”任意结构但很长”的场景。

20.4.4 Cache 友好的消息结构设计

graph TB
    subgraph "❌ Cache 不友好:时间戳/随机内容在前"
        B1[SP: You are a helpful assistant<br/>当前时间是 2026-04-17 14:23:15<br/>今天天气晴]
        B2[User: ...]
        B1 --> B2
    end

    subgraph "✅ Cache 友好:静态在前、动态在后"
        G1[SP: You are a helpful assistant]
        G2[工具定义]
        G3[cache_control 断点]
        G4[Dynamic: 时间戳 / 用户 ID / session meta]
        G5[对话历史]
        G1 --> G2 --> G3 --> G4 --> G5
    end

    style B1 fill:#ef4444,color:#fff,stroke:none
    style G3 fill:#10b981,color:#fff,stroke:none

原则:越静态的内容放越前面。任何会变的内容都放在 cache_control 断点之后。

20.4.5 写入溢价 1.25× 与读取折扣 10%:精确数学

把前一节源码里的系数落实——

  • Cache WritepromptCacheWriteTokens = inputTokens * 1.25(Sonnet 33 → 3.75、Opus 4.6 55 → 6.25)
  • Cache ReadpromptCacheReadTokens = inputTokens * 0.10(Sonnet 33 → 0.3、Opus 4.6 55 → 0.5)

盈亏平衡点——若一段缓存要用 N 次才能回本:

N×0.10+1.25N×1.0N1.39N \times 0.10 + 1.25 \leq N \times 1.0 \Rightarrow N \geq 1.39

只要命中 2 次、缓存就赚。一次对话 10-20 轮——命中次数远超 2——收益空间巨大

20.4.6 5m vs 1h 两档 TTL 选择

如果 provider 支持多档 TTL,长 TTL 通常意味着更高写入成本或更严格的使用条件。不要默认”越长越好”。

使用判据——

  • 短 TTL——适合连续对话、IDE 循环和快速多轮任务,重点看前缀稳定性
  • 长 TTL——适合低频但重复查询的场景,如 daily ops agent,重点看写入溢价是否能被后续命中摊平

踩坑——把所有调用都加长 TTL,误以为”更长肯定更好”。TTL 选择本质上是对”下次请求何时来”的预测,必须用命中率和写入成本回算。

20.4.7 缓存被悄悄打破的四种典型场景

缓存命中要求字节级前缀相等——下列变化都会静默打破缓存、用户不会收到警告、只会看到账单涨:

  1. tools 列表顺序变了——registerTool 注册顺序不稳定(比如 for ... of Map 遍历顺序受 key 插入顺序影响)——命中率断崖下跌
  2. system prompt 里有时间戳——"当前时间是 ${new Date()}" 每次请求都变——零命中
  3. user_id 塞在 system prompt 里——多租户场景下人均独立前缀、无跨用户复用
  4. SDK 版本升级改了 tool schema 序列化——additionalProperties: false 从被省略变为显式输出——字节不同

防御——定期打印 cache_read_input_tokens / (cache_read + input_tokens) 的比值、异常下跌时第一时间报警

20.5 三级缓存架构

Prompt Caching 是应用 layer;再往下还有工具结果缓存语义缓存

graph TB
    subgraph "3 级缓存"
        L1[L1: Prompt Cache<br/>LLM provider 侧<br/>5 min TTL<br/>90% 折扣]
        L2[L2: Tool Result Cache<br/>本地 LRU<br/>60 s TTL<br/>零成本命中]
        L3[L3: Semantic Cache<br/>向量检索<br/>永久<br/>跨 session 复用]
    end

    Request[新请求]
    Request --> L3
    L3 --> |Miss| L2
    L2 --> |Miss| L1
    L1 --> |Miss| LLM[调用 LLM 付全价]

    style L1 fill:#3b82f6,color:#fff,stroke:none
    style L2 fill:#10b981,color:#fff,stroke:none
    style L3 fill:#f59e0b,color:#fff,stroke:none

20.5.1 L2:工具结果缓存

相同文件在短时间内被读多次——缓存:

import { LRUCache } from 'lru-cache'

const fileCache = new LRUCache<string, { content: string; mtime: number }>({
  max: 200,
  ttl: 60_000,  // 60 秒
})

async function readFileCached(path: string) {
  const cached = fileCache.get(path)
  const currentMtime = (await fs.stat(path)).mtime.getTime()

  // 用 mtime 做 staleness check
  if (cached && cached.mtime === currentMtime) {
    return cached.content
  }

  const content = await fs.readFile(path, 'utf-8')
  fileCache.set(path, { content, mtime: currentMtime })
  return content
}

mtime 检查——文件被修改后缓存失效。没这个的话 Agent 改了文件后 cache 还返回旧内容,会走进死循环。

20.5.2 L3:语义缓存

完全重复的查询之间可以直接返回历史答案:

class SemanticCache:
    def __init__(self, similarity_threshold=0.95):
        self.embeddings = VectorDB()  # 比如 Chroma / pgvector
        self.threshold = similarity_threshold

    async def get(self, query: str) -> Optional[str]:
        embedding = await embed(query)
        matches = await self.embeddings.search(
            embedding, k=1, threshold=self.threshold
        )
        if matches:
            return matches[0].answer
        return None

    async def put(self, query: str, answer: str):
        embedding = await embed(query)
        await self.embeddings.insert(embedding, answer)

语义缓存的 tricky point:命中判定阈值要高。0.95+ 以上才能基本保证语义等价。低了容易”答错”——两个看起来相似但细节不同的问题返回同一答案。

20.5.3 语义缓存的”假阳性”事故——一个真实教训

2024 年有一家 SaaS 把语义缓存阈值设到 0.85——以为”余弦 0.85 够近了”——结果:

  • 用户 A 问 “删除 配置文件 foo.yml”——Agent 真的删了
  • 用户 B 随后问 “备份 配置文件 foo.yml”——余弦 0.88 命中缓存——返回”文件已删除”——用户按这个回答发了 PR——生产事故

根因——嵌入向量对”动词”的区分度常常不够、尤其是”操作类动词”(删/改/读/写)——0.85 阈值在闲聊场景足够、在操作场景灾难

工程化对策——

  • 阈值按场景分层:问答 0.90、搜索 0.93、操作类 0.98 或直接禁用
  • 命中后校验 query 的动词集合与原 query 一致(用简单词表即可)
  • 命中答案附 cached: true 标记、前端 UI 明示——让用户知道自己看的是缓存

这个事故比任何论文都能让读者记住阈值的重要性——便宜的代价是正确性、语义缓存不是白拿的。

20.6 三大延迟优化

成本之外,用户感知延迟也是产品核心指标。TTFT(首 token 延迟)和总完成时间都要优化。

20.6.1 流式输出:TTFT 降 70%

非流式:
  用户等待总完成时间(30s)→ 一下看到全部结果

流式:
  用户等待首 token(1s)→ 持续看到 token 流出

TTFT 从 30s 降到 1s——用户体感速度提升 30 倍,即使总耗时不变。

for await (const chunk of agent.stream(userMessage)) {
  switch (chunk.type) {
    case 'text_delta':
      process.stdout.write(chunk.delta)  // 立即显示
      break
    case 'tool_use_start':
      ui.showToolRunning(chunk.tool_name)
      break
    case 'tool_result':
      ui.showToolComplete(chunk.tool_name)
      break
    case 'completion':
      ui.markDone()
      break
  }
}

20.6.2 并行工具调用

当 Agent 一次返回多个 tool_use(独立的),并行执行:

// Agent: 我需要读 a.ts、b.ts、c.ts
const tool_uses = response.content.filter(c => c.type === 'tool_use')

// 判断哪些可以并行(独立的工具)
const parallel_groups = identifyParallelizableGroups(tool_uses)

for (const group of parallel_groups) {
  const results = await Promise.all(
    group.map(tu => executeTool(tu.name, tu.input))
  )
  // ...
}

识别独立性的规则:

  • 都是 Read / Grep / Glob 等只读操作 → 可并行
  • 涉及 Write / Edit / Bash 的 → 只能串行(可能互相影响)
  • LangGraph 的 Send 原语 / OpenAI 的 parallel tool calls 都是这个机制的框架级实现

并行收益也不要直接写死。应该同时记录 getTotalDurationgetTotalAPIDuration:前者代表用户体感,后者代表累计 API 时间。并行工具调用通常能降低墙钟时间,但不会让所有 API 调用成本消失。

20.6.3 预取(Predictive Prefetch)

用户打字时、或 Agent 正在处理一步时,预测下一步可能需要什么

// 用户打开了 src/auth.ts
async function onFileOpened(path: string) {
  const imports = await parseImports(path)
  // 后台预读取依赖文件
  imports.forEach(dep =>
    readFileCached(resolveImportPath(path, dep))
  )
}

// Agent 正在思考时,预测它可能用什么工具
async function onAgentThinking(currentContext: AgentContext) {
  const predicted_tools = await predict(currentContext)  // Haiku 模型预测
  predicted_tools.forEach(tool => tool.warmup())
}

预取是双刃剑——做对了用户感到 Agent “神速”;做错了白白浪费资源。只在预测置信度高时才预取

20.7 多级熔断器

这是最重要的安全机制——没有熔断,一个 bug 或 prompt injection 能在几分钟内烧掉你几百上千美元。

20.7.1 四级熔断结构

graph TB
    Request[新 LLM Call]
    Request --> L1[Level 1: per-task<br/>单任务 ≤ $5]
    L1 -->|超限| Kill1[终止任务]
    L1 -->|过| L2[Level 2: per-user<br/>单用户日上限 $50]
    L2 -->|超限| Kill2[限制用户]
    L2 -->|过| L3[Level 3: per-tenant<br/>单租户日上限 $10000]
    L3 -->|超限| Kill3[冻结租户]
    L3 -->|过| L4[Level 4: global<br/>服务总预算日 $100000]
    L4 -->|超限| Kill4[全局降级]
    L4 -->|过| Continue[执行]

    style Kill1 fill:#f59e0b,color:#fff,stroke:none
    style Kill2 fill:#f59e0b,color:#fff,stroke:none
    style Kill3 fill:#ef4444,color:#fff,stroke:none
    style Kill4 fill:#ef4444,color:#fff,stroke:none

20.7.2 实现

class MultiLevelCircuitBreaker {
  async checkAndRecord(userId: string, tenantId: string, taskId: string, cost: number) {
    const task_spent = await this.redis.incrbyfloat(`cost:task:${taskId}`, cost)
    if (task_spent > 5) {
      throw new BudgetExceeded('Per-task limit exceeded', 'task', taskId)
    }

    const user_spent = await this.redis.incrbyfloat(
      `cost:user:${userId}:${today()}`, cost,
    )
    if (user_spent > 50) {
      throw new BudgetExceeded('Per-user daily limit', 'user', userId)
    }

    const tenant_spent = await this.redis.incrbyfloat(
      `cost:tenant:${tenantId}:${today()}`, cost,
    )
    if (tenant_spent > 10_000) {
      throw new BudgetExceeded('Per-tenant daily limit', 'tenant', tenantId)
    }

    const global_spent = await this.redis.incrbyfloat(
      `cost:global:${today()}`, cost,
    )
    if (global_spent > 100_000) {
      throw new BudgetExceeded('Global daily budget', 'global')
    }

    // 所有检查通过
  }
}

每次 LLM call 前调 checkAndRecord。命中任何一级限制就立即中断。

20.7.2.5 并发正确性——check-then-set 的坑

上面代码有个隐藏漏洞——incrbyfloat 原子、但 if (task_spent > 5) throw 的检查不是

  • 并发请求 A、B 同时发起、各自 incrbyfloat 后各自拿回 4.95.1
  • A 拿到 4.9、通过检查、继续执行;B 拿到 5.1、报错——但 A 实际消耗已经写进 Redis
  • 高并发下**预算超支 20-50%**是常态——用户抱怨”明明设了 5上限、实际花了5 上限、实际花了 6.3”

正确做法——用 Lua 脚本原子的 check-then-increment

-- budget_check.lua
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
local delta = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
if current + delta > limit then
  return {0, current}  -- 拒绝
end
redis.call('INCRBYFLOAT', KEYS[1], delta)
return {1, current + delta}  -- 通过

Lua 在 Redis 里整段原子执行——并发请求自动排队、不会超支。本书第 12 章”hyper-tower” 讨论过 tower RateLimit 中间件的实现——思路一致:把”读 + 判 + 写”合并成一个原子动作。

20.7.3 graceful degradation

熔断不一定要硬中断——可以降级

if (user_spent > 50) {
  // 1. 切到便宜模型
  request.model = 'haiku'
  // 2. 限制 max_tokens
  request.max_tokens = Math.min(request.max_tokens, 500)
  // 3. 禁用昂贵工具(web search 等)
  request.disabled_tools.push('web_search', 'code_execution')
}

这样不会完全拒绝用户,但后续成本不会继续飙升。

20.8 降本测算模板:从账单到工程动作

不要把没有一手来源的数字包装成案例。下面给的是测算模板:你需要把自己的 usage 日志填进去,再计算每一步的收益。

20.8.1 初始状态

字段从哪里取用来判断什么
月任务量任务表 / billing tag总规模
平均任务成本usage 聚合是否值得专项优化
input/output 占比provider usage优先压输入还是压输出
cache read/writeprovider usage缓存是否真的命中
按模型分布cost tracker路由是否有效
工具输出长度trace是否需要截断和摘要

20.8.2 优化路径

Step 1:Prompt Caching

在 SP 和工具定义后加 cache_control。上线后看 cache_read_input_tokens 是否上升,不能只看配置是否存在。

Step 2:SP + 工具定义精简

统计 system prompt 和 tool definitions 的 token 占比。删掉重复规则,把工具描述里的自然语言改成结构化约束。

Step 3:分级模型路由

引入 Meta-Router 前,先离线回放历史任务,标注”可降级但不影响结果”的类别;上线后监控升级率和回退率。

Step 4:工具结果截断 + 本地缓存

Bash 输出截断、文件 mtime 缓存、长文件按 offset/limit 读取。收益来自”少把无关内容塞回下一轮 input”。

20.8.3 成本趋势可视化

graph LR
    S0[初始账单]
    S1[+Prompt Cache]
    S2[+精简 Prompt/Tools]
    S3[+模型路由]
    S4[+截断/本地缓存]

    S0 --> S1
    S1 --> S2
    S2 --> S3
    S3 --> S4

    Total[汇总节省]

    S4 -.-> Total

    style S0 fill:#ef4444,color:#fff,stroke:none
    style S4 fill:#10b981,color:#fff,stroke:none
    style Total fill:#3b82f6,color:#fff,stroke:none

这四步的顺序有意安排:先做低风险、可观测的缓存和精简,再做会影响质量的模型路由。每一步都要有前后对比,不要把多项改动一起上线后再猜是哪一步省了钱。

20.9 成本仪表盘:看得见才管得住

┌─────────────────────────────────────────────────────────┐
│  Agent 成本仪表盘                     2026-04-17 14:32  │
├─────────────────────────────────────────────────────────┤
│                                                          │
│  本月累计成本:           $4,230  / 预算 $6,000 (70%)    │
│  本日累计成本:           $164                            │
│  当前 burn rate:         $0.18 / min                    │
│  预测月底总成本:         $5,490  ✅ 在预算内            │
│                                                          │
│  按模型分布                                              │
│    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━               │
│    Opus:     $1,820 (43%)    30% 任务 = $/task $0.28    │
│    Sonnet:   $1,770 (42%)    55% 任务 = $/task $0.15    │
│    Haiku:    $640  (15%)     15% 任务 = $/task $0.02    │
│                                                          │
│  按任务类型                                              │
│    Code Edit:     $1,580 (37%)                           │
│    Bug Fix:       $890  (21%)                           │
│    Code Review:   $720  (17%)                           │
│    Documentation: $410  (10%)                           │
│    Other:         $630  (15%)                           │
│                                                          │
│  成本异常 (本月)                                         │
│    ⚠️ task-9821: $18.3 (平均 61×) — 死循环触发 max-turns │
│    ⚠️ user-2341: $127 日消耗 (平均用户 8×) — 已自动限速 │
│                                                          │
│  Prompt Cache 命中率                                     │
│    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━               │
│    SP 缓存命中:   92%  ✅ 优秀                          │
│    Tools 缓存:    87%  ✅ 优秀                          │
│    历史对话缓存:  41%  ⚠️ 可优化(考虑更多 checkpoint) │
│                                                          │
└─────────────────────────────────────────────────────────┘

每个指标都有阈值告警。异常项自动列出——运营人员不用猜”哪里出了问题”。

20.10 本章小结

Agent 成本控制是产品能不能经济上可持续的关键:

  • Agent 成本结构:每次调用花钱、规模红利弱、输入 token 经常是大头
  • Token 三刀:SP 精简、工具定义精简、工具结果截断
  • 分级模型路由:简单任务下探,复杂任务保守;Meta-Router 必须用本地数据证明 ROI
  • Prompt Caching:关注稳定前缀、cache read/write 账本和 TTL 选择
  • 三级缓存架构:Prompt Cache(LLM 侧)+ 工具结果缓存(本地 LRU)+ 语义缓存(向量)
  • 延迟三招:流式输出、并行工具调用、预取
  • 多级熔断:per-task / per-user / per-tenant / global 四道防线;支持 graceful degradation
  • 降本模板:缓存、精简、路由、截断分步上线并逐步量化
  • 成本仪表盘:可视化 + 自动告警

下一章是全书收官——我们把前 20 章的所有设计模式和架构决策汇总成”Harness Engineering 的 12 条原则”,让你带一张地图出门。

20.11 五本姊妹书如何呼应本章

本章谈的”Agent 成本”不是孤立话题、它和本系列另外几本书各有接口:

  • 《Claude Code 源码》第 11 章 “Cost Tracker”——讲 src/cost-tracker.ts 323 行如何实现”每 session 多模型聚合”——本章的仪表盘原型就是那章代码的产品化
  • 《hyper-tower》第 12 章 “RateLimit”——tower 的 rate-limit middleware 是 §20.7.2.5 Lua 原子检查的 Rust 实现对应——协议不同、算法同
  • 《LangGraph 源码》第 13 章 “Streaming”——checkpoint + 增量重放避免”每次 LLM call 都重发完整历史”——直接压低 §20.1.1 的 55k input token
  • 《Vite 源码》第 8 章 “Optimizer”——lockfileHash + configHash 双键缓存、和 §20.4.7”缓存被打破的四种场景”同源——都是”前缀稳定性 → 命中率”
  • 《MCP 协议》第 20 章 “Build Server”——_notifyToolListChanged 100ms debounce——本质上是”把多次事件合并成一次 LLM call”、间接省钱

五本书交叉点——缓存前缀稳定、批量合并、原子化资源账本——是 Agent 系统通用的”三驾马车”。

20.12 你能带走的三张清单

清单 A:首次接手 Agent 项目的 7 项成本体检

  1. Anthropic usage dashboard 上月单日成本曲线有没有尖刺?
  2. cache_read_input_tokens / (cache_read + input_tokens) 近 7 日比值是否 ≥ 60%?
  3. 前 5% 用户消耗占比?>30% 说明缺 per-user 熔断
  4. 单任务 max_turns 有没有硬上限?没有 → 第一周就装
  5. 有没有任务曾经 > $5?若有→ §20.7 熔断直接加
  6. Haiku 用量占比 < 5%?→ 路由策略可以做、下一个 sprint 的事
  7. 工具结果 > 20k token 频率?高 → §20.2.3 截断马上做

清单 B:Prompt Caching 落地 7 步

  1. 确认 SDK 版本 ≥ 支持 cache_control 的版本
  2. 把 SP 从”段落散文”改成”结构化条目”——便于比对
  3. 在 SP 末尾加 cache_control: { type: 'ephemeral' }
  4. 在 tools 定义末尾再加一个 cache_control(断点 2)
  5. 在对话轮次结束后加第三个(断点 3)
  6. 部署一周、观察 cache_read_input_tokens 是否占 input 60%+
  7. 若低于 50%——回看 §20.4.7 的四种打破场景、逐条排查

清单 C:成本异常 5 分钟排查法

  1. 先看 cache_read 占比——忽然跌 20% 以上→缓存前缀被破坏
  2. 再看 thinking_tokens——占 output 超 30% → reasoning 跑飞、没上 budget
  3. 第三看 max_turns 触发率——超 5% → Agent 进入死循环
  4. 第四看 单任务分布 P99 / P50 比值——> 10× → 少量任务在吃掉大部分钱
  5. 第五看 tool_result 字节分布——P99 > 50k → 截断规则失效

带走这三张清单、比读完本章任何一段都重要

20.13 把成本观念内化到开发节奏里

最后——请把”看账单”当成每周例会的固定议程:每周一 15 分钟——成本负责人报一次上周数据(按模型、按任务类型、按租户的三视图)——发现任何 >20% 环比变化、当周定位。

这是最低成本的治理方式——不需要任何新工具、新人员——只需要把数据变成对话的一部分

成本不是优化出来的,是治理出来的——看得见、讨论得到、才会被当回事

20.14 Claude Code 的 state.ts:单机账本的工业级写法

前面数次引用 src/bootstrap/state.ts——这个 900 行的模块就是 Claude Code 的”单机账本”。核心是 addToTotalCostStatestate.ts:557-564):

export function addToTotalCostState(
  cost: number,
  modelUsage: ModelUsage,
  model: string,
): void {
  STATE.modelUsage[model] = modelUsage
  STATE.totalCostUSD += cost
}

三个值得学的细节——

  • modelUsage[model] = modelUsage 直接赋值而非累加——因为 modelUsage 对象本身在上游已经累加过(cost-tracker.ts:206-207)、这里只是存最新快照——职责分离、不重复劳动
  • STATE.totalCostUSD += cost——单变量累加、无并发问题(Node.js 单线程)——但多进程 Agent 必须换成 Redis(本章 §20.7.2.5)
  • 全局 STATE 对象——整个 process 共享——resetCostState() 在测试里清零、setCostStateForRestore() 在 session 恢复时回填——“可测试 + 可恢复”是账本模块的双重要求

把这三条搬到你自己的 Agent——一天之内就能搭出最小可用成本账本

20.15 为什么 getTotalDuration = Date.now() - startTime——不是累加 API 时间

state.ts:573-575

export function getTotalDuration(): number {
  return Date.now() - STATE.startTime
}

getTotalAPIDuration 的区别——

  • getTotalDuration——墙钟时间(user 从敲回车到看到最终结果)
  • getTotalAPIDuration——累加每次 LLM API 调用耗时

为什么要区分?——并行工具调用场景下两者差异巨大

  • 串行调 3 个工具、每个 1s——getTotalAPIDuration = 3sgetTotalDuration ≈ 3s
  • 并行调 3 个工具、每个 1s——getTotalAPIDuration = 3sgetTotalDuration ≈ 1s

业务侧关心墙钟时间(用户体感)、成本归因关心 API 时间(真钱花了多久)——一个模块同时暴露两者、让不同角色各取所需

这呼应 §20.6.2 并行工具调用——只有同时看这两个指标、才能量化”并行优化带来的体感提升”

20.16 Fast Mode 的加价结构值得单独分析

src/utils/modelCost.ts:60-67COST_TIER_30_150——Opus 4.6 Fast Mode——30输入/30 输入 / 150 输出、比非 Fast 贵 6×。

工程含义——源码只能证明 Fast Mode 走不同成本档,不能证明底层调度为什么这样定价。对 Harness 来说,关键是把 Fast Mode 当成显式预算开关:

  • 只有用户体感强相关的路径才允许开启,例如 inline completion 或强交互 UI。
  • 成本仪表盘必须按 speed 或等价字段切片,否则 Fast 流量会混在普通模型成本里。
  • SDK 配置不能全局默认 Fast;每次开启都要有调用点和业务理由。

工程决策——

  • 交互式 tab completion、inline suggest——值得 6×(用户体感价值 > 6× 成本)
  • batch 异步任务(如凌晨跑 1 万个 PR review)——不该用 Fast——把账单从 600打成600 打成 3600 纯浪费

Claude Code 的判定src/utils/fastMode.ts)——只有当用户在交互式终端显式开启 Fast Mode、且当前会话处于”主循环等待输入”时、才切到 Fast Mode 计费通道——默认不开、避免意外溢出

20.17 成本数据如何进 OpenTelemetry

本书第 19 章”可观测性”讲过 OTel——Agent 成本也可以以 metric 形式送进去:

const cost_counter = meter.createCounter('agent.llm.cost.usd', {
  description: 'LLM cost in USD',
  unit: 'USD',
})

// 每次 LLM 调用后
cost_counter.add(cost, {
  model: resolvedModel,
  task_type: task.type,
  tenant: currentTenant,
  cache_hit: usage.cache_read_input_tokens > 0,
})

关键是 label 设计——不要把 user_id 当 label(cardinality 爆炸、Prometheus 存储崩)——tenant / task_type / model / cache_hit 四维就够、组合不超过几百——Grafana 一张饼图搞定

本章 §20.9 的仪表盘每个 widget 都能用一个 PromQL 查询表达——这就是”从本地账本到分布式观测”的升级路径

20.18 三本书合读:为什么成本问题最终变成调度问题

本章、langgraph 书第 18 章”Design Patterns”、claude-code 书第 12 章(主循环)——合起来讲的是同一件事的三个侧面

  • 本章——钱从哪里流出去
  • langgraph 第 18 章——节点如何编排、checkpoint 如何切断重放
  • claude-code 第 12 章——主循环如何决定”下一步调哪个模型、调几轮”

三章的最大公约数——“Agent 的经济学本质上是调度问题”——每一次”是否继续调 LLM”的决策、都是一次”花钱还是收工”的权衡——成本控制的真正杠杆不在 prompt 精简、不在缓存命中、而在什么时候停”。

这是第 20 章留给读者的最后一个提醒——优化参数能省 50%、优化调度能省 90%——下一章的”Harness Engineering 12 条原则”会把这个结论落成可操作的规则

20.19 11 个典型”亏钱反模式”——挨个自检

基于公开的事故复盘、Anthropic / OpenAI 论坛典型讨论、以及 LangChain / LangGraph 社区 issue 汇总的 11 个常见”账单杀手”、每一条都对应一个可立刻自查的点:

反模式 1——messages 数组在每次 LLM call 前重新 JSON.parse / stringify 一次。看似无害、但破坏对象引用稳定性、某些 SDK 版本的 cache key 算法对此敏感——缓存命中率腰斩。修复:直接传引用、必要时用 structuredClone 浅复制。

反模式 2——system prompt 里放 new Date().toISOString()。前面讲过——每次都是不同字节——缓存永不命中。修复:时间戳挪到 user message 的末尾。

反模式 3——工具 schema 里用 z.string().describe("...") 动态生成 description。Zod 在某些场景会把 description 序列化得不稳定(空格、逗号差异)——同样破坏前缀相等。修复:把 description 提到 tool 定义里做静态字符串。

反模式 4——把整个日志文件直接喂给 LLM。大日志会让 input token 暴涨,循环几轮后成本和延迟都会失控。修复:head -100 + tail -100 + grep ERROR 三件套截断。

反模式 5——没装 max_turns 兜底。Agent 某次进入死循环(工具结果相互矛盾)、多轮不收敛。修复:硬上限、超限总结已做步骤并交还用户。

反模式 6——SubAgent 递归深度不限。父 Agent 调 SubAgent、SubAgent 又调 SubSub——指数膨胀。修复:max_depth 硬上限 3、超限拒绝。

反模式 7——web_search 工具无频率限制。LLM 在 agentic loop 里连续搜索,外部工具费用会变成隐形开销。修复:web_search_requests 装 per-task 上限。

反模式 8——错把 Opus 4.6 Fast Mode 当默认。SDK 初始化时 speed: 'fast' 写在全局配置里——所有请求 6× 加价——月底看账单才发现。修复:Fast 必须按请求级别显式开启。

反模式 9——fallback 逻辑写成”先便宜模型失败再强模型”。初衷是省钱,但如果失败率高,就会出现同一任务付两次钱。修复:fallback 只在”明确的简单任务”启用,或者直接换 Meta-Router。

反模式 10——把 PR 评论全文当 context 传。长讨论会把大量低相关历史塞进 input。修复:只传最近评论 + 被检索器标记为”相关”的历史条目。

反模式 11——cache_control 只加在 SP 最前面。缓存命中只到 SP 结尾、tools 列表变化时整个 SP 后段全 miss。修复:§20.4.2 讲的每个对话轮次后打一个断点——最多 4 个、榨干命中率。

自检方法——把这 11 条打印贴在工位、每次加功能时过一遍——比任何监控系统都有用

20.20 预算超支的复盘模板

当账单意外爆掉、复盘要在 24 小时内完成(再晚根因证据就被日志轮转覆盖):

第一步:定位

  • 哪段时间 burn rate 异常?(Grafana 定位到分钟)
  • 哪个 tenant / user / task 贡献最大?(按 label 切片)
  • 哪个模型?(cache_read 是否骤降?)

第二步:根因

  • 新代码上线吗?(git log --since 看发布记录)
  • SDK 升级吗?(package.json diff)
  • 上游工具出新 bug 吗?(tool 返回的字节数 P99 异常吗)

第三步:止血

  • 熔断阈值临时下调 50%——先不追求体验、保住预算
  • 把嫌疑代码回滚到前一 release
  • 在 Anthropic Admin Console 设 Hard Limit——SDK 侧再失控、账单也到天花板

第四步:写一页 postmortem

  • 时间线
  • 根因
  • 影响金额
  • 改进项 + owner + ddl

这一页三个月后再看——比任何文章都能帮你成长。

20.21 分规模阶段的动作清单

回到本章开头那句:

“Premature optimization is the root of all evil. But ignoring cost at scale is the root of all bankruptcies.”

本章讲数学、讲源码、讲事故,目的不是让读者陷入”优化焦虑”,而是把钱花到哪、杠杆在哪、什么时候该优化这三件事讲透。

早期项目(< 100 用户)——先不管成本——产品跑通最重要。

规模化阶段(100-10000 用户)——三把刀 + Prompt Caching——两周能打下 50% 成本、PMF 就能多撑 6 个月。

规模化后期(> 10000 用户)——分级路由 + 三级缓存 + 多级熔断——把成本从 20k/月压到20k/月压到 5k 以下、让公司从”烧钱”变成”单位经济为正”。

每一个阶段都有对应的动作——不做早期的动作、不付后期的代价——这就是本章最核心的一条原则。

20.22 formatCost 的小心思——成本显示位数的工程细节

cost-tracker.ts:177

function formatCost(cost: number, maxDecimalPlaces: number = 4): string {
  return `$${cost > 0.5 ? round(cost, 100).toFixed(2) : cost.toFixed(maxDecimalPlaces)}`
}

9 个字符的三元表达式里藏着三个决策——

  • 0.5是分界——低于它显示4位小数(0.5 是分界**——低于它显示 4 位小数(0.0087)、高于它显示 2 位(1.23)——低价场景不能四舍五入成1.23)——**低价场景不能四舍五入成 0.01 掩盖真实分布
  • round(cost, 100)——先乘 100 再整除、避免 0.1 + 0.2 = 0.30000000000000004 这种浮点坑
  • 默认 maxDecimalPlaces = 4——不是 2、因为大多数 Agent 单 task < $0.5、2 位会把不同 task 的差异全舍没

这 9 个字符——是”显示层的严谨”——成本数据从来不只是数值、还包括呈现——显示错误会误导决策

20.23 formatModelUsage 的按短名聚合策略

cost-tracker.ts:181-227formatModelUsage——claude-3-7-sonnet-latestclaude-3-7-sonnet-20250219claude-3-7-sonnet-bedrock 这三个底层 ID 通过 getCanonicalName 归一到 sonnet-3.7、然后 sum

const shortName = getCanonicalName(model)
if (!usageByShortName[shortName]) { ... }
accumulated.inputTokens += usage.inputTokens
// ...

为什么要归一——因为 Anthropic 会给同一个模型起好几个 alias(latest 指针、带日期的稳定版、第三方托管版如 Bedrock)——不归一就会出现”Opus 4.6 花了 100Opus4.6(bedrock)花了100、Opus 4.6 (bedrock) 花了 200”这种令人误读的分散统计

在你自己的仪表盘里——务必做类似的 canonical 映射——否则按 model_id 切片时会看到 20+ 行、抓不住重点

20.24 Message Batches:异步通道的成本结构

本章大篇幅讲 Prompt Caching,但还有一个经常被忽略的省钱手段:把不需要实时返回的任务放进批处理通道。

  • 成本特征——批处理通常用更低单价换更长延迟,具体折扣以 provider 官方价格页为准
  • 要求——把多个请求打包成一个 batch,异步处理,不保证实时
  • 适用——离线批处理(夜间跑 PR review、文档摘要、数据清洗)

什么时候不用——交互式 chat、IDE inline completion、实时客服——异步延迟不可接受

决策表

场景是否可批处理建议
用户交互问答实时 + Prompt Cache
夜间离线 PR reviewBatches + Prompt Cache
文档索引建立Batches
代码助手 tab completionFast Mode + Prompt Cache
每周报表汇总Batches(凌晨跑完)

判断标准很简单:如果用户不在等待这个结果,就不要用实时通道付实时价格。

20.25 OpenAI vs Anthropic 生态的缓存策略差异

站在协议中立的角度比较两类策略。具体价格和折扣会变,落地时必须查官方价格页;本节只讨论工程差异:

维度显式缓存策略隐式缓存策略
控制方式开发者显式标记 cache boundaryprovider 自动识别重复前缀
主要收益可控、可解释、适合长 SP 和固定工具定义接入简单、少改协议
主要风险标错边界会导致写入成本或 miss命中规则不透明,排障难
适合场景Agent harness、IDE、固定工具集合普通 chat、prompt 变化较大的业务

显式策略适合 Harness,因为 Harness 本来就知道哪些内容稳定:system prompt、工具定义、长期规则和部分历史摘要。

隐式策略适合接入成本敏感的产品,因为你不需要改消息结构;代价是很难解释为什么某次命中、某次没命中。

如果两者都能用,不要只比较折扣数字,要比较可观测性 + 命中稳定性 + 实现复杂度

20.26 本章的三把钥匙

把本章 20 多节压成三句话:

一、把每一分钱拆到 Usage 的 5 个字段里——没拆清就是黑盒、治不了。

二、把缓存的”前缀稳定性”当代码规范——和 type 安全、lint 通过同等重要,任何破坏前缀的 PR 应该被 reviewer 打回。

三、把熔断当启动默认值——maxTurnsmaxCostPerTaskmaxDepthPerSubAgent 三个参数必须有硬编码默认值。

20.27 路由准确率的量化:Meta-Router ROI 计算

§20.3.2 策略 C 提过 Meta-Router。这里给计算结构,不替你的业务编造”精确收益”:

需要先量化四个分布

  • 任务难度分布:简单 / 中等 / 复杂各占多少
  • 各模型单任务成本:从 usage 日志聚合,不从感觉估算
  • 路由判定成本:router 本身也是一次模型调用或规则执行
  • 误判补救成本:错误降级后是否需要重跑、升级或人工介入

公式可以写成:

router_total_cost =
  router_call_cost
  + sum(task_count_by_bucket * selected_model_cost_by_bucket)
  + rerun_cost_from_under_routing
  + quality_loss_cost_from_bad_routing

盈亏分界点不是固定准确率,而是:

router_total_cost < all_strong_model_cost
并且
quality_regression <= product_defined_budget

这组数字是写代码之前就要在表格里算一遍的。先算后做,能避免把路由做成”账单省了、质量赔了”。

20.28 如何监控路由准确率:一个被忽视的 KPI

Meta-Router 上线后真正要监控的指标不是”节省了多少钱”——是”路由准不准”:

  • Upgrade Rate——被路由到中档模型的任务、最终因 confidence 低而升级到强模型的比例
  • Downgrade Rate——被路由到强模型的任务、事后评估”本可以用中档模型”的比例
  • First-Try Success Rate——路由首选模型直接产出可用答案的比例

这三个指标每周跑一次、曲线画一张——发现退化(如模型版本升级后首选模型能力变化)及时调整

20.29 回到开头:为什么降本不能无限压

§20.8 的模板给出了四步降本路径,但很多团队会继续追问:为什么不能把账单继续往下压?

回答是:能,但边际收益会递减,而且会开始伤害质量、延迟或研发速度。

  • 第一轮优化:缓存、截断、prompt 精简,通常风险低、收益清楚
  • 第二轮优化:细粒度路由、批处理、语义缓存,需要更多评估和监控
  • 第三轮优化:自研模型、蒸馏、本地推理,研发周期长,质量风险高

成本曲线是凸函数——越往后越难。工程师要学会识别拐点:当继续降本需要牺牲用户体验或占用核心研发资源,就该停手。

这是一个”经济学思维”——不是纯工程——知道什么时候该停、比知道怎么优化更重要。

20.30 未来趋势的正确读法

模型价格、上下文长度、缓存折扣都会变,所以本章不应该依赖某个固定未来预测。更稳的读法是关注两个方向:

趋势一——模型单价下降会改变路由收益。某些今天必须精细路由的场景,明天可能直接用强模型更划算。

趋势二——本地模型和私有部署会改变隐私、延迟和边际成本结构,但也会带来模型维护、更新和硬件利用率问题。

这两条趋势会让本章某些数字失效——但方法论不会失效:拆 Usage 五字段、管前缀稳定、装熔断、做路由——这套工程动作在 10 年前的 CDN 行业、20 年前的 CPU 缓存行业都是类似的,因为”资源账本 + 命中率 + 配额”是一切按量付费系统的通用语言。

20.31 结语:成本的伦理一面

最后一个话题、不是技术、但值得思考——Agent 成本优化的伦理边界

边界一——不能把”省钱”转嫁给用户体验。如果路由降级让关键任务质量下降,省下的模型费用可能远小于用户流失损失。

边界二——不能为了省钱违反合同。企业客户买的是”Opus 4.6 能力”、你后台偷偷切 Sonnet——合同欺诈。如果要混合路由、合同里必须写清、透明度是底线。

边界三——不能用熔断当借口规避 SLA。熔断要配合 graceful degradation,不能硬中断后收口不管。

边界四——成本数据不能误导管理层。把”Opus 4.6 Fast Mode 忘了关”导致的 6× 开销归因成”模型变贵了”、推卸责任——这是不诚实的工程文化、会慢性杀死团队。

这四条边界——比任何优化技巧都更决定一个 Agent 公司能走多远——技术能救你一次、文化才能救你十次

20.32 一句话概括

会算账、会缓存、会熔断、会调度——这 12 个字是一个 Agent 系统”能上规模”的必要条件。

20.33 附录:一页纸复习卡

这张复习卡用于把本章的工程动作压缩成一页:

  • 成本 = input/M·in + output/M·out + cache_read/M·0.1·in + cache_write/M·1.25·in + web_search·$0.01
  • Sonnet 4.6:3/3/15、cache 0.3/0.3/3.75 ; Opus 4.6:5/5/25 ; Haiku 4.5:1/1/5(Claude Code modelCost.ts 确认口径)
  • Prompt Cache 盈亏点:命中 ≥ 2 次就赚
  • Cache TTL:5m(默认)/ 1h(2× 写入溢价);选 5m 除非你有特殊理由
  • 破坏前缀的四种行为:tools 顺序、时间戳、user_id 入 SP、schema 序列化差异
  • 三级缓存:Prompt Cache / 工具 LRU / 语义缓存
  • 延迟三招:流式、并行、预取(需高置信度)
  • 熔断四级:per-task / per-user / per-tenant / global
  • 并发保护:用 Redis Lua 脚本、不要 check-then-set
  • Meta-Router 启用条件:总成本下降且质量回归不超过产品预算
  • Batches API:离线任务优先走异步通道,具体折扣查官方价格页
  • 反模式 11 条:别 JSON.parse messages、别 SP 含时间戳、别 fallback 滥用、别 Fast Mode 默认开
  • 规模分阶段:<100 用户不管 / 100-10k 做三把刀 + Caching / >10k 做路由 + 熔断 + 仪表盘

把这张卡的条目每条能讲清楚 1 分钟——就是本章读懂了。

20.34 参考文献与源码锚点(合订)

本章引用的 Claude Code 源码位置——

  • src/utils/modelCost.ts:34-142——所有模型的价格常量 + tokensToUSDCost 公式
  • src/cost-tracker.ts:164-227——formatModelUsage 按短名聚合 + formatCost 显示策略
  • src/cost-tracker.ts:268-300——逐字段累加 + tokenCounter 多维上报
  • src/bootstrap/state.ts:557-575——addToTotalCostState 全局账本 + getTotalDuration
  • src/utils/fastMode.ts——Fast Mode 判定入口

Anthropic 公开文档——

  • Prompt Caching:docs.anthropic.com/en/docs/build-with-claude/prompt-caching
  • Message Batches:docs.anthropic.com/en/docs/build-with-claude/batch-processing
  • Pricing Page:platform.claude.com/docs/en/about-claude/pricing

其他开源项目对标——

  • LangChain get_openai_callback / get_callback_manager——Python 侧成本追踪
  • OpenLLMetry OTel exporter——multi-provider 成本 metric 导出
  • LiteLLM Proxy——多 provider 统一账单 + 熔断

延伸阅读