成本计费接口

要点

  • 每次调用 AI 模型都有成本,接口需要在请求到达供应商之前就完成预算检查
  • 不同模型的定价策略不同:按 Token 计费、按次计费、包月订阅,需要一套统一的计费抽象
  • 计费逻辑拆成三层:价格配置、成本计算、账单记录,各自独立变更
  • 余额检查和实际扣费分布在请求链路的不同节点——请求前预扣、响应后结算
  • 账单数据存在 D1 里,按用户 + 时间维度聚合,支持分页查询和统计导出
  • 异常场景(超时、供应商报错、重复扣费)要在设计阶段就覆盖,不能留到上线后补

1. 什么是成本计费接口

前面的文章里,我们实现了聊天接口、图片生成接口、语音合成接口。这些接口有一个共同特点:每次调用都会消耗 AI 供应商的配额,而配额是要花钱的。

如果只管调接口不管成本,一个月下来账单可能会超出预期很多。对外提供服务时,用户调用模型的费用需要有地方记录、有规则计费、有余额可以检查。

成本计费接口要回答三个问题:

  1. 这次调用花了多少钱
  2. 用户的余额够不够
  3. 历史上总共花了多少

它不是单一接口,而是一组围绕「费用」展开的模块:请求到达时的余额检查、请求完成后的成本记录和扣费、账单查询、价格配置读取。

2. 为什么需要成本计费接口

也许你会想,直接在每个接口里算一下花了多少钱不就行了。项目小的时候确实可以这样做。但随着模型种类和用户量增加,这种方式会遇到几个问题。

同一套计费逻辑散落在多个接口里。聊天接口要算 Token 费用,图片接口要算张数费用。每个接口都写一遍价格读取、成本计算、余额扣减,改一个价格要翻五六个文件。

没有统一的扣费时机。有的接口在请求前扣费,有的响应后才扣,有的甚至忘了扣。用户投诉账单对不上,排查起来很困难。

无法做预算控制。没有余额检查,用户可以一直调用到欠费。包月套餐的用户也没有次数限制的机制。

把计费逻辑抽成独立模块,价格配置集中在一个地方,扣费时机统一放在中间件里,余额检查和限流逻辑只写一次、所有接口共享。

3. 计费场景和定价策略

AI 服务的计费场景可以归成三类。

按 Token 计费

大语言模型通常按 Token 数计费,输入和输出的价格不同:

GPT-4o:输入 $2.50 / 1M tokens,输出 $10.00 / 1M tokens

一次请求的费用 = 输入 Token 数 × 输入单价 + 输出 Token 数 × 输出单价。

按次计费

图片生成、语音合成这类接口按调用次数或生成数量计费:

DALL-E 3:标准质量 $0.040 / 张,高清质量 $0.080 / 张

包月订阅

部分场景提供包月套餐,有效期内限定次数使用:

基础版:¥99/月,包含 1000 次调用
专业版:¥299/月,包含 5000 次调用

超出额度后按单价补差价,或者拒绝请求提示升级套餐。

统一抽象

三种定价策略可以抽象成同一组类型定义:

// src/billing/types.ts
// 计费策略
export type PricingStrategy =
  | { type: 'per_token'; inputPricePer1M: number; outputPricePer1M: number }
  | { type: 'per_call'; pricePerUnit: number }
  | { type: 'subscription'; monthlyLimit: number; overagePricePerUnit: number }
 
// 一次调用的用量记录
export type UsageRecord = {
  model: string
  inputTokens?: number
  outputTokens?: number
  callCount?: number
}
 
// 计费结果
export type BillingResult = {
  cost: number           // 单位:分,避免浮点精度问题
  billable: boolean
  pricingStrategy: string
}

用「分」而不是「元」作为金额单位,是为了避免浮点数精度问题。0.1 + 0.2 在 JavaScript 里等于 0.30000000000000004,在金额计算里会被放大。

4. 价格配置与成本计算

价格配置

价格配置存在 KV 里,所有计费逻辑从这里读取,不硬编码在接口中。调价只需更新 KV,不需要重新部署。

// src/billing/pricing.ts
import type { PricingStrategy } from './types'
 
type PricingConfig = Record<string, PricingStrategy>
 
export const DEFAULT_PRICING: PricingConfig = {
  'gpt-4o': { type: 'per_token', inputPricePer1M: 250, outputPricePer1M: 1000 },
  'gpt-4o-mini': { type: 'per_token', inputPricePer1M: 15, outputPricePer1M: 60 },
  'dall-e-3-standard': { type: 'per_call', pricePerUnit: 4 },
  'dall-e-3-hd': { type: 'per_call', pricePerUnit: 8 },
}
 
