Python Decimal大模型费用估算:缓存拆分、整次选档与15项离线测试

本文基于Python标准库Decimal实现大模型API调用费用的离线精确估算,支持缓存拆分、整次请求档位选择及多单位(千/百万Token)价格计算。强调输入契约明确性、舍入顺序敏感性与精度可控性,覆盖15项边界与异常测试,不依赖外部服务或真实账单数据,适用于教学与本地验证场景。

input_tokens * price算出一个数,不代表费用就算对了:单价可能按百万Token;输入总量可能已经包含缓存;长上下文档位可能对整次请求生效;打印成两位小数还会掩盖很小的非零金额。

本篇交付一个标准库离线估算器,输入明确的价格卡和已知用量,输出分类明细、未舍入金额与单独的显示金额。不连接任何模型,不读取用户账单;所有示例价格均为虚构。

1. 范围先固定:这不是通用账单引擎

环境:Python 3.11及以上,仅标准库,无需pip安装。

本例只支持CNY或USD标签、每千或每百万Token、普通输入/缓存读取/输出三个互斥类别,以及“按输入总量选择整次请求档位”的虚构规则。输入总量包含缓存读取部分,这是本例显式前提。缓存写入、图片、音频、视频、工具费用、税费、最低计费、账户折扣、历史价查询和币种换算均不实现。

2026年10月27日Anthropic的GSA公告区分了政府适用对象和采购渠道的生效时间。它说明价格条件要有来源;本文没有把政府折扣当作一般API零售价,而是采用独立的假设价格卡。官方公告

2. 定义输入契约

字段本程序的要求
model_id与价格卡精确一致,不猜别名
currency明确标注CNY或USD,不自动转换
unit_tokens整数1000或1000000,拒绝字符串与bool
version/source_ref/effective_date保留版本、来源说明和有效日期;有字段不等于已验证来源真实性
三类Token计数已知的非负整数,上限10的12次方;None、bool、小数和字符串都拒绝
价格非负普通十进制字符串,最多9位整数和8位小数
档位从0开始、下界严格递增,达到下界即选择该档,作用于整次请求

限制数字长度是为了给本地精度和资源消耗划定范围,不是厂商限制。使用其他币种或规则,应新增有依据的显式实现,不能悄悄回退。

3. 完整文件offline_cost.py

"""Teaching estimator only. All demo prices and usage are fictional."""
from dataclasses import dataclass
from datetime import date
from decimal import Decimal, localcontext, ROUND_HALF_UP
import json
import re


def amount(value: str) -> Decimal:
    # Bound digits/scale so precision=50 is sufficient for this demo's counts.
    if type(value) is not str or re.fullmatch(r"(?:0|[1-9][0-9]{0,8})(?:\.[0-9]{1,8})?", value) is None:
        raise ValueError("rate must be a non-negative decimal string, at most 9+8 digits")
    return Decimal(value)


def tokens(value: int, name: str) -> int:
    if type(value) is not int or not 0 <= value <= 10**12:
        raise ValueError(f"{name}: known integer count in 0..10**12 required")
    return value


@dataclass(frozen=True)
class Tier:
    min_input_tokens: int
    input_rate: str
    cache_read_rate: str
    output_rate: str


@dataclass(frozen=True)
class PriceCard:
    model_id: str
    currency: str
    unit_tokens: int
    version: str
    effective_date: str
    source_ref: str
    tiers: tuple[Tier, ...]


