15.3 成本与用量核算
15.1 的 span 树告诉你「慢在哪」,15.2 的结构化日志告诉你「错在哪」,但还有一个问题它们都没答:贵在哪。Agent 和普通程序最大的经济差异是——每一次模型调用都在烧钱,而且烧多少取决于 token 数,token 数又取决于上下文长度、轮数、工具返回的体量。一个「跑得通」的 Agent,可能在不知不觉间把单次请求的成本推到了几毛甚至几块钱。不做核算,你连「该优化哪里」都不知道。
本节把 minagent 推进到
minagent/cost.py:把每次ModelResponse.usage的input_tokens/output_tokens累加起来,配一张价目表算出成本,实现CostTracker——支持按run_id、session_id、tool三个维度聚合 token 与美元。
15.3.1 为什么成本要单独算
有人会问:成本不就是 token 数乘个单价吗,为什么值得单开一节?因为成本是唯一一个会随使用量线性增长、且能直接决定产品生死的指标。三个具体理由:
| 理由 | 说明 |
|---|---|
| 线性放大 | 一次请求几分钱,十万次就是几千块;单价再低也扛不住规模 |
| 隐蔽 | 成本藏在上下文里——多加一段系统提示、多带几条工具返回,单次看不出来,累积惊人 |
| 可优化 | 成本可被归因到具体环节,因而可被定向优化(裁上下文、减轮数、换小模型) |
第一点是硬约束:延迟可以靠加机器缓解,成本不行——它直接按用量计费,规模越大越疼。第二点最阴险:一个 Agent 从「能用」到「好用」,往往是在提示词和工具返回上不断加料,每加一点单次成本都涨一点,等发现时账单已经翻倍。第三点才是核算的目的:不为了看账单,为了找到能砍的地方。要能砍,就得先知道钱花在哪个环节——这正是「分维度」的意义。
换个说法:可观测性的前两节管的是质量与速度,本节管的是钱。三者凑齐,一个 Agent 才谈得上「可控」——你既知道它跑得快不快、错不错,也知道它烧不烧得起。
15.3.2 usage 累积:token 从哪来
token 数不用自己数,模型每次返回都会带上。卷一 1.x 定义的 ModelResponse 里就有这个字段:
@dataclass
class ModelResponse:
message: Message
stop_reason: str = "end_turn"
usage: dict[str, int] = field(default_factory=dict) # {"input_tokens":..,"output_tokens":..}
usage 里两个关键数:
| 字段 | 含义 | 计费特点 |
|---|---|---|
input_tokens | 送进模型的 prompt token 数 | 每轮都重算,含全部历史 |
output_tokens | 模型生成的 token 数 | 通常单价是输入的几倍 |
input_tokens 有个容易被忽略的性质:它包含整个上下文。第 3 轮的调用,prompt 里带着前 3 轮的所有消息、所有工具返回——所以同一段对话,越到后面每轮越贵。这解释了为什么「长对话」和「多轮工具循环」是成本大户:不是单次调用贵,是累积的上下文让每一轮都变贵。
一次 run 的总用量,就是把每轮 usage 累加。真正要小心的坑是:累加必须在每次模型调用返回后立刻做,因为 usage 是每次调用独立的,不会自动汇总。漏掉任何一轮,成本就少算一截。
还有一个边界情况:有些响应拿不到 usage。比如流式返回中途被打断,或者某些兼容实现干脆不回传用量。这时候 usage.get("input_tokens", 0) 的默认值会把它记成 0——成本被悄悄少算。真实系统里遇到这种情况,要么按估算补一个值,要么显式标记「用量缺失」,绝不能静默当 0,否则账永远对不上。
15.3.3 一张示例价目表
有了 token 数,乘单价就是钱。价目表长这样:
# minagent/cost.py
# 示例价目表:单位 USD / 每 100 万 token。
# 价格为示例值,仅用于演示计算逻辑,实际单价以各供应商官方公布为准。
PRICES: dict[str, dict[str, float]] = {
"claude-sonnet": {"input": 3.0, "output": 15.0},
"claude-haiku": {"input": 0.8, "output": 4.0},
"gpt-mini": {"input": 0.15, "output": 0.6},
}
这张表里的数字是示例值,不是任何供应商的真实报价,实际价格一律以官方为准。 把它写死在正文里,是因为核算逻辑与价格无关——今天单价变了,换掉这张表即可,CostTracker 一行不用改。这正是把价格外置成配置而不是焊进代码的价值。
表结构本身有两个设计点。其一是输入/输出分开计价,因为几乎所有供应商的输出都比输入贵得多(示例表里是 5 倍),混在一起算会严重低估。其二是按模型名分档,因为不同档位价差极大(示例表里最贵与最便宜差 20 倍),核算时必须知道这次调用用的是哪个模型。
价目表放在模块顶层而不是塞进类里,是刻意的:它是配置,不是逻辑。真实部署时这张表通常来自配置文件或环境变量,好让运营在不发版的情况下改价——这正是「价格与算法分离」的落地方式。
15.3.4 CostTracker 实现
CostTracker 干三件事:记一条用量、按维度聚合、给总计。
# minagent/cost.py
from dataclasses import dataclass
@dataclass
class UsageRecord:
run_id: str
session_id: str
model: str
tool: str
input_tokens: int
output_tokens: int
cost_usd: float
class CostTracker:
def __init__(self, prices: dict[str, dict[str, float]]) -> None:
self.prices = prices
self.records: list[UsageRecord] = []
def record(self, run_id: str, session_id: str, model: str,
input_tokens: int, output_tokens: int, tool: str = "-") -> UsageRecord:
p = self.prices[model]
cost = input_tokens / 1e6 * p["input"] + output_tokens / 1e6 * p["output"]
rec = UsageRecord(run_id, session_id, model, tool,
input_tokens, output_tokens, cost)
self.records.append(rec)
return rec
def by(self, key: str) -> dict[str, dict[str, float]]:
agg: dict[str, dict[str, float]] = {}
for r in self.records:
k = getattr(r, key)
slot = agg.setdefault(k, {"input": 0, "output": 0, "cost": 0.0})
slot["input"] += r.input_tokens
slot["output"] += r.output_tokens
slot["cost"] += r.cost_usd
return agg
def total(self) -> dict[str, float]:
return {
"input": sum(r.input_tokens for r in self.records),
"output": sum(r.output_tokens for r in self.records),
"cost": sum(r.cost_usd for r in self.records),
}
逐块读:
UsageRecord是一张扁平的明细行,七个字段各自可作聚合键。把它设计成扁平行而不是嵌套结构,是为了by(key)能一行getattr拿到任意维度——这是「宽表」思路,聚合时最省事。record里那行input_tokens / 1e6 * p["input"]是核心算式:单价按「每百万 token」计,所以先除以1e6换成「每 token」,再乘单价。单位换算错一位,成本就差一百万倍,这里必须盯死。by(key)用getattr(r, key)泛化了聚合维度:传"run_id"就按运行聚合,传"session_id"就按会话聚合,传"tool"就按工具聚合。一个方法覆盖三个维度,靠的正是明细行的扁平设计。total()给全局合计,用于「今天花了多少」这类总账。
把 record 接进 run 循环,唯一的新问题是:一次模型调用的成本,该算到哪个工具头上。工具本身不消耗 token,但每次模型调用都是「为了决定调哪个工具(或给出最终答案)」而发生的,所以自然的归属是:这一轮模型调用的成本,记到这一轮所触发的工具名下;末轮不再调工具,就记到 "final"。这样 by("tool") 得到的,就是「为了用某个工具而花掉的模型成本」。
record 返回刚建好的 UsageRecord,是给调用方一个即时的「本次花了多少」回执——15.2 的 run.end 日志就可以顺手把它带上。这种「记录即返回」的小设计,让成本数据自然流进日志,不必事后再去查表。
15.3.5 真跑:三次 run 的成本
跑三次 run:会话 A 两次(分别触发 1 个和 2 个工具),会话 B 一次,全部用 claude-sonnet 计价。假模型每次调用的 usage 都带 token 数:
def run_with_cost(model, tools, user_input, session_id, tracker, model_name, max_steps=8):
run_id = uuid.uuid4().hex[:8]
messages = [Message(role="user", content=user_input)]
for i in range(max_steps):
resp = model.complete(messages, tools)
label = resp.message.tool_calls[0].name if resp.message.tool_calls else "final"
tracker.record(run_id, session_id, model_name,
resp.usage.get("input_tokens", 0),
resp.usage.get("output_tokens", 0), tool=label)
messages.append(resp.message)
if not resp.message.tool_calls:
return run_id, resp.message.content
for tc in resp.message.tool_calls:
messages.append(Message(role="tool", content=tools(tc), tool_call_id=tc.id))
return run_id, ""
跑完三个 run 后,分别按三个维度聚合(真跑,Python 3.14.6):
run_id=81c907be session=sess-A final=完成。
run_id=ed760739 session=sess-A final=完成。
run_id=375ccac4 session=sess-B final=完成。
=== 按 run_id 聚合 ===
run_id input output cost(USD)
81c907be 380 130 0.003090
ed760739 640 220 0.005220
375ccac4 380 130 0.003090
=== 按 session_id 聚合 ===
sess-A 1020 350 0.008310
sess-B 380 130 0.003090
=== 按 tool 聚合 ===
search 780 270 0.006390
final 360 120 0.002880
read 260 90 0.002130
累计: input=1400 output=480 cost=$0.011400
三张表恰好展示了三个维度的不同用途:
- 按 run_id:
ed760739那次(0.005220)比另外两次(各 0.003090)贵了近一倍——因为它多调了一轮工具,token 多了。单次运行的成本异常,靠这一维发现。 - 按 session_id:
sess-A花了 0.008310,是sess-B的 2.7 倍——因为它跑了两轮对话。按会话看总账,才能知道「哪个用户在烧钱」。 - 按 tool:
search归了 0.006390,是三个里最高的。注意这里的含义——不是search工具本身贵,而是「为了决定并执行 search」花掉的模型调用最贵。这一维直接指向优化靶子。
累计 0.0114 美元、1400 input token——数字很小,因为假模型的 token 数被刻意调小了;真实一次 run 动辄几千到几万 token。
15.3.6 为什么要按 run / 会话 / 工具三个维度切
同一批明细,切三个维度看,得到的是三种不同的决策依据:
| 维度 | 聚合键 | 回答的问题 | 决策用途 |
|---|---|---|---|
| run | run_id | 这一次贵不贵 | 排查单次异常、定位超支运行 |
| 会话 | session_id | 这个用户/这段对话贵不贵 | 计费、配额、识别高耗用户 |
| 工具 | tool | 钱花在哪个环节 | 优化靶点:该裁上下文还是该换模型 |
三个维度的关系是从细到粗:run 是原子单位,会话是 run 的集合,工具是 run 内部的环节切分。少了任何一个,决策就会缺角:
- 只看总计:知道「这个月花了一万」,但不知道是哪个功能、哪个用户、哪个环节造成的——无法行动。
- 只有 run 维度:知道哪次贵,但不知道「贵在工具还是贵在轮数」,优化无从下手。
- 只有工具维度:知道
search最贵,但没法按用户计费,也没法发现「某个用户单次跑了 20 轮」。
三张表合起来才完整:会话维度做计费与配额,run 维度做异常排查,工具维度做成本优化。这也是为什么 by(key) 要用一个泛化方法——它让三个维度共享同一套明细,不重复存储、不各算各的。
维度也不是越多越好。每加一个聚合键,就多一份索引与存储成本;真实系统通常只保留「能驱动决策」的那几个维度——本节这三个,恰好对应计费、排查、优化三类动作。加一个用不上的维度,只是徒增复杂度。
一个容易被忽视的点:成本维度必须和 15.1、15.2 的标识对齐。by("run_id") 里的 run_id 就是 15.2 日志里的那个 run_id、15.1 span 树 root 的那个运行。对齐之后,一次异常排查可以这样串起来:从成本表的某个 run_id 出发 → 跳到日志看它经历了什么 → 跳到 span 树看它卡在哪 → 回到成本表看该砍哪。三节的观测数据共用一套标识,才能真正「互相下钻」。
15.3.7 从核算到优化:三个可砍的方向
核算本身不省钱,它只是把靶子照亮。拿着三张表,能砍的方向有三个,按性价比排:
| 方向 | 做法 | 影响维度 | 风险 |
|---|---|---|---|
| 裁上下文 | 用 6.2 的裁剪/摘要压缩历史 | 降低每轮 input_tokens | 可能丢信息 |
| 减轮数 | 更好的提示词让 Agent 少绕弯 | 减少 run 内的调用次数 | 可能降低成功率 |
| 换模型 | 简单步骤改用便宜档位 | 降低单价 | 可能降低质量 |
三者常组合使用。比如用便宜模型做「决定调哪个工具」这类低难度判断,只在最后生成答案时用贵模型——这样 by("tool") 里那些「为工具决策花的钱」就被压下来了。这类分级路由(model routing)是真实系统最常见的降本手段,而它的前提正是本节的三维核算:你得先知道「钱花在哪一步」,才谈得上「哪一步可以换便宜的模型」。
不过要提醒一句:降本不能只看成本。把模型换便宜、把上下文砍短,成本是降了,成功率也可能跟着掉。所以优化必须和评测绑定——第 16 章做评测,正是为了给「降本」配一个「质量不降」的约束:在成功率不跌的前提下把成本压到最低,才是真正值得的优化。
一句话收束本章:15.1 给运行拍了张结构快照,15.2 给每个动作记了行账,15.3 给整件事算清了钱——可观测性的三块拼图到此凑齐。
真实模型下会怎样:本节的价目表和 token 数都是编的,真实世界的三个差异会立刻改写核算逻辑。第一,真实 token 数由分词器决定,你估不准——同一段中文,不同模型切出的 token 数可能差一倍,所以真实系统从不自己估算,而是信 usage 字段的返回值,本节的假模型正是在模拟这个「由模型回报用量」的约定。第二,真实计费远不止单价乘 token:还有提示缓存(cache write/read 各是一个价)、批处理折扣、峰谷定价、按月的阶梯价——PRICES 表在真实系统里会复杂得多,但 record 的算式结构不变。第三,真实成本的量级完全不同:假模型一次 run 才几分之一美分,真实 Agent 一次带长上下文的 run 可能就是几分到几角美元,一个活跃用户一天几十次请求,账单按天滚动。所以真实系统里,CostTracker 通常是常驻服务而不是内存对象——明细落库、按小时聚合、超阈值告警,甚至对单次 run 设硬性成本上限(超过就中断),把 14.2 的超时取消从「时间维度」扩展到「成本维度」。这正是下一章评测要用的地基:只有能算成本,才能谈「这个改动值不值」。
阅读导航:上一节:15.2 结构化日志与关联 ID · 下一节:16.1 确定性测试 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。