返回博客

AI 入门指南

在 Python 中限制 LLM 批处理的成本:账本与硬预算

将每次 API 调用的 token 数和费用记录到 JSONL 文件,在达到预算时停止批处理,并在运行前估算账单。包含 Python 代码。

作者:CheapTokens Team发布于 5 分钟阅读

终端显示批处理运行在达到预算时停止
本页内容
  1. Clocktower 运行中你可以借鉴的做法
  2. 账本:一个文件,每次调用一行
  3. 批处理循环:跳过已完成项,达到上限即停止
  4. 在按下运行前估算账单
  5. 当账单超出预期:读取账本
  6. 启动之前

你的脚本循环处理 200 条提示词,你合上笔记本电脑,第二天早上才发现这一夜花了多少钱。两个小习惯就能解决这个问题。每次 API 调用后,向账本文件追加一行,记录 token 数和费用。每次调用前,将累计总额与预算比较,达到预算就停止。这不到 50 行 Python 代码,而且用 5 条提示词试跑一次,就能在正式运行开始前知道账单。

Clocktower 运行中你可以借鉴的做法#

Dwarkesh Podcast 的主持人 Dwarkesh Patel 让 11 个模型在 Blood on the Clocktower 中互相对战,根据 README 共进行了 386 局游戏,并公开了代码、日志和评分(github.com/dwarkeshsp/clocktower,2026 年 11 月 8 日检查)。仓库中有两个细节值得借鉴。每个游戏目录都保留一份逐次调用的日志,记录 token 数和费用,支出写入 games/ledger.jsonl。一个 --budget 标志用于限制总支出;默认值为 $1,000。基准测试网站还增加了第三道防线:每个模型每回合都有相同的 8,000 token 上限(clocktowerbench.com)。

所以:每次调用有上限,每次调用有日志,总支出有封顶。锦标赛需要这三样,你的夜间运行也需要,只是规模更小。

把账本想象成商店收银机里的收据卷。每笔销售打印一行;经理随时可以汇总收据卷,并知道当天的备用金何时用完。

账本:一个文件,每次调用一行#