def estimate(card: PriceCard, *, model_id: str, input_tokens: int,
             output_tokens: int, cached_input_tokens: int) -> dict:
    if model_id != card.model_id or not model_id:
        raise ValueError("exact model_id mismatch")
    if card.currency not in ("CNY", "USD"):
        raise ValueError("only explicitly labelled CNY or USD supported")
    if type(card.unit_tokens) is not int or card.unit_tokens not in (1000, 1_000_000):
        raise ValueError("unit_tokens must explicitly be 1000 or 1000000")
    if not card.version or not card.source_ref:
        raise ValueError("price version and source_ref required")
    try:
        date.fromisoformat(card.effective_date)
    except (ValueError, TypeError):
        raise ValueError("valid price effective_date required") from None
    n_in = tokens(input_tokens, "input_tokens")
    n_out = tokens(output_tokens, "output_tokens")
    cached = tokens(cached_input_tokens, "cached_input_tokens")
    if cached > n_in:
        raise ValueError("cached input must be a subset of total input")
    if type(card.tiers) is not tuple or not card.tiers:
        raise ValueError("nonempty immutable tier tuple required")
    previous = -1
    for tier in card.tiers:
        lower = tokens(tier.min_input_tokens, "tier minimum")
        if lower <= previous or (previous == -1 and lower != 0):
            raise ValueError("tier minima must start at zero and strictly increase")
        for rate in (tier.input_rate, tier.cache_read_rate, tier.output_rate):
            amount(rate)
        previous = lower
    # Entire-request tier selection, never progressive/marginal pricing.
    chosen = max((t for t in card.tiers if n_in >= t.min_input_tokens),
                 key=lambda t: t.min_input_tokens)
    categories = (("uncached_input", n_in - cached, chosen.input_rate),
                  ("cached_input", cached, chosen.cache_read_rate),
                  ("output", n_out, chosen.output_rate))
    with localcontext() as context:
        context.prec = 50
        context.rounding = ROUND_HALF_UP
        lines = []
        total = Decimal("0")
        for label, count, rate in categories:
            cost = Decimal(count) * amount(rate) / Decimal(card.unit_tokens)
            total += cost
            lines.append({"category": label, "tokens": count, "rate": rate,
                          "unrounded_cost": format(cost, "f")})
        display = total.quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP)
    return {"model_id": card.model_id, "currency": card.currency,
            "price_version": card.version, "effective_date": card.effective_date,
            "source_ref": card.source_ref, "unit_tokens": card.unit_tokens,
            "tier_min_input_tokens": chosen.min_input_tokens, "lines": lines,
            "unrounded_total": format(total, "f"), "display_total": format(display, "f"),
            "rounding_policy": "sum first; display 6dp HALF_UP; not a settlement rule",
            "is_invoice": False}


DEMO_CARD = PriceCard(
    model_id="demo-text-v1", currency="CNY", unit_tokens=1_000_000,
    version="fictional-lesson-6", effective_date="2026-10-28",
    source_ref="fictional:classroom-only",
    tiers=(Tier(0, "2.00", "0.50", "8.00"),
           Tier(200_000, "4.00", "1.00", "12.00")))


if __name__ == "__main__":
    print(json.dumps(estimate(DEMO_CARD, model_id="demo-text-v1",
                              input_tokens=10_000, output_tokens=2_000,
                              cached_input_tokens=4_000), ensure_ascii=False, indent=2))

本例用Decimal字符串和局部上下文执行运算。计数与价格的位数有界,单位又是10的整数次幂,因此示例范围内的分类乘除可保留精确十进制结果。若扩展到任意分母或无限精度数据,要重新审查舍入与精度,不能直接复用这个结论。Python 3.11 Decimal官方文档

4. 演示输入、输出与计算依据

python offline_cost.py

DEMO_CARD与固定演示用量在文件末尾,都是人工假设。本次实际本地输出为:

{
  "model_id": "demo-text-v1",
  "currency": "CNY",
  "price_version": "fictional-lesson-6",
  "effective_date": "2026-10-28",
  "source_ref": "fictional:classroom-only",
  "unit_tokens": 1000000,
  "tier_min_input_tokens": 0,
  "lines": [
    {
      "category": "uncached_input",
      "tokens": 6000,
      "rate": "2.00",
      "unrounded_cost": "0.012"
    },
    {
      "category": "cached_input",
      "tokens": 4000,
      "rate": "0.50",
      "unrounded_cost": "0.002"
    },
    {
      "category": "output",
      "tokens": 2000,
      "rate": "8.00",
      "unrounded_cost": "0.016"
    }
  ],
  "unrounded_total": "0.030",
  "display_total": "0.030000",
  "rounding_policy": "sum first; display 6dp HALF_UP; not a settlement rule",
  "is_invoice": false
}

输入总量10000,其中缓存4000,所以普通输入是6000。三类金额分别为0.012、0.002、0.016,合计0.030元。若直接把10000全部按普通输入计价,再加4000缓存费用,就重复计算了缓存部分。

unrounded_total用于保留数学结果,display_total按本例约定保留6位小数。这个约定只服务于教学显示,不是任何平台的扣费舍入规则。返回值里is_invoice=false刻意保留边界。

effective_date与source_ref是由调用方传入的证据索引,本程序只做结构检查,不会联网确认价格,也不会自动判断历史请求是否匹配价格时段。示例从固定价格卡开始,真实对接还需单独选择并验证价格版本。

