Day 18 / 共 20 天 · 第 4 周 高级特性与实战

AI Token 用量解析

这是把前面能力"串起来"的一天。AI 网关要按 Token 计费和限流,第一步是"知道每次请求用了多少 Token"。但各家大模型的响应格式都不一样。今天看 tokenusage 包怎么统一从 OpenAI/Gemini/Anthropic 等格式里提取 Token 数——它要用到 Day 11 的流式处理、Day 12 的 Context、Day 15 的 Redis。

📍 你在整门课的位置(wasm-go 20 天 · 第 4 周 高级特性与实战)
Leader 选举 D17 AI Token 解析 D18 性能/测试 D19 构建部署 D20 🎓 毕业
L01

为什么要解析 Token

💡 本质:tokenusage = 多国货币的"统一验钞机" 各家大模型报账单的"格式"就像不同国家的货币:OpenAI 把用量写在 usage.prompt_tokens、Gemini 写在 usageMetadata.promptTokenCount、Anthropic 写在 message.usage.input_tokens——同一个"输入 Token 数",藏在完全不同的路径里。tokenusage 就是那台"统一验钞机":不管塞进去哪国钞票,都换算成同一种"标准币"(TokenUsage 结构)交给下游。输入多样、输出统一——这是"适配层"的经典套路(回想上一站 ai-proxy 统一成 OpenAI 协议)。
Token 是大模型的"计量单位" 大模型按 Token(词元)收费——输入多少 Token、输出多少 Token,各有单价。AI 网关要做计费、配额、限流,就必须知道每次调用用了多少 Token。这个数据在 LLM 的响应里(usage 字段)。但麻烦在于:OpenAI 叫 usage.prompt_tokens、Gemini 叫 usageMetadata.promptTokenCount、Anthropic 叫 message.usage.input_tokens——每家路径都不同!tokenusage 包就是把这些差异抹平,统一提取。上一站的 ai-statistics、ai-token-ratelimit 插件都靠它。
三种厂商格式 → 一台"验钞机" → 统一结构 OpenAI: usage.prompt_tokens Gemini: usageMetadata.promptTokenCount Anthropic: message.usage.input_tokens GetTokenUsage多路径兜底,哪个存在用哪个 TokenUsageInputToken/OutputToken/TotalToken/Model…
图注:三家不同路径的账单进"验钞机",Extract 系列挨个路径试,最后都吐出同一个 TokenUsage 结构给下游。
L02

多协议路径常量

tokenusage/tokenusage.go:23-60+ 定义了各家协议的 gjson 路径常量:

字段OpenAIGeminiAnthropic
模型modelmodelVersionmessage.model
输入 Tokenusage.prompt_tokensusageMetadata.promptTokenCountmessage.usage.input_tokens
会话 IDidresponseIdmessage.id
读法:常量名清晰标注了协议:ModelPathOpenAIChatCompletionsUsageInputTokensPathGeminiModelPathAnthropicMessages…还覆盖了 OpenAI Responses/Batches/Images、Doubao 等变体。这张"路径映射表"就是抹平差异的核心资产——加新厂商就加几行路径常量。
L03

TokenUsage 结构

tokenusage.go:94TokenUsage 是统一的输出结构,含输入/输出/总 Token + 明细 map + 模型名等。上下文 key 常量(:23-31):CtxKeyInputToken/CtxKeyOutputToken/CtxKeyTotalToken/CtxKeyModel/CtxKeyChatId

统一的"出口格式" 不管输入是哪家格式,解析结果都装进同一个 TokenUsage 结构。这样下游(计费、限流、统计插件)只需认识这一个结构,不用管上游是 OpenAI 还是 Gemini。"输入多样、输出统一"是适配层的经典模式——回想上一站 ai-proxy 也是"统一成 OpenAI 协议"。
L04

GetTokenUsage

tokenusage.go:107 的核心函数:

func GetTokenUsage(ctx wrapper.HttpContext, body []byte) TokenUsage {
    chunks := bytes.SplitSeq(wrapper.UnifySSEChunk(body), []byte("\n\n"))  // 按 SSE 分块
    u := TokenUsage{InputTokenDetails: ..., OutputTokenDetails: ...}
    for chunk := range chunks {
        if !bytes.Contains(chunk, []byte(`"usage"`)) &&
           !bytes.Contains(chunk, []byte(`"usageMetadata"`)) { continue }  // 只看含 usage 的块
        ExtractModel(ctx, chunk, &u)
        ExtractInputTokens(ctx, chunk, &u)
        ExtractOutputTokens(ctx, chunk, &u)
        ExtractInputTokenDetails(ctx, chunk, &u)
        ExtractOutputTokenDetails(ctx, chunk, &u)
        ExtractTotalTokens(ctx, chunk, &u)
    }
    return u
}
读法:流程:把响应体按 SSE 分块 → 只处理含 usage/usageMetadata 的块(其余是内容块,跳过)→ 用一组 Extract 函数逐项提取。返回填好的 TokenUsage。
L05

