在项目刚接入模型 API 时,我们通常更关注调用是否成功、响应速度是否够快。
但随着调用量增加,另一个问题很快就会出现:
“今天到底消耗了多少 Token,又花了多少钱?”
如果只依赖服务商后台查看账单,往往存在以下问题:
- 无法按项目、用户或者业务功能拆分成本。
- 账单数据可能存在一定延迟。
- 无法在预算即将耗尽时主动告警。
- 很难定位某一天成本突然上涨的具体原因。
- 接入多个模型或多个渠道后,统计口径不统一。
本文使用 Python、Decimal 和 SQLite,实现一个轻量级的模型 API 成本统计模块。
最终实现的能力包括:
- 分别计算输入 Token 和输出 Token 成本
- 记录每一次模型调用
- 防止重复写入账单
- 按项目统计每日消费
- 设置每日预算和告警阈值
- 处理流式响应、失败请求等特殊情况
一、模型 API 成本如何计算
大多数文本模型会分别统计输入 Token 和输出 Token。
假设某个模型的价格为:
- 输入:每 100 万 Token 0.20 元
- 输出:每 100 万 Token 0.80 元
一次请求使用了 12,000 个输入 Token,并生成了 3,000 个输出 Token,那么费用为:
输入成本:
12,000 ÷ 1,000,000 × 0.20 = 0.0024 元
输出成本:
3,000 ÷ 1,000,000 × 0.80 = 0.0024 元
总成本:
0.0024 + 0.0024 = 0.0048 元
计算公式可以写成:
总成本 = 输入Token ÷ 1,000,000 × 输入单价
+ 输出Token ÷ 1,000,000 × 输出单价
需要注意,本文使用的价格仅为演示数据。实际项目必须根据当前供应商、具体模型和计费规则更新价格。
二、为什么金额不能直接使用 float
Python 中的 float 使用二进制浮点数表示数据,某些十进制小数无法被精确存储。
例如:
print(0.1 + 0.2)
输出结果并不是严格的 0.3:
0.30000000000000004
单次请求的误差很小,但经过几十万甚至上百万次累计后,可能影响账单核对。
因此,涉及金额时建议使用 Decimal,并且通过字符串创建:
from decimal import Decimal
price = Decimal("0.20")
不建议这样写:
price = Decimal(0.20)
因为传入 Decimal 之前,0.20 已经先被转换成了 float。
三、定义模型价格配置
首先使用 dataclass 保存模型价格。
from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True)
class ModelPrice:
input_per_million: Decimal
output_per_million: Decimal
PRICE_TABLE = {
"example-model-small": ModelPrice(
input_per_million=Decimal("0.20"),
output_per_million=Decimal("0.80"),
),
"example-model-large": ModelPrice(
input_per_million=Decimal("1.00"),
output_per_million=Decimal("4.00"),
),
}
这里把价格表独立出来有两个好处:
- 供应商调整价格时,只需要修改价格配置。
- 业务代码不需要关注不同模型的具体单价。
生产环境中,可以进一步把价格配置存入数据库或者配置中心。
四、计算单次请求成本
接下来实现一个成本计算函数。
from decimal import Decimal, ROUND_HALF_UP
ONE_MILLION = Decimal("1000000")
STORAGE_PRECISION = Decimal("0.00000001")
def calculate_cost(
input_tokens: int,
output_tokens: int,
price: ModelPrice,
) -> dict:
if input_tokens < 0 or output_tokens < 0:
raise ValueError("Token 数量不能为负数")
input_cost = (
Decimal(input_tokens)
/ ONE_MILLION
* price.input_per_million
)
output_cost = (
Decimal(output_tokens)
/ ONE_MILLION
* price.output_per_million
)
total_cost = input_cost + output_cost
return {
"input_cost": input_cost.quantize(
STORAGE_PRECISION,
rounding=ROUND_HALF_UP,
),
"output_cost": output_cost.quantize(
STORAGE_PRECISION,
rounding=ROUND_HALF_UP,
),
"total_cost": total_cost.quantize(
STORAGE_PRECISION,
rounding=ROUND_HALF_UP,
),
}
这里保留了 8 位小数。原因是模型调用的单次成本可能非常低,如果过早保留两位小数,大量请求都会被统计为 0。
更合理的做法是:
- 计算和存储时保留较高精度
- 页面展示时再格式化为合适的小数位数
- 月度结算时根据业务规则统一取整
五、使用 SQLite 建立调用账本
下面建立一张 api_usage 表,记录每次模型调用。
import sqlite3
DB_PATH = "model_cost.db"
def get_connection():
return sqlite3.connect(DB_PATH)
def init_db():
with get_connection() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS api_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT NOT NULL UNIQUE,
project_id TEXT NOT NULL,
model TEXT NOT NULL,
input_tokens INTEGER NOT NULL,
output_tokens INTEGER NOT NULL,
input_cost TEXT NOT NULL,
output_cost TEXT NOT NULL,
total_cost TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS
idx_usage_project_created
ON api_usage(project_id, created_at)
"""
)
这里有两个设计细节。
第一,request_id 被设置为 UNIQUE。
如果调用方因为网络超时重复提交统计请求,相同 request_id 不会被重复计费。
第二,金额字段使用 TEXT 保存。
SQLite 的 REAL 本质上仍然是浮点数。使用字符串保存 Decimal,可以避免金额在存取过程中损失精度。
六、记录一次模型调用
下面将价格计算和数据库写入组合起来。
from datetime import datetime, timezone
def record_usage(
request_id: str,
project_id: str,
model: str,
input_tokens: int,
output_tokens: int,
status: str = "success",
) -> bool:
if model not in PRICE_TABLE:
raise ValueError(f"未配置模型价格:{model}")
costs = calculate_cost(
input_tokens=input_tokens,
output_tokens=output_tokens,
price=PRICE_TABLE[model],
)
created_at = datetime.now(timezone.utc).isoformat()
with get_connection() as conn:
cursor = conn.execute(
"""
INSERT INTO api_usage (
request_id,
project_id,
model,
input_tokens,
output_tokens,
input_cost,
output_cost,
total_cost,
status,
created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(request_id) DO NOTHING
""",
(
request_id,
project_id,
model,
input_tokens,
output_tokens,
str(costs["input_cost"]),
str(costs["output_cost"]),
str(costs["total_cost"]),
status,
created_at,
),
)
return cursor.rowcount == 1
返回值为 True,表示成功写入;返回 False,表示 request_id 已存在,本次记录被忽略。
调用示例:
saved = record_usage(
request_id="req_20260818_0001",
project_id="customer-service",
model="example-model-small",
input_tokens=12000,
output_tokens=3000,
)
print("是否成功写入:", saved)
七、准确统计每日消费
由于金额是以字符串形式存储的,不建议直接让 SQLite 使用 SUM 计算,否则可能被隐式转换成浮点数。
可以读取数据后使用 Decimal 累加。
from datetime import datetime, time, timezone
from decimal import Decimal
def get_daily_total(
project_id: str,
target_date,
) -> Decimal:
start = datetime.combine(
target_date,
time.min,
tzinfo=timezone.utc,
)
end = datetime.combine(
target_date,
time.max,
tzinfo=timezone.utc,
)
with get_connection() as conn:
rows = conn.execute(
"""
SELECT total_cost
FROM api_usage
WHERE project_id = ?
AND created_at >= ?
AND created_at <= ?
""",
(
project_id,
start.isoformat(),
end.isoformat(),
),
).fetchall()
return sum(
(Decimal(row[0]) for row in rows),
start=Decimal("0"),
)
如果系统按照北京时间统计每日预算,需要统一处理时区。
一种简单方案是:
- 数据库统一存储 UTC 时间
- 查询日报时把北京时间的起止时间转换成 UTC
- 不要混用服务器本地时间和 UTC 时间
八、实现预算告警
下面设置三个预算状态:
- normal:消费低于预算的 80%
- warning:达到 80%,但尚未超出预算
- exceeded:达到或超过预算
def check_budget(
spent: Decimal,
budget: Decimal,
warning_ratio: Decimal = Decimal("0.80"),
) -> dict:
if budget <= 0:
raise ValueError("预算必须大于 0")
ratio = spent / budget
if ratio >= Decimal("1"):
level = "exceeded"
message = "今日预算已经用完"
elif ratio >= warning_ratio:
level = "warning"
message = "今日消费已接近预算上限"
else:
level = "normal"
message = "今日预算使用正常"
return {
"level": level,
"message": message,
"spent": spent,
"budget": budget,
"ratio": ratio,
"remaining": max(
budget - spent,
Decimal("0"),
),
}
使用示例:
from datetime import date
from decimal import Decimal
spent = get_daily_total(
project_id="customer-service",
target_date=date.today(),
)
result = check_budget(
spent=spent,
budget=Decimal("10.00"),
)
print("状态:", result["level"])
print("提示:", result["message"])
print("今日消费:", result["spent"])
print("剩余预算:", result["remaining"])
print(
"使用比例:",
f'{result["ratio"] * Decimal("100"):.2f}%',
)
九、完整可运行示例
把前面的代码保存为 cost_tracker.py,并在文件末尾添加:
if __name__ == "__main__":
from datetime import date
init_db()
saved = record_usage(
request_id="req_demo_0001",
project_id="demo-project",
model="example-model-small",
input_tokens=12000,
output_tokens=3000,
)
print("新增账单:", saved)
spent = get_daily_total(
project_id="demo-project",
target_date=date.today(),
)
budget_result = check_budget(
spent=spent,
budget=Decimal("1.00"),
)
print("今日消费:", spent)
print("预算状态:", budget_result)
运行:
python cost_tracker.py
首次运行后,当前目录会生成:
model_cost.db
再次运行时,由于示例中的 request_id 没有变化,不会重复写入账单。
十、接入真实模型调用
不同 SDK 返回 Token 用量的字段名称可能不同,常见形式类似:
usage = response.usage
record_usage(
request_id=response.id,
project_id="knowledge-base",
model="example-model-small",
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
)
实际接入时不要直接照搬字段名称,应以所使用 SDK 的响应结构为准。
建议模型请求和账单写入遵循以下顺序:
发起模型请求
↓
取得最终响应
↓
读取实际 Token 用量
↓
使用唯一 request_id 写入账本
↓
检查项目预算
↓
必要时发送告警
十一、流式响应如何统计
流式接口会返回多个数据片段,但这些片段仍然属于同一个请求。
不要在每个数据片段到达时写一次账单,否则会造成:
- 同一个请求被重复统计
- 输出 Token 统计不完整
- 数据库产生大量无效记录
正确的处理方式是:
- 接收并转发流式内容。
- 等待流结束。
- 从最终事件或 SDK 的统计对象中读取 Token 用量。
- 使用本次请求唯一的 request_id 写入一次账单。
伪代码如下:
final_usage = None
for event in stream:
if event.type == "content":
yield event.text
if event.type == "completed":
final_usage = event.usage
if final_usage is not None:
record_usage(
request_id=request_id,
project_id=project_id,
model=model,
input_tokens=final_usage.input_tokens,
output_tokens=final_usage.output_tokens,
)
十二、失败请求是否需要计费
“请求失败”不一定等于“没有产生费用”。
例如:
- 请求在到达模型之前就被参数校验拦截,通常没有模型用量。
- 模型已经开始生成,但客户端中途断开,可能已经产生用量。
- 流式响应传输失败,服务端可能已经完成部分生成。
- 上游超时,不代表上游没有处理请求。
因此,不建议简单地把所有异常请求都记为零费用。
更稳妥的处理方式是:
- 优先读取服务端返回的实际 usage。
- 有实际用量就记录 Token 和成本。
- 没有 usage 时,将状态标记为 unknown 或 failed。
- 定期与供应商账单进行对账。
- 不同失败类型分开统计。
例如:
status = "success"
try:
response = call_model()
except TimeoutError:
status = "timeout"
raise
finally:
# 只有获得可信的 usage 后才写入成本
if usage is not None:
record_usage(
request_id=request_id,
project_id=project_id,
model=model,
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
status=status,
)
十三、生产环境还需要考虑什么
本文实现的是一个轻量级版本,正式使用时还需要补充以下能力。
- 价格版本管理
模型价格可能发生变化。如果只维护一张当前价格表,历史账单重新计算后可能出现变化。
建议为价格增加生效时间:
model
input_price
output_price
effective_from
effective_to
计算费用时使用请求发生当时的价格。
- 缓存 Token
部分模型会区分普通输入 Token 和缓存命中 Token,两者价格可能不同,需要单独记录。
- 多模态计费
图片、音频、视频或工具调用可能采用不同计费方式,不能全部套用文本 Token 公式。
- 软限制与硬限制
软限制只发送提醒,不中断业务。
硬限制会在预算耗尽后停止请求,或者自动切换到成本更低的模型。
涉及核心业务时,建议优先使用软限制,避免因为统计延迟导致正常请求被错误拦截。
- 并发超额
高并发场景下,多个请求可能同时通过预算检查,最终一起造成超额。
如果必须执行严格的硬预算,需要使用事务、行锁、Redis 原子操作或者独立的配额服务。
- 账单对账
本地统计的作用是实时观察和业务拆分,但供应商的最终账单仍然是重要核对依据。
建议每天进行一次自动对账,检查:
- 请求数量差异
- Token 数量差异
- 金额差异
- 未知模型或未知价格
- 本地缺失的请求
十四、总结
实现模型 API 成本统计,核心并不只是套用一个 Token 乘以价格的公式。
一套相对可靠的成本系统至少需要处理:
- 输入与输出分别计费
- Decimal 精确计算
- 唯一请求编号和幂等写入
- 项目级成本拆分
- 日预算聚合和告警
- 流式响应最终结算
- 失败请求的实际用量
- 价格变化和历史版本
- 本地数据与供应商账单对账
对于小型项目,Python 加 SQLite 已经能够满足基础统计需求。
当请求量继续增长时,可以在保持相同数据结构的基础上,将 SQLite 替换为 MySQL、PostgreSQL 或其他数据库,再配合定时任务和监控系统完成更完善的成本治理。
409



被折叠的 条评论
为什么被折叠?



