Codex、Claude Code 跑一夜,API 账单为什么超预算?先查这 5 类请求
凌晨让 Codex 或 Claude Code 修一个问题,早上看起来只完成了一项任务,控制台里却多出了十几次甚至更多请求。模型价格没变,用户也只点了一次,费用为什么还会超出预估?
这类问题不能只看最后一条回答。Agent 会读文件、调用工具、运行测试,再把工具结果送回模型。一次任务通常对应多轮 API 请求,其中任何一轮都可能重试,上下文也可能越带越长。
所以先别急着换模型。第一步是把“一次点击”还原成实际请求,弄清楚每笔 Token 花在了哪里。
先别看总 Token,先给“一个任务”划清边界
普通聊天比较好算:发一个问题,收一个回答。
Agent 不是这样。一次“修复登录失败并运行测试”的任务,内部可能经历读取文件、分析、工具执行、测试和失败后的再次修改。用户侧只有一条任务,接口侧已经走了多轮。
排查时需要给业务任务生成 task_id,每次模型请求再生成独立的 attempt_id。至少记录这些字段:
task_id
attempt_id
model
request_id
status_code
input_tokens
cached_tokens
output_tokens
reasoning_tokens
tool_calls
retry_index
duration_ms
final_status
request_id 用于向服务商定位单次请求,task_id 才能把多轮调用重新归到同一个业务任务。两者不能互相替代。
下面是一段演示数据,不是任何平台的生产结果:
task_id attempt status input cached output retry tool
t-1042 1 200 8200 0 640 0 read_file
t-1042 2 200 19100 4096 510 0 run_tests
t-1042 3 502 42500 8192 0 0 -
t-1042 4 200 42720 8192 780 1 run_tests
t-1042 5 200 81100 8192 930 0 write_file
这五行已经暴露了三个问题:第三次失败后发生了重试;输入 Token 从 8200 涨到 81100;缓存虽然已经命中,但命中量停留在 8192,缓存占输入的比例从约 19% 降到约 10%。没有请求级数据,只看任务最终成功,很难发现这些变化。
SDK 重试和业务重试叠在了一起
本文按 openai-python v2.46.0 核对:SDK 默认会对连接错误、408、409、429 和 5xx 自动重试 2 次。这里的“重试 2 次”意味着一次 SDK 调用最多可能产生 3 次网络请求。后续版本如有变化,应以官方 README 和当前安装版本为准。
如果业务代码外面又包了一层“失败后最多尝试 3 次”,最坏情况不是 3 次,而是:
业务层 3 次尝试 × SDK 每次最多 3 个请求 = 9 个网络请求
下面这种写法看起来很普通,实际已经有双重重试风险:
from openai import OpenAI
client = OpenAI() # SDK 默认 max_retries=2
for attempt in range(3):
try:
response = client.responses.create(
model="YOUR_MODEL_ID",
input="分析测试失败原因",
)
break
except Exception:
if attempt == 2:
raise
更容易审计的方式,是只保留一层重试。比如由业务层统一处理,就明确关闭 SDK 重试:
import random
import time
from email.utils import parsedate_to_datetime
from datetime import datetime, timezone
import openai
from openai import OpenAI
client = OpenAI(max_retries=0)
def retry_delay(exc, attempt):
retry_after = exc.response.headers.get("retry-after") if exc.response else None
if retry_after:
if retry_after.isdigit():
return max(0, int(retry_after))
try:
target = parsedate_to_datetime(retry_after)
return max(0, (target - datetime.now(timezone.utc)).total_seconds())
except (TypeError, ValueError, OverflowError):
pass
return (2 ** attempt) + random.random()
for attempt in range(3):
try:
response = client.responses.create(
model="YOUR_MODEL_ID",
input="分析测试失败原因",
)
break
except (openai.APIConnectionError, openai.APITimeoutError):
if attempt == 2:
raise
time.sleep((2 ** attempt) + random.random())
except openai.APIStatusError as exc:
retryable = exc.status_code in {408, 409, 429} or exc.status_code >= 500
if not retryable or attempt == 2:
raise
time.sleep(retry_delay(exc, attempt))
401、确定的 400 参数错误、模型不存在等问题,通常不会因为多试几次而恢复。反复请求只会制造更多日志,某些情况下还会放大费用。
服务端如果返回 Retry-After,客户端还应优先尊重它。RFC 9110 规定,该字段可以是一个 HTTP 日期,也可以是需要等待的秒数。
还有一个容易漏算的情况:客户端超时,不代表服务端一定停止处理。上游如果已经接收并完成请求,这次调用仍可能进入用量统计;客户端随后重试,又会产生下一次请求。是否计费必须结合服务端 request_id 和账单记录确认,不能把所有超时都当作“没有发生”。
每轮都把越来越长的上下文重新带上
第一轮也许只读了一个配置文件。第二轮加入搜索结果,第三轮加入测试日志,第四轮又加入 Diff 和报错堆栈。后续每轮的输入规模可能不断变大。
不要用“原始提示词只有 500 字”估算成本,要看每一轮响应里的 usage。下面用 AI快站的 OpenAI Compatible 地址把示例写完整;使用其他接口时,替换 Base URL、环境变量和真实模型 ID 即可。
import json
import os
import time
import uuid
import openai
from openai import OpenAI
client = OpenAI(
api_key=os.environ["AIFAST_API_KEY"],
base_url="https://www.aifast.club/v1",
max_retries=0,
)
task_id = str(uuid.uuid4())
attempt_id = str(uuid.uuid4())
started = time.perf_counter()
record = {
"task_id": task_id,
"attempt_id": attempt_id,
"request_id": None,
"model": "YOUR_REAL_MODEL_ID",
"status_code": None,
"input_tokens": None,
"cached_tokens": None,
"cache_write_tokens": None,
"output_tokens": None,
"reasoning_tokens": None,
"total_tokens": None,
"tool_calls": 0,
"retry_index": 0, # SDK 重试已关闭,由业务层填写实际序号
"duration_ms": None,
"final_status": "started",
}
error = None
try:
response = client.responses.create(
model=record["model"],
input="只回复 COST_LOG_OK",
)
usage = response.usage
input_details = getattr(usage, "input_tokens_details", None)
output_details = getattr(usage, "output_tokens_details", None)
record.update({
"request_id": response._request_id,
"model": response.model,
"status_code": 200,
"input_tokens": getattr(usage, "input_tokens", None),
"cached_tokens": getattr(input_details, "cached_tokens", None),
"cache_write_tokens": getattr(input_details, "cache_write_tokens", None),
"output_tokens": getattr(usage, "output_tokens", None),
"reasoning_tokens": getattr(output_details, "reasoning_tokens", None),
"total_tokens": getattr(usage, "total_tokens", None),
"final_status": "completed",
})
except openai.APIStatusError as exc:
error = exc
record.update({
"request_id": exc.request_id,
"status_code": exc.status_code,
"final_status": "api_error",
})
except (openai.APIConnectionError, openai.APITimeoutError) as exc:
error = exc
record["final_status"] = type(exc).__name__
finally:
record["duration_ms"] = round((time.perf_counter() - started) * 1000)
print(json.dumps(record, ensure_ascii=False))
if error:
raise error
这段代码有两个边界需要说明:
- 某些 OpenAI Compatible 服务不会返回所有明细字段,生产代码应做空值兼容;
- 响应里的
usage适合排查请求结构,正式费用仍应与服务商控制台账单核对。
失败请求也要写入同一套日志。上面的代码会记录 APIStatusError 的状态码和 request_id,连接错误与超时则通过 final_status 区分。无论哪种情况,都不要把完整 API Key、用户提示词或公司源码写进日志。
拿到日志后,把同一 task_id 的 input_tokens 按请求顺序画出来。如果是 8K → 19K → 42K → 81K 这种持续上涨,问题就不在模型单价,而在上下文管理。
处理时先砍掉最明显的无效输入:
- 只读取当前任务真正相关的文件,不把整个仓库一次性塞进去;
- 工具输出先过滤,保留错误摘要、关键行和必要上下文;
- 大段测试日志保存到文件,让 Agent 按需搜索,不要每轮原样回传;
- 长任务拆成有明确产物的阶段,阶段结束后再压缩上下文;
- 设置单任务输入 Token 上限,超过后停止并要求人工确认。
固定前缀不断变化,缓存收益被稀释
系统提示、编码规范、工具描述和项目规则通常会在多次请求中重复出现。这些内容适合保持稳定,便于服务端进行提示缓存。
但很多程序会把时间戳、随机 ID、用户临时信息放在提示词最前面:
当前时间:2026-07-22 18:03:41
请求编号:b1f8...
固定系统规则:...
工具定义:...
用户问题:...
每次请求开头都不同,后面的固定内容就更难复用。
更合理的顺序是:
固定系统规则:...
稳定的工具定义:...
项目约束:...
当前时间:2026-07-22 18:03:41
请求编号:b1f8...
用户问题:...
是否真正命中缓存,不能凭感觉判断。检查 cached_tokens、缓存写入字段和 cached_tokens / input_tokens 的变化,再与控制台账单核对。已经出现缓存 Token 也不等于缓存效果理想:输入持续增长而缓存量不变时,命中比例仍会下降。还要先确认当前模型和服务确实支持对应缓存机制。缓存价格会变化,本文不写固定折扣。
工具调用失败后,Agent 在同一个坑里打转
有一类日志很典型:模型连续调用同一个工具,只改了文件名或相对路径,底层错误却一直没变。
例如一个文件写入工具因为目录没有权限而失败。模型没有拿到清晰的错误类型,只看到“执行失败”,于是继续换文件名、换相对路径、再试一次。每一轮都会产生新的输入和输出 Token。
工具层至少要返回结构化错误:
{
"ok": false,
"error_code": "PERMISSION_DENIED",
"retryable": false,
"message": "目标目录不可写",
"suggested_action": "请求用户选择可写目录"
}
同时给 Agent 设置硬限制:
- 同一工具连续失败 2 次后停止自动尝试;
retryable=false时不得修改参数继续碰运气;- 发邮件、创建订单、写数据库等有副作用的操作必须使用幂等键;
- 单任务工具调用次数达到阈值后,输出当前证据并交给人工判断。
这类限制有时会让 Agent 少一点“自动完成”的感觉,却能避免一次配置错误变成长时间循环。
只比较每百万 Token 单价,没有比较完整任务成本
“一次调用多少钱”只能比较请求。“完成一个通过验收的任务多少钱”,才适合比较 Agent 方案。
可以先用这个基础公式:
单次请求成本
= 输入 Token ÷ 1,000,000 × 输入单价
+ 输出 Token ÷ 1,000,000 × 输出单价
单任务模型成本
= 该任务全部请求成本之和
单个成功任务成本
= 全部任务模型成本 ÷ 最终通过验收的任务数
举个只用于演示算法的例子。假设某批任务计划执行 100 次,平均每次输入 20,000 Token、输出 2,000 Token,输入和输出价格分别是每百万 Token 2 和 8 个计费单位:
单次成本 = 20,000 / 1,000,000 × 2
+ 2,000 / 1,000,000 × 8
= 0.056
100 次基础成本 = 5.6
如果日志显示额外重试比例为 20%,仅按相同 Token 规模粗算,总成本会变成:
5.6 × (1 + 20%) = 6.72
但这仍不是完整业务成本。图像、视频、检索、缓存写入、外部工具、失败后的人工返工,都可能单独计费或产生时间成本。
不想手算时,可以把控制台的当前价格和真实日志填进 大模型 API Token 成本计算器。这是 AI快站文档站提供的浏览器端工具,会单独列出基础成本和额外重试成本,不内置容易过期的模型报价。
用一段脚本把最贵的任务找出来
假设上面的记录按 JSON Lines 格式写入 agent-usage.jsonl,可以先用下面的脚本做粗排。它不会计算价格,只负责找出请求次数和 Token 总量异常的任务。
import json
from collections import defaultdict
tasks = defaultdict(lambda: {
"requests": 0,
"failed": 0,
"unknown_status": 0,
"retries": 0,
"input_tokens": 0,
"output_tokens": 0,
})
with open("agent-usage.jsonl", encoding="utf-8") as file:
for line in file:
row = json.loads(line)
item = tasks[row["task_id"]]
item["requests"] += 1
status_code = row.get("status_code")
item["failed"] += int(row.get("final_status") != "completed")
item["unknown_status"] += int(status_code is None)
item["retries"] += int(row.get("retry_index", 0) > 0)
item["input_tokens"] += row.get("input_tokens") or 0
item["output_tokens"] += row.get("output_tokens") or 0
ranking = sorted(
tasks.items(),
key=lambda pair: pair[1]["input_tokens"] + pair[1]["output_tokens"],
reverse=True,
)
for task_id, item in ranking[:10]:
print(task_id, item)
排在前十的任务不一定有问题。复杂任务本来就可能消耗更多 Token。接下来要看它是否完成、是否重复读取同一批内容、是否连续调用失败工具,以及重试是否由配置错误触发。
一次排查可以按这个顺序走
如果账单已经开始异常,不需要先重构整套 Agent。先选一小批近期任务,把最大的一块找出来:
- 选 10 个最近执行过的真实任务,给每个任务恢复
task_id; - 统计每个任务的请求数,而不是只看用户点击次数;
- 检查 SDK 重试和业务重试是否同时开启;
- 按时间画出每轮输入 Token,确认上下文是否持续膨胀;
- 统计
cached_tokens / input_tokens,查看固定前缀是否命中; - 按工具名称统计失败次数,找出重复调用最多的工具;
- 用“总成本 ÷ 成功任务数”比较模型和路由方案;
- 给请求次数、Token 和工具循环设置预算上限,再做一次小流量复测。
没有请求级日志时,先补日志。只看控制台的一条总金额,很难分清问题来自单价、重试、上下文还是工具循环。
模型先不换,把账算明白
Agent 成本超预算往往是几件小事叠在一起:SDK 重试,业务层又重试;上下文每轮变长;缓存没命中;工具失败后继续循环。
模型单价当然重要,但它只是公式中的一个变量。把一次业务任务拆回请求日志,能看到的东西比价格表多得多:任务为什么变贵、哪里能停、哪种模型真的更省,以及成本下降后完成率有没有一起下降。
本文日志中的 Token 和价格数字只用于演示算法,不是实测结果或实际报价。代码示例使用的接口地址为 https://www.aifast.club/v1。复现前请通过 /v1/models 获取账号当前可用的模型 ID;开放范围、价格和计费规则以实时控制台为准。
参考资料
更多推荐

所有评论(0)