之前一直在用 Codex 做 AI 编程,最头疼的就是 token 消耗太快。明明只是自动补全、跑个测试,一天下来账单数字就让人肉疼。更麻烦的是,OpenAI、Anthropic、DeepSeek、Gemini 这些模型家族的定价口径完全不一样,有的按百万 token 算,有的按千 token 算,输入输出价格差异又大,还有缓存命中、上下文缓存这些细碎计费项。每次估算项目成本,都要翻好几个官网,换算半天。
后来我花了不少时间,把主流 AI 模型的公开定价、计费口径和常见中转方式整理到一起,做了一个免费的 AI 充值比价网站。核心功能很简单:把不同平台的 token 单价拉到同一个口径下,用户输入一个场景,直接估算大概消耗多少钱,并对比各家方案。这篇文章把整个项目从数据采集、价格归一化、费用计算引擎到前后端部署的完整过程拆开讲,适合想做 AI 工具、或者自己开发中需要做成本测算的开发者。即便你不做网站,里面的价格计算模型和接口设计思路,也可以直接用到自己的项目里。
1. 为什么需要 AI 价格比价
1.1 痛点来源:Codex 与 token 计费
Codex 是 OpenAI 推出的 AI 编程代理,它和普通聊天机器人不太一样,会自主完成代码编写、命令执行、测试反馈等一系列操作。正因为它是“代理式”工作,一次任务可能触发很多轮模型调用,token 消耗会成倍增长。比如一个很小的代码重构,聊天式工具可能只消耗 5000 token,但 Codex 从解析代码、生成补丁、执行命令到分析报错,整体消耗可能达到几万甚至十几万 token。
这带来一个很现实的问题:token 用量很难预估。你在开工之前,根本不知道这个任务会花多少钱。而不同平台的计费差异非常大,某款模型的输入价格可能是另一款的十几倍。如果只是无脑选贵的模型,项目成本会迅速失控。
1.2 定价口径不统一
我做比价网站最核心的障碍,不是数据难找,而是“单位不统一”。
- 有的平台按“每 1000 token”计费,比如 0.03 美元/1K tokens。
- 有的平台按“每 100 万 token”计费,比如 3 美元/1M tokens。
- 输入价格和输出价格通常不一样。
- 缓存命中(cached input)价格往往远低于常规输入价格。
- 还有的模型支持提示词缓存,写入缓存也要计费。
如果不把这些口径全部转换成同一个基准,比如统一为“美元 / 每 100 万 token”,比较起来完全没有意义。
1.3 网站要解决什么问题
这个网站的目标非常具体:
- 把主流模型的价格统一到同一计费口径,支持快速查询。
- 输入“任务类型 + 预估 token 量”,自动估算成本。
- 对比不同供应商在相同场景下的成本差异。
- 提供免费的查询和估算能力,供开发者日常使用。
这篇文章记录的就是从零实现这个比价网站的过程,重点讲数据模型设计、价格归一化逻辑、费用计算引擎的代码实现,以及部署上线时遇到的典型问题。
2. 整体架构与功能拆解
2.1 项目范围
既然是个人开发维护的免费工具,架构不能搞得太重。最终采用前后端分离的轻量方案:
- 后端:Python + FastAPI,负责价格数据查询和费用估算接口。
- 前端:原生 HTML + JavaScript(也可以替换成 Vue,但纯静态放在服务器上更省成本)。
- 数据存储:SQLite,保存价格数据和更新记录。
- 定时任务:APScheduler 或系统 cron,定期更新价格快照。
这个方案的好处是,服务器资源占用非常小,一台低配云主机就能跑起来,方便长期免费提供给大家使用。
2.2 核心功能模块
整个网站拆成下面几个模块:
| 模块 | 职责 | 关键点 |
|---|---|---|
| 数据采集模块 | 收集官方公开价格数据 | 优先使用公开页面/JSON,人工校验 |
| 价格归一化模块 | 将不同单位统一为 USD/1M tokens | 核心逻辑,不能出错 |
| 费用估算引擎 | 根据 token 量估算调用成本 | 区分输入/输出/缓存 |
| API 层 | 对外提供查询接口 | 支持列表查询、价格估算 |
| 前端展示 | 模型价格表和成本计算器 | 简单清晰,移动端可用 |
| 价格快照 | 记录历史价格 | 为后续价格趋势提供数据 |
2.3 目录结构
项目源码目录规划如下,后面所有代码都围绕这个结构展开:
ai-price-compare/ ├── app/ │ ├── main.py # FastAPI 入口 │ ├── database.py # SQLite 连接与建表 │ ├── models.py # Pydantic 数据模型 │ ├── price_service.py # 价格查询服务 │ ├── estimate_service.py # 费用估算引擎 │ └── scheduler.py # 定时更新任务 ├── data/ │ ├── prices.json # 原始价格数据 │ └── price_history.db # SQLite 数据库 ├── frontend/ │ ├── index.html # 首页 │ ├── app.js # 前端逻辑 │ └── style.css # 样式 ├── scripts/ │ └── update_prices.py # 手动更新价格脚本 └── requirements.txt接下来我重点讲三个核心部分:数据模型、价格归一化、费用估算引擎。
3. 数据模型与存储设计
3.1 价格表结构
价格记录需要支持不同模型、不同供应商、不同计费类型。一张表如果只存“model、price”两个字段,完全不够用。我设计了下面这个表结构:
CREATE TABLE IF NOT EXISTS model_prices ( id INTEGER PRIMARY KEY AUTOINCREMENT, provider TEXT NOT NULL, -- 供应商,例如 openai model_name TEXT NOT NULL, -- 模型名称,例如 gpt-4o price_type TEXT NOT NULL, -- input / output / cached_input / cache_write price_per_1m REAL NOT NULL, -- 每 100 万 token 价格,单位美元 currency TEXT DEFAULT 'USD', -- 币种 effective_date TEXT NOT NULL, -- 生效日期 source_url TEXT, -- 来源地址 updated_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_model ON model_prices(model_name);这里有几个字段需要特别说明:
price_type用来区分计费类型。同一种模型,输入、输出、缓存命中的价格差异非常大,必须分开存。effective_date表示该价格从什么时候开始生效。AI 模型降价特别频繁,保留生效日期,后续才能做价格趋势。source_url是价格来源链接,方便审计和人工核对。
3.2 Python 数据模型
在应用层,我用 Pydantic 定义模型,方便做接口参数校验:
# 文件路径:app/models.py from typing import Literal from pydantic import BaseModel class PriceRecord(BaseModel): provider: str model_name: str price_type: Literal["input", "output", "cached_input", "cache_write"] price_per_1m: float currency: str = "USD" effective_date: str source_url: str = "" class EstimateRequest(BaseModel): model_name: str input_tokens: int = 1000 output_tokens: int = 500 cached_input_tokens: int = 0 cache_write_tokens: int = 0 class EstimateResult(BaseModel): model_name: str total_cost_usd: float input_cost_usd: float output_cost_usd: float cached_input_cost_usd: float cache_write_cost_usd: float detail: str = ""PriceRecord用于价格数据写入和查询,EstimateRequest是费用估算接口的请求体,EstimateResult是返回给前端的估算结果。
3.3 为什么必须区分 cached_input 和 cache_write
很多开发者做 token 成本计算时,只考虑“输入 + 输出”,忽略了缓存逻辑。但实际调用中,如果用了 prompt caching,缓存命中的输入价格可能只有常规输入价格的 25% 甚至 10%,而写入缓存的费用是单独计算的。
举个例子:
- 普通输入:3 美元/1M tokens
- 缓存命中输入:0.3 美元/1M tokens
- 缓存写入:3.75 美元/1M tokens
如果在计算时不区分这三者,你估算的成本可能比实际成本高出好几倍。这在需要频繁调用同一批系统指令、上下文模板的 AI 编程场景中特别明显。
4. 价格归一化逻辑
4.1 为什么要归一化
不同平台公布价格时,单位差异很大:
- 某平台写:$0.00003 / 1K tokens
- 另一平台写:$3.00 / 1M tokens
- 还有的平台写:¥0.015 / 1K tokens
如果直接把价格乘进代码里,很容易算错数量级。所以我写了一个normalize_price函数,统一转换为“美元 / 每 100 万 token”,也就是USD per 1M tokens。
# 文件路径:app/price_service.py from typing import Tuple def normalize_price( price_value: float, unit_tokens: int = 1000, currency: str = "USD", usd_rate: float = 1.0 ) -> float: """ 将任意平台公布的价格归一化为 USD / 1M tokens。 :param price_value: 原始价格数字 :param unit_tokens: 该价格对应的 token 数量,默认 1000 :param currency: 原始币种 :param usd_rate: 该币种兑美元汇率,默认 1.0 :return: 归一化后的 USD / 1M tokens 价格 """ # 先换算成每 1 个 token 的美元价格 price_per_token = price_value * usd_rate / unit_tokens # 再换算成每 1M token 的价格 return round(price_per_token * 1_000_000, 6) def normalize_cny_to_usd(price_cny_per_1k: float, usd_rate: float) -> float: """ 示例:如果是人民币价格,先按汇率换算。 这个函数只是示例思路,实际汇率需要从接口或配置获取。 """ price_usd_per_1k = price_cny_per_1k / usd_rate return normalize_price(price_usd_per_1k, unit_tokens=1000, currency="USD")写进数据库前,我还会做一次边界检查,防止负数和明显异常的大数污染数据:
def validate_price(value: float) -> bool: if value < 0: return False if value > 1000: # 每 1M token 超过 1000 美元的项目需要人工复核 return False return True4.2 构建模型价格字典
处理完归一化之后,为了方便查询,我在内存里维护一份模型价格字典,结构如下:
{ "gpt-4o": { "provider": "openai", "input": 2.50, "output": 10.00, "cached_input": 0.30, "cache_write": 3.75 }, "claude-3-5-sonnet": { "provider": "anthropic", "input": 3.00, "output": 15.00, "cached_input": 0.30, "cache_write": 3.75 }, "deepseek-chat": { "provider": "deepseek", "input": 0.27, "output": 1.10, "cached_input": 0.07, "cache_write": 1.10 } }注意,真实价格随时间变化很快,上文这些价格只用于演示数据结构,实际部署一定要以官方最新公布的价格为准。千万不要把网上某一个时间点的价格写死进代码。
4.3 数据更新策略
价格数据更新的核心原则是“官方公开,半自动维护”。
全自动爬取官方定价页面风险较高,因为很多网站有反爬策略,页面结构频繁变化,而且模型价格调整需要人工确认。我的做法是:
- 定期从官方公开的 pricing 页面或 JSON 文件抓取价格数据。
- 抓取完之后,先经过自动校验,再通过一个管理后台或者手动脚本审核入库。
- 如果抓取失败,保留上一次成功的数据,不中断服务。
手动更新脚本示意如下:
# 文件路径:scripts/update_prices.py import json import sqlite3 # 这里只是示意,实际需要从官方源拉取 RAW_PRICES = [ { "provider": "openai", "model_name": "gpt-4o", "input": 2.50, "output": 10.00, "cached_input": 0.30, "cache_write": 3.75 } ] def update_prices(conn: sqlite3.Connection, prices: list[dict]) -> None: for item in prices: for price_type in ["input", "output", "cached_input", "cache_write"]: if price_type not in item: continue conn.execute( """ INSERT INTO model_prices (provider, model_name, price_type, price_per_1m, effective_date) VALUES (?, ?, ?, ?, date('now')) """, (item["provider"], item["model_name"], price_type, item[price_type]) ) conn.commit() if __name__ == "__main__": conn = sqlite3.connect("data/price_history.db") update_prices(conn, RAW_PRICES) conn.close()5. 费用估算引擎
5.1 计费公式
费用估算的逻辑并不复杂,但很容易算漏。核心公式是:
总费用 = 输入 token 数 × 输入单价 + 缓存命中 token 数 × 缓存命中单价 + 缓存写入 token 数 × 缓存写入单价 + 输出 token 数 × 输出单价这里有一个容易忽略的地方:如果请求命中了 prompt 缓存,那一部分 token 不应该再按普通输入价格计费。也就是说,普通输入和缓存命中输入要分开计算,不能把input_tokens和cached_input_tokens混在一起。
5.2 估算引擎实现
# 文件路径:app/estimate_service.py from app.models import EstimateRequest, EstimateResult from app.price_service import get_price_map def estimate_cost(req: EstimateRequest) -> EstimateResult: """ 根据模型名称和 token 用量估算费用。 """ price_map = get_price_map() model = price_map.get(req.model_name) if not model: return EstimateResult( model_name=req.model_name, total_cost_usd=0, input_cost_usd=0, output_cost_usd=0, cached_input_cost_usd=0, cache_write_cost_usd=0, detail=f"未找到模型 {req.model_name} 的价格,请检查模型名称。" ) input_cost = req.input_tokens / 1_000_000 * model.get("input", 0) output_cost = req.output_tokens / 1_000_000 * model.get("output", 0) cached_input_cost = req.cached_input_tokens / 1_000_000 * model.get("cached_input", 0) cache_write_cost = req.cache_write_tokens / 1_000_000 * model.get("cache_write", 0) total_cost = input_cost + output_cost + cached_input_cost + cache_write_cost return EstimateResult( model_name=req.model_name, total_cost_usd=round(total_cost, 6), input_cost_usd=round(input_cost, 6), output_cost_usd=round(output_cost, 6), cached_input_cost_usd=round(cached_input_cost, 6), cache_write_cost_usd=round(cache_write_cost, 6), detail="估算结果仅供参考,实际费用以供应商账单为准。" )这里的关键是统一用/ 1_000_000把 token 数量换算成“百万 token”,再乘以price_per_1m。这个口径一旦统一,整个系统就不会出现数量级错误。
5.3 场景示例
假设我们要对比在 Codex 中接入不同模型时,重构一个模块的成本。场景参数如下:
- 输入上下文:200,000 tokens
- 其中缓存命中:150,000 tokens
- 缓存写入:20,000 tokens
- 输出:5,000 tokens
调用estimate_cost之后,前端会展示一张对比表。不同模型的成本差异会非常直观。比如某些模型常规输入价格高,但缓存命中价格很低,适合高频复用上下文的任务;有些模型输出价格便宜,适合大量生成代码的场景。
6. API 与前端部署实战
6.1 FastAPI 接口
API 层只需要两个接口:模型价格列表查询、费用估算。
# 文件路径:app/main.py from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.models import EstimateRequest, EstimateResult from app.price_service import list_prices from app.estimate_service import estimate_cost app = FastAPI(title="AI Price Compare API", version="1.0.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], # 生产环境建议收紧 allow_methods=["GET", "POST"], allow_headers=["*"], ) @app.get("/api/price/list") def get_price_list(): return list_prices() @app.post("/api/price/estimate", response_model=EstimateResult) def get_cost_estimate(req: EstimateRequest): return estimate_cost(req)要注意,allow_origins=["*"]只适用于开发环境,正式上线建议改成自己的域名,避免被任意页面跨域调用。
6.2 前端页面
前端我采用了最朴素的方式:一个输入区域 + 一个结果表格。用户选择模型、输入 token 参数,点击估算,前端把请求发到后端接口并渲染结果。
核心 JS 逻辑如下:
// 文件路径:frontend/app.js async function loadPrices() { const res = await fetch('/api/price/list'); const data = await res.json(); const select = document.getElementById('modelSelect'); data.forEach(item => { const option = document.createElement('option'); option.value = item.model_name; option.textContent = `${item.model_name} (${item.provider})`; select.appendChild(option); }); } async function estimate() { const modelName = document.getElementById('modelSelect').value; const inputTokens = parseInt(document.getElementById('inputTokens').value) || 0; const outputTokens = parseInt(document.getElementById('outputTokens').value) || 0; const cachedInputTokens = parseInt(document.getElementById('cachedInputTokens').value) || 0; const cacheWriteTokens = parseInt(document.getElementById('cacheWriteTokens').value) || 0; const res = await fetch('/api/price/estimate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model_name: modelName, input_tokens: inputTokens, output_tokens: outputTokens, cached_input_tokens: cachedInputTokens, cache_write_tokens: cacheWriteTokens }) }); const result = await res.json(); renderResult(result); }前端页面不需要做太复杂的交互,重点是清晰展示:
- 模型名称
- 输入成本
- 输出成本
- 缓存命中成本
- 总成本
我还在页面底部加了免责声明,提醒用户估算结果仅供参考,实际价格以各家官网为准。
6.3 本地运行
在项目根目录安装依赖并启动:
pip install -r requirements.txt uvicorn app.main:app --host 0.0.0.0 --port 8000启动后,打开http://localhost:8000就能访问前端页面。接口文档会自动生成在http://localhost:8000/docs。
7. 开发 Codex 相关功能的踩坑记录
做这个网站期间,我大量使用了 Codex 来辅助开发,也遇到了几个很典型的报错,这里集中记录一下排查思路。
7.1 Codex CLI 无法启动
报错信息类似:
unable to locate the codex cli binary. set codex cli path or ensure the executable is in your PATH这个问题的原因是系统找不到 Codex 的可执行文件。排查顺序:
- 确认是否安装了 Codex CLI,安装命令一般是
npm install -g @openai/codex或通过官方安装脚本。 - 确认 Codex 可执行文件所在目录是否在
PATH环境变量中。 - 如果 IDE 插件仍然报错,可以在插件设置里手动指定 Codex 路径,常见环境变量是
CODEX_CLI_PATH。
# 查看 codex 路径 which codex # 临时加入 PATH(macOS/Linux) export PATH="$PATH:/usr/local/bin"7.2 登录 token 交换失败
报错信息常见的有:
sign-in could not be completed token exchange failed token endpoint returned status 403 forbidden这个报错通常是认证流程中,本地客户端拿临时授权码去换取访问令牌时,网络出口 IP 或地区不被目标服务支持。也可能是系统时间不准导致 JWT 校验失败。
排查步骤:
- 检查本机系统时间,误差过大会导致 token 校验失败。
- 检查网络环境,确认出口 IP 是否在服务允许范围内。
- 清空本地缓存配置后重新登录。
- 如果使用团队网关或代理,确认请求头中的认证信息没有被篡改。
7.3 模型不支持报错
报错信息类似:
the 'gpt-5.6-sol' model is not supported when using codex with a ...这个原因是模型名写错,或者当前 Codex 版本还不支持该模型。解决方案是:
- 检查 Codex 配置文件和启动命令中的模型名。
- 升级 Codex CLI 到最新版本。
- 如果已经通过第三方接口接入其他模型,确认模型名与供应商平台完全一致。
我在写比价网站时,就遇到过一次模型名大小写出错导致请求直接失败。后来所有模型名统一小写存储,并在查询时做大小写归一化,才彻底解决。
7.4 token 用量统计不准确
做网站之前,我习惯直接用 Codex 的上下文显示去估成本,后来发现完全不准。正确做法是累计读取接口返回的prompt_tokens、completion_tokens和cached_tokens字段,再做聚合。
def accumulate_usage(usage: dict, total: dict) -> None: total["prompt_tokens"] = total.get("prompt_tokens", 0) + usage.get("prompt_tokens", 0) total["completion_tokens"] = total.get("completion_tokens", 0) + usage.get("completion_tokens", 0) total["cached_tokens"] = total.get("cached_tokens", 0) + usage.get("prompt_tokens_details", {}).get("cached_tokens", 0)8. 常见问题排查表
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 接口返回模型价格为零 | 数据表未初始化或价格缺失 | 检查 SQLite 中 model_prices 表数据 |
| 前端跨域请求失败 | CORS 配置未生效 | 检查 FastAPI CORSMiddleware 配置 |
| 估算结果数量级不对 | 输入 token 单位混淆 | 确认是否统一按百万 token 计算 |
| token 用量和账单对不上 | 忽略缓存 token 计费 | 仔细阅读供应商计费文档 |
| 定时任务更新价格失败 | 数据源反爬或接口变更 | 降级为保留旧数据,并人工告警 |
| Codex 登录报 403 | 网络出口限制 | 检查地区限制、系统时间和代理设置 |
| Codex 模型不支持 | 模型名写错或版本过旧 | 升级 CLI,核对模型名 |
9. 最佳实践与工程建议
9.1 价格数据必须可配置化
我见过很多工具把价格直接写成常量硬编码在 Python 文件里。这在一两周内没问题,但 AI 模型降价、调价非常频繁,硬编码意味着每次调价都要改代码、发版。更合理的做法是:
- 价格放在数据库或 JSON 配置文件中。
- 启动时加载到内存缓存。
- 提供手动更新入口和定时更新任务。
9.2 接口鉴权与频率限制
既然是免费公开服务,接口很容易被人写脚本循环请求,造成资源浪费。建议加一个简单的频率限制,比如同一个 IP 每分钟最多请求 30 次。FastAPI 生态里可以直接用slowapi实现。
# 示意代码:限制请求频率 from slowapi import Limiter from slowapi.util import get_remote_address limiter = Limiter(key_func=get_remote_address) # @app.get("/api/price/list") # @limiter.limit("30/minute")9.3 不要暴露 API Key
任何比价工具,如果接入了真实供应商 API 获取实时价格,都必须把 Key 放在服务端,不能让浏览器直接请求供应商接口。我目前的方案全部基于公开价格数据,因此不涉及用户 Key。如果后续做“真实充值渠道比价”,难度和合规要求会完全不同,这里只是提醒:敏感凭证永远不要进前端代码。
9.4 日志与观测
上线之后,我加了两条最基本的日志:
- 每次估算请求记录模型名、token 参数、估算结果。
- 定时更新任务记录成功/失败状态、更新条数。
日志不只是排查问题用,更重要的是能观察用户都在查哪些模型。比如发现某个模型被反复查询但价格一直显示旧数据,就需要及时更新。
9.5 合规与免责声明
网站上一定要声明“价格信息仅供参考,实际以官方为准”。因为 AI 模型价格调整频繁,而且不同渠道的结算方式可能包含折扣、赠送额度等,任何第三方比价工具都不能保证 100% 准确。我在页面底部添加了免责声明,同时在 API 返回结果里也加了一个detail字段提醒用户。
10. 后续规划
目前这个比价网站已经可以正常使用,核心能力是:
- 模型价格统一查询
- 输入场景化费用估算
- 缓存计费细节计算
- 常见模型横向对比
后续可以继续扩展的方向包括:
- 价格历史趋势图,方便观察模型降价节奏。
- 把估算工具做成浏览器插件,在 ChatGPT、Codex 网页版中直接显示当前对话的预估成本。
- 增加按任务类型推荐模型的逻辑,比如“代码重构优先推荐 X 模型,长文总结推荐 Y 模型”。
如果你也在做 AI 成本控制相关工具,建议先从最小的价格归一化模块入手,把它做成一个函数库。这样无论后续是接命令行、做网页,还是做浏览器插件,都可以复用同一套计算逻辑。代码里的所有模型价格数据,也一定记得从官方及时核对。