24.05-额度系统
要点
- 额度(Quota)是 AI SaaS 成本控制的核心,需要精确追踪每个租户的资源消耗
- 额度类型包括:Token 额度、请求次数、存储空间、知识库数量
- 额度扣减必须保证原子性,防止超额使用
- 额度检查应该在业务逻辑之前执行,作为中间件统一拦截
内容
1. 额度类型
AI SaaS 通常需要控制以下几类额度:
| 额度类型 | 单位 | 重置周期 | 说明 |
|---|---|---|---|
| Token 额度 | tokens | 每月 | LLM 调用的 token 消耗 |
| 请求次数 | 次 | 每日/每月 | API 调用次数限制 |
| 存储空间 | MB/GB | 累计 | R2 中上传的文件大小 |
| 知识库数量 | 个 | 累计 | RAG 知识库数量 |
| 团队成员数 | 人 | 累计 | 租户内的用户数 |
额度的特点:
- 可重置的:Token 额度、请求次数,每月/每日重置
- 累计的:存储空间、知识库数量,持续占用直到主动释放
- 套餐相关的:不同套餐有不同的额度上限
2. 额度数据模型
-- 额度使用记录表
CREATE TABLE quota_usage (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
user_id TEXT NOT NULL,
quota_type TEXT NOT NULL, -- tokens/requests/storage/knowledge_bases/members
amount INTEGER NOT NULL, -- 使用量
model TEXT, -- 模型名称(仅 tokens 类型需要)
metadata TEXT, -- 额外信息(JSON)
created_at INTEGER NOT NULL,
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
)
-- 额度快照表(用于快速查询当前用量)
CREATE TABLE quota_snapshots (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
quota_type TEXT NOT NULL,
period_start INTEGER NOT NULL, -- 周期开始时间
total_used INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
UNIQUE(tenant_id, quota_type, period_start)
)
-- 索引:加速周期内查询
CREATE INDEX idx_quota_usage_period
ON quota_usage(tenant_id, quota_type, created_at)TypeScript 类型:
// src/types/quota.ts
export type QuotaType =
| 'tokens'
| 'requests'
| 'storage'
| 'knowledge_bases'
| 'members'
export interface QuotaUsage {
id: string
tenantId: string
userId: string
quotaType: QuotaType
amount: number
model?: string
metadata?: Record<string, any>
createdAt: number
}
export interface QuotaSnapshot {
id: string
tenantId: string
quotaType: QuotaType
periodStart: number
totalUsed: number
updatedAt: number
}
export interface QuotaCheck {
type: QuotaType
used: number
limit: number
remaining: number
percentage: number
}3. 额度扣减
3.1 原子性扣减
额度扣减必须保证原子性,防止并发请求导致超额使用:
// src/lib/quota.ts
import { QuotaType } from '../types/quota'
export class QuotaManager {
constructor(private db: D1Database, private env: Env) {}
/**
* 检查并扣减额度
* 返回是否成功,以及当前用量
*/
async consume(
tenantId: string,
userId: string,
type: QuotaType,
amount: number,
model?: string,
metadata?: Record<string, any>
): Promise<{ success: boolean; used: number; limit: number }> {
// 1. 获取套餐配额
const limit = await this.getQuotaLimit(tenantId, type)
// 2. 获取当前用量
const used = await this.getCurrentUsage(tenantId, type)
// 3. 检查是否超额
if (limit > 0 && used + amount > limit) {
return { success: false, used, limit }
}
// 4. 记录使用(原子操作)
const now = Date.now()
const id = crypto.randomUUID()
await this.db.prepare(`
INSERT INTO quota_usage (id, tenant_id, user_id, quota_type, amount, model, metadata, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).bind(
id,
tenantId,
userId,
type,
amount,
model || null,
metadata ? JSON.stringify(metadata) : null,
now
).run()
// 5. 更新快照(使用 UPSERT)
const periodStart = this.getPeriodStart(type)
await this.db.prepare(`
INSERT INTO quota_snapshots (id, tenant_id, quota_type, period_start, total_used, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(tenant_id, quota_type, period_start)
DO UPDATE SET total_used = total_used + ?, updated_at = ?
`).bind(
crypto.randomUUID(),
tenantId,
type,
periodStart,
amount,
now,
amount,
now
).run()
return { success: true, used: used + amount, limit }
}
/**
* 获取当前周期内的使用量
*/
async getCurrentUsage(tenantId: string, type: QuotaType): Promise<number> {
const periodStart = this.getPeriodStart(type)
const snapshot = await this.db.prepare(`
SELECT total_used FROM quota_snapshots
WHERE tenant_id = ? AND quota_type = ? AND period_start = ?
`).bind(tenantId, type, periodStart).first()
return snapshot?.total_used || 0
}
/**
* 获取配额上限
*/
private async getQuotaLimit(tenantId: string, type: QuotaType): Promise<number> {
const tenant = await this.db.prepare(`
SELECT plan FROM tenants WHERE id = ?
`).bind(tenantId).first()
if (!tenant) return 0
const plan = getPlan(tenant.plan)
switch (type) {
case 'tokens':
return plan.tokenQuota
case 'requests':
return plan.requestQuota || -1
case 'storage':
return plan.storageQuota || -1
case 'knowledge_bases':
return plan.maxKnowledgeBases
case 'members':
return plan.maxTeamMembers
default:
return -1
}
}
/**
* 获取周期开始时间
*/
private getPeriodStart(type: QuotaType): number {
const now = new Date()
switch (type) {
case 'tokens':
case 'requests':
// 月度周期:当月 1 号 00:00:00
return new Date(now.getFullYear(), now.getMonth(), 1).getTime()
case 'storage':
case 'knowledge_bases':
case 'members':
// 累计型:从 0 开始
return 0
default:
return 0
}
}
}3.2 Token 额度扣减示例
// src/routes/chat.ts
import { Hono } from 'hono'
import { QuotaManager } from '../lib/quota'
const app = new Hono()
app.post('/chat', async (c) => {
const tenantId = c.get('tenantId')
const userId = c.get('userId')
const { messages, model } = c.req.valid('json')
const quotaManager = new QuotaManager(c.env.DB, c.env)
// 1. 预估 token 消耗(简单估算)
const estimatedTokens = messages.reduce((sum, msg) => {
return sum + Math.ceil(msg.content.length / 4)
}, 0) + 1000 // 预留 1000 token 给回复
// 2. 检查并预扣额度
const preConsume = await quotaManager.consume(
tenantId,
userId,
'tokens',
estimatedTokens,
model,
{ type: 'estimate' }
)
if (!preConsume.success) {
return c.json({
error: 'Token 额度不足',
quota: {
used: preConsume.used,
limit: preConsume.limit,
},
}, 429)
}
// 3. 调用 LLM
const response = await callLLM(messages, model)
const actualTokens = response.usage.total_tokens
// 4. 修正额度(多退少补)
const diff = actualTokens - estimatedTokens
if (diff !== 0) {
await quotaManager.consume(
tenantId,
userId,
'tokens',
diff,
model,
{ type: 'correction' }
)
}
return c.json({
message: response.message,
usage: {
promptTokens: response.usage.prompt_tokens,
completionTokens: response.usage.completion_tokens,
totalTokens: response.usage.total_tokens,
},
})
})4. 额度检查中间件
// src/middleware/quota-check.ts
import { Context, Next } from 'hono'
import { QuotaManager } from '../lib/quota'
export function requireQuota(type: QuotaType, amount: number = 1) {
return async (c: Context, next: Next) => {
const tenantId = c.get('tenantId')
const userId = c.get('userId')
const quotaManager = new QuotaManager(c.env.DB, c.env)
// 获取当前用量
const used = await quotaManager.getCurrentUsage(tenantId, type)
const limit = await quotaManager.getQuotaLimit(tenantId, type)
// 检查是否超额
if (limit > 0 && used + amount > limit) {
return c.json({
error: '额度不足',
quota: {
type,
used,
limit,
remaining: Math.max(0, limit - used),
},
}, 429)
}
// 存储到上下文
c.set('quotaUsed', used)
c.set('quotaLimit', limit)
await next()
}
}
// 使用
app.post('/knowledge-bases',
requireQuota('knowledge_bases', 1),
async (c) => {
// 创建知识库
// 在成功后扣减额度
const quotaManager = new QuotaManager(c.env.DB, c.env)
await quotaManager.consume(
c.get('tenantId'),
c.get('userId'),
'knowledge_bases',
1
)
}
)5. 额度查询接口
// src/routes/quota.ts
import { Hono } from 'hono'
import { QuotaManager } from '../lib/quota'
const app = new Hono()
// 获取所有额度信息
app.get('/', async (c) => {
const tenantId = c.get('tenantId')
const quotaManager = new QuotaManager(c.env.DB, c.env)
const quotaTypes: QuotaType[] = [
'tokens',
'requests',
'storage',
'knowledge_bases',
'members',
]
const quotas = await Promise.all(
quotaTypes.map(async (type) => {
const used = await quotaManager.getCurrentUsage(tenantId, type)
const limit = await quotaManager.getQuotaLimit(tenantId, type)
return {
type,
used,
limit,
remaining: limit > 0 ? Math.max(0, limit - used) : -1,
percentage: limit > 0 ? Math.round((used / limit) * 100) : 0,
}
})
)
return c.json({ quotas })
})
// 获取使用历史
app.get('/history', async (c) => {
const tenantId = c.get('tenantId')
const type = c.req.query('type')
const days = parseInt(c.req.query('days') || '30')
const startTime = Date.now() - days * 24 * 60 * 60 * 1000
let query = `
SELECT quota_type, SUM(amount) as total, created_at
FROM quota_usage
WHERE tenant_id = ? AND created_at > ?
`
const params: any[] = [tenantId, startTime]
if (type) {
query += ` AND quota_type = ?`
params.push(type)
}
query += ` GROUP BY DATE(created_at / 1000, 'unixepoch'), quota_type
ORDER BY created_at DESC`
const history = await c.env.DB.prepare(query).bind(...params).all()
return c.json({ history: history.results })
})6. 额度预警
6.1 预警阈值
当额度使用达到一定比例时,触发预警:
// src/lib/quota-alert.ts
export async function checkQuotaAlerts(
tenantId: string,
type: QuotaType,
used: number,
limit: number,
env: Env
) {
if (limit <= 0) return
const percentage = (used / limit) * 100
// 预警阈值:80%, 90%, 100%
const thresholds = [80, 90, 100]
for (const threshold of thresholds) {
if (percentage >= threshold) {
// 检查是否已发送过该阈值的预警
const alertKey = `quota_alert:${tenantId}:${type}:${threshold}`
const sent = await env.QUOTA_ALERT.get(alertKey)
if (!sent) {
// 发送预警
await sendQuotaAlert(tenantId, type, used, limit, threshold, env)
// 标记已发送(24 小时内不重复)
await env.QUOTA_ALERT.put(alertKey, '1', {
expirationTtl: 24 * 60 * 60,
})
}
}
}
}
async function sendQuotaAlert(
tenantId: string,
type: QuotaType,
used: number,
limit: number,
threshold: number,
env: Env
) {
const tenant = await env.DB.prepare(`
SELECT name FROM tenants WHERE id = ?
`).bind(tenantId).first()
const message = `租户 ${tenant.name} 的 ${type} 额度已达到 ${threshold}%`
// 发送邮件
await sendEmail({
to: await getTenantAdminEmail(tenantId, env),
subject: '额度预警',
body: `${message}\n\n已使用:${used} / ${limit}`,
})
// 记录日志
console.warn(`[Quota Alert] ${message}`, {
tenantId,
type,
used,
limit,
percentage: Math.round((used / limit) * 100),
})
}6.2 在额度扣减时检查
// 在 QuotaManager.consume 中添加
async consume(...) {
// ... 扣减逻辑
// 检查预警
await checkQuotaAlerts(tenantId, type, used + amount, limit, this.env)
return { success: true, used: used + amount, limit }
}7. 额度重置
7.1 定时任务
使用 Cron Triggers 每月重置额度快照:
// src/cron/quota-reset.ts
export async function resetMonthlyQuota(env: Env) {
const now = new Date()
const currentPeriodStart = new Date(
now.getFullYear(),
now.getMonth(),
1
).getTime()
// 获取所有活跃租户
const tenants = await env.DB.prepare(`
SELECT id FROM tenants WHERE status = 'active'
`).all()
for (const tenant of tenants.results) {
// 为新周期创建初始快照
const quotaTypes: QuotaType[] = ['tokens', 'requests']
for (const type of quotaTypes) {
await env.DB.prepare(`
INSERT OR IGNORE INTO quota_snapshots (id, tenant_id, quota_type, period_start, total_used, updated_at)
VALUES (?, ?, ?, ?, 0, ?)
`).bind(
crypto.randomUUID(),
tenant.id,
type,
currentPeriodStart,
Date.now()
).run()
}
}
console.log(`[Cron] Monthly quota reset completed for ${tenants.results.length} tenants`)
}
// wrangler.toml
// [triggers]
// crons = ["0 0 1 * *"] // 每月 1 号 00:008. 小结
额度系统的关键点:
- 额度类型:Token、请求次数、存储空间、知识库数量、团队成员数
- 原子扣减:使用数据库事务或 UPSERT 保证并发安全
- 预扣机制:先预扣额度,完成后修正(多退少补)
- 中间件拦截:在业务逻辑之前检查额度
- 快照优化:使用快照表加速用量查询
- 预警机制:达到阈值时通知租户
- 定时重置:使用 Cron Triggers 每月重置
一句话带走:
额度系统的核心是「精确计量 + 原子扣减 + 实时检查」。使用快照表加速查询,预扣机制防止超额,中间件统一拦截,预警机制及时通知。