Python解析大模型usage:缺失字段、显式0与流式快照的完整实现

本文介绍一个纯Python实现的大模型API用量解析适配器,支持Chat Completions与Responses两种字段协议,严格区分缺失字段、显式0值与推导总量,避免流式响应中用量重复累计。适配器仅依赖标准库,不连接真实模型,通过本地mock和边界测试验证健壮性,涵盖字段缺失、总量冲突、快照重复、Unicode边界等12类场景。

你的请求成功返回了文字,日志程序却在usage["total_tokens"]上报错;于是把异常兜底成0,看板立刻恢复正常,但真实情况被掩盖了。

本篇实现一个纯Python用量适配器:明确区分未知和0、区分服务报告总量与本地推导总量、支持显式选择Chat Completions或Responses字段模式,并演示流式用量快照怎样避免重复累计。所有输入都是本地mock,不连接模型,也不读取真实账户用量。

一、先确定协议,不要凭字段猜供应商

Chat Completions常见usage字段为prompt_tokens、completion_tokens和total_tokens。Responses使用input_tokens、output_tokens等字段。所谓“兼容”仍需对照目标服务的实际文档与脱敏样本;本文不自动把任意字段别名猜成Token。Chat Completions文档、Responses文档

2026年10月26日OpenAI更新日志列出了gpt-6.1-terra的API发布。型号新增并不会让各接口的用量字段自动变成同一个格式;这里借此复查适配层边界,不做新模型评测,也不声称本站已支持该型号。官方更新日志

本文采用以下项目策略:

输入情况本程序行为
usage缺失、null或空对象相关计数为None,JSON中显示null
显式整数0保留为已知0
只有输入用量保留输入,输出与总量未知
输入与输出都有、总量缺失本地相加,总量来源标为derived
总量已报告且两项齐全但不相等拒绝,交给异常记录与人工核查
布尔、负数、小数、数字字符串拒绝,不静默转换
额外明细字段忽略,不重复加到总量上

这是严格教学适配器的约束,不是宣称所有厂商都必须如此。若目标服务的统计定义不同,应新增有文档和样本支持的显式适配器,不能为了通过校验任意改写响应。

二、完整实现:usage_adapter.py

环境:Python 3.11及以上,仅标准库,无pip安装步骤。创建一个空目录,将下列代码保存为usage_adapter.py。

"""Offline only. Normalize explicit OpenAI-style usage schemas, never prices."""
from collections.abc import Mapping
from dataclasses import asdict, dataclass
import json


@dataclass(frozen=True)
class Usage:
    input_tokens: int | None
    output_tokens: int | None
    total_tokens: int | None
    total_source: str  # reported, derived, unknown


def count(raw: Mapping, key: str) -> int | None:
    value = raw.get(key)
    if value is None:
        return None
    # bool is an int subclass, but is not a valid count here.
    if type(value) is not int or value < 0:
        raise ValueError(f"{key}: expected a non-negative integer or null")
    return value


def normalize_usage(raw, *, schema: str = "chat") -> Usage:
    fields = {"chat": ("prompt_tokens", "completion_tokens"),
              "responses": ("input_tokens", "output_tokens")}
    if schema not in fields:
        raise ValueError("schema must be chat or responses")
    if raw is None:
        raw = {}
    if not isinstance(raw, Mapping):
        raise ValueError("usage must be an object or null")
    left, right = fields[schema]
    input_n, output_n = count(raw, left), count(raw, right)
    total = count(raw, "total_tokens")
    source = "reported" if total is not None else "unknown"
    if input_n is not None and output_n is not None:
        if total is None:
            total, source = input_n + output_n, "derived"
        elif total != input_n + output_n:
            raise ValueError("reported total does not equal input + output")
    return Usage(input_n, output_n, total, source)


class ChatUsageCollector:
    """Accept a final usage snapshot. Never sum cumulative snapshots."""
    def __init__(self):
        self._snapshot = None

    def accept(self, chunk: Mapping) -> None:
        if not isinstance(chunk, Mapping):
            raise ValueError("chunk must be an object")
        if chunk.get("usage") is None:
            return
        current = normalize_usage(chunk["usage"], schema="chat")
        if self._snapshot is not None and current != self._snapshot:
            raise ValueError("conflicting usage snapshots: quarantine this request")
        self._snapshot = current

    def result(self) -> Usage:
        return self._snapshot or normalize_usage(None)


if __name__ == "__main__":
    stream = ChatUsageCollector()
    for chunk in ({"usage": None}, {"choices": [], "usage": {
            "prompt_tokens": 18, "completion_tokens": 7, "total_tokens": 25}}):
        stream.accept(chunk)
    print(json.dumps({"complete_mock": asdict(stream.result()),
                      "interrupted_mock": asdict(ChatUsageCollector().result()),
                      "partial_mock": asdict(normalize_usage({"prompt_tokens": 18}))},
                     ensure_ascii=False, indent=2))