export async function getPricing(
  kv: KVNamespace, model: string
): Promise<PricingStrategy | null> {
  const raw = await kv.get('pricing_config', 'json') as PricingConfig | null
  const config = raw ?? DEFAULT_PRICING
  return config[model] ?? null
}

成本计算

有了价格和用量,计算成本是一个纯函数,方便测试和维护:

// src/billing/cost-calculator.ts
import type { PricingStrategy, UsageRecord, BillingResult } from './types'
 
export function calculateCost(pricing: PricingStrategy, usage: UsageRecord): BillingResult {
  switch (pricing.type) {
    case 'per_token': {
      const input = usage.inputTokens ?? 0
      const output = usage.outputTokens ?? 0
      const cost = Math.ceil(input * pricing.inputPricePer1M / 1_000_000)
                  + Math.ceil(output * pricing.outputPricePer1M / 1_000_000)
      return { cost, billable: true, pricingStrategy: 'per_token' }
    }
    case 'per_call': {
      const cost = pricing.pricePerUnit * (usage.callCount ?? 1)
      return { cost, billable: true, pricingStrategy: 'per_call' }
    }
    case 'subscription': {
      // 超出额度的部分按 overagePricePerUnit 收费,callCount 由调用方传入超出量
      const count = usage.callCount ?? 0
      if (count <= 0) return { cost: 0, billable: false, pricingStrategy: 'subscription' }
      return { cost: pricing.overagePricePerUnit * count, billable: true, pricingStrategy: 'subscription' }
    }
  }
}

5. 计费记录存储

每次调用的费用需要持久化,用于账单查询、用量统计和异常排查。

-- migrations/0010_create_billing_records.sql
CREATE TABLE billing_records (
  id               INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id          TEXT    NOT NULL,
  model            TEXT    NOT NULL,
  usage_json       TEXT    NOT NULL,   -- JSON 格式,不同模型存不同字段
  cost             INTEGER NOT NULL DEFAULT 0,  -- 单位:分
  pricing_snapshot TEXT    NOT NULL,   -- 价格快照,历史账单不受后续调价影响
  request_id       TEXT,
  status           TEXT    NOT NULL DEFAULT 'completed',
  created_at       TEXT    NOT NULL DEFAULT (datetime('now'))
);
 
CREATE INDEX idx_billing_user_created ON billing_records(user_id, created_at);
CREATE INDEX idx_billing_request ON billing_records(request_id);
 
-- 用户余额表
CREATE TABLE user_balances (
  user_id       TEXT PRIMARY KEY,
  balance_cents INTEGER NOT NULL DEFAULT 0,
  plan_type     TEXT    NOT NULL DEFAULT 'pay_as_you_go',  -- pay_as_you_go | basic | pro
  plan_used     INTEGER NOT NULL DEFAULT 0,
  plan_reset_at TEXT    NOT NULL DEFAULT (datetime('now')),
  updated_at    TEXT    NOT NULL DEFAULT (datetime('now'))
);

几个设计要点:

  1. pricing_snapshot 存计费时的价格快照,不是引用全局配置——价格随时会调,但历史账单不应该跟着变
  2. usage_json 用 JSON 存用量明细,因为不同模型的用量字段不同
  3. status 字段区分正常完成(completed)、免费额度(free_tier)、退费(refunded)等状态

写入计费记录的代码:

// src/billing/recorder.ts
import type { UsageRecord, BillingResult } from './types'
 
export async function recordBilling(
  db: D1Database,
  userId: string, model: string, usage: UsageRecord,
  result: BillingResult, requestId: string, pricingSnapshot: string
): Promise<void> {
  await db.prepare(`
    INSERT INTO billing_records
      (user_id, model, usage_json, cost, pricing_snapshot, request_id, status)
    VALUES (?, ?, ?, ?, ?, ?, ?)
  `).bind(
    userId, model, JSON.stringify(usage), result.cost,
    pricingSnapshot, requestId,
    result.billable ? 'completed' : 'free_tier',
  ).run()
}

6. 余额检查与预扣

余额检查放在中间件里,在请求到达 AI 供应商之前执行。核心逻辑:包月用户检查剩余次数,按量付费用户检查余额并预扣一笔预估费用。

// src/billing/balance-check.ts
import { createMiddleware } from 'hono/factory'
import type { AppEnv } from '../types'
 
