成本计费接口
要点
- 每次调用 AI 模型都有成本,接口需要在请求到达供应商之前就完成预算检查
- 不同模型的定价策略不同:按 Token 计费、按次计费、包月订阅,需要一套统一的计费抽象
- 计费逻辑拆成三层:价格配置、成本计算、账单记录,各自独立变更
- 余额检查和实际扣费分布在请求链路的不同节点——请求前预扣、响应后结算
- 账单数据存在 D1 里,按用户 + 时间维度聚合,支持分页查询和统计导出
- 异常场景(超时、供应商报错、重复扣费)要在设计阶段就覆盖,不能留到上线后补
1. 什么是成本计费接口
前面的文章里,我们实现了聊天接口、图片生成接口、语音合成接口。这些接口有一个共同特点:每次调用都会消耗 AI 供应商的配额,而配额是要花钱的。
如果只管调接口不管成本,一个月下来账单可能会超出预期很多。对外提供服务时,用户调用模型的费用需要有地方记录、有规则计费、有余额可以检查。
成本计费接口要回答三个问题:
- 这次调用花了多少钱
- 用户的余额够不够
- 历史上总共花了多少
它不是单一接口,而是一组围绕「费用」展开的模块:请求到达时的余额检查、请求完成后的成本记录和扣费、账单查询、价格配置读取。
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'))
);几个设计要点:
pricing_snapshot存计费时的价格快照,不是引用全局配置——价格随时会调,但历史账单不应该跟着变usage_json用 JSON 存用量明细,因为不同模型的用量字段不同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服务