AI 项目额度与权限控制

要点

  • AI 项目的额度控制涉及:请求次数、Token 用量、模型权限、并发数
  • 额度检查应该在请求进入 AI API 之前完成
  • Token 用量需要从 AI API 的响应中提取,不能预估
  • 计费系统需要处理:预付费(余额)、后付费(账单)、订阅(计划)
  • 额度耗尽时应该有明确的错误提示和升级引导
  • AI 项目的权限控制需要区分:模型访问、功能开关、数据范围

1. 额度类型

1.1 请求次数

限制每月/每天的请求次数:

export const quotas = pgTable('quotas', {
  id: uuid('id').primaryKey().defaultRandom(),
  userId: uuid('user_id').notNull(),
  period: text('period').notNull(),  // daily, monthly
  maxRequests: integer('max_requests').notNull(),
  usedRequests: integer('used_requests').default(0).notNull(),
  resetAt: timestamp('reset_at').notNull(),
})

1.2 Token 用量

限制每月使用的 Token 数量:

export const tokenQuotas = pgTable('token_quotas', {
  id: uuid('id').primaryKey().defaultRandom(),
  userId: uuid('user_id').notNull(),
  period: text('period').notNull(),
  maxTokens: integer('max_tokens').notNull(),
  usedTokens: integer('used_tokens').default(0).notNull(),
  resetAt: timestamp('reset_at').notNull(),
})

1.3 模型权限

不同计划可以使用不同的模型:

const MODEL_PERMISSIONS = {
  free: ['gpt-3.5-turbo', 'claude-3-haiku'],
  pro: ['gpt-3.5-turbo', 'gpt-4', 'claude-3-haiku', 'claude-3-sonnet'],
  enterprise: ['gpt-3.5-turbo', 'gpt-4', 'gpt-4-turbo', 'claude-3-haiku', 'claude-3-sonnet', 'claude-3-opus'],
}

1.4 并发限制

限制同时进行的请求数:

// Redis 记录并发数
async function checkConcurrency(userId: string, maxConcurrent: number) {
  const current = await redis.get(`concurrent:${userId}`)
 
  if (current && parseInt(current) >= maxConcurrent) {
    throw new Error('Too many concurrent requests')
  }
 
  await redis.incr(`concurrent:${userId}`)
  await redis.expire(`concurrent:${userId}`, 60)  // 1 分钟过期
}
 
async function releaseConcurrency(userId: string) {
  await redis.decr(`concurrent:${userId}`)
}

2. 额度检查

2.1 中间件

const quotaMiddleware = createMiddleware(async (c, next) => {
  const user = c.get('user')
 
  // 检查请求次数
  const requestQuota = await db.query.quotas.findFirst({
    where: and(
      eq(quotas.userId, user.id),
      eq(quotas.period, 'monthly')
    ),
  })
 
  if (requestQuota && requestQuota.usedRequests >= requestQuota.maxRequests) {
    return c.json({
      error: 'Monthly request quota exceeded',
      upgradeUrl: '/billing/upgrade',
    }, 402)
  }
 
  // 检查 Token 用量
  const tokenQuota = await db.query.tokenQuotas.findFirst({
    where: and(
      eq(tokenQuotas.userId, user.id),
      eq(tokenQuotas.period, 'monthly')
    ),
  })
 
  if (tokenQuota && tokenQuota.usedTokens >= tokenQuota.maxTokens) {
    return c.json({
      error: 'Monthly token quota exceeded',
      upgradeUrl: '/billing/upgrade',
    }, 402)
  }
 
  // 检查模型权限
  const model = c.get('model')
  const plan = user.plan || 'free'
  const allowedModels = MODEL_PERMISSIONS[plan as keyof typeof MODEL_PERMISSIONS]
 
  if (!allowedModels.includes(model)) {
    return c.json({
      error: `Model ${model} not available for ${plan} plan`,
      upgradeUrl: '/billing/upgrade',
    }, 403)
  }
 
  // 检查并发
  await checkConcurrency(user.id, plan === 'free' ? 1 : 5)
 
  await next()
})

2.2 额度更新

请求完成后更新用量:

app.post('/v1/chat/completions',
  quotaMiddleware,
  async (c) => {
    const user = c.get('user')
    const body = await c.req.json()
 
    try {
      // 调用 AI API
      const response = await callAI(body)
 
      // 提取 Token 用量
      const usage = response.usage
 
      // 更新用量
      await updateUsage(user.id, {
        requests: 1,
        promptTokens: usage.prompt_tokens,
        completionTokens: usage.completion_tokens,
        totalTokens: usage.total_tokens,
      })
 
      return c.json(response)
    } finally {
      // 释放并发
      await releaseConcurrency(user.id)
    }
  }
)

