《AI Agent 开发入门》15.3 成本与用量核算

把 ModelResponse.usage 的 input_tokens/output_tokens 累加起来,乘上一张示例价目表算出每次 run 的估算成本。给出 CostTracker:按 run_id、session_id、tool 三个维度聚合 token 与美元。用带 usage 的假模型跑三次 run,真跑打印每 run 成本与累计表,讲清成本核算为什么必须按这三个维度切。

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 / 会话 / 工具三个维度切

同一批明细,切三个维度看,得到的是三种不同的决策依据:

维度聚合键回答的问题决策用途
runrun_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 确定性测试 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距