Token用量统计
要点
- 大模型按 Token 计费,响应里的
usage字段就是账单数据,不记录等于把钱扔掉了 - Token 统计同时服务于计费、限流、成本分析和异常检测
- KV 适合存时间窗口内的预聚合计数,关键是 key 的设计和 TTL 策略
- 统计模块只负责提供数据,计费规则不应该写在统计模块里
waitUntil让统计写入不阻塞主流程- 月度和长期数据通过 Cron Trigger 聚合后存入 D1,KV 只管近期高频读写
1. Token 用量统计在解决什么问题
前面的文章讲了怎么调用大模型、怎么管理会话、怎么做上下文截断。这些环节都有一个共同的输入:Token 数量。
大模型按 Token 收费。一次请求消耗的 Token = 输入 Token + 输出 Token,不同模型单价不同。调用 100 次 gpt-4o 和 100 次 gpt-4o-mini,成本差几十倍。如果你的服务对外提供 API,不统计每个用户的 Token 用量,就没办法回答:
- 这个用户这个月花了多少钱
- 哪些模型消耗了大部分预算
- 某个用户的用量突然翻倍,是正常增长还是接口被刷了
这些问题在日活十几个人的时候可以忽略,日活过千之后就变成运营硬约束。
2. 从模型响应中提取 usage
大模型 API 的响应自带用量数据:
// response.json
{
"model": "gpt-4o",
"choices": [{ "message": { "role": "assistant", "content": "..." } }],
"usage": { "prompt_tokens": 156, "completion_tokens": 89, "total_tokens": 245 }
}提取逻辑做一层防御性处理,模型 API 偶尔返回不完整的响应:
// src/stats/token-stats.ts
type TokenUsage = { promptTokens: number; completionTokens: number; totalTokens: number }
function extractUsage(response: unknown): TokenUsage | null {
const usage = (response as any)?.usage
if (!usage || typeof usage.prompt_tokens !== 'number') return null
return {
promptTokens: usage.prompt_tokens,
completionTokens: usage.completion_tokens ?? 0,
totalTokens: usage.total_tokens ?? 0
}
}流式响应的情况不同:SSE 的最后一个 chunk 才带 usage,中间 chunk 没有。如果需要在流式场景下统计,可以在流结束后异步更新,不阻塞主推送。
3. 按什么维度存储
先想清楚要回答哪些查询:
- 用户 A 今天用了多少 Token — 按用户 + 天
- 模型
gpt-4o这周被调用了多少次 — 按模型 + 周 - 用户 B 过去一小时的用量是否异常 — 按用户 + 小时
核心维度是三个:用户 ID、模型名称、时间窗口。
方案一:预聚合计数器
每次请求直接累加到时间窗口计数器。KV 存储:
key: stats:{userId}:{model}:{timeWindow}
value: { promptTokens, completionTokens, totalTokens, requestCount }优点:写入快,一次 KV 读-改-写完成。读取快,一个 key 就是一个汇总值。
缺点:KV 读-改-写不是原子操作,并发可能丢计数。统计场景下这个误差通常可以接受。
方案二:写原始记录,异步聚合
每次写一条原始记录,定时任务扫描聚合。数据完整但写入量大,KV 做明细存储不太合适。
对 Cloudflare Workers + KV 的组合,方案一更实用。统计不需要绝对精确,差几条请求不影响决策。
4. KV 存储实现
时间窗口生成
// src/stats/time-window.ts
export type Granularity = 'hour' | 'day' | 'month'
export function getTimeWindow(timestamp: number, granularity: Granularity): string {
const d = new Date(timestamp)
const y = d.getUTCFullYear()
const m = String(d.getUTCMonth() + 1).padStart(2, '0')
const day = String(d.getUTCDate()).padStart(2, '0')
const h = String(d.getUTCHours()).padStart(2, '0')
if (granularity === 'hour') return `${y}${m}${day}${h}`
if (granularity === 'day') return `${y}${m}${day}`
return `${y}${m}`
}写入统计
每次请求完成后,同时写入小时和天两个粒度:
// src/stats/token-stats.ts
type StatsRecord = { promptTokens: number; completionTokens: number; totalTokens: number; requestCount: number }
async function incrementStats(kv: KVNamespace, userId: string, model: string, usage: TokenUsage) {
const now = Date.now()
await incrementWindow(kv, userId, model, getTimeWindow(now, 'hour'), usage, 48 * 3600)
await incrementWindow(kv, userId, model, getTimeWindow(now, 'day'), usage, 90 * 86400)
}
async function incrementWindow(kv: KVNamespace, userId: string, model: string, window: string, usage: TokenUsage, ttl: number) {
const key = `stats:${userId}:${model}:${window}`
const existing = await kv.get(key, 'json') as StatsRecord | null
const record: StatsRecord = {
promptTokens: (existing?.promptTokens ?? 0) + usage.promptTokens,
completionTokens: (existing?.completionTokens ?? 0) + usage.completionTokens,
totalTokens: (existing?.totalTokens ?? 0) + usage.totalTokens,
requestCount: (existing?.requestCount ?? 0) + 1
}
await kv.put(key, JSON.stringify(record), { expirationTtl: ttl })
}TTL 设置:小时粒度保留 48 小时(够用近 24h 趋势),天粒度保留 90 天(够季度报表)。月数据不在 KV 里存,而是从日数据聚合查询。
调用时机
在聊天接口里,模型调用成功后用 waitUntil 异步写入,不阻塞响应:
// src/routes/chat.ts
const result = await callModelAPI({ model: body.model, messages: body.messages })
const usage = extractUsage(result)
if (usage) {
c.executionCtx.waitUntil(incrementStats(c.env.KV, userId, body.model, usage))
}
return c.json(result)5. 统计查询接口
查询某用户某天的用量
// src/routes/stats.ts
statsApp.get('/usage/daily', async (c) => {
const userId = c.get('user').id
const dateStr = c.req.query('date') || getTimeWindow(Date.now(), 'day')
const prefix = `stats:${userId}:`
const suffix = `:${dateStr}`
const { keys } = await c.env.KV.list({ prefix })
const matched = keys.filter(k => k.name.endsWith(suffix))
const results = await Promise.all(matched.map(async (k) => {
const record = await c.env.KV.get(k.name, 'json') as StatsRecord | null
const model = k.name.split(':')[2]
return { model, ...record }
}))
return c.json({ date: dateStr, models: results })
})KV 的 list() 只支持前缀匹配,所以 key 设计要把查询维度放在前缀能覆盖的位置。stats:{userId}: 做前缀列出某用户的所有记录,再用 endsWith 过滤时间窗口。
查询趋势数据
逐时间窗口查询,组装成数组:
// src/routes/stats.ts
statsApp.get('/usage/hourly-trend', async (c) => {
const userId = c.get('user').id
const hours = parseInt(c.req.query('hours') || '24')
const now = Date.now()
const points: { window: string; totalTokens: number; requestCount: number }[] = []
for (let i = hours - 1; i >= 0; i--) {
const window = getTimeWindow(now - i * 3600 * 1000, 'hour')
// 对该窗口内所有模型求和,代码同 daily 查询,此处省略
points.push({ window, totalTokens: 0, requestCount: 0 }) // 实际有值
}
return c.json({ userId, hours, points })
})24 小时就是 24 次 KV list。如果查 7 天趋势,换成天粒度减少调用次数。
6. 长期聚合与 TTL 策略
整理各粒度的生命周期:
| 粒度 | TTL | 用途 |
|---|---|---|
| 小时 | 48 小时 | 实时监控、近 24h 趋势 |
| 天 | 90 天 | 日报表、月度聚合数据源 |
| 月 | 从日数据聚合 | 季度/年度报表 |
月数据在查询时从日粒度实时聚合。如果需要长期保存,用 Cron Trigger 定时聚合写入 D1:
// src/cron/daily-aggregate.ts
export async function dailyAggregate(env: Env) {
const yesterday = getTimeWindow(Date.now() - 86400000, 'day')
const { keys } = await env.KV.list({ prefix: 'stats:' })
const userMap = new Map<string, StatsRecord>()
for (const k of keys) {
if (!k.name.endsWith(`:${yesterday}`)) continue
const record = await env.KV.get(k.name, 'json') as StatsRecord | null
if (!record) continue
const userId = k.name.split(':')[1]
const existing = userMap.get(userId) ?? { promptTokens: 0, completionTokens: 0, totalTokens: 0, requestCount: 0 }
userMap.set(userId, {
promptTokens: existing.promptTokens + record.promptTokens,
completionTokens: existing.completionTokens + record.completionTokens,
totalTokens: existing.totalTokens + record.totalTokens,
requestCount: existing.requestCount + record.requestCount
})
}
for (const [userId, record] of userMap) {
await env.DB.prepare(`
INSERT INTO monthly_stats (user_id, month, prompt_tokens, completion_tokens, total_tokens, request_count)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, month) DO UPDATE SET
prompt_tokens = prompt_tokens + excluded.prompt_tokens,
completion_tokens = completion_tokens + excluded.completion_tokens,
total_tokens = total_tokens + excluded.total_tokens,
request_count = request_count + excluded.request_count
`).bind(userId, yesterday.slice(0, 6), record.promptTokens, record.completionTokens, record.totalTokens, record.requestCount).run()
}
}wrangler.jsonc 配置 Cron Trigger:
// wrangler.jsonc
{ "triggers": { "crons": ["0 2 * * *"] } } // 每天 UTC 2:007. 与计费系统对接
统计模块的职责到此为止:记录 Token 消耗,提供多维度查询。计费系统从统计模块拿数据,按自己的规则算钱。
统计模块不应该知道计费规则。 把单价、阶梯计费、免费额度写在统计模块里,改价格要动统计代码,不同客户的计费方案会让统计模块被 if-else 淹没。
给计费系统的数据接口,按模型和日期返回明细:
// src/routes/stats.ts
statsApp.get('/usage/billing', async (c) => {
const userId = c.req.query('userId')
const from = c.req.query('from') // YYYYMMDD
const to = c.req.query('to')
// ... 过滤并聚合,按模型分组
return c.json({ userId, from, to, byModel: {
"gpt-4o": [{ day: "20260601", promptTokens: 12000, completionTokens: 5400, requestCount: 45 }],
"gpt-4o-mini": [{ day: "20260601", promptTokens: 45000, completionTokens: 22000, requestCount: 120 }]
}})
})计费模块拿到数据后按自己维护的价格表算钱。价格变了只改价格表,不动统计接口。
统计模块还有一个直接用途——实时限流。在中间件里查用户当前小时的 Token 消耗,超过阈值就拒绝:
// src/middleware/token-limit.ts
export const tokenLimit = async (c: any, next: () => Promise<void>) => {
const userId = c.get('user').id
const hourWindow = getTimeWindow(Date.now(), 'hour')
// 读取该用户当前小时所有模型的总消耗
let hourlyTotal = 0
// ... 遍历 keys 求和,代码同 daily 查询
const limit = c.get('user').tokenLimit ?? 100000
if (hourlyTotal >= limit) {
return c.json({ error: { code: 'TOKEN_LIMIT_EXCEEDED', message: '本小时 Token 用量已达上限' } }, 429)
}
c.set('hourlyTokenUsage', hourlyTotal)
await next()
}总结
回顾一下这篇的要点:
- 大模型响应自带
usage字段,统计模块的职责是记录、汇总、提供查询 - 预聚合计数器适合 KV,读-改-写的竞态在统计场景下可以接受
- 时间窗口按小时(TTL 48h)和天(TTL 90 天)两个粒度存储
- 月度数据从日粒度聚合查询,长期归档通过 Cron Trigger 写入 D1
- 统计模块只管数据不管计费规则,边界划清楚,两边独立演进
waitUntil让统计写入不阻塞主流程
下一篇讨论怎么把统计数据展示在管理后台。