token 估算器:在计费之前猜对 token 数

专栏:new-api 源码拆解 · 第 12 / 12 篇
new-apitoken 估算计费

:::info 学习目标 完成本篇后你能够:解释网关为什么需要 token 估算器及其三个消费场景;读懂字符级状态机估算器的权重表与规则;手算一段中英混合文本的估算值;描述图像 token 的 tile/patch 两种公式。 前置:第 8 篇(计费)完成。预计时长:60 分钟。 :::

:::note 本章术语速查(新手建议先读)

  • Tokenizer(分词器):把文本切成 token 的程序。各家模型用自己的分词器,同一段文字切出来的 token 数不同。
  • tiktoken:OpenAI 官方的分词器库(BPE 算法)——对 OpenAI 模型精确,对别家只是数字游戏。
  • BPE(字节对编码):常见的分词算法,高频组合成一个 token、生僻内容拆成多块——“abc123xyz” 会被切开的原因。
  • 状态机(State Machine):程序按当前”状态”决定如何处理下一个输入。估算器用它记住”现在是否在单词中间”。
  • 权重表(multipliers):每种字符类的经验 token 成本(如中文字 0.68~1.21、Emoji 2+)。
  • tile / patch:图片计 token 的两种官方公式——按 512px 瓦片数计费,或按 32px 小块数计费。 :::

第 8 篇的计费三段式有个隐含前提:预扣费发生在上游响应之前——那时谁也不知道真实的 token 数。这篇的主角就是填补这个空白的模块:token 估算器(service/token_estimator.go + service/token_counter.go)。

它在哪被消费:三个场景

图表(newapi-token-estimator.md)

三个场景的精度要求不同:预扣费只要”大致够”(结算时多退少补);兜底场景则是最终账单(上游不给 usage,本地估算就是唯一依据);计数端点必须尽量准(用户直接看着这个数字)。

分层设计:估算器 vs 完整计数器

文件职责何时用
token_estimator.go(231 行)字符级状态机启发式估算,按厂商分权重非 OpenAI 模型(注释原话:“非openai模型,使用tiktoken-go计算没有意义,使用估算节省资源”)
token_counter.go(419 行)完整请求计数:文本 + 消息格式开销 + 图片/音频/视频/文件预扣费估算与 count_tokens 端点
usage_helpr.go(33 行)上游缺 usage 时的兜底组装渠道适配器调用

核心 1:字符级状态机估算器

没有 tokenizer 怎么估算 token?new-api 的答案是一个按字符分类的状态机:不同字符类有不同权重,同类连续字符只在”进词”时计一次。先看权重表(token_estimator.go:36-46):

// vendor 参考值:每厂商每字符类的经验权重
multipliersMap = map[Provider]multipliers{
    Gemini: { Word: 1.15, Number: 2.8,  CJK: 0.68, Symbol: 0.38,
              MathSymbol: 1.05, URLDelim: 1.2, AtSign: 2.5,
              Emoji: 1.08, Newline: 1.15, Space: 0.2, BasePad: 0 },
    Claude: { Word: 1.13, Number: 1.63, CJK: 1.21, Symbol: 0.4,
              MathSymbol: 4.52, URLDelim: 1.26, AtSign: 2.82,
              Emoji: 2.6, Newline: 0.89, Space: 0.39, BasePad: 0 },
    OpenAI: { Word: 1.02, Number: 1.55, CJK: 0.85, Symbol: 0.4,
              MathSymbol: 2.68, URLDelim: 1.0, AtSign: 2.0,
              Emoji: 2.12, Newline: 0.5, Space: 0.42, BasePad: 0 },
}

这张表浓缩了大量 tokenizer 的经验规律:中日韩字符在 Gemini 里便宜(0.68/字)、在 Claude 里贵(1.21/字);Emoji数学符号是所有 tokenizer 的贵客(Claude 的数学符号高达 4.52);@ 会把单词切碎(2.02.82);URL 分隔符反而被 tokenizer 优化得很好(1.01.26)。权重带读写锁(multipliersLock),支持运行时调整。

状态机的核心循环(简化为伪码,原码见 EstimateToken,token_estimator.go

):

对 text 的每个字符 r:
  空白        → currentWordType 归位;\n/\t 用 Newline 权重,空格用 Space
  中日韩      → 归位;count += CJK
  Emoji       → 归位;count += Emoji
  字母/数字   → 若之前不在词中,或从字母切到数字(BPE 会拆 "abc123")
                → 计一次 Word 或 Number;词中字符免费
  其他        → 归位;按数学符号/@/URL 分隔符/普通标点分别计
最后:ceil(count) + BasePad

手算检查点EstimateToken(Claude, "你好 world 3.14")——你好 = 2×1.21;空格 = 0.39;world 进词 1×1.13(4 个字母免费)+ 空格 0.39;数字 “3.14”:3 进词 1.63,”.” 0.4,“14” 换类型 1.63 → 合计 ≈ 2.42+0.39+1.13+0.39+1.63+0.4+1.63 ≈ 7.99 → ceil = 8。估算器对中英混合文本的误差通常在 ±20% 内——对”预扣费”和”兜底计费”两个用途足够。