5. 单独观察舍入顺序

将下列文件保存为rounding_audit.py:

"""Fictional arithmetic evidence; neither rounding method is a vendor promise."""
from decimal import Decimal, localcontext, ROUND_HALF_UP
import json


def compare_rounding(values: list[str], currency: str) -> dict:
    if currency not in ("CNY", "USD") or not values or len(values) > 1000:
        raise ValueError("one labelled currency and 1..1000 fictional costs required")
    if any(type(value) is not str for value in values):
        raise ValueError("decimal strings required; do not pass floats")
    numbers = [Decimal(value) for value in values]
    if any(not n.is_finite() or n < 0 or n > 1000000 or n.as_tuple().exponent < -8
           or len(n.as_tuple().digits) > 16 for n in numbers):
        raise ValueError("finite non-negative costs with bounded precision required")
    with localcontext() as context:
        context.prec = 40
        cents = Decimal("0.01")
        each = sum((n.quantize(cents, rounding=ROUND_HALF_UP) for n in numbers), Decimal("0"))
        raw = sum(numbers, Decimal("0"))
        aggregate = raw.quantize(cents, rounding=ROUND_HALF_UP)
    return {"currency": currency, "sum_raw": format(raw, "f"),
            "round_each_then_sum": format(each, ".2f"),
            "sum_then_round": format(aggregate, ".2f"),
            "vendor_settlement_rule_verified": False}


if __name__ == "__main__":
    print(json.dumps(compare_rounding(["0.0049", "0.0049"], "CNY"), indent=2))

运行python rounding_audit.py,输出:

{
  "currency": "CNY",
  "sum_raw": "0.0098",
  "round_each_then_sum": "0.00",
  "sum_then_round": "0.01",
  "vendor_settlement_rule_verified": false
}

各0.0049的两笔假数据,逐笔按分舍入与汇总后按分舍入产生不同显示值。真实系统可以有不同合同规则,不能因为这次演示就判断哪种一定正确。

6. 完整测试文件

将以下内容保存为同目录的test_offline_cost.py。三个文件放在同一个新目录即可运行。

from dataclasses import replace
from decimal import Decimal, localcontext
import unittest
from offline_cost import DEMO_CARD, Tier, estimate, amount
from rounding_audit import compare_rounding


class CostTests(unittest.TestCase):
    def call(self, card=DEMO_CARD, **changes):
        args = dict(model_id="demo-text-v1", input_tokens=10000,
                    output_tokens=2000, cached_input_tokens=4000)
        args.update(changes)
        return estimate(card, **args)

    def test_partition_without_double_counting(self):
        result = self.call()
        self.assertEqual([x["tokens"] for x in result["lines"]], [6000, 4000, 2000])
        self.assertEqual(Decimal(result["unrounded_total"]), Decimal("0.030"))
        self.assertEqual(result["display_total"], "0.030000")

    def test_exact_tier_boundary(self):
        low = self.call(input_tokens=199999, cached_input_tokens=0)
        high = self.call(input_tokens=200000, cached_input_tokens=0)
        self.assertEqual(low["tier_min_input_tokens"], 0)
        self.assertEqual(high["tier_min_input_tokens"], 200000)
        self.assertEqual(high["lines"][0]["rate"], "4.00")

    def test_units_equivalent_when_rates_converted(self):
        small = replace(DEMO_CARD, unit_tokens=1000,
                        tiers=(Tier(0, "0.002", "0.0005", "0.008"),))
        self.assertEqual(Decimal(self.call(small)["unrounded_total"]),
                         Decimal(self.call()["unrounded_total"]))

    def test_missing_not_zero(self):
        for key in ("input_tokens", "output_tokens", "cached_input_tokens"):
            with self.subTest(key=key), self.assertRaises(ValueError):
                self.call(**{key: None})

    def test_known_zero(self):
        result = self.call(input_tokens=0, output_tokens=0, cached_input_tokens=0)
        self.assertEqual(result["display_total"], "0.000000")

    def test_bad_counts(self):
        for value in (True, -1, 1.2, "10", 10**12 + 1):
            with self.subTest(value=value), self.assertRaises(ValueError):
                self.call(input_tokens=value)

    def test_cache_subset(self):
        with self.assertRaises(ValueError):
            self.call(cached_input_tokens=10001)

    def test_rates_are_bounded_strings(self):
        for value in (0.1, "NaN", "Infinity", "-1", "1e-3", "1.123456789", " 2.0"):
            with self.subTest(value=value), self.assertRaises(ValueError):
                amount(value)

    def test_model_and_unit_rejected(self):
        with self.assertRaises(ValueError):
            self.call(model_id="another-model")
        for unit in (True, 100, "1000000"):
            with self.assertRaises(ValueError):
                self.call(replace(DEMO_CARD, unit_tokens=unit))

    def test_invalid_card_evidence(self):
        for key, value in (("currency", "unknown"), ("version", ""),
                           ("source_ref", ""), ("effective_date", "not-a-date")):
            with self.subTest(key=key), self.assertRaises(ValueError):
                self.call(replace(DEMO_CARD, **{key: value}))

    def test_malformed_tiers(self):
        for tiers in ((), (Tier(10, "2", "1", "3"),),
                      (Tier(0, "2", "1", "3"), Tier(0, "4", "2", "6"))):
            with self.assertRaises(ValueError):
                self.call(replace(DEMO_CARD, tiers=tiers))

    def test_local_context_and_immutable_card(self):
        with localcontext() as c:
            c.prec = 3
            result = self.call()
            self.assertEqual(c.prec, 3)
            self.assertEqual(result["display_total"], "0.030000")
        result["lines"][0]["tokens"] = 999
        self.assertEqual(self.call()["lines"][0]["tokens"], 6000)


