第1776篇:AI功能的按需付费设计——面向B端客户的用量计费系统架构
2026/4/30大约 9 分钟
第1776篇:AI功能的按需付费设计——面向B端客户的用量计费系统架构
做B端SaaS产品,最头疼的定价问题之一:AI功能怎么收钱?
按座位收?AI用多用少一个价,重度用户觉得赚了,轻度用户觉得亏了,最终两头都不讨好。
按功能模块收?每开一个AI功能加多少钱,结构简单,但细颗粒度的用量差异没法体现。
按用量收(Pay-as-you-go)才是最公平的方案——用多少付多少,成本可预期,客户接受度也高。
但按用量收钱,背后的工程比按座位复杂得多:需要精确计量每个客户的AI消耗,要能出账单,要能做预算管控,还要处理欠费、超量等各种边界情况。
这篇文章,我来拆解面向B端客户的用量计费系统该怎么设计。
计费系统的核心要素
一个完整的B端AI用量计费系统需要覆盖:
计量层:精确记录每一分消耗
计量是计费的基础,必须准确,允许有延迟但不能有丢失。
数据模型设计
-- 客户账户表
CREATE TABLE billing_account (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id VARCHAR(64) NOT NULL UNIQUE COMMENT '租户ID',
company_name VARCHAR(200) NOT NULL COMMENT '公司名称',
balance_cny DECIMAL(14,4) NOT NULL DEFAULT 0 COMMENT '余额(元)',
credit_limit_cny DECIMAL(14,4) NOT NULL DEFAULT 0 COMMENT '信用额度(可透支)',
billing_model VARCHAR(20) NOT NULL COMMENT 'PREPAID预付费/POSTPAID后付费',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE/SUSPENDED/CLOSED',
alert_threshold_percent INT DEFAULT 20 COMMENT '余额告警阈值(%)',
monthly_budget_cny DECIMAL(14,4) COMMENT '月度预算上限',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_tenant (tenant_id)
) COMMENT '计费账户';
-- 用量明细表(按月分表)
CREATE TABLE billing_usage_detail_202504 (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id VARCHAR(64) NOT NULL COMMENT '租户ID',
biz_type VARCHAR(64) NOT NULL COMMENT '业务类型',
feature_code VARCHAR(64) NOT NULL COMMENT '功能代码',
model_name VARCHAR(64) NOT NULL COMMENT '模型名称',
trace_id VARCHAR(128) COMMENT '调用追踪ID',
input_tokens INT NOT NULL DEFAULT 0,
output_tokens INT NOT NULL DEFAULT 0,
cost_cny DECIMAL(12,6) NOT NULL DEFAULT 0 COMMENT '费用(元)',
call_time DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_tenant_time (tenant_id, call_time),
INDEX idx_tenant_feature (tenant_id, feature_code)
) COMMENT '用量明细';
-- 账户流水表
CREATE TABLE billing_ledger (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id VARCHAR(64) NOT NULL,
ledger_type VARCHAR(20) NOT NULL COMMENT 'DEBIT扣费/CREDIT充值/REFUND退款',
amount_cny DECIMAL(14,4) NOT NULL COMMENT '金额(正数为加,负数为减)',
balance_after_cny DECIMAL(14,4) NOT NULL COMMENT '操作后余额',
ref_id VARCHAR(128) COMMENT '关联单号(充值单号/账单ID等)',
description VARCHAR(500) COMMENT '说明',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_tenant_time (tenant_id, created_at)
) COMMENT '账户流水';用量采集服务
@Service
@Slf4j
public class UsageMeteringService {
@Autowired
private BillingAccountRepository accountRepository;
@Autowired
private UsageDetailRepository usageDetailRepository;
@Autowired
private BillingLedgerRepository ledgerRepository;
@Autowired
private BillingPriceConfig priceConfig;
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 记录AI用量并扣费
* 这是核心方法,每次AI调用完成后调用
*/
@Transactional
public UsageRecordResult recordAndDeduct(UsageRecord record) {
// 1. 计算费用
BigDecimal costCny = calculateCost(record);
// 2. 保存用量明细
UsageDetail detail = UsageDetail.builder()
.tenantId(record.getTenantId())
.bizType(record.getBizType())
.featureCode(record.getFeatureCode())
.modelName(record.getModelName())
.traceId(record.getTraceId())
.inputTokens(record.getInputTokens())
.outputTokens(record.getOutputTokens())
.costCny(costCny)
.callTime(record.getCallTime())
.build();
usageDetailRepository.save(detail);
// 3. 扣减账户余额(带乐观锁防并发)
int updated = accountRepository.deductBalance(record.getTenantId(), costCny);
if (updated == 0) {
// 余额不足或账户异常
log.warn("余额不足,记录欠费: tenantId={}, cost={}", record.getTenantId(), costCny);
handleInsufficientBalance(record.getTenantId(), costCny);
}
// 4. 记录流水
BillingAccount account = accountRepository.findByTenantId(record.getTenantId());
ledgerRepository.save(BillingLedger.builder()
.tenantId(record.getTenantId())
.ledgerType("DEBIT")
.amountCny(costCny.negate())
.balanceAfterCny(account.getBalanceCny())
.refId(record.getTraceId())
.description(record.getFeatureCode() + " AI调用扣费")
.build());
// 5. 更新实时统计(Redis)
updateRealtimeStats(record, costCny);
// 6. 检查是否需要发送用量告警
checkAndSendAlert(record.getTenantId());
return UsageRecordResult.success(costCny);
}
/**
* 计算费用(支持不同计价模式)
*/
private BigDecimal calculateCost(UsageRecord record) {
// 获取该租户的价格方案(不同客户可能有不同折扣)
PricePlan plan = priceConfig.getPlanForTenant(record.getTenantId());
ModelPrice modelPrice = plan.getModelPrice(record.getModelName());
if (modelPrice == null) {
log.error("未找到模型价格配置: model={}", record.getModelName());
return BigDecimal.ZERO;
}
// 基础成本(按token计价)
BigDecimal inputCost = modelPrice.getInputPricePer1KTokenCny()
.multiply(new BigDecimal(record.getInputTokens()))
.divide(new BigDecimal(1000), 6, RoundingMode.HALF_UP);
BigDecimal outputCost = modelPrice.getOutputPricePer1KTokenCny()
.multiply(new BigDecimal(record.getOutputTokens()))
.divide(new BigDecimal(1000), 6, RoundingMode.HALF_UP);
BigDecimal baseCost = inputCost.add(outputCost);
// 应用阶梯折扣(月用量越多越便宜)
BigDecimal discount = getVolumeDiscount(record.getTenantId(), plan);
return baseCost.multiply(discount).setScale(6, RoundingMode.HALF_UP);
}
/**
* 阶梯折扣计算
* 根据当月已用量给予折扣
*/
private BigDecimal getVolumeDiscount(String tenantId, PricePlan plan) {
// 获取当月已消费金额
BigDecimal monthlySpend = getMonthlySpend(tenantId);
// 阶梯折扣:
// 0~1000元:无折扣(100%)
// 1000~5000元:95折
// 5000~20000元:9折
// 20000元以上:85折
if (monthlySpend.compareTo(new BigDecimal("20000")) >= 0) {
return new BigDecimal("0.85");
} else if (monthlySpend.compareTo(new BigDecimal("5000")) >= 0) {
return new BigDecimal("0.90");
} else if (monthlySpend.compareTo(new BigDecimal("1000")) >= 0) {
return new BigDecimal("0.95");
}
return BigDecimal.ONE;
}
/**
* 更新Redis实时统计
* 用于前端实时展示用量和费用
*/
private void updateRealtimeStats(UsageRecord record, BigDecimal costCny) {
String dateKey = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String monthKey = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMM"));
// 今日用量
String dailyCostKey = "billing:daily:" + record.getTenantId() + ":" + dateKey;
redisTemplate.opsForValue().increment(dailyCostKey + ":tokens_input", record.getInputTokens());
redisTemplate.opsForValue().increment(dailyCostKey + ":tokens_output", record.getOutputTokens());
// cost 用lua脚本原子性增加(字符串形式)
// 月度用量
String monthlyCostKey = "billing:monthly:" + record.getTenantId() + ":" + monthKey;
redisTemplate.opsForValue().increment(monthlyCostKey + ":calls", 1);
// 设置过期时间(日统计保留31天,月统计保留366天)
redisTemplate.expire(dailyCostKey + ":tokens_input", Duration.ofDays(31));
redisTemplate.expire(monthlyCostKey + ":calls", Duration.ofDays(366));
}
}预算控制与限流
B端客户通常需要月度预算控制,避免AI用量失控。
@Service
public class BudgetControlService {
@Autowired
private BillingAccountRepository accountRepository;
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 调用前检查:是否允许此次AI调用
* 返回 false 表示应该拒绝(超预算/欠费/账户暂停)
*/
public BudgetCheckResult checkBeforeCall(String tenantId, int estimatedInputTokens) {
// 1. 检查账户状态
BillingAccount account = accountRepository.findByTenantId(tenantId);
if (account == null) {
return BudgetCheckResult.denied("账户不存在");
}
if ("SUSPENDED".equals(account.getStatus())) {
return BudgetCheckResult.denied("账户已暂停,请联系客服");
}
if ("CLOSED".equals(account.getStatus())) {
return BudgetCheckResult.denied("账户已关闭");
}
// 2. 检查余额(预付费账户)
if ("PREPAID".equals(account.getBillingModel())) {
BigDecimal estimatedCost = estimateCost(tenantId, estimatedInputTokens);
BigDecimal availableBalance = account.getBalanceCny()
.add(account.getCreditLimitCny());
if (availableBalance.compareTo(estimatedCost) < 0) {
return BudgetCheckResult.denied(
String.format("余额不足,当前余额: %.2f元,预估费用: %.4f元",
availableBalance, estimatedCost)
);
}
}
// 3. 检查月度预算
if (account.getMonthlyBudgetCny() != null) {
BigDecimal monthlySpend = getMonthlySpend(tenantId);
BigDecimal estimatedCost = estimateCost(tenantId, estimatedInputTokens);
if (monthlySpend.add(estimatedCost).compareTo(account.getMonthlyBudgetCny()) > 0) {
return BudgetCheckResult.denied(
String.format("月度预算已达上限,已用: %.2f元,预算: %.2f元",
monthlySpend, account.getMonthlyBudgetCny())
);
}
}
// 4. 检查速率限制(防止短时间内爆发性消耗)
boolean rateLimitOk = checkRateLimit(tenantId, account.getRateLimitConfig());
if (!rateLimitOk) {
return BudgetCheckResult.denied("请求频率超限,请稍后再试");
}
return BudgetCheckResult.allowed();
}
/**
* 速率限制检查
* 防止某个租户在短时间内大量调用
*/
private boolean checkRateLimit(String tenantId, RateLimitConfig config) {
if (config == null) return true;
String minuteKey = "ratelimit:" + tenantId + ":" +
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm"));
Long count = redisTemplate.opsForValue().increment(minuteKey);
redisTemplate.expire(minuteKey, Duration.ofMinutes(2));
return count <= config.getMaxRequestsPerMinute();
}
}账单生成
每月自动生成账单,这是B端客户必须的。
@Service
public class InvoiceGenerationService {
/**
* 生成月度账单
*/
public MonthlyInvoice generateMonthlyInvoice(String tenantId, int year, int month) {
// 1. 汇总当月用量
List<UsageSummary> summaries = usageDetailRepository
.aggregateByFeature(tenantId, year, month);
// 2. 按功能模块整理账单行
List<InvoiceLine> lines = summaries.stream().map(s -> InvoiceLine.builder()
.featureCode(s.getFeatureCode())
.featureName(getFeatureName(s.getFeatureCode()))
.inputTokens(s.getTotalInputTokens())
.outputTokens(s.getTotalOutputTokens())
.callCount(s.getCallCount())
.subtotalCny(s.getTotalCostCny())
.build()
).collect(Collectors.toList());
BigDecimal subtotal = lines.stream()
.map(InvoiceLine::getSubtotalCny)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// 3. 计算折扣(如果本月用量大,给回溯折扣)
BigDecimal discountAmount = calculateRetroDiscount(tenantId, subtotal);
BigDecimal finalAmount = subtotal.subtract(discountAmount);
// 4. 生成账单
MonthlyInvoice invoice = MonthlyInvoice.builder()
.invoiceNo(generateInvoiceNo(tenantId, year, month))
.tenantId(tenantId)
.year(year)
.month(month)
.lines(lines)
.subtotalCny(subtotal)
.discountCny(discountAmount)
.totalCny(finalAmount)
.status("GENERATED")
.generatedAt(LocalDateTime.now())
.build();
invoiceRepository.save(invoice);
// 5. 如果是后付费,自动从账户扣款
BillingAccount account = accountRepository.findByTenantId(tenantId);
if ("POSTPAID".equals(account.getBillingModel())) {
deductMonthlyInvoice(account, invoice);
}
// 6. 发送账单通知邮件
notificationService.sendInvoiceEmail(tenantId, invoice);
return invoice;
}
/**
* 导出账单明细(供客户下载)
*/
public byte[] exportInvoiceDetail(String invoiceNo, String format) {
MonthlyInvoice invoice = invoiceRepository.findByInvoiceNo(invoiceNo);
List<UsageDetail> details = usageDetailRepository
.findByTenantIdAndMonth(invoice.getTenantId(), invoice.getYear(), invoice.getMonth());
if ("CSV".equalsIgnoreCase(format)) {
return exportAsCsv(details);
} else {
return exportAsPdf(invoice, details);
}
}
}用量告警与欠费处理
@Service
public class AlertAndSuspendService {
/**
* 检查并发送告警
*/
public void checkAndAlert(String tenantId) {
BillingAccount account = accountRepository.findByTenantId(tenantId);
// 余额告警(预付费账户)
if ("PREPAID".equals(account.getBillingModel())) {
BigDecimal totalAvailable = account.getBalanceCny().add(account.getCreditLimitCny());
// 余额不足阈值时告警
if (account.getMonthlyBudgetCny() != null) {
BigDecimal usedPercent = getMonthlySpend(tenantId)
.divide(account.getMonthlyBudgetCny(), 4, RoundingMode.HALF_UP)
.multiply(new BigDecimal(100));
if (usedPercent.compareTo(new BigDecimal("80")) >= 0
&& !hasAlertSentToday(tenantId, "BUDGET_80")) {
sendAlert(tenantId, "BUDGET_80",
String.format("月度预算已使用 %.1f%%,请注意控制用量", usedPercent));
markAlertSent(tenantId, "BUDGET_80");
}
}
// 余额低于阈值告警
BigDecimal alertThreshold = account.getMonthlyBudgetCny() != null
? account.getMonthlyBudgetCny().multiply(new BigDecimal("0.2"))
: new BigDecimal("100");
if (totalAvailable.compareTo(alertThreshold) < 0
&& !hasAlertSentToday(tenantId, "LOW_BALANCE")) {
sendAlert(tenantId, "LOW_BALANCE",
String.format("账户余额不足,当前余额 %.2f 元,请及时充值", totalAvailable));
markAlertSent(tenantId, "LOW_BALANCE");
}
}
}
/**
* 处理欠费:暂停账户
*/
@Transactional
public void handleDebt(String tenantId) {
BillingAccount account = accountRepository.findByTenantId(tenantId);
// 超出信用额度时暂停账户
BigDecimal totalAvailable = account.getBalanceCny().add(account.getCreditLimitCny());
if (totalAvailable.compareTo(BigDecimal.ZERO) < 0) {
log.warn("账户欠费,暂停服务: tenantId={}, balance={}",
tenantId, account.getBalanceCny());
accountRepository.updateStatus(tenantId, "SUSPENDED");
// 发送欠费暂停通知
notificationService.sendSuspendedNotice(tenantId, account.getBalanceCny());
}
}
/**
* 充值后恢复账户
*/
@Transactional
public void rechargeAndResume(String tenantId, BigDecimal amount, String rechargeOrderId) {
BillingAccount account = accountRepository.findByTenantId(tenantId);
// 更新余额
accountRepository.addBalance(tenantId, amount);
// 记录充值流水
ledgerRepository.save(BillingLedger.builder()
.tenantId(tenantId)
.ledgerType("CREDIT")
.amountCny(amount)
.balanceAfterCny(account.getBalanceCny().add(amount))
.refId(rechargeOrderId)
.description("充值")
.build());
// 如果是因为欠费被暂停,充值后自动恢复
if ("SUSPENDED".equals(account.getStatus())) {
BigDecimal newBalance = account.getBalanceCny().add(amount);
if (newBalance.compareTo(BigDecimal.ZERO) > 0) {
accountRepository.updateStatus(tenantId, "ACTIVE");
notificationService.sendResumedNotice(tenantId, newBalance);
log.info("账户充值后恢复: tenantId={}, newBalance={}", tenantId, newBalance);
}
}
}
}客户用量查询API
客户自己也需要能查看用量情况。
@RestController
@RequestMapping("/api/billing")
public class BillingQueryController {
/**
* 查询账户信息(余额、状态)
*/
@GetMapping("/account")
public ApiResponse<AccountInfoVO> getAccountInfo(@RequestHeader("X-Tenant-Id") String tenantId) {
BillingAccount account = accountRepository.findByTenantId(tenantId);
BigDecimal monthlySpend = meteringService.getMonthlySpend(tenantId);
AccountInfoVO vo = AccountInfoVO.builder()
.tenantId(tenantId)
.companyName(account.getCompanyName())
.balance(account.getBalanceCny())
.creditLimit(account.getCreditLimitCny())
.availableBalance(account.getBalanceCny().add(account.getCreditLimitCny()))
.billingModel(account.getBillingModel())
.status(account.getStatus())
.monthlyBudget(account.getMonthlyBudgetCny())
.currentMonthSpend(monthlySpend)
.budgetUsedPercent(account.getMonthlyBudgetCny() != null
? monthlySpend.divide(account.getMonthlyBudgetCny(), 4, RoundingMode.HALF_UP)
.multiply(new BigDecimal(100))
: null)
.build();
return ApiResponse.success(vo);
}
/**
* 查询实时用量(今日)
*/
@GetMapping("/usage/today")
public ApiResponse<TodayUsageVO> getTodayUsage(@RequestHeader("X-Tenant-Id") String tenantId) {
return ApiResponse.success(meteringService.getTodayUsage(tenantId));
}
/**
* 查询月度用量明细
*/
@GetMapping("/usage/monthly")
public ApiResponse<PageResult<UsageDetailVO>> getMonthlyDetail(
@RequestHeader("X-Tenant-Id") String tenantId,
@RequestParam int year,
@RequestParam int month,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize) {
Page<UsageDetail> details = usageDetailRepository
.findByTenantIdAndMonth(tenantId, year, month, PageRequest.of(page - 1, pageSize));
return ApiResponse.success(PageResult.from(details, UsageDetailVO::from));
}
}落地时要特别注意的几件事
账单和用量要独立存储,不能依赖API调用日志反推。我见过有团队账单出了问题,去翻调用日志重新算,结果日志有丢失,账单不准,客户投诉。用量明细必须作为计费数据独立持久化,不是日志。
扣费要防重入。AI调用可能因为网络超时触发重试,同一次调用可能被记录两次。用trace_id做幂等,相同trace_id的用量只记录一次。
汇率要锁定。B端客户的合同通常是人民币计价,但底层是美元API。汇率波动会影响你的利润空间。建议在合同期内锁定计价汇率,或者在定价上留出汇率缓冲。
测试环境和生产环境分离计费。开发测试的调用不应该计入客户账单。用独立的API Key或者环境标记区分,测试调用走内部成本,不向客户收费。