3. Token 用量提取

3.1 从响应中提取

async function callAI(body: any) {
  const response = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
    },
    body: JSON.stringify(body),
  })
 
  const data = await response.json()
 
  // 提取用量
  return {
    ...data,
    usage: {
      prompt_tokens: data.usage.prompt_tokens,
      completion_tokens: data.usage.completion_tokens,
      total_tokens: data.usage.total_tokens,
    },
  }
}

3.2 流式响应的用量

流式响应需要从最后一个 chunk 提取用量:

app.post('/v1/chat/completions/stream',
  quotaMiddleware,
  async (c) => {
    const user = c.get('user')
    const body = await c.req.json()
 
    const response = await fetch('https://api.openai.com/v1/chat/completions', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
      },
      body: JSON.stringify({
        ...body,
        stream: true,
      }),
    })
 
    const reader = response.body!.getReader()
    const encoder = new TextEncoder()
 
    let totalTokens = 0
 
    const stream = new ReadableStream({
      async start(controller) {
        let buffer = ''
 
        while (true) {
          const { done, value } = await reader.read()
 
          if (done) {
            // 更新用量
            await updateUsage(user.id, {
              requests: 1,
              totalTokens,
            })
 
            controller.close()
            break
          }
 
          buffer += new TextDecoder().decode(value)
 
          const lines = buffer.split('\n')
          buffer = lines.pop() || ''
 
          for (const line of lines) {
            if (line.startsWith('data: ')) {
              const data = line.slice(6)
 
              if (data === '[DONE]') {
                controller.enqueue(encoder.encode('data: [DONE]\n\n'))
                continue
              }
 
              try {
                const parsed = JSON.parse(data)
 
                // 提取用量(如果包含)
                if (parsed.usage) {
                  totalTokens = parsed.usage.total_tokens
                }
 
                controller.enqueue(encoder.encode(`data: ${data}\n\n`))
              } catch {
                // 忽略解析错误
              }
            }
          }
        }
      },
    })
 
    return new Response(stream, {
      headers: {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        Connection: 'keep-alive',
      },
    })
  }
)

4. 计费系统

4.1 预付费(余额)

用户先充值,后消费:

export const userBalance = pgTable('user_balance', {
  userId: uuid('user_id').primaryKey().references(() => users.id),
  balance: decimal('balance', { precision: 10, scale: 2 }).default('0').notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
})
 
// 充值
app.post('/billing/topup', async (c) => {
  const user = c.get('user')
  const { amount } = await c.req.json()
 
  // 调用支付接口
  await processPayment(user.id, amount)
 
  // 更新余额
  await db
    .update(userBalance)
    .set({
      balance: sql`${userBalance.balance} + ${amount}`,
    })
    .where(eq(userBalance.userId, user.id))
 
  return c.json({ balance: await getBalance(user.id) })
})
 
// 扣费
async function deductBalance(userId: string, amount: number) {
  const balance = await getBalance(userId)
 
  if (balance < amount) {
    throw new Error('Insufficient balance')
  }
 
  await db
    .update(userBalance)
    .set({
      balance: sql`${userBalance.balance} - ${amount}`,
    })
    .where(eq(userBalance.userId, userId))
}

4.2 订阅(计划)

用户订阅计划,按月/年付费:

export const subscriptions = pgTable('subscriptions', {
  id: uuid('id').primaryKey().defaultRandom(),
  userId: uuid('user_id').notNull(),
  plan: text('plan').notNull(),  // free, pro, enterprise
  status: text('status').notNull(),  // active, canceled, past_due
  currentPeriodStart: timestamp('current_period_start').notNull(),
  currentPeriodEnd: timestamp('current_period_end').notNull(),
  canceledAt: timestamp('canceled_at'),
})
 
// 订阅检查
app.use('/api/*', async (c, next) => {
  const user = c.get('user')
 
  const subscription = await db.query.subscriptions.findFirst({
    where: eq(subscriptions.userId, user.id),
  })
 
  if (!subscription || subscription.status !== 'active') {
    // 免费计划
    c.set('plan', 'free')
  } else {
    c.set('plan', subscription.plan)
  }
 
  await next()
})

4.3 后付费(账单)

先消费,后结算:

export const usageRecords = pgTable('usage_records', {
  id: uuid('id').primaryKey().defaultRandom(),
  userId: uuid('user_id').notNull(),
  model: text('model').notNull(),
  promptTokens: integer('prompt_tokens').notNull(),
  completionTokens: integer('completion_tokens').notNull(),
  cost: decimal('cost', { precision: 10, scale: 6 }).notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
})
 