核心 2:OpenAI 用真 tokenizer,其余用估算

// service/token_counter.go(节选)
func CountTextToken(text string, model string) int {
    if common.IsOpenAITextModel(model) {
        tokenEncoder := getTokenEncoder(model)
        return getTokenNum(tokenEncoder, text)      // tiktoken-go:精确
    }
    // 非openai模型,使用tiktoken-go计算没有意义,使用估算节省资源
    return EstimateTokenByModel(model, text)
}

tiktoken 是 OpenAI 的 BPE 编码器——对 OpenAI 模型精确,对别家模型毫无意义。这个”该精确时精确、该省时省”的判断,配合 EstimateTokenByModel 的模型名路由(含 gemini→Gemini、claude→Claude、其余→OpenAI),就是整个估算层的调度逻辑。

核心 3:图片 token——两种官方公式

图片 token 不是”估”出来的,是按各厂商公开的计费公式逐字实现的(getImageToken):

tile 公式(4o/4.1/4.5/o1/o3 系):① 先把图缩进 2048×2048 的方框 → ② 再把短边缩放到 768 → ③ 数 512×512 的瓦片数 → 瓦片数 × tileTokens + baseTokens。每档模型的 base/tile 常数不同(gpt-4o 是 85/170,o1/o3 是 75/150,gpt-4o-mini 高达 2833/5667)。

patch 公式(o4-mini/gpt-4.1-mini/5-mini/nano 系):32×32 像素一块,总 patch 数封顶 1536(超了按面积等比缩小再对齐),每模型乘一个经验系数(1.62~2.46)。

图表(newapi-token-estimator.md)

特例也有趣:glm-4 直接返回常数 1047(实测值);detail: "low" 只收 baseTokens;运算符开关(GetMediaToken/GetMediaTokenNotStream)可以让管理员关闭图片/非流式场景的本地计数。

核心 4:音频与文件——按时长与固定值

  • 音频转写:按时长计费——ceil(时长秒) / 60 × 1000 token/分钟(与”按分钟定价”对齐);负时长先钳到 0(源码注释:时长来自用户可伪造的元数据,负值会让预扣费变负);
  • 音频输出(TTS/Realtime):时长 / 60 × 200 / 0.24 换算成 quota(对齐 $0.24/分钟);
  • 文件类:音频 256、视频 8192(4096×2)、未知/文件 4096——固定估算值,宁高勿低。

组装:CountRequestToken 的完整账单

CountRequestToken(token_counter.go

起)把所有部件加总:文本 token(或纯 rune 计数——TokenTypeTextNumber 用于按字符计价的模型)+ 消息格式开销(OpenAI 格式:每条消息 +3、每个工具 +8、每个 name +3、整体 +3——对话模板本身也占 token)+ 各文件的图片/音频/视频 token。结果写入上下文键 ContextKeyPromptTokens,预扣费(第 8 篇)直接取用。

与计费链路的联系

  • 预扣费(第 8 篇):EstimateRequestTokenCountToken 开关控制——管理员可关闭中继的 token 估算;
  • count_tokens 端点CountRequestToken 不受该开关控制(源码注释:Claude 的 messages/count_tokens 这类工具端点必须始终可用);
  • 兜底计费ResponseText2Usage 被 10+ 个渠道适配器调用(palm/cloudflare/tencent/xai/dify…),并写入 ContextKeyLocalCountTokens——下游知道这笔账是估的,审计时能区分”上游实测”与”本地估算”。

常见踩坑

  1. 以为估算等于真实——估算只服务预扣与兜底;有真实 usage 时永远以结算为准。两个数字的差异可从日志的 Other 字段观察;
  2. 非 OpenAI 模型跑 tiktoken——数字”精确”但语义错误;这正是估算器存在的原因;
  3. 用户可控的时长/文件大小不做钳制——负数与天文数字都会击穿计费(源码里的负时长钳位注释就是为此);
  4. 忘了消息格式开销——只数文本会系统性低估(OpenAI 每条消息 3 token 的模板成本)。

随堂练习(带验收标准)

  1. 手算 EstimateToken(OpenAI, "user@example.com")——注意 @ 的 2.0 权重与单词切分。验收:与 go test 或日志中的值一致;
  2. 用管理台发起一次带图请求(4o 模型,已知分辨率的图),手算 tile 公式并与消费日志对比。验收:误差 ≤ 2 token;
  3. 找一个”上游不返回 usage”的渠道(如部分 cloudflare/dify 配置),确认日志里该笔消费来自本地估算(ContextKeyLocalCountTokens);
  4. 进阶:给权重表加一个新 Provider(如 DeepSeek),用真实响应的 usage 反推各字符类权重——这就是当年这张表的经验来源。

← 返回文章列表