class RoundingTests(unittest.TestCase):
    def test_rounding_order_changes_display(self):
        r = compare_rounding(["0.0049", "0.0049"], "CNY")
        self.assertEqual(r["round_each_then_sum"], "0.00")
        self.assertEqual(r["sum_then_round"], "0.01")
        self.assertFalse(r["vendor_settlement_rule_verified"])

    def test_invalid_amounts(self):
        for value in ("NaN", "Infinity", "-0.1", 0.1):
            with self.subTest(value=value), self.assertRaises((ValueError, TypeError)):
                compare_rounding([value], "CNY")

    def test_currency_must_be_labelled(self):
        with self.assertRaises(ValueError):
            compare_rounding(["1"], "")


if __name__ == "__main__":
    unittest.main()

执行:

python -m unittest -v test_offline_cost.py

本次Python3.11.9执行15项测试通过。测试覆盖缓存互斥分配、精确阈值、千/百万单位换算、未知与零、非法计数、缓存超范围、非法价格、模型不匹配、来源字段、档位顺序、局部精度及显示舍入。没有真实模型调用,也没有访问真实费用记录。

7. 常见异常怎样整改

known integer count ... required:先看是不是None。上一课保留的“未知”不能到这里自动填0;未拿到输出或缓存用量时,阻止完整估算并保留待核对状态。

rate must be ... decimal string:不要把float转换成字符串当作通用修复。应从原始价格文本、受控配置或可信十进制记录建立价格;确认单位后再输入。NaN、Infinity和科学计数法在本例中明确不接受。

cached input must be a subset ...:检查字段含义或请求归属。本例要求输入总量包含缓存读取,目标服务若不同,应换适配器,不能用min(cache,input)把错误掩盖过去。

阈值附近费用突然变化:本例是达到200000后整次请求采用B档,不是把超出的1个Token按B档收费。先核对真实规则是哪一种,不凭函数名推断。

显示0.000000:检查unrounded_total,它可能仍是非零。显示精度与结算精度不同,不能把界面0当免费承诺。

计算结果与真实账单不同:本篇没有实现税费、工具、最低计费、折扣或特殊用量分类。先核对范围和价格版本,不改动原始证据来凑金额。

8. 小练习与下期

复制虚构价格卡,把单位改为1000,同时把三项单价除以1000,验证结果不变。再把缓存计数改为None,检查它能否阻止完整金额输出。最后为“边界前一项、边界值、边界后一项”各写一个断言,练习说明整次选档与累进算法的差别。

下一篇给日志脱敏写恶意输入样本:如何保留排错证据,却不泄露密钥、请求头或用户原文。

欢迎在评论区描述一个不含账户、支付和用户信息的虚构边界用例;先列出公式的前提,再讨论实现。

作者说明:本文经AI辅助整理与校对。程序、价格卡、用量和结果全部为本地教学样本,不是报价工具或实际账单。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值