None、显式0、推导总量分别占据不同状态。不要对原始字段使用value or 0,那会混淆缺失与0。也不要用int(value)接受True或"12",否则协议变化会悄悄穿过边界。

推导总量仅在输入与输出计数均已知时成立;即使报告总量与输入已知,本文也不倒推输出,以免隐藏目标协议差异。total_source保留证据来源,后续对账可以据此选择是否接受。

三、流式快照为什么不能逐块相加

本例的ChatUsageCollector适用的是最终usage快照模式。它不是SSE解析器,不负责连接、重试、正文拼接和[DONE]识别。传入的chunk应是上游已经解析好的字典,每个请求单独创建一个收集器。

Chat Completions官方说明:请求stream_options.include_usage后,可在[DONE]前收到额外用量块,choices可为空;中间块的usage为null;连接中断可能错过最终用量。因此本例允许带正文的中间块没有用量,也允许空choices的末尾块提供用量。官方流式选项说明

相同快照重复到达,本例只保留一次。不同的非null快照发生冲突,本例抛错,要求隔离该请求核查。如果你的供应商明确使用“每块增量用量”,这个收集器不适用,需要另一套规则;不要把累计与增量混用。

缓存、推理等细项可能是父级计数的明细,不能见到一个数字就再加一遍。本文只处理三个顶层计数,不计算费用,不处理图片、语音、工具收费与服务端账单。

四、运行演示,检查输入与输出

python usage_adapter.py

本次本地执行结果:

{
  "complete_mock": {
    "input_tokens": 18,
    "output_tokens": 7,
    "total_tokens": 25,
    "total_source": "reported"
  },
  "interrupted_mock": {
    "input_tokens": null,
    "output_tokens": null,
    "total_tokens": null,
    "total_source": "unknown"
  },
  "partial_mock": {
    "input_tokens": 18,
    "output_tokens": null,
    "total_tokens": null,
    "total_source": "unknown"
  }
}

complete_mock是人工构造的完整用量;interrupted_mock表示没拿到任何usage快照,partial_mock只有输入计数。后两种情况下不能生成一笔声称真实的0用量账单。

业务调用时要显式选择模式:

from usage_adapter import normalize_usage

response = {"usage": {"input_tokens": 12, "output_tokens": 3, "total_tokens": 15}}
record = normalize_usage(response.get("usage"), schema="responses")
print(record)

这只是本地字典,不是API请求。对接真实服务前,应另外核对模型ID、认证、限流、参数和费用;不要用教学假数据冒充接口验收。

五、完整边界测试

下面测试文件还使用了一个独立的文本大小检查器,目的是固定“字符保护不等于Token校验”的边界。先将text_budget.py保存到同目录:

"""Project text-size guard, not a tokenizer or API context-limit check."""
import json


def inspect_text(messages: list[dict], *, max_text_chars: int,
                 max_json_bytes: int) -> dict:
    for name, limit in (("max_text_chars", max_text_chars),
                        ("max_json_bytes", max_json_bytes)):
        if type(limit) is not int or limit < 1:
            raise ValueError(f"{name} must be a positive integer")
    if type(messages) is not list or not messages:
        raise ValueError("messages must be a nonempty list")
    rows = []
    for index, item in enumerate(messages):
        if (type(item) is not dict or set(item) != {"role", "content"}
                or item["role"] not in ("system", "user", "assistant")
                or type(item["content"]) is not str):
            raise ValueError(f"messages[{index}]: only plain text role/content supported")
        text = item["content"]
        rows.append({"index": index, "code_points": len(text),
                     "utf8_bytes": len(text.encode("utf-8"))})
    raw = json.dumps({"messages": messages}, ensure_ascii=False,
                     separators=(",", ":")).encode("utf-8")
    total = sum(row["code_points"] for row in rows)
    return {"per_message": rows, "text_code_points": total,
            "request_json_bytes": len(raw),
            "project_size_ok": total <= max_text_chars and len(raw) <= max_json_bytes,
            "token_count": None, "model_context_ok": None}


if __name__ == "__main__":
    messages = [{"role": "system", "content": "只整理给定材料。"},
                {"role": "user", "content": "周六10:00在图书角集合。"}]
    print(json.dumps(inspect_text(messages, max_text_chars=1000,
                                  max_json_bytes=4096), ensure_ascii=False, indent=2))

再保存test_usage_adapter.py:

import unittest
from usage_adapter import ChatUsageCollector, normalize_usage
from text_budget import inspect_text


