凌晨让 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_idinput_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。先选一小批近期任务,把最大的一块找出来:

  1. 选 10 个最近执行过的真实任务,给每个任务恢复 task_id
  2. 统计每个任务的请求数,而不是只看用户点击次数;
  3. 检查 SDK 重试和业务重试是否同时开启;
  4. 按时间画出每轮输入 Token,确认上下文是否持续膨胀;
  5. 统计 cached_tokens / input_tokens,查看固定前缀是否命中;
  6. 按工具名称统计失败次数,找出重复调用最多的工具;
  7. 用“总成本 ÷ 成功任务数”比较模型和路由方案;
  8. 给请求次数、Token 和工具循环设置预算上限,再做一次小流量复测。

没有请求级日志时,先补日志。只看控制台的一条总金额,很难分清问题来自单价、重试、上下文还是工具循环。

模型先不换,把账算明白

Agent 成本超预算往往是几件小事叠在一起:SDK 重试,业务层又重试;上下文每轮变长;缓存没命中;工具失败后继续循环。

模型单价当然重要,但它只是公式中的一个变量。把一次业务任务拆回请求日志,能看到的东西比价格表多得多:任务为什么变贵、哪里能停、哪种模型真的更省,以及成本下降后完成率有没有一起下降。

本文日志中的 Token 和价格数字只用于演示算法,不是实测结果或实际报价。代码示例使用的接口地址为 https://www.aifast.club/v1。复现前请通过 /v1/models 获取账号当前可用的模型 ID;开放范围、价格和计费规则以实时控制台为准。

参考资料

更多推荐