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 插件都靠它。图注:三家不同路径的账单进"验钞机",Extract 系列挨个路径试,最后都吐出同一个 TokenUsage 结构给下游。
L02
多协议路径常量
tokenusage/tokenusage.go:23-60+ 定义了各家协议的 gjson 路径常量:
| 字段 | OpenAI | Gemini | Anthropic |
|---|---|---|---|
| 模型 | model | modelVersion | message.model |
| 输入 Token | usage.prompt_tokens | usageMetadata.promptTokenCount | message.usage.input_tokens |
| 会话 ID | id | responseId | message.id |
读法:常量名清晰标注了协议:
ModelPathOpenAIChatCompletions、UsageInputTokensPathGemini、ModelPathAnthropicMessages…还覆盖了 OpenAI Responses/Batches/Images、Doubao 等变体。这张"路径映射表"就是抹平差异的核心资产——加新厂商就加几行路径常量。L03
TokenUsage 结构
tokenusage.go:94 的 TokenUsage 是统一的输出结构,含输入/输出/总 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 用量通常在最后一个块里(或某个特定块)。所以 GetTokenUsage 用 wrapper.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 块:
同理得
若换成 Gemini 的块,OpenAI 路径取不到 → 自动落到
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=42、TotalToken=170、Model="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 单元测试工具。