// 按模型给出预估费用上限(单位:分)
function estimateMaxCost(model: string): number {
  const estimates: Record<string, number> = {
    'gpt-4o': 50, 'gpt-4o-mini': 5,
    'dall-e-3-standard': 4, 'dall-e-3-hd': 8,
  }
  return estimates[model] ?? 100
}
 
export const balanceCheck = createMiddleware<AppEnv>(async (c, next) => {
  const db = c.env.DB
  const userId = c.get('user').id
  const body = await c.req.json().catch(() => ({}))
  const model = body.model ?? 'unknown'
 
  const balance = await db.prepare(
    'SELECT balance_cents, plan_type, plan_used FROM user_balances WHERE user_id = ?'
  ).bind(userId).first<{ balance_cents: number; plan_type: string; plan_used: number }>()
 
  if (!balance) return c.json({ error: 'User balance not found' }, 403)
 
  // 包月用户:检查剩余次数
  if (balance.plan_type !== 'pay_as_you_go') {
    const limit = balance.plan_type === 'basic' ? 1000 : 5000
    if (balance.plan_used >= limit) {
      return c.json({ error: 'Monthly quota exceeded', used: balance.plan_used, limit }, 429)
    }
    c.set('billingEstimate', { cost: 0, planType: balance.plan_type })
    return await next()
  }
 
  // 按量付费用户:检查余额并预扣
  const estimated = estimateMaxCost(model)
  if (balance.balance_cents < estimated) {
    return c.json({ error: 'Insufficient balance', balance: balance.balance_cents, estimated }, 402)
  }
 
  await db.prepare(
    `UPDATE user_balances SET balance_cents = balance_cents - ?,
     updated_at = datetime('now') WHERE user_id = ?`
  ).bind(estimated, userId).run()
 
  c.set('billingEstimate', { cost: estimated, planType: 'pay_as_you_go' })
  await next()
})

预扣的金额在请求完成后结算——实际花费少于预扣的部分退回给用户。

7. 计费结算:多退少补

请求到达 AI 供应商并拿到响应之后,才能确定实际消耗量。结算逻辑在接口处理完请求之后执行:

// src/billing/settlement.ts
import type { UsageRecord, BillingResult } from './types'
import { getPricing } from './pricing'
import { calculateCost } from './cost-calculator'
import { recordBilling } from './recorder'
 
export async function settleBilling(
  db: D1Database, kv: KVNamespace,
  userId: string, model: string, usage: UsageRecord,
  requestId: string, prepaidCost: number
): Promise<BillingResult> {
  const pricing = await getPricing(kv, model)
  if (!pricing) throw new Error(`No pricing found for model: ${model}`)
 
  const result = calculateCost(pricing, usage)
  const diff = prepaidCost - result.cost
 
  // D1 batch:余额调整 + 计费记录写入,在一个事务里执行
  await db.batch([
    db.prepare(
      `UPDATE user_balances SET balance_cents = balance_cents + ?,
       updated_at = datetime('now') WHERE user_id = ?`
    ).bind(diff, userId),
    db.prepare(`
      INSERT INTO billing_records
        (user_id, model, usage_json, cost, pricing_snapshot, request_id, status)
      VALUES (?, ?, ?, ?, ?, ?, ?)
    `).bind(
      userId, model, JSON.stringify(usage), result.cost,
      JSON.stringify(pricing), requestId, 'completed',
    ),
  ])
 
  // 包月用户增加已用次数
  if (result.pricingStrategy === 'subscription') {
    await db.prepare(
      `UPDATE user_balances SET plan_used = plan_used + 1,
       updated_at = datetime('now') WHERE user_id = ?`
    ).bind(userId).run()
  }
 
  return result
}

在聊天接口里,拿到 AI 响应之后调用:

// src/routes/chat.ts(节选)
const usage: UsageRecord = {
  model,
  inputTokens: response.usage.prompt_tokens,
  outputTokens: response.usage.completion_tokens,
}
await settleBilling(db, kv, userId, model, usage, requestId, prepaidCost)

8. 账单查询接口

用户需要看到自己的消费记录。三个接口覆盖主要需求:

// src/routes/billing.ts
import { Hono } from 'hono'
import type { AppEnv } from '../types'
 
export const billingApp = new Hono<AppEnv>()
 
