24.07-用量统计
要点
- 用量统计是计费、运营决策的基础,需要从多个维度聚合数据
- 统计维度包括:时间(小时/天/月)、模型、用户、功能模块
- 使用聚合表加速查询,避免每次都从原始记录计算
- 支持数据导出,方便租户进行成本分析
内容
1. 统计维度
AI SaaS 的用量统计通常需要从以下维度分析:
| 维度 | 说明 | 用途 |
|---|---|---|
| 时间 | 小时/天/周/月 | 趋势分析、周期性规律 |
| 模型 | GPT-3.5/GPT-4/Claude | 成本分析、模型偏好 |
| 用户 | 租户内的每个用户 | 用户活跃度、配额分配 |
| 功能 | Chat/RAG/Embedding | 功能使用率 |
| 状态 | 成功/失败 | 质量监控 |
2. 统计数据库设计
2.1 原始记录表
-- 用量明细表(原始记录)
CREATE TABLE usage_records (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
user_id TEXT NOT NULL,
feature TEXT NOT NULL, -- chat/rag/embedding
model TEXT,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
total_tokens INTEGER DEFAULT 0,
request_count INTEGER DEFAULT 1,
cost INTEGER DEFAULT 0, -- 成本(美分)
status TEXT DEFAULT 'success', -- success/failed
latency_ms INTEGER,
created_at INTEGER NOT NULL,
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
)
-- 索引
CREATE INDEX idx_usage_tenant_time ON usage_records(tenant_id, created_at)
CREATE INDEX idx_usage_user_time ON usage_records(user_id, created_at)
CREATE INDEX idx_usage_model_time ON usage_records(model, created_at)2.2 聚合表
-- 小时聚合表
CREATE TABLE usage_hourly (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
hour INTEGER NOT NULL, -- 小时开始时间(时间戳)
feature TEXT,
model TEXT,
total_tokens INTEGER DEFAULT 0,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
request_count INTEGER DEFAULT 0,
success_count INTEGER DEFAULT 0,
failed_count INTEGER DEFAULT 0,
total_cost INTEGER DEFAULT 0,
total_latency_ms INTEGER DEFAULT 0,
UNIQUE(tenant_id, hour, feature, model)
)
-- 日聚合表
CREATE TABLE usage_daily (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
date TEXT NOT NULL, -- YYYY-MM-DD
feature TEXT,
model TEXT,
total_tokens INTEGER DEFAULT 0,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
request_count INTEGER DEFAULT 0,
success_count INTEGER DEFAULT 0,
failed_count INTEGER DEFAULT 0,
total_cost INTEGER DEFAULT 0,
total_latency_ms INTEGER DEFAULT 0,
UNIQUE(tenant_id, date, feature, model)
)
-- 月聚合表
CREATE TABLE usage_monthly (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
month TEXT NOT NULL, -- YYYY-MM
feature TEXT,
model TEXT,
total_tokens INTEGER DEFAULT 0,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
request_count INTEGER DEFAULT 0,
success_count INTEGER DEFAULT 0,
failed_count INTEGER DEFAULT 0,
total_cost INTEGER DEFAULT 0,
total_latency_ms INTEGER DEFAULT 0,
UNIQUE(tenant_id, month, feature, model)
)3. 记录用量
// src/lib/usage.ts
export class UsageRecorder {
constructor(private db: D1Database, private env: Env) {}
/**
* 记录一次 AI 调用
*/
async record(params: {
tenantId: string
userId: string
feature: string
model?: string
inputTokens?: number
outputTokens?: number
cost?: number
status?: 'success' | 'failed'
latencyMs?: number
}) {
const {
tenantId,
userId,
feature,
model,
inputTokens = 0,
outputTokens = 0,
cost = 0,
status = 'success',
latencyMs,
} = params
const totalTokens = inputTokens + outputTokens
const now = Date.now()
const id = crypto.randomUUID()
// 1. 记录原始数据
await this.db.prepare(`
INSERT INTO usage_records (id, tenant_id, user_id, feature, model, input_tokens, output_tokens, total_tokens, cost, status, latency_ms, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).bind(
id,
tenantId,
userId,
feature,
model || null,
inputTokens,
outputTokens,
totalTokens,
cost,
status,
latencyMs || null,
now
).run()
// 2. 更新聚合表(异步,不阻塞请求)
this.env.executionCtx.waitUntil(
this.updateAggregations({
tenantId,
feature,
model,
inputTokens,
outputTokens,
totalTokens,
cost,
status,
latencyMs: latencyMs || 0,
timestamp: now,
})
)
}
/**
* 更新聚合表
*/
private async updateAggregations(data: {
tenantId: string
feature: string
model?: string
inputTokens: number
outputTokens: number
totalTokens: number
cost: number
status: string
latencyMs: number
timestamp: number
}) {
const { tenantId, feature, model, inputTokens, outputTokens, totalTokens, cost, status, latencyMs, timestamp } = data
const date = new Date(timestamp)
const hourStart = new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours()).getTime()
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
const monthStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
const successCount = status === 'success' ? 1 : 0
const failedCount = status === 'failed' ? 1 : 0
// 更新小时聚合
await this.upsertAggregation('usage_hourly', {
tenantId,
timeKey: hourStart.toString(),
feature,
model,
inputTokens,
outputTokens,
totalTokens,
cost,
requestCount: 1,
successCount,
failedCount,
latencyMs,
})
// 更新日聚合
await this.upsertAggregation('usage_daily', {
tenantId,
timeKey: dateStr,
feature,
model,
inputTokens,
outputTokens,
totalTokens,
cost,
requestCount: 1,
successCount,
failedCount,
latencyMs,
})
// 更新月聚合
await this.upsertAggregation('usage_monthly', {
tenantId,
timeKey: monthStr,
feature,
model,
inputTokens,
outputTokens,
totalTokens,
cost,
requestCount: 1,
successCount,
failedCount,
latencyMs,
})
}
private async upsertAggregation(
table: string,
data: {
tenantId: string
timeKey: string
feature: string
model?: string
inputTokens: number
outputTokens: number
totalTokens: number
cost: number
requestCount: number
successCount: number
failedCount: number
latencyMs: number
}
) {
const id = crypto.randomUUID()
const { tenantId, timeKey, feature, model, inputTokens, outputTokens, totalTokens, cost, requestCount, successCount, failedCount, latencyMs } = data
const timeColumn = table === 'usage_hourly' ? 'hour' : table === 'usage_daily' ? 'date' : 'month'
await this.db.prepare(`
INSERT INTO ${table} (id, tenant_id, ${timeColumn}, feature, model, total_tokens, input_tokens, output_tokens, request_count, success_count, failed_count, total_cost, total_latency_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(tenant_id, ${timeColumn}, feature, model)
DO UPDATE SET
total_tokens = total_tokens + ?,
input_tokens = input_tokens + ?,
output_tokens = output_tokens + ?,
request_count = request_count + ?,
success_count = success_count + ?,
failed_count = failed_count + ?,
total_cost = total_cost + ?,
total_latency_ms = total_latency_ms + ?
`).bind(
id,
tenantId,
timeKey,
feature,
model || null,
totalTokens,
inputTokens,
outputTokens,
requestCount,
successCount,
failedCount,
cost,
latencyMs,
totalTokens,
inputTokens,
outputTokens,
requestCount,
successCount,
failedCount,
cost,
latencyMs
).run()
}
}4. 查询用量统计
// src/routes/usage.ts
import { Hono } from 'hono'
const app = new Hono()
// 获取租户用量概览
app.get('/overview', async (c) => {
const tenantId = c.get('tenantId')
// 本月用量
const now = new Date()
const monthStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
const monthlyUsage = await c.env.DB.prepare(`
SELECT
SUM(total_tokens) as tokens,
SUM(request_count) as requests,
SUM(total_cost) as cost,
SUM(success_count) as successCount,
SUM(failed_count) as failedCount
FROM usage_monthly
WHERE tenant_id = ? AND month = ?
`).bind(tenantId, monthStr).first()
// 今日用量
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
const dailyUsage = await c.env.DB.prepare(`
SELECT
SUM(total_tokens) as tokens,
SUM(request_count) as requests,
SUM(total_cost) as cost
FROM usage_daily
WHERE tenant_id = ? AND date = ?
`).bind(tenantId, dateStr).first()
// 按模型分布
const modelUsage = await c.env.DB.prepare(`
SELECT
model,
SUM(total_tokens) as tokens,
SUM(request_count) as requests,
SUM(total_cost) as cost
FROM usage_monthly
WHERE tenant_id = ? AND month = ?
GROUP BY model
ORDER BY tokens DESC
`).bind(tenantId, monthStr).all()
return c.json({
monthly: {
tokens: monthlyUsage?.tokens || 0,
requests: monthlyUsage?.requests || 0,
cost: monthlyUsage?.cost || 0,
successCount: monthlyUsage?.successCount || 0,
failedCount: monthlyUsage?.failedCount || 0,
},
daily: {
tokens: dailyUsage?.tokens || 0,
requests: dailyUsage?.requests || 0,
cost: dailyUsage?.cost || 0,
},
byModel: modelUsage.results,
})
})
// 获取用量趋势
app.get('/trend', async (c) => {
const tenantId = c.get('tenantId')
const days = parseInt(c.req.query('days') || '30')
const groupBy = c.req.query('groupBy') || 'day' // hour/day/month
const table = groupBy === 'hour' ? 'usage_hourly' : groupBy === 'month' ? 'usage_monthly' : 'usage_daily'
const timeColumn = groupBy === 'hour' ? 'hour' : groupBy === 'month' ? 'month' : 'date'
// 计算开始时间
const now = new Date()
let startTime: string
if (groupBy === 'hour') {
const start = new Date(now.getTime() - days * 24 * 60 * 60 * 1000)
startTime = Math.floor(start.getTime() / (60 * 60 * 1000)) * (60 * 60 * 1000)).toString()
} else if (groupBy === 'month') {
const start = new Date(now.getFullYear(), now.getMonth() - days, 1)
startTime = `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}`
} else {
const start = new Date(now.getTime() - days * 24 * 60 * 60 * 1000)
startTime = `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}-${String(start.getDate()).padStart(2, '0')}`
}
const trend = await c.env.DB.prepare(`
SELECT
${timeColumn} as time,
SUM(total_tokens) as tokens,
SUM(request_count) as requests,
SUM(total_cost) as cost,
SUM(success_count) as successCount,
SUM(failed_count) as failedCount,
AVG(CASE WHEN request_count > 0 THEN total_latency_ms * 1.0 / request_count ELSE 0 END) as avgLatency
FROM ${table}
WHERE tenant_id = ? AND ${timeColumn} >= ?
GROUP BY ${timeColumn}
ORDER BY ${timeColumn} ASC
`).bind(tenantId, startTime).all()
return c.json({ trend: trend.results })
})
// 按用户统计
app.get('/by-user', async (c) => {
const tenantId = c.get('tenantId')
const days = parseInt(c.req.query('days') || '30')
const startTime = Date.now() - days * 24 * 60 * 60 * 1000
const userUsage = await c.env.DB.prepare(`
SELECT
u.id as userId,
u.email,
u.name,
SUM(ur.total_tokens) as tokens,
SUM(ur.request_count) as requests,
SUM(ur.cost) as cost
FROM usage_records ur
JOIN users u ON ur.user_id = u.id
WHERE ur.tenant_id = ? AND ur.created_at > ?
GROUP BY ur.user_id
ORDER BY tokens DESC
`).bind(tenantId, startTime).all()
return c.json({ users: userUsage.results })
})
// 按功能统计
app.get('/by-feature', async (c) => {
const tenantId = c.get('tenantId')
const days = parseInt(c.req.query('days') || '30')
const startTime = Date.now() - days * 24 * 60 * 60 * 1000
const featureUsage = await c.env.DB.prepare(`
SELECT
feature,
SUM(total_tokens) as tokens,
SUM(request_count) as requests,
SUM(cost) as cost,
COUNT(*) as callCount
FROM usage_records
WHERE tenant_id = ? AND created_at > ?
GROUP BY feature
ORDER BY tokens DESC
`).bind(tenantId, startTime).all()
return c.json({ features: featureUsage.results })
})5. 导出用量数据
// src/routes/usage.ts(补充)
app.get('/export', async (c) => {
const tenantId = c.get('tenantId')
const format = c.req.query('format') || 'csv' // csv/json
const startDate = c.req.query('startDate')
const endDate = c.req.query('endDate')
let query = `
SELECT
ur.created_at,
u.email as userEmail,
u.name as userName,
ur.feature,
ur.model,
ur.input_tokens,
ur.output_tokens,
ur.total_tokens,
ur.cost,
ur.status,
ur.latency_ms
FROM usage_records ur
JOIN users u ON ur.user_id = u.id
WHERE ur.tenant_id = ?
`
const params: any[] = [tenantId]
if (startDate) {
query += ` AND ur.created_at >= ?`
params.push(parseInt(startDate))
}
if (endDate) {
query += ` AND ur.created_at <= ?`
params.push(parseInt(endDate))
}
query += ` ORDER BY ur.created_at DESC`
const records = await c.env.DB.prepare(query).bind(...params).all()
if (format === 'csv') {
const headers = ['时间', '用户邮箱', '用户姓名', '功能', '模型', '输入Token', '输出Token', '总Token', '成本(美分)', '状态', '延迟(ms)']
const rows = records.results.map(r => [
new Date(r.created_at).toISOString(),
r.userEmail,
r.userName || '',
r.feature,
r.model || '',
r.input_tokens,
r.output_tokens,
r.total_tokens,
r.cost,
r.status,
r.latency_ms || '',
])
const csv = [
headers.join(','),
...rows.map(row => row.map(v => `"${v}"`).join(',')),
].join('\n')
return new Response(csv, {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': `attachment; filename="usage-${Date.now()}.csv"`,
},
})
}
return c.json({ records: records.results })
})6. 管理员用量统计
// src/routes/admin/usage.ts
import { Hono } from 'hono'
import { requireRole } from '../../middleware/permission'
const app = new Hono()
// 全局用量统计(管理员)
app.get('/global', requireRole('admin'), async (c) => {
const days = parseInt(c.req.query('days') || '30')
const startTime = Date.now() - days * 24 * 60 * 60 * 1000
// 全局汇总
const globalStats = await c.env.DB.prepare(`
SELECT
COUNT(DISTINCT tenant_id) as activeTenants,
SUM(total_tokens) as totalTokens,
SUM(request_count) as totalRequests,
SUM(cost) as totalCost,
AVG(latency_ms) as avgLatency
FROM usage_records
WHERE created_at > ?
`).bind(startTime).first()
// 按租户排名
const topTenants = await c.env.DB.prepare(`
SELECT
t.id as tenantId,
t.name as tenantName,
SUM(ur.total_tokens) as tokens,
SUM(ur.cost) as cost
FROM usage_records ur
JOIN tenants t ON ur.tenant_id = t.id
WHERE ur.created_at > ?
GROUP BY ur.tenant_id
ORDER BY tokens DESC
LIMIT 10
`).bind(startTime).all()
// 按模型分布
const modelStats = await c.env.DB.prepare(`
SELECT
model,
COUNT(*) as requestCount,
SUM(total_tokens) as tokens,
SUM(cost) as cost,
AVG(latency_ms) as avgLatency
FROM usage_records
WHERE created_at > ?
GROUP BY model
ORDER BY tokens DESC
`).bind(startTime).all()
return c.json({
global: globalStats,
topTenants: topTenants.results,
modelStats: modelStats.results,
})
})7. 小结
用量统计的关键点:
- 多维度统计:时间、模型、用户、功能、状态
- 聚合表优化:小时/日/月聚合表加速查询
- 异步更新:使用
waitUntil异步更新聚合表,不阻塞请求 - 趋势分析:支持按小时/日/月查看趋势
- 数据导出:支持 CSV/JSON 格式导出
- 管理员视角:全局统计、租户排名、模型分布
一句话带走:
用量统计的核心是「原始记录 + 聚合表」双层架构。原始记录保证精确,聚合表加速查询。异步更新不阻塞请求,多维度分析支持运营决策。