AI 功能商业化(用量/限流/计费)
AI 功能最大的运营挑战是成本——每一次 LLM 调用都有真金白银的 API 费用。如果不做用量控制,一个恶意用户或一次代码 Bug 就可能产生数千美元的账单。本章从 Token 用量统计出发,实现配额管理、速率限制、按量计费和成本仪表盘,确保 AI 功能既能创造价值,又不会成为财务黑洞。
1. AI 成本模型
1.1 成本构成
AI 功能的运行成本分为四个部分:
| 成本项 | 计费方式 | 典型价格 | 占比 |
|---|---|---|---|
| LLM 调用 | 按 input/output token 计费 | GPT-4o: $2.5/$10 per 1M tokens | 60-80% |
| Embedding | 按 input token 计费 | $0.02 per 1M tokens | 5-10% |
| 向量存储 | 按存储量 + 查询量 | pgvector: 包含在数据库费用中 | 5-10% |
| 文档处理 | 按计算时间 | Serverless Function 执行时间 | 5-15% |
在绝大多数场景下,LLM 调用是绝对的大头。一次 GPT-4o 的对话(2000 tokens 输入 + 1000 tokens 输出)成本约 $0.015;换成 GPT-4o-mini,同样的对话只需 $0.0009——差 16 倍。
1.2 每用户成本估算
| 用户行为 | 模型 | 日均调用 | 月成本/用户 |
|---|---|---|---|
| 轻度使用(5 次对话/天) | GPT-4o-mini | 5 | ~$0.14 |
| 中度使用(20 次对话/天) | GPT-4o-mini | 20 | ~$0.54 |
| 中度使用(20 次对话/天) | GPT-4o | 20 | ~$9.00 |
| 重度使用(50 次对话/天) | GPT-4o | 50 | ~$22.50 |
| RAG 查询(含 Embedding) | GPT-4o-mini | 20 | ~$0.60 |
| Agent(5 步/次) | GPT-4o | 10 | ~$7.50 |
关键洞察:
- 免费用户用 mini 模型,月成本可以控制在 $0.15 以下——几乎可以忽略
- 付费用户用 GPT-4o,月成本 $5-20——需要订阅费覆盖
- Agent 是成本最高的场景——每次多步执行可能消耗 5 倍的 token
1.3 定价策略
| 策略 | 说明 | 适用场景 |
|---|---|---|
| 包含在订阅中 | 每个套餐包含固定 AI 额度 | 大多数 SaaS 产品 |
| 按量付费 | 超出额度后按 token 计费 | API 平台、重度 AI 产品 |
| 混合模式 | 基础额度包含 + 超量按量 | ✅ 推荐:兼顾体验和成本 |
| AI 作为增值 | AI 功能仅限高级套餐 | 传统 SaaS 升级卖点 |
推荐 混合模式——每个套餐包含一定额度(免费用户 50K tokens/月,Pro 1M tokens/月),超出后用户可以购买额外额度。
2. Token 用量统计
2.1 用量记录
每次 LLM 调用后,精确记录 token 消耗:
// lib/ai/usage.ts
import { AI_CONFIG } from './config'
interface UsageData {
promptTokens: number
completionTokens: number
}
export async function recordUsage(
tenantId: string,
userId: string,
model: string,
usage: UsageData
) {
const costs = AI_CONFIG.costs[model]
const cost = costs
? (usage.promptTokens / 1000) * costs.input + (usage.completionTokens / 1000) * costs.output
: 0
await db.insert(aiUsage).values({
tenantId,
userId,
model,
inputTokens: usage.promptTokens,
outputTokens: usage.completionTokens,
cost: cost.toFixed(6),
operationType: 'chat',
createdAt: new Date(),
})
}2.2 用量聚合查询
// lib/ai/usage-stats.ts
interface UsageSummary {
totalTokens: number
totalCost: number
byModel: Record<string, { tokens: number; cost: number }>
dailyUsage: { date: string; tokens: number }[]
}
export async function getUsageSummary(
tenantId: string,
period: { start: Date; end: Date }
): Promise<UsageSummary> {
// 总量统计
const [totals] = await db.select({
totalInputTokens: sql<number>`sum(input_tokens)`,
totalOutputTokens: sql<number>`sum(output_tokens)`,
totalCost: sql<number>`sum(cost::numeric)`,
})
.from(aiUsage)
.where(and(
eq(aiUsage.tenantId, tenantId),
gte(aiUsage.createdAt, period.start),
lte(aiUsage.createdAt, period.end),
))
// 按模型分组
const byModel = await db.select({
model: aiUsage.model,
inputTokens: sql<number>`sum(input_tokens)`,
outputTokens: sql<number>`sum(output_tokens)`,
cost: sql<number>`sum(cost::numeric)`,
})
.from(aiUsage)
.where(and(
eq(aiUsage.tenantId, tenantId),
gte(aiUsage.createdAt, period.start),
lte(aiUsage.createdAt, period.end),
))
.groupBy(aiUsage.model)
// 按天统计(用于图表)
const daily = await db.select({
date: sql<string>`date(created_at)`,
tokens: sql<number>`sum(input_tokens + output_tokens)`,
})
.from(aiUsage)
.where(and(
eq(aiUsage.tenantId, tenantId),
gte(aiUsage.createdAt, period.start),
lte(aiUsage.createdAt, period.end),
))
.groupBy(sql`date(created_at)`)
.orderBy(sql`date(created_at)`)
const totalTokens = (totals.totalInputTokens || 0) + (totals.totalOutputTokens || 0)
return {
totalTokens,
totalCost: totals.totalCost || 0,
byModel: Object.fromEntries(
byModel.map(m => [m.model, {
tokens: (m.inputTokens || 0) + (m.outputTokens || 0),
cost: m.cost || 0,
}])
),
dailyUsage: daily.map(d => ({ date: d.date, tokens: d.tokens || 0 })),
}
}2.3 用量仪表盘
// app/(dashboard)/settings/ai-usage/page.tsx
export default async function AIUsagePage() {
const { membership } = await requirePermission('ai:usage:view')
const now = new Date()
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
const summary = await getUsageSummary(membership.tenantId, {
start: monthStart,
end: now,
})
const quota = getPlanQuota(membership.plan)
const usagePercent = (summary.totalTokens / quota.monthlyTokens) * 100
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">AI Usage</h1>
{/* 配额进度 */}
<div className="rounded-lg border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Monthly Token Usage</p>
<p className="text-2xl font-bold">
{formatNumber(summary.totalTokens)} / {formatNumber(quota.monthlyTokens)}
</p>
</div>
<div className="text-right">
<p className="text-sm text-muted-foreground">Estimated Cost</p>
<p className="text-2xl font-bold">${summary.totalCost.toFixed(2)}</p>
</div>
</div>
<div className="mt-4 h-3 rounded-full bg-muted overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
usagePercent > 90 ? 'bg-red-500' : usagePercent > 70 ? 'bg-yellow-500' : 'bg-green-500'
}`}
style={{ width: `${Math.min(usagePercent, 100)}%` }}
/>
</div>
<p className="mt-2 text-sm text-muted-foreground">
{usagePercent.toFixed(1)}% used · Resets on {formatDate(getNextResetDate())}
</p>
</div>
{/* 按模型细分 */}
<div className="rounded-lg border p-6">
<h3 className="font-semibold mb-4">Usage by Model</h3>
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2">Model</th>
<th className="text-right py-2">Tokens</th>
<th className="text-right py-2">Cost</th>
</tr>
</thead>
<tbody>
{Object.entries(summary.byModel).map(([model, data]) => (
<tr key={model} className="border-b">
<td className="py-2 font-mono text-xs">{model}</td>
<td className="py-2 text-right">{formatNumber(data.tokens)}</td>
<td className="py-2 text-right">${data.cost.toFixed(4)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}3. 配额管理
3.1 套餐配额定义
// lib/ai/quota.ts
interface PlanQuota {
monthlyTokens: number
maxTokensPerRequest: number
allowedModels: string[]
maxKnowledgeBases: number
maxDocumentsPerKB: number
maxAgentSteps: number
}
const PLAN_QUOTAS: Record<string, PlanQuota> = {
free: {
monthlyTokens: 50_000,
maxTokensPerRequest: 2000,
allowedModels: ['gpt-4o-mini'],
maxKnowledgeBases: 1,
maxDocumentsPerKB: 10,
maxAgentSteps: 2,
},
pro: {
monthlyTokens: 1_000_000,
maxTokensPerRequest: 8000,
allowedModels: ['gpt-4o-mini', 'gpt-4o'],
maxKnowledgeBases: 5,
maxDocumentsPerKB: 100,
maxAgentSteps: 5,
},
business: {
monthlyTokens: 10_000_000,
maxTokensPerRequest: 32000,
allowedModels: ['gpt-4o-mini', 'gpt-4o', 'claude-3-5-sonnet'],
maxKnowledgeBases: 20,
maxDocumentsPerKB: 1000,
maxAgentSteps: 10,
},
}
export function getPlanQuota(plan: string): PlanQuota {
return PLAN_QUOTAS[plan] || PLAN_QUOTAS.free
}3.2 配额检查
在每次 AI 调用前检查配额——这是防止超支的核心防线:
export async function checkAIQuota(tenantId: string, userId: string) {
// 获取当前套餐
const [tenant] = await db.select().from(tenants).where(eq(tenants.id, tenantId))
const quota = getPlanQuota(tenant.plan)
// 查询当月已用量
const monthStart = new Date()
monthStart.setDate(1)
monthStart.setHours(0, 0, 0, 0)
const [usage] = await db.select({
totalTokens: sql<number>`sum(input_tokens + output_tokens)`,
})
.from(aiUsage)
.where(and(
eq(aiUsage.tenantId, tenantId),
gte(aiUsage.createdAt, monthStart),
))
const usedTokens = usage.totalTokens || 0
if (usedTokens >= quota.monthlyTokens) {
throw new AIQuotaExceededError(
`Monthly AI quota exceeded. Used ${formatNumber(usedTokens)} of ${formatNumber(quota.monthlyTokens)} tokens.`,
{ usedTokens, quota: quota.monthlyTokens, plan: tenant.plan }
)
}
// 返回剩余额度,用于 maxTokens 限制
const remainingTokens = quota.monthlyTokens - usedTokens
return {
remainingTokens,
maxTokensPerRequest: Math.min(quota.maxTokensPerRequest, remainingTokens),
allowedModels: quota.allowedModels,
}
}
class AIQuotaExceededError extends Error {
constructor(message: string, public details: any) {
super(message)
this.name = 'AIQuotaExceededError'
}
}3.3 配额缓存
每次调用都查数据库统计用量太慢。用 Redis 缓存当月用量,调用后增量更新:
// lib/ai/quota-cache.ts
import { Redis } from '@upstash/redis'
const redis = new Redis({
url: process.env.UPSTASH_REDIS_URL!,
token: process.env.UPSTASH_REDIS_TOKEN!,
})
function getQuotaKey(tenantId: string): string {
const month = new Date().toISOString().slice(0, 7) // '2024-01'
return `ai:quota:${tenantId}:${month}`
}
export async function getCachedUsage(tenantId: string): Promise<number> {
const key = getQuotaKey(tenantId)
const cached = await redis.get<number>(key)
if (cached !== null) return cached
// 缓存未命中,从数据库加载
const usage = await getMonthlyUsageFromDB(tenantId)
await redis.set(key, usage, { ex: 3600 }) // 1 小时过期
return usage
}
export async function incrementCachedUsage(tenantId: string, tokens: number) {
const key = getQuotaKey(tenantId)
await redis.incrby(key, tokens)
}这样配额检查只需一次 Redis GET(亚毫秒级),不影响 AI 响应延迟。
3.4 模型权限控制
免费用户只能用便宜的模型,付费用户可以选择更强的模型:
export function getModelByPlan(
requestedModel: string,
plan: string
): ReturnType<typeof openai> {
const quota = getPlanQuota(plan)
if (!quota.allowedModels.includes(requestedModel)) {
// 降级到套餐允许的最佳模型
const fallback = quota.allowedModels[quota.allowedModels.length - 1]
return openai(fallback)
}
return openai(requestedModel)
}4. 速率限制
4.1 多层限流
AI 接口需要比普通 API 更严格的限流——每次调用都有成本:
| 层级 | 限制 | 窗口 | 实现 |
|---|---|---|---|
| 全局 | 1000 req/min | 1 分钟 | Middleware |
| 每租户 | 100 req/min | 1 分钟 | Redis 滑动窗口 |
| 每用户 | 20 req/min | 1 分钟 | Redis 滑动窗口 |
| 每 IP | 10 req/min(未登录) | 1 分钟 | Middleware |
// lib/ai/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const redis = new Redis({
url: process.env.UPSTASH_REDIS_URL!,
token: process.env.UPSTASH_REDIS_TOKEN!,
})
// 每用户限流
const userLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(20, '1 m'),
prefix: 'ai:rl:user',
})
// 每租户限流
const tenantLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(100, '1 m'),
prefix: 'ai:rl:tenant',
})
export async function checkRateLimit(tenantId: string, userId: string) {
const [userResult, tenantResult] = await Promise.all([
userLimiter.limit(userId),
tenantLimiter.limit(tenantId),
])
if (!userResult.success) {
throw new Error(`Rate limit exceeded. Try again in ${Math.ceil(userResult.reset - Date.now()) / 1000}s`)
}
if (!tenantResult.success) {
throw new Error('Organization rate limit exceeded. Please try again shortly.')
}
}4.2 并发控制
防止同一用户同时发起多个 AI 请求(尤其是 Agent 场景):
// lib/ai/concurrency.ts
export async function acquireLock(userId: string, ttlMs: number = 60000): Promise<boolean> {
const key = `ai:lock:${userId}`
const acquired = await redis.set(key, '1', { nx: true, px: ttlMs })
return acquired === 'OK'
}
export async function releaseLock(userId: string) {
await redis.del(`ai:lock:${userId}`)
}
// 在 API Route 中使用
export async function POST(req: Request) {
const session = await getSession()
if (!await acquireLock(session!.user.id)) {
return new Response('Another AI request is still processing', { status: 429 })
}
try {
// ... 处理 AI 请求
} finally {
await releaseLock(session!.user.id)
}
}5. 超量与追加购买
5.1 超量处理策略
当用户用完月度额度后,有几种处理方式:
| 策略 | 用户体验 | 收入影响 |
|---|---|---|
| 硬限制 | 直接拒绝,提示升级 | 推动升级转化 |
| 软限制 | 降级到更便宜的模型 | 保持体验,降低成本 |
| 按量追加 | 允许超量,按 token 扣费 | 最大化收入 |
| 等待重置 | 显示倒计时,下月重置 | 简单但体验差 |
推荐 软限制 + 按量追加 组合——超出额度后自动降级到 mini 模型,同时提示用户可以购买额外额度:
export async function getEffectiveModel(
requestedModel: string,
tenantId: string,
plan: string
): Promise<{ model: string; isDowngraded: boolean }> {
const remaining = await getCachedUsage(tenantId)
const quota = getPlanQuota(plan)
if (remaining < quota.monthlyTokens) {
// 额度充足,使用请求的模型
return { model: requestedModel, isDowngraded: false }
}
// 检查是否有额外购买的额度
const extraQuota = await getExtraQuota(tenantId)
if (extraQuota > 0) {
return { model: requestedModel, isDowngraded: false }
}
// 降级到最便宜的模型
return { model: 'gpt-4o-mini', isDowngraded: true }
}5.2 额度购买
// lib/actions/ai-credits.ts
'use server'
export async function purchaseAICredits(tokenAmount: number) {
const { session, membership } = await requirePermission('ai:usage:view')
// 价格:$10 / 1M tokens
const PRICE_PER_MILLION = 10
const amount = Math.ceil((tokenAmount / 1_000_000) * PRICE_PER_MILLION * 100) // cents
// 创建 Stripe Checkout Session
const checkoutSession = await stripe.checkout.sessions.create({
mode: 'payment',
customer: membership.stripeCustomerId,
line_items: [{
price_data: {
currency: 'usd',
product_data: {
name: `${formatNumber(tokenAmount)} AI Tokens`,
description: 'Additional AI tokens for your organization',
},
unit_amount: amount,
},
quantity: 1,
}],
metadata: {
tenantId: membership.tenantId,
tokenAmount: String(tokenAmount),
type: 'ai_credits',
},
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/settings/ai-usage?purchased=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/settings/ai-usage`,
})
return { url: checkoutSession.url }
}Webhook 处理购买成功:
// 在 Stripe Webhook 中处理 ai_credits 类型
if (session.metadata?.type === 'ai_credits') {
const tenantId = session.metadata.tenantId
const tokenAmount = parseInt(session.metadata.tokenAmount)
await db.insert(aiCredits).values({
tenantId,
tokens: tokenAmount,
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), // 90 天有效
})
// 清除配额缓存
await redis.del(getQuotaKey(tenantId))
}6. 成本监控与告警
6.1 异常检测
突然的用量飙升通常意味着 Bug 或滥用:
// lib/ai/cost-alert.ts
export async function checkCostAnomaly(tenantId: string) {
const today = new Date()
const todayStart = new Date(today.setHours(0, 0, 0, 0))
// 今日成本
const [todayCost] = await db.select({
cost: sql<number>`sum(cost::numeric)`,
})
.from(aiUsage)
.where(and(
eq(aiUsage.tenantId, tenantId),
gte(aiUsage.createdAt, todayStart),
))
// 过去 7 天日均成本
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
const [avgCost] = await db.select({
avgCost: sql<number>`sum(cost::numeric) / 7`,
})
.from(aiUsage)
.where(and(
eq(aiUsage.tenantId, tenantId),
gte(aiUsage.createdAt, weekAgo),
lte(aiUsage.createdAt, todayStart),
))
const dailyAvg = avgCost.avgCost || 0
const todayTotal = todayCost.cost || 0
// 如果今日成本超过日均的 3 倍,触发告警
if (dailyAvg > 0 && todayTotal > dailyAvg * 3) {
await sendCostAlert(tenantId, {
todayCost: todayTotal,
dailyAverage: dailyAvg,
ratio: todayTotal / dailyAvg,
})
}
}6.2 Admin 成本总览
在后台管理面板(第 72 章)中添加 AI 成本总览:
// lib/actions/admin-ai-stats.ts
export async function getAICostOverview() {
const monthStart = new Date()
monthStart.setDate(1)
monthStart.setHours(0, 0, 0, 0)
// 全平台本月成本
const [overview] = await db.select({
totalCost: sql<number>`sum(cost::numeric)`,
totalTokens: sql<number>`sum(input_tokens + output_tokens)`,
uniqueUsers: sql<number>`count(distinct user_id)`,
totalRequests: sql<number>`count(*)`,
})
.from(aiUsage)
.where(gte(aiUsage.createdAt, monthStart))
// 按租户的 Top 10 消耗
const topTenants = await db.select({
tenantId: aiUsage.tenantId,
cost: sql<number>`sum(cost::numeric)`,
tokens: sql<number>`sum(input_tokens + output_tokens)`,
})
.from(aiUsage)
.where(gte(aiUsage.createdAt, monthStart))
.groupBy(aiUsage.tenantId)
.orderBy(desc(sql`sum(cost::numeric)`))
.limit(10)
return { overview, topTenants }
}6.3 自动熔断
当全平台成本达到阈值时,自动降级或暂停 AI 功能:
// lib/ai/circuit-breaker.ts
const DAILY_BUDGET = parseFloat(process.env.AI_DAILY_BUDGET || '100') // $100/天
export async function checkGlobalBudget(): Promise<boolean> {
const key = `ai:daily-cost:${new Date().toISOString().slice(0, 10)}`
const currentCost = await redis.get<number>(key) || 0
if (currentCost >= DAILY_BUDGET) {
// 触发熔断:只允许 mini 模型
return false
}
return true
}
export async function incrementGlobalCost(cost: number) {
const key = `ai:daily-cost:${new Date().toISOString().slice(0, 10)}`
await redis.incrbyfloat(key, cost)
await redis.expire(key, 86400 * 2) // 2 天过期
}本章小结
- 成本构成:LLM 调用占 60-80%,GPT-4o 和 GPT-4o-mini 成本差 16 倍
- 定价策略:推荐混合模式——基础额度包含在订阅中 + 超量按量付费
- 用量统计:每次调用精确记录 input/output tokens 和成本,支持按模型、按天聚合
- 用量仪表盘:进度条 + 按模型细分 + 日趋势图,让用户了解自己的消耗
- 配额管理:Free 50K / Pro 1M / Business 10M 月度 token 配额
- 配额检查:每次调用前检查,Redis 缓存用量避免频繁查库
- 模型降级:超出额度自动降级到 mini 模型,同时提示升级或购买
- 速率限制:用户级 20/min + 租户级 100/min,Upstash Ratelimit 实现
- 并发控制:Redis 锁防止同一用户同时发起多个 AI 请求
- 额度购买:Stripe 一次性付款购买额外 token,90 天有效
- 成本告警:今日成本超过 7 天均值 3 倍时自动告警
- 全局熔断:日预算达到上限时自动降级到 mini 模型