class UsageTests(unittest.TestCase):
    def test_missing_is_unknown_not_zero(self):
        for value in (None, {}, {"completion_tokens_details": {}}):
            self.assertIsNone(normalize_usage(value).total_tokens)

    def test_explicit_zero_is_known(self):
        u = normalize_usage({"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0})
        self.assertEqual((u.total_tokens, u.total_source), (0, "reported"))

    def test_derived_is_labelled(self):
        u = normalize_usage({"prompt_tokens": 4, "completion_tokens": 3})
        self.assertEqual((u.total_tokens, u.total_source), (7, "derived"))

    def test_partial_does_not_invent_output(self):
        u = normalize_usage({"prompt_tokens": 4, "total_tokens": 9})
        self.assertIsNone(u.output_tokens)
        self.assertEqual(u.total_tokens, 9)

    def test_schema_is_explicit(self):
        raw = {"input_tokens": 4, "output_tokens": 3, "extra": "ignored"}
        self.assertIsNone(normalize_usage(raw).total_tokens)
        self.assertEqual(normalize_usage(raw, schema="responses").total_tokens, 7)
        with self.assertRaises(ValueError):
            normalize_usage(raw, schema="auto")

    def test_invalid_counts_rejected(self):
        for value in (-1, True, 1.5, "12"):
            with self.subTest(value=value), self.assertRaises(ValueError):
                normalize_usage({"prompt_tokens": value})
        with self.assertRaises(ValueError):
            normalize_usage([])

    def test_inconsistent_total_rejected(self):
        with self.assertRaises(ValueError):
            normalize_usage({"prompt_tokens": 4, "completion_tokens": 3, "total_tokens": 8})

    def test_snapshot_not_summed(self):
        c = ChatUsageCollector()
        c.accept({"usage": None})
        chunk = {"choices": [], "usage": {"prompt_tokens": 4, "completion_tokens": 3}}
        c.accept(chunk)
        c.accept(chunk)
        self.assertEqual(c.result().total_tokens, 7)
        with self.assertRaises(ValueError):
            c.accept({"usage": {"prompt_tokens": 4, "completion_tokens": 4}})

    def test_interrupt_keeps_unknown(self):
        c = ChatUsageCollector()
        c.accept({"choices": [{"delta": {"content": "hello"}}]})
        self.assertIsNone(c.result().total_tokens)


class TextTests(unittest.TestCase):
    def test_unicode_units_differ(self):
        result = inspect_text([{"role": "user", "content": "中🙂"}],
                              max_text_chars=2, max_json_bytes=1000)
        self.assertEqual(result["text_code_points"], 2)
        self.assertEqual(result["per_message"][0]["utf8_bytes"], 7)
        self.assertTrue(result["project_size_ok"])
        self.assertIsNone(result["token_count"])
        self.assertIsNone(result["model_context_ok"])

    def test_project_size_gate(self):
        result = inspect_text([{"role": "user", "content": "abc"}],
                              max_text_chars=2, max_json_bytes=1000)
        self.assertFalse(result["project_size_ok"])

    def test_only_text_subset_and_valid_limits(self):
        with self.assertRaises(ValueError):
            inspect_text([{"role": "user", "content": []}], max_text_chars=5, max_json_bytes=100)
        with self.assertRaises(ValueError):
            inspect_text([{"role": "user", "content": "a"}], max_text_chars=True, max_json_bytes=100)


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

执行:

python -m unittest -v test_usage_adapter.py

本次验证环境为Python 3.11.9,12个离线测试通过。覆盖未知、显式0、推导来源、部分缺失、字段模式、非法计数、总量冲突、重复快照、中断和Unicode大小边界。没有真实模型调用,也没有测性能。

六、常见报错与整改

expected a non-negative integer or null:先看原始字段的类型,而不是立即强转。若目标官方文档确实规定字符串计数,新增独立转换层并测试,不修改本适配器来掩盖来源变化。

reported total does not equal input + output:保留请求关联ID、服务名称与脱敏后的计数字段,核对是否选错schema、是否混入多个请求、是否存在不同统计定义。不要修改总量让表格看起来一致。

conflicting usage snapshots:确认每个请求使用独立收集器,再检查快照究竟是累计还是增量,以及是否有网关改写。本文策略会阻止继续输出正常用量记录,适合让调用方标为待核查。

接口完成,但result()仍未知:正文完成与用量已知是两个状态。检查请求选项、连接是否完整结束与官方响应规则;不要用字符长度补写真实Token数。

七、小练习与下期

把mock中的completion_tokens改成字符串"7",观察异常;再保留输入与输出、删掉total_tokens,观察来源变成derived。练习时不需要任何密钥。

再给测试增加一条:两个不同请求必须使用不同收集器,避免把上一轮usage带到下一轮。下期基于明确单位与Decimal做离线费用估算,遇到未知用量时停止出金额。

欢迎在评论区给出不含账号、密钥和正文隐私的字段样例,说明你用的是哪个协议以及哪一项缺失。

作者说明:本文经AI辅助整理与校对。程序、mock和测试均本地离线运行;示例计数不是任何真实模型或用户的消耗记录。

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

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

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

抵扣说明:

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

余额充值