你的请求成功返回了文字,日志程序却在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和测试均本地离线运行;示例计数不是任何真实模型或用户的消耗记录。
本文介绍一个纯Python实现的大模型API用量解析适配器,支持Chat Completions与Responses两种字段协议,严格区分缺失字段、显式0值与推导总量,避免流式响应中用量重复累计。适配器仅依赖标准库,不连接真实模型,通过本地mock和边界测试验证健壮性,涵盖字段缺失、总量冲突、快照重复、Unicode边界等12类场景。
407

被折叠的 条评论
为什么被折叠?



