1. 从一条计费通知说起:为什么我要自己算 Token 账
上周三凌晨,我照例跑完当天的批量文本处理任务,顺手打开控制台看了一眼账单,发现同样的任务量,费用比前一天少了将近四成。第一反应是脚本出 bug 了,检查日志才发现是上游模型服务调整了计费策略。这件事让我意识到一个很现实的问题:把成本控制完全交给平台账单,等于把自己的钱包交给别人管。
这篇文章要聊的就是这件事——Claude API 的 Token 成本计算与用量分析。我会从 Token 的计费原理讲起,带你搞清楚输入 Token、输出 Token、缓存命中这几项到底怎么算钱,然后给出一套可以直接跑的 Python 脚本,帮你统计自己项目里每一次调用的真实开销。不管你是刚接触大模型 API 的新手,还是已经在生产环境跑了几十万次调用的老手,这套方法都能用得上。
适合谁看?三类人:一是刚开始用 Claude API 做个人项目的开发者,想知道钱花在哪;二是团队里负责成本核算的同学,需要一份可复现的用量报表;三是像我这样习惯自己掌控数据的人,不满足于平台给的那张汇总账单。
需要提前说明的是,模型服务的定价策略会不定期调整,本文涉及的具体单价仅作为计算方法的演示,实际计费请以官方最新公告为准。方法本身是通用的,换个模型、换个单价,脚本改一行配置就能继续用。
2. Token 计费的核心逻辑拆解
2.1 Token 到底是什么,为什么按它收费
很多人第一次接触 API 计费时会困惑:为什么不是按字数、按请求次数收费,而是按 Token?这里得先把这个概念说清楚。
Token 是模型处理文本的最小单位,它不是字符,也不是单词。英文里一个常见单词通常是 1 个 Token,长单词会被拆成 2 到 3 个;中文里一个汉字大约对应 1 到 2 个 Token,具体取决于分词器的实现。你可以把它理解成"模型眼里的文字颗粒度"——模型读进去的是 Token 序列,吐出来的也是 Token 序列,算力消耗自然就按 Token 数量来计量。
提示:不同模型家族的分词规则不一样,同样一段中文,在 A 模型里是 800 Token,在 B 模型里可能是 1100 Token。做成本对比时,一定要用目标模型自己的分词器去估算,不能拿字数直接换算。
为什么按 Token 而不是按字数?因为模型的注意力计算复杂度跟序列长度强相关,处理 1000 个 Token 和处理 2000 个 Token,算力开销不是线性翻倍那么简单。按 Token 计费,本质上是在按真实算力消耗收费,这对平台和用户都更公平。
2.2 输入、输出、缓存:三笔账要分开算
Claude API 的计费不是单一价格,而是分成了几个维度,这是很多人算错账的根源。我把它拆成三块:
| 计费项 | 说明 | 单价特点 |
|---|---|---|
| 输入 Token | 你发给模型的提示词、上下文、文档内容 | 基准价,通常最低 |
| 输出 Token | 模型生成的回复内容 | 通常是输入价的数倍 |
| 缓存写入 Token | 把长上下文写入缓存供后续复用 | 略高于普通输入 |
| 缓存读取 Token | 命中缓存后读取的内容 | 显著低于普通输入 |
这里最关键的一点是:输出 Token 的单价普遍比输入贵好几倍。这意味着如果你的任务输出很长,成本大头在输出侧;如果输入是超长文档,那输入侧和缓存策略就成了优化重点。
我见过不少人的账单里,输出费用占了七成以上,原因就是让模型生成了大量冗余内容。控制输出长度,往往比压缩输入更省钱。
2.3 缓存机制:省钱的关键杠杆
缓存(Prompt Caching)是这套计费体系里最值得研究的部分。原理不复杂:如果你有一段很长的固定上下文(比如系统提示词、知识库文档、代码库说明),每次请求都重复发送,那这部分 Token 每次都要按输入价计费,非常浪费。
缓存机制允许你把这段内容"存"起来,第一次写入时付一次缓存写入费,后续请求命中缓存时,这部分只按缓存读取价计费,而缓存读取价通常只有普通输入价的十分之一左右。
注意:缓存有有效期,通常是几分钟到一小时不等,超时后需要重新写入。所以缓存策略适合"短时间内高频重复使用同一段长上下文"的场景,比如连续多轮对话、批量处理同一份文档。如果你的请求间隔很长,缓存可能还没命中就过期了,反而多付了写入费。
算一笔账:假设你有 10000 Token 的固定上下文,普通输入价是每百万 Token 3 美元,缓存读取价是每百万 Token 0.3 美元。不做缓存,100 次请求要花 100 × 10000 ÷ 1000000 × 3 = 3 美元;做了缓存,写入一次 10000 Token 按 3.75 美元每百万算约 0.0375 美元,剩下 99 次读取花 99 × 10000 ÷ 1000000 × 0.3 ≈ 0.297 美元,合计约 0.33 美元。差了将近十倍。这个杠杆,不用白不用。
3. 用量分析脚本的设计思路
3.1 为什么要自己写统计脚本
平台控制台不是有账单吗,为什么还要自己写脚本?三个理由。
第一,账单是滞后的、聚合的。控制台通常按天或按小时汇总,你看到的是总数,不知道哪次调用、哪个功能、哪个用户消耗了多少。做成本优化时,你需要的是细粒度数据。
第二,账单不区分业务维度。一个项目里可能有摘要任务、翻译任务、问答任务,它们的 Token 消耗结构完全不同。只有自己在调用层埋点,才能按业务标签拆分成本。
第三,实时性。等账单出来再发现超支,往往已经晚了。自己统计可以做到每次调用后立即累计,设置阈值告警,把成本控制在事前。
所以我的做法是:在 API 调用封装层加一个轻量的统计模块,每次请求记录 Token 用量和费用,写入本地文件或数据库,随时可以出报表。
3.2 脚本整体架构
脚本不复杂,核心就三块:
- 计费配置模块:把各模型的单价写成配置字典,方便随时更新。
- 用量记录模块:每次调用后,从 API 返回的 usage 字段里提取 Token 数,按配置算出费用,追加写入记录。
- 报表分析模块:读取记录文件,按时间、按任务类型、按模型聚合,输出汇总表和趋势。
我特意把计费配置独立出来,是因为单价会变。把单价硬编码在计算逻辑里,改价时就得翻代码;独立成配置,改一个数字就行。这是踩过坑之后的经验——之前有一次平台调价,我花半小时才找到散落在三个文件里的单价常量。
3.3 关键设计决策与取舍
有几个设计点值得说明,因为它们直接影响脚本好不好用。
用 JSON Lines 而不是 CSV 存记录。JSON Lines 每行一个独立 JSON 对象,追加写入不会破坏已有数据,字段可以灵活增减,读取时逐行解析也不占内存。CSV 虽然 Excel 能直接打开,但字段一变就要改表头,追加时还容易出格式问题。我选 JSON Lines,报表阶段再转成需要的格式。
费用计算保留高精度。Token 单价很小,比如每百万 Token 3 美元,单次调用可能只花 0.0001 美元。如果用浮点数累加,误差会累积。我用 Python 的decimal.Decimal做费用计算,避免精度问题。这个细节很多人忽略,但做成本核算时,几分钱的误差累积起来也够呛。
记录原始 usage 字段。除了算出的费用,我把 API 返回的原始 usage 对象也存下来。这样万一计费规则理解错了,可以回溯原始数据重新计算,不用重新跑一遍任务。
4. 可运行脚本的完整实现
4.1 环境准备与依赖
脚本只依赖 Python 标准库,不需要额外装包,这是刻意的选择——统计脚本应该轻量、好部署,不该因为依赖问题跑不起来。Python 3.8 以上都能用。
如果你还没装 Python,去官网下载安装包,安装时记得勾选"Add Python to PATH"。装完在命令行敲python --version能看到版本号就说明好了。这一步是很多新手卡住的地方,Windows 上如果提示"不是内部或外部命令",基本就是 PATH 没配好,重新装一遍勾上那个选项即可。
4.2 计费配置模块
先定义计费配置。单价用"每百万 Token 多少美元"表示,这是行业惯例,也方便阅读。
# pricing_config.py # 单价单位:美元 / 每百万 Token # 注意:以下数值仅作演示,实际请以官方最新公告为准 PRICING = { "claude-3-5-sonnet": { "input": 3.00, "output": 15.00, "cache_write": 3.75, "cache_read": 0.30, }, "claude-3-5-haiku": { "input": 0.80, "output": 4.00, "cache_write": 1.00, "cache_read": 0.08, }, "claude-3-opus": { "input": 15.00, "output": 75.00, "cache_write": 18.75, "cache_read": 1.50, }, } def get_price(model: str, token_type: str) -> float: """获取指定模型指定类型的单价,找不到时抛错而不是静默返回 0""" if model not in PRICING: raise KeyError(f"未知模型: {model},请在 PRICING 中补充配置") if token_type not in PRICING[model]: raise KeyError(f"模型 {model} 不支持计费类型: {token_type}") return PRICING[model][token_type]这里有个细节:get_price遇到未知模型直接抛错,而不是返回 0。为什么?因为静默返回 0 会让费用统计悄悄失真,你以为是免费的,其实只是没配价格。宁可报错让你发现,也不要算出一份错误的报表。
4.3 用量记录与费用计算
接下来是核心的记录模块。它接收一次调用的 usage 数据,算出费用,追加写入文件。
# usage_tracker.py import json import os from datetime import datetime, timezone from decimal import Decimal from pricing_config import get_price RECORD_FILE = "usage_records.jsonl" def calculate_cost(model: str, usage: dict) -> Decimal: """ 根据 usage 计算单次调用费用。 usage 字段命名参考常见 API 返回结构,实际以你的接口为准。 """ cost = Decimal("0") # 普通输入 Token input_tokens = usage.get("input_tokens", 0) cost += Decimal(str(input_tokens)) / Decimal("1000000") * Decimal(str(get_price(model, "input"))) # 输出 Token output_tokens = usage.get("output_tokens", 0) cost += Decimal(str(output_tokens)) / Decimal("1000000") * Decimal(str(get_price(model, "output"))) # 缓存写入 cache_write = usage.get("cache_creation_input_tokens", 0) cost += Decimal(str(cache_write)) / Decimal("1000000") * Decimal(str(get_price(model, "cache_write"))) # 缓存读取 cache_read = usage.get("cache_read_input_tokens", 0) cost += Decimal(str(cache_read)) / Decimal("1000000") * Decimal(str(get_price(model, "cache_read"))) return cost def record_usage(model: str, usage: dict, task_tag: str = "default"): """记录一次调用,追加写入 JSON Lines 文件""" cost = calculate_cost(model, usage) record = { "timestamp": datetime.now(timezone.utc).isoformat(), "model": model, "task_tag": task_tag, "usage": usage, "cost_usd": str(cost), # Decimal 转字符串存储,避免精度丢失 } with open(RECORD_FILE, "a", encoding="utf-8") as f: f.write(json.dumps(record, ensure_ascii=False) + "\n") return cost几个关键点解释一下。usage里的字段名我用了常见命名,比如input_tokens、output_tokens、cache_creation_input_tokens、cache_read_input_tokens。不同接口的字段名可能略有差异,你对接时按实际返回结构调整即可,逻辑是一样的。
费用用Decimal计算,最后转成字符串存储。为什么不直接存浮点数?因为 JSON 里的浮点数在解析时可能丢精度,存字符串最稳妥,报表阶段再转回Decimal累加。
task_tag参数是给业务打标签用的。你可以在调用时传入"summary"、"translate"、"qa"之类的标签,后面就能按标签拆分成本。
4.4 报表生成与聚合分析
记录有了,接下来出报表。这个模块读取 JSON Lines 文件,按不同维度聚合。
# report.py import json from collections import defaultdict from decimal import Decimal from datetime import datetime RECORD_FILE = "usage_records.jsonl" def load_records(): records = [] if not os.path.exists(RECORD_FILE): return records with open(RECORD_FILE, "r", encoding="utf-8") as f: for line in f: line = line.strip() if line: records.append(json.loads(line)) return records def summarize_by(records, key_func): """通用聚合:按 key_func 分组,累加费用和各类 Token""" summary = defaultdict(lambda: { "calls": 0, "cost": Decimal("0"), "input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0, "cache_write_tokens": 0, }) for r in records: k = key_func(r) s = summary[k] s["calls"] += 1 s["cost"] += Decimal(r["cost_usd"]) u = r["usage"] s["input_tokens"] += u.get("input_tokens", 0) s["output_tokens"] += u.get("output_tokens", 0) s["cache_read_tokens"] += u.get("cache_read_input_tokens", 0) s["cache_write_tokens"] += u.get("cache_creation_input_tokens", 0) return summary def print_report(records): if not records: print("暂无记录") return total_cost = sum(Decimal(r["cost_usd"]) for r in records) print(f"总调用次数: {len(records)}") print(f"总费用: ${total_cost:.6f}") print() print("=== 按模型拆分 ===") by_model = summarize_by(records, lambda r: r["model"]) for model, s in sorted(by_model.items(), key=lambda x: -x[1]["cost"]): print(f"{model}: {s['calls']} 次, ${s['cost']:.6f}, " f"输入 {s['input_tokens']} / 输出 {s['output_tokens']} / " f"缓存读 {s['cache_read_tokens']} / 缓存写 {s['cache_write_tokens']}") print() print("=== 按任务标签拆分 ===") by_tag = summarize_by(records, lambda r: r.get("task_tag", "default")) for tag, s in sorted(by_tag.items(), key=lambda x: -x[1]["cost"]): print(f"{tag}: {s['calls']} 次, ${s['cost']:.6f}") print() print("=== 按日期拆分 ===") by_date = summarize_by(records, lambda r: r["timestamp"][:10]) for date, s in sorted(by_date.items()): print(f"{date}: {s['calls']} 次, ${s['cost']:.6f}")这个报表输出三个维度:按模型、按任务标签、按日期。按模型看,能发现是不是某个贵模型用多了;按标签看,能定位哪个业务最烧钱;按日期看,能观察成本趋势,及时发现异常增长。
4.5 接入实际调用的示例
把上面几块拼起来,看一个完整调用示例。这里用伪代码展示接入方式,实际对接时替换成你的 API 客户端即可。
# main.py from usage_tracker import record_usage from report import load_records, print_report def call_model(client, model: str, messages: list, task_tag: str): """封装一次模型调用,自动记录用量""" response = client.messages.create( model=model, messages=messages, max_tokens=1024, ) # 从响应中提取 usage 并记录 usage = { "input_tokens": response.usage.input_tokens, "output_tokens": response.usage.output_tokens, "cache_creation_input_tokens": getattr(response.usage, "cache_creation_input_tokens", 0), "cache_read_input_tokens": getattr(response.usage, "cache_read_input_tokens", 0), } cost = record_usage(model, usage, task_tag=task_tag) print(f"本次调用费用: ${cost:.6f}") return response if __name__ == "__main__": # 跑完任务后出报表 records = load_records() print_report(records)getattr那两行是为了兼容不支持缓存的接口——如果响应里没有缓存字段,就默认 0,不会报错。这种防御性写法在对接多个模型时特别有用,因为不同模型的 usage 结构不完全一致。
5. 成本优化的实操经验
5.1 从报表里能看出什么
脚本跑起来之后,报表会告诉你很多反直觉的事实。我拿自己的项目举例。
第一次出报表时,我发现"文档摘要"任务的成本占了总成本的 65%,远超预期。细看数据,问题出在输出侧:摘要任务的平均输出 Token 是输入的 1.5 倍。按理说摘要应该比原文短,为什么输出这么长?检查提示词才发现,我写的指令是"请总结以下内容",模型倾向于生成结构完整、面面俱到的摘要,结果比原文还啰嗦。
改成"用不超过 100 字概括核心结论"之后,输出 Token 直接降了六成,成本跟着降。这就是报表的价值——它把模糊的"感觉贵"变成了精确的"哪里贵、贵多少"。
5.2 三个立竿见影的省钱手段
根据我的实操,按见效速度排序,三个手段最值得先做。
第一,压缩输出。在提示词里明确限制输出长度,比如"用三句话回答"、"只输出 JSON,不要解释"。输出 Token 单价高,压输出立竿见影。我一般会在系统提示词里加一句"回答尽量简洁,避免重复用户的问题",这一句能省不少。
第二,用好缓存。如果你的任务有固定的长上下文,务必启用缓存。判断标准很简单:同一段长文本在短时间内被重复发送超过 2 次,缓存就划算。我有个知识库问答项目,把 8000 Token 的知识库说明做了缓存,单次成本从 0.024 美元降到 0.003 美元。
第三,分级用模型。不是所有任务都需要最强的模型。分类、抽取、格式转换这类任务,用便宜的小模型完全够用;只有需要复杂推理的任务才上大模型。我在脚本里加了任务标签,就是为了方便做这种分级——报表一看,哪个标签用了贵模型但效果没差别,就换掉。
5.3 容易被忽略的隐性成本
有几个成本项,账单上不一定单独列,但实实在在花钱。
重试成本。网络抖动导致的失败重试,每次重试都是一次完整计费。如果你的重试逻辑没做好幂等,可能同一份输入被计费好几次。我的做法是在记录里标记重试次数,报表里单独看这块占比,超过 5% 就要查网络或限流问题。
超长上下文。有些接口对超长输入有特殊计费,或者会触发截断。如果你的输入经常接近上下文上限,要留意是不是有冗余内容可以裁掉。我见过有人把整个日志文件塞进提示词,其实只需要相关的那几行。
测试流量。开发调试阶段的调用也是要花钱的。建议给测试流量单独打标签,和生产流量分开统计,这样能清楚知道"为了开发这个功能,我花了多少调试成本"。
6. 常见问题与排查技巧
6.1 费用对不上账单怎么办
这是最常被问到的问题。自己算的费用和平台账单有出入,先别慌,按这个顺序排查。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 自算偏低 | 单价配置过期 | 核对官方最新单价 |
| 自算偏低 | 漏记了某类 Token | 检查 usage 字段是否完整提取 |
| 自算偏高 | 重复记录 | 检查是否有重试导致的重复写入 |
| 差异很小 | 精度或四舍五入 | 属正常,关注大额差异 |
| 差异很大 | 计费口径不同 | 确认是否含缓存、是否含失败请求 |
我的经验是,差异在 1% 以内属于正常,可能是精度或统计口径的细微差别。超过 5% 就要认真查,通常是单价配置过期或者漏记了某类 Token。有一次我漏了缓存写入 Token 的统计,导致自算比账单低了 8%,补上之后就对齐了。
6.2 脚本运行报错的典型场景
报错一:KeyError: 未知模型。说明你用的模型没在PRICING里配置。解决办法是补上配置,别图省事改成默认值,否则费用会算错。
报错二:usage字段取不到。不同接口返回结构不同,有的把 usage 放在response.usage,有的放在response.meta.usage。打印一下完整响应结构,找到正确路径。
报错三:JSON 解析失败。通常是记录文件被并发写入破坏了。如果多进程同时写,需要加文件锁,或者改成每个进程写独立文件,报表阶段再合并。
报错四:Decimal转换异常。检查传入的是不是字符串或数字,Decimal不接受浮点数直接构造(会有精度警告),统一先转字符串再转Decimal。
6.3 让统计更准的几个细节
最后分享几个让统计更准的实操细节,都是踩坑换来的。
第一,记录时间用 UTC。本地时间有时区、有夏令时,跨时区协作时容易乱。统一用 UTC 存储,展示时再转本地时间。
第二,费用字段存字符串。前面说过,JSON 浮点数会丢精度,存字符串最稳。
第三,定期归档。记录文件会越来越大,建议按月归档,老数据压缩存起来,报表只读当月数据,速度快很多。
第四,加个阈值告警。在记录模块里判断,如果单日累计费用超过设定值,打印醒目提示或发通知。这个功能救过我好几次——有次脚本死循环疯狂调用,告警及时触发,避免了更大的损失。
提示:统计脚本本身不产生费用,但它的价值在于让你对费用有掌控感。建议从项目第一天就接入,别等到账单吓人一跳才想起来做统计。
这套方法我在几个项目里都跑过,从个人小工具到日调用量几万次的服务,逻辑是一样的。核心就一句话:把成本数据握在自己手里,你才有优化的依据。脚本不长,但每一行都是为了解决一个具体问题而写的,你可以直接拿去改改用。