SSE 分块处理

为什么要处理 SSE 分块? LLM 流式响应是 SSE(Server-Sent Events)格式:一段段 data: {...}\n\n。Token 用量通常在最后一个块里(或某个特定块)。所以 GetTokenUsagewrapper.UnifySSEChunk 归一化后按 \n\n 切块,遍历找含 usage 的块。"只处理含 usage 的块"是个优化——大部分块是内容(文字),不用解析。这呼应 Day 11 的流式响应体处理:Token 解析常在流式响应钩子里边收边算。
L06

Extract 系列

每个 ExtractXxx 函数(如 ExtractInputTokens)内部按不同协议路径尝试 gjson 取值:

// 伪代码:ExtractInputTokens 逐个协议路径尝试
if v := gjson.GetBytes(chunk, UsageInputTokensPathOpenAIChatCompletions); v.Exists() {
    u.InputToken = v.Int()
} else if v := gjson.GetBytes(chunk, UsageInputTokensPathGemini); v.Exists() {
    u.InputToken = v.Int()
} else if v := gjson.GetBytes(chunk, UsageInputTokensPathAnthropicMessages); v.Exists() {
    ...
}
📝 举个例子:一个 OpenAI 结束块被解析 输入 SSE 块:data: {"model":"gpt-4o","usage":{"prompt_tokens":128,"completion_tokens":42,"total_tokens":170}}
ExtractInputTokens 试 OpenAI 路径 usage.prompt_tokens → 存在 → u.InputToken=128(不再试 Gemini/Anthropic 路径)。
同理得 OutputToken=42TotalToken=170Model="gpt-4o"
若换成 Gemini 的块,OpenAI 路径取不到 → 自动落到 usageMetadata.promptTokenCount,结果照样填对。
读法:"多路径兜底"——挨个协议的路径试,哪个存在用哪个。这样一个函数就能处理所有厂商,不用外面判断"这是哪家"。还处理了 Anthropic 的缓存 Token(cache_creation_input_tokens/cache_read_input_tokens)等细节。gjson 的按路径取值在这里发挥到极致。
L07

存进 Context

解析出的 Token 数常存进请求级 Context(用 CtxKeyInputToken 等 key),供后续阶段/其他插件读取。

Token 数据的流转 ai-proxy 或 ai-statistics 在响应体阶段调 GetTokenUsage 解析 → 存进 Context → StreamDone 阶段(Day 11)取出上报 metric / 写自定义日志(Day 12)。ai-token-ratelimit 则拿它扣减 Redis 里的配额(Day 15)。看,这一天把前面所有能力串起来了:流式处理(Day 11)+ Context(Day 12)+ Redis(Day 15)+ Token 解析(今天)= 一个完整的 AI 计费限流插件。回想上一站 Console 的 AI Dashboard,数据源头就在这里。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • 为什么 AI 网关要解析 Token?各家格式差异体现在哪?
  • 路径常量表怎么抹平多协议差异?
  • GetTokenUsage 为什么按 SSE 分块、只处理含 usage 的块?
  • Extract 的"多路径兜底"怎么一个函数处理所有厂商?
🎯 记忆口诀 "多厂商入、统一结构出;按 SSE 切块、只挑含 usage 的块;每项 Extract 多路径兜底,哪家路径在用哪家。"
⚠️ 常见误解:小白以为"每个流式块都带 Token 用量"。其实用量通常只在最后一个(或特定)块里,内容块根本没有 usage 字段——所以 GetTokenUsage 才要跳过不含 usage 的块,别在每块都白解析。

✋ 动手

cd /Users/bitmart/work/codes/github/higress-group/wasm-go
sed -n '23,105p' pkg/tokenusage/tokenusage.go
sed -n '107,132p' pkg/tokenusage/tokenusage.go
grep -n "func Extract" pkg/tokenusage/tokenusage.go
明天预告 · Day 19性能、稳定性与测试——插件的 rebuild 重建机制(防内存泄漏/GC 抖动)、IO 并发限制、streaming inject、以及 pkg/test 单元测试工具。
← Day 17 Day 19 · 性能与测试 →