完整代码如下。它使用 openai 包并指向 CheapTokens(https://api.cheaptokens.dev/v1,密钥和模型 ID 见文档)。

python
import json
import os
from pathlib import Path

from openai import OpenAI

client = OpenAI(
    base_url='https://api.cheaptokens.dev/v1',
    api_key=os.environ['CHEAPTOKENS_API_KEY'],
)
MODEL = 'deepseek-ai/DeepSeek-V4-Flash-0731'
PRICE_IN, PRICE_OUT = 1.00, 2.00  # USD per 1M tokens: example, substitute current ones


class BudgetExceeded(Exception):
    pass


class Ledger:
    def __init__(self, path, budget_usd):
        self.path = Path(path)
        self.budget = budget_usd
        self.rows = []
        if self.path.exists():
            with self.path.open() as f:
                self.rows = [json.loads(line) for line in f if line.strip()]

    @property
    def spent(self):
        return sum(r['cost_usd'] for r in self.rows)

    def done(self):
        return {r['unit'] for r in self.rows}

    def check(self, unit):
        if self.spent >= self.budget:
            raise BudgetExceeded(
                f'stopped before {unit}: spent {self.spent:.4f} of {self.budget} USD'
            )

    def add(self, unit, usage, finish_reason):
        cost = (usage.prompt_tokens * PRICE_IN
                + usage.completion_tokens * PRICE_OUT) / 1_000_000
        row = {
            'unit': unit,
            'model': MODEL,
            'prompt_tokens': usage.prompt_tokens,
            'completion_tokens': usage.completion_tokens,
            'finish_reason': finish_reason,
            'cost_usd': round(cost, 6),
        }
        self.rows.append(row)
        with self.path.open('a') as f:
            f.write(json.dumps(row) + '\n')


def ask(ledger, unit, prompt, max_tokens=400):
    ledger.check(unit)
    resp = client.chat.completions.create(
        model=MODEL,
        messages=[{'role': 'user', 'content': prompt}],
        max_tokens=max_tokens,
    )
    ledger.add(unit, resp.usage, resp.choices[0].finish_reason)
    return resp.choices[0].message.content

每次调用都经过同样的四个步骤:

每次 API 调用周围的四个步骤:检查预算、调用、读取用量、追加到账本
包装器中每次调用背后的循环

其中有三个选择是刻意为之。

  • 每行一个 JSON 对象(JSONL),以追加模式打开。 如果脚本在第 130 次调用时崩溃,第 1 到 129 次调用已经写入磁盘。
  • 总额来自文件,而不是变量。 重启脚本后上限仍然有效,因为账本记住了上一次运行花了多少。
  • finish_reason 与 token 数一起记录。 一行显示 length 意味着回答达到了你的 max_tokens 并被截断。你之后会想知道这一点。

两个 PRICE_ 常量是占位符。请从价格表获取当前数字。对于你阅读本文时的 DeepSeek-V4-Flash,每 100 万输入 token 为 $0.015,每 100 万输出 token 为 $0.10。将 MODEL 改为 zai-org/GLM-5.3-Flash 或 MiniMaxAI/MiniMax-M2.7 并修改这两个常量,同一个账本对它们同样适用。

批处理循环:跳过已完成项,达到上限即停止#

循环很短。假设你的提示词放在一个以单元 ID 为键的字典中,单元 ID 是一项工作的名称,例如 doc-001:

python
prompts = {
    'doc-001': 'Summarize this ticket in one sentence: ...',
    'doc-002': 'Summarize this ticket in one sentence: ...',
    # ... your 200 prompts
}

ledger = Ledger('ledger.jsonl', budget_usd=0.50)
already = ledger.done()

for unit, prompt in prompts.items():
    if unit in already:
        continue
    try:
        answer = ask(ledger, unit, prompt)
    except BudgetExceeded as e:
        print(e)
        break
    Path(f'out-{unit}.txt').write_text(answer)

运行它,中途按 Ctrl+C,再次运行:已完成的单元会被跳过,因此账本同时充当进度文件。让预算用完,你会得到一行提示而不是意外,类似 stopped before doc-142: spent 0.5003 of 0.5 USD。

注意 before 这个词。检查发生在每次调用之前,所以最后一次调用可能使总额略微超出预算。超出多少是有界的:一次调用的费用不会超过你最长的提示词加上 max_tokens。对于 1,500 token 的提示词和 max_tokens=400,最多为 $0.000063。这就是为什么包装器中的每次调用都有 max_tokens:它把无界风险变成一个你可以规划的数字。

账本文件的行,包含每次调用的 token 数和最终的预算停止
示例账本行和停止消息(示意值)

在按下运行前估算账单#

现在说另一半:在启动前知道成本。用较小的预算让前五条提示词通过 ask() 运行,并读取账本。假设这五行平均为 1,500 输入 token 和 400 输出 token。这些就是整批的数字。

对于 200 条提示词,即 200 × 1,500 = 300,000 输入 token 和 200 × 400 = 80,000 输出 token。以下是该工作负载按实时价格计算的费用:

模型 每次请求 200 次请求 OpenRouter,200 次请求
GLM-5.3-Flash $0.000046 $0.0092 $0.0167
DeepSeek-V4-Flash $0.000063 $0.0125 $0.0322
MiniMax-M2.7 $0.00014 $0.027 $0.324

每次请求 1,500 个输入 token 和 400 个输出 token。每 100 万 token 的价格为当前价格,会随我们的资费一同变动;OpenRouter 列显示相同工作负载的参考价格。

表中还有 OpenRouter 一列。将输入和输出合计计算,对于此负载,DeepSeek-V4-Flash 比 OpenRouter 更便宜,而这个总和才是批处理实际支付的费用。

提示词长度不同,所以五个样本只是猜测,不是承诺。当你想要安全的上限时,使用五个中最长的那个,然后让预算上限兜住其余部分。想在同样的 200 条提示词上比较两个模型?给每个模型各自的账本文件,例如 Ledger(f'ledger-{MODEL.split("/")[-1]}.jsonl', 0.50),之后两个总额可以并排查看。

在这里试试你自己的批处理:根据样本设置每次请求的 token 数,以及你计划请求的次数。

成本计算器

你的批处理:来自 5 条提示词样本的 token 数

每次请求的输入 token 数: 1,500 · 每次请求的输出 token 数: 400 · 每日请求数: 200

模型每天每月
GLM-5.3-Flash$0.0092$0.276
DeepSeek-V4-Flash$0.0125$0.375
MiniMax-M2.7$0.027$0.81

关于发送前计算 token 的更多内容,请参阅使用 tiktoken 计算 token。

当账单超出预期:读取账本#

第三种读者场景是最糟糕的。一次 agent 运行出错并陷入循环,而仪表盘只显示一个总额。账本显示钱花在哪里。按费用对行排序:

python
rows = [json.loads(line) for line in open('ledger.jsonl')]
for r in sorted(rows, key=lambda r: r['cost_usd'], reverse=True)[:5]:
    print(r['unit'], r['prompt_tokens'], r['completion_tokens'], r['finish_reason'])

对于 agent,用步骤命名单元(step-17),这样你能看到运行的形态。有几种模式会反复出现;将每种模式与其常见原因对应起来:

配对

将每种账本模式与其常见原因对应

  • prompt_tokens 每行都略有增长循环在每一步都重新发送整个历史
  • completion_tokens 等于 max_tokens 且 finish_reason 为 length回答在你的上限处被截断,而你为被截断的文本付费
  • 同一个单元连续出现多次重试循环,或 agent 重复某一步骤
  • 某一行的费用远高于其他所有行一个过大的输入,例如将整个文件粘贴到提示词中

每种模式在发票之前很久就能在账本中看到。第一种模式在为什么 agent 会重新读取其历史中有所介绍。

如果你还想在脚本之外获得按项目统计的支出和告警,这篇按项目预算告警指南涵盖了这方面。这里的账本是存在于你代码中、能够说停就停的那部分。

启动之前#

清单

首次正式运行之前

DeepSeek-V4-Flash 的上下文大小和回答限制在你选择 max_tokens 时很有用,详见其页面:CheapTokens 上的 DeepSeek-V4-Flash。