// 记录用量
async function recordUsage(userId: string, model: string, usage: {
  promptTokens: number
  completionTokens: number
}) {
  const pricing = await getModelPricing(model)
 
  const cost =
    (usage.promptTokens / 1000) * pricing.promptPricePer1K +
    (usage.completionTokens / 1000) * pricing.completionPricePer1K
 
  await db.insert(usageRecords).values({
    userId,
    model,
    promptTokens: usage.promptTokens,
    completionTokens: usage.completionTokens,
    cost: cost.toString(),
  })
}
 
// 生成账单
async function generateInvoice(userId: string, period: { start: Date; end: Date }) {
  const records = await db
    .select({
      totalCost: sql<string>`sum(${usageRecords.cost})`,
      totalTokens: sql<number>`sum(${usageRecords.promptTokens} + ${usageRecords.completionTokens})`,
    })
    .from(usageRecords)
    .where(and(
      eq(usageRecords.userId, userId),
      gte(usageRecords.createdAt, period.start),
      lte(usageRecords.createdAt, period.end)
    ))
 
  return {
    amount: records[0].totalCost,
    tokens: records[0].totalTokens,
    period,
  }
}

5. 错误处理

5.1 额度耗尽

// 返回 402 Payment Required
return c.json({
  error: {
    message: 'Monthly quota exceeded',
    type: 'quota_exceeded',
    code: 'quota_exceeded',
    upgradeUrl: 'https://app.example.com/billing/upgrade',
    currentUsage: {
      requests: 1000,
      maxRequests: 1000,
    },
  },
}, 402)

5.2 模型不可用

// 返回 403 Forbidden
return c.json({
  error: {
    message: `Model ${model} is not available for your plan`,
    type: 'model_not_available',
    code: 'model_not_available',
    availableModels: ['gpt-3.5-turbo', 'claude-3-haiku'],
    upgradeUrl: 'https://app.example.com/billing/upgrade',
  },
}, 403)

5.3 余额不足

// 返回 402 Payment Required
return c.json({
  error: {
    message: 'Insufficient balance',
    type: 'insufficient_balance',
    code: 'insufficient_balance',
    currentBalance: 0.50,
    estimatedCost: 1.00,
    topupUrl: 'https://app.example.com/billing/topup',
  },
}, 402)

6. 监控与告警

6.1 用量监控

// 监控异常用量
async function monitorUsage() {
  const users = await db.query.users.findMany()
 
  for (const user of users) {
    const usage = await getCurrentUsage(user.id)
    const quota = await getQuota(user.id)
 
    // 超过 80% 告警
    if (usage.usedTokens > quota.maxTokens * 0.8) {
      await sendAlert({
        type: 'quota_warning',
        userId: user.id,
        usage: usage.usedTokens,
        quota: quota.maxTokens,
      })
    }
 
    // 超过 100% 通知
    if (usage.usedTokens > quota.maxTokens) {
      await sendNotification({
        type: 'quota_exceeded',
        userId: user.id,
      })
    }
  }
}
 
// 定时任务
cron.schedule('0 * * * *', monitorUsage)  // 每小时检查

6.2 用量仪表板

app.get('/api/usage', async (c) => {
  const user = c.get('user')
 
  const usage = await getCurrentUsage(user.id)
  const quota = await getQuota(user.id)
 
  return c.json({
    current: {
      requests: usage.usedRequests,
      tokens: usage.usedTokens,
    },
    quota: {
      maxRequests: quota.maxRequests,
      maxTokens: quota.maxTokens,
    },
    percentage: {
      requests: (usage.usedRequests / quota.maxRequests) * 100,
      tokens: (usage.usedTokens / quota.maxTokens) * 100,
    },
    resetAt: quota.resetAt,
  })
})

总结

AI 项目的额度控制涉及请求次数、Token 用量、模型权限、并发数。计费系统需要支持预付费、订阅、后付费。

这一节涉及到的几个实践:

  1. 额度类型:请求次数、Token 用量、模型权限、并发限制
  2. 额度检查:中间件统一检查,请求前验证
  3. Token 提取:从 AI API 响应中提取,流式响应特殊处理
  4. 计费系统:预付费、订阅、后付费
  5. 错误处理:402 额度耗尽、403 模型不可用
  6. 监控告警:用量监控、异常告警、用量仪表板

额度控制是 AI SaaS 的核心。检查要快、计费要准、错误要清晰。用户应该随时能看到自己的用量和剩余额度。

下一篇看认证安全最佳实践——密码策略、Token 安全、攻击防护。