// 消费明细(分页)
billingApp.get('/records', async (c) => {
  const db = c.env.DB
  const userId = c.get('user').id
  const page = Number(c.req.query('page') ?? '1')
  const pageSize = Math.min(Number(c.req.query('pageSize') ?? '20'), 100)
  const offset = (page - 1) * pageSize
 
  const [records, countResult] = await Promise.all([
    db.prepare(`
      SELECT id, model, usage_json, cost, status, created_at
      FROM billing_records WHERE user_id = ?
      ORDER BY created_at DESC LIMIT ? OFFSET ?
    `).bind(userId, pageSize, offset).all(),
    db.prepare('SELECT COUNT(*) as total FROM billing_records WHERE user_id = ?')
      .bind(userId).first<{ total: number }>(),
  ])
 
  return c.json({ records: records.results, total: countResult?.total ?? 0, page, pageSize })
})
 
// 按月汇总
billingApp.get('/summary', async (c) => {
  const db = c.env.DB
  const userId = c.get('user').id
  const months = Number(c.req.query('months') ?? '6')
 
  const rows = await db.prepare(`
    SELECT strftime('%Y-%m', created_at) as month, model,
           COUNT(*) as call_count, SUM(cost) as total_cost_cents
    FROM billing_records
    WHERE user_id = ? AND created_at >= datetime('now', '-' || ? || ' months')
    GROUP BY month, model ORDER BY month DESC, total_cost_cents DESC
  `).bind(userId, months).all()
 
  return c.json({ summary: rows.results })
})
 
// 查询余额
billingApp.get('/balance', async (c) => {
  const db = c.env.DB
  const userId = c.get('user').id
  const balance = await db.prepare(
    'SELECT balance_cents, plan_type, plan_used, plan_reset_at FROM user_balances WHERE user_id = ?'
  ).bind(userId).first()
 
  if (!balance) return c.json({ error: 'Balance not found' }, 404)
  return c.json(balance)
})

9. 计费异常处理

计费链路涉及环节较多,三类异常需要提前考虑。

供应商请求失败

请求发到 AI 供应商后失败了(超时、5xx、限流),但余额已经预扣了。需要全额退回:

// src/billing/error-refund.ts
export async function refundPrepaid(
  db: D1Database, userId: string, prepaidCost: number, reason: string
): Promise<void> {
  await db.prepare(
    `UPDATE user_balances SET balance_cents = balance_cents + ?,
     updated_at = datetime('now') WHERE user_id = ?`
  ).bind(prepaidCost, userId).run()
 
  // 记录退费日志,cost 为负数
  await db.prepare(`
    INSERT INTO billing_records (user_id, model, usage_json, cost, pricing_snapshot, request_id, status)
    VALUES (?, ?, ?, ?, ?, ?, ?)
  `).bind(
    userId, 'refund', JSON.stringify({ reason, amount: prepaidCost }),
    -prepaidCost, '{}', crypto.randomUUID(), 'refunded',
  ).run()
}

接口里的用法——把 AI 调用包在 try/catch 里:

const prepaidCost = c.get('billingEstimate').cost
try {
  const response = await callAIProvider(model, messages)
  await settleBilling(db, kv, userId, model, usage, requestId, prepaidCost)
} catch (error) {
  await refundPrepaid(db, userId, prepaidCost, 'provider_error')
  throw error
}

结算过程本身出错

如果结算逻辑执行到一半崩了(比如 D1 写入失败),可能出现「钱扣了但没记录」。上面的 settleBilling 已经用 db.batch() 把余额调整和记录写入放在同一个事务里,要么全部成功、要么全部回滚。

对于更复杂的情况,可以再加一层保障:写入一条 pending 状态的记录,结算完成后更新为 completed,后台定时任务扫描长时间处于 pending 的记录做补偿处理。

重复扣费防护

同一个 request_id 只能结算一次。在写入前做去重:

const existing = await db.prepare(
  'SELECT id FROM billing_records WHERE request_id = ? AND status = ?'
).bind(requestId, 'completed').first()
 
if (existing) {
  console.warn(`Duplicate billing for request_id: ${requestId}`)
  return  // 已结算,跳过
}

request_id 字段有索引,这个查询不会影响接口性能。

总结

成本计费接口是 AI 服务从「能用」到「能用且可运营」的关键一步。回顾这篇的内容:

  • 三种定价策略(按 Token、按次、包月)抽象成统一的类型定义,计费逻辑和模型供应商解耦
  • 价格配置存在 KV 里,和代码分离,调价不需要重新部署
  • 成本计算是纯函数,方便测试和验证
  • 计费记录存在 D1 里,带价格快照,历史账单不受后续调价影响
  • 余额检查和预扣放在中间件里,所有接口统一走,结算放在响应处理之后多退少补
  • 异常场景(供应商失败、结算出错、重复扣费)在设计阶段就覆盖

下一篇我们来看怎么把这些计费相关的接口接入到聊天接口里,串起完整的调用链路。

所属分组

12-Hono开发AI API服务