计费这事把人逼疯了:我们抄了 Stripe 的两阶段协议来扣 LLM 的钱
摘要:本文介绍了如何借鉴 Stripe 的两阶段支付协议(reserve-commit-release)解决 LLM 计费中“事前不知花费”的难题。通过预扣(reserve)冻结用户余额、按实际用量结算(commit)、失败或多余时释放(release)的三步流程,配合幂等、乐观锁、状态机约束和 TTL 过期兜底等机制,确保了在高并发、流式中断、Agent 多步骤等复杂场景下扣费准确无误。文章详细阐述了表结构设计、核心代码实现以及实践中踩过的坑,为构建可靠、可扩展的 AI 计费系统提供了完整方案。
做 AI 平台最让我头秃的不是接模型、不是调并发,是计费。
听着像个小事——用户调一次模型,按花了多少 token 扣多少钱,完事。真做起来你会发现全是坑。这篇讲讲我们最后是怎么借鉴 Stripe 的支付协议来解决 LLM 计费问题的。
一切的源头:你不知道一次调用最终要花多少钱
普通的业务计费很直接。比如卖会员,价格 100 块,校验下余额够不够,扣 100,开会员。金额事前确定,一把扣完。
但 LLM 调用不一样。用户发起一个对话,输入 2000 token,这时候你根本不知道模型最后会吐多少 token 出来。可能是 100,可能是 5000,也可能是 50000(用户让它写篇论文)。而钱是按总 token 算的。
这就很尴尬了:
- 事前不校验余额吧:用户余额只剩 0.1 块,发了个要烧 50 块的请求,模型生成完了才发现没钱——这钱谁出?调用方血亏。
- 事前就扣死吧:扣多少?不知道。只能按"最坏情况"估个上限,比如预估最多花 50 就先扣 50。但实际可能只花了 5 块,多扣的 45 块退不退?不退用户骂娘,退了又是一次数据库写操作。
- 并发更头疼:用户同时开 3 个标签页发消息,3 个请求同时读到余额 50,各自觉得"够扣",结果真扣的时候超支了。
灵感来源:信用卡支付早就解决了这事
被这个问题折磨了几天后,突然想起来——信用卡支付不就是这样的吗。
你去酒店住,前台刷一下你的卡"预授权"(pre-authorization),冻结个 500 块额度。这时候钱还没真扣,只是银行帮你"占着",保证你花得起。等你退房结算,实际消费 320,银行就只扣 320,剩下冻结的 180 解冻还你。
这套机制叫两阶段提交(Two-Phase Commit),Stripe 的 PaymentIntent 也是这个思路:下单时不知道最终金额,先 authorize 冻结卡片额度,发货后正式 capture。
LLM 计费的场景简直一模一样:
- 用户发起对话 = 下单
- 事前不知道最终花费 = 不知道要 capture 多少
- 调用前要校验"花得起" = 需要 authorize 冻结
- 调用后按实际 token 结算,多退少补 = capture + release
所以我们干脆抄了这套协议,叫reserve-commit-release。
协议长什么样
状态机很简单:
reserve(amount) ┌─────────┐ ─────────────────→ ┌──────────┐ │ (none) │ │ RESERVED │ 已冻结,待结算 └─────────┘ └──────────┘ │ │ commit(actual) │ │ release() ┌─────────────────┘ │ ▼ ▼ ┌───────────┐ ┌──────────┐ │ COMMITTED │ │ RELEASED │ 退多余冻结 └───────────┘ └──────────┘三个动作:
| 动作 | 干啥 | 钱包怎么变 |
|---|---|---|
| reserve(amount) | 预扣:按预估最大花费冻结 | 星源值 -= amount,冻结 += amount |
| commit(actual) | 结算:按实际花费扣 | 优先从冻结扣,不够再从星源值扣 |
| release() | 释放:退还没花的冻结 | 冻结 -= 剩余,星源值 += 剩余 |
放到 AI 网关里,一次对话的完整流程:
用户发请求 ↓ 网关: reserve_credits(预估) → Java 后端冻结 50 星源值 ↓ 网关: 转发到上游 LLM,流式接收 ↓ 流结束,拿到 usage(实际 10600 token) ↓ 网关: commit(实际花费 32 星源值) → Java 从冻结里扣 32 ↓ 网关: release() → Java 退还剩余 18 冻结最妙的是失败场景:如果上游模型挂了(5xx/超时),网关直接走release(),把冻结的 50 全额退还,用户一分不花。这才是合理的——模型没成功,凭啥扣钱。
表怎么设计
支撑这套协议用了四张表:
-- 钱包:双账户设计CREATETABLEt_wallet(user_idBIGINT,balanceDECIMAL(15,2),-- 人民币(充值/退款/会员用)star_creditsDECIMAL(15,2),-- 星源值(实际消费货币)frozenDECIMAL(15,2),-- 冻结金额(预扣占位)versionINTDEFAULT0,-- 乐观锁版本号(重点!)UNIQUEKEYuk_user_id(user_id));-- 预扣记录:一次 reserve 一行CREATETABLEt_wallet_reservation(reservation_idVARCHAR(100)UNIQUE,-- 外部单号,幂等用idempotency_keyVARCHAR(100),-- 幂等键amountDECIMAL(15,2),-- 冻结金额committed_amountDECIMAL(15,2),-- 已结算金额statusVARCHAR(20),-- RESERVED/COMMITTED/RELEASED/EXPIREDexpires_atDATETIME-- 过期时间);-- 操作流水:每次 commit/release 一行,同时也是幂等去重表CREATETABLEt_reservation_ledger(reservation_idVARCHAR(100),kindVARCHAR(20),-- commit/release/refund/extendamountDECIMAL(15,2),idempotency_keyVARCHAR(100),balance_afterDECIMAL(15,2),-- 审计用UNIQUEKEYuk_idempotency_key(idempotency_key)-- DB 级幂等);-- token 用量明细:commit 时写入CREATETABLEt_token_usage(model_nameVARCHAR(100),input_tokensINT,output_tokensINT,cost_credits_rawDECIMAL(15,6),-- 折前原价discount_rateDECIMAL(5,4),-- 折扣率快照actual_creditsDECIMAL(15,6),-- 实扣app_idBIGINT,channel_idBIGINT-- 分账维度);几个设计决策值得说一下:
双账户(balance + star_credits)。一开始我也纳闷为啥要分人民币和星源值两套余额。后来理解了——人民币是"财务账户",充值退款走它,变动要财务对账;星源值是"消费货币",高频扣减。1 元 = 10 星源值。分开是为了不让高频的消费扣减搅乱低频但严肃的财务流水。而且星源值可以送(充值满 100 送 20),和人民币不能 1:1 对应。
t_reservation_ledger一表两用。这表本来就要写(审计流水),顺手靠idempotency_key唯一索引做幂等去重。没有引入 Redis 做幂等——复用流水表,少一个中间件少一个故障点。这个决定我挺得意的,简化了架构。
t_token_usage存折扣率快照。这个点容易被忽视,但很重要——每次 commit 时把当时的折扣率一起存进去(比如 0.9)。这样历史账单是自包含的,以后对账时能看到"这条当时是 9 折",不会因为后续折扣变了而对不上。涉及钱的系统,历史记录必须快照当时的关键参数。
reserve:预扣的实现
直接看代码,核心是"幂等检查 + 乐观锁重试":
@Transactional(rollbackFor=Exception.class)publicMap<String,Object>reserve(LonguserId,StringreservationId,StringidempotencyKey,BigDecimalamount,...){// 1. 幂等:同一个 reservation_id 来了第二次,直接返回上次结果WalletReservationexisting=baseMapper.selectOne(newLambdaQueryWrapper<WalletReservation>().eq(WalletReservation::getReservationId,reservationId));if(existing!=null&&"RESERVED".equals(existing.getStatus())){returnMap.of("reservation_id",existing.getReservationId());// 幂等返回}// 2. 余额校验 + 乐观锁扣减(最多 3 次)for(intattempt=0;attempt<3;attempt++){Walletwallet=walletMapper.selectOne(...);// 读最新(含 version)if(wallet.getStarCredits().compareTo(amount)<0){thrownewBusinessException(402,"星源值余额不足");// 事前拦截}wallet.setStarCredits(wallet.getStarCredits().subtract(amount));wallet.setFrozen(wallet.getFrozen().add(amount));introws=walletMapper.updateById(wallet);// 自动带 WHERE version=?if(rows>0)break;// CAS 成功if(attempt==2)thrownewBusinessException("并发冲突,请重试");// 失败了:重读最新值,重算,重试}// 3. 写预扣记录WalletReservationreservation=newWalletReservation();reservation.setReservationId(reservationId);reservation.setAmount(amount);reservation.setStatus("RESERVED");reservation.setExpiresAt(LocalDateTime.now().plusSeconds(300));// 5 分钟 TTLbaseMapper.insert(reservation);returnMap.of("reservation_id",reservationId);}乐观锁那块(version字段 + 3 次重试)我专门写了篇博客讲,这里不展开了。核心就是不加悲观锁、不用 Redis 分布式锁,靠数据库的 CAS 解决并发扣减。
commit:最精巧的一块
commit 是整个协议里设计最讲究的。它要支持增量多次 commit(Agent 多步骤场景),还要处理折扣和"预扣不够用"的情况:
publicMap<String,Object>commitIncremental(StringreservationId,BigDecimalcommitAmount,StringidempotencyKey,StringmodelName,...){// 1. 幂等:查 ledger 有没有同 idempotency_keyReservationLedgerexisting=ledgerMapper.selectOne(...idempotencyKey...);if(existing!=null)returnMap.of("committed",existing.getAmount());// 2. 校验状态(必须是 RESERVED)WalletReservationreservation=...;if(!"RESERVED".equals(reservation.getStatus())){/* 状态机校验 */}if(LocalDateTime.now().isAfter(reservation.getExpiresAt())){thrownewBusinessException("预扣已过期");// TTL 兜底}// 3. 折扣计算(四级优先级链,另写了一篇)BigDecimaldiscountRate=userDiscountService.getDiscountRate(userId,modelName);BigDecimaldiscounted=commitAmount.multiply(discountRate).setScale(4,HALF_UP);// 4. 智能扣减:折后金额优先从 frozen 扣,超出部分从 star_credits 扣BigDecimalremainingFrozen=reservation.getAmount().subtract(reservation.getCommittedAmount());BigDecimalfromFrozen,fromCredits;if(discounted.compareTo(remainingFrozen)<=0){fromFrozen=discounted;// 预扣够用,全从冻结扣fromCredits=BigDecimal.ZERO;}else{fromFrozen=remainingFrozen;// 吃光冻结fromCredits=discounted.subtract(remainingFrozen);// 超的从余额扣}// 5. 乐观锁更新钱包wallet.setFrozen(wallet.getFrozen().subtract(fromFrozen));wallet.setStarCredits(wallet.getStarCredits().subtract(fromCredits));walletMapper.updateById(wallet);// 带乐观锁// 6. 写流水(幂等表)+ token 明细(含折扣快照)writeLedger(reservationId,"commit",discounted,idempotencyKey,...);writeTokenUsage(modelName,commitAmount,discountRate,discounted,...);}为什么实际花费可能超过预扣?因为 reserve 时是按"保守上限"估的,但有些情况会突破:
- Agent 多步骤执行,每步都在累积消耗
- 用户预扣后余额被别的操作动了
智能扣减策略保证:折后金额先吃冻结,不够再吃余额。这样 commit 阶段不会再次冻结超额(避免冻结越累越多),又能兜住"预扣不够用"。
过期兜底:防止冻结金额永久占着
有个问题——如果用户 reserve 了,但既不 commit 也不 release(比如关了浏览器、网络断了),那笔冻结就永远挂着,余额永远少一块。
所以预扣有 TTL(默认 5 分钟,最长 48 小时),配个定时任务兜底:
@Scheduled(fixedRate=120_000)// 每 2 分钟扫一次publicvoidcleanupExpiredReservations(){List<WalletReservation>expired=baseMapper.selectList(newLambdaQueryWrapper<WalletReservation>().eq(WalletReservation::getStatus,"RESERVED").lt(WalletReservation::getExpiresAt,LocalDateTime.now()));for(WalletReservationr:expired){releaseWithReason(r.getReservationId(),"预扣超时自动释放");}}这个定时任务是最后的安全网——哪怕应用层所有逻辑都漏了 release,TTL 到期也会自动退。这样冻结金额不会无限累积。
三重保证:扣费不多不少
这套协议怎么保证"不重复扣、不漏扣"?靠三重机制:
第一重:业务级幂等(idempotency_key)
每个请求带唯一 key,同一个 key 调 N 次只扣一次:
# Python 网关侧reservation_id=f"llm_{db_user_id}_{uuid4().hex[:16]}"idempotency_key=f"commit_{reservation_id}"# 每次都唯一Java 侧每个动作先查 ledger 有没有同 key,有就直接返回原结果。这样网络重试不会导致重复扣。
第二重:乐观锁(CAS + 重试)
钱包表有 version 字段,updateById自动带WHERE version=?。并发扣减时谁先成功谁的 version+1,后到的发现 version 变了就失败重试。我那篇乐观锁的博客专门讲了这块。
第三重:状态机约束
RESERVED → COMMITTED / RELEASED / EXPIRED每个操作都校验当前状态。比如 commit 只允许 RESERVED 状态,已经是 COMMITTED 的直接返回幂等结果,不会重复 commit。
三重叠加,基本能做到"扣费永远准确"。上线至今没出过扣多扣少的客诉。
踩过的坑
这套协议也是迭代出来的,踩了几个坑才长这样。
坑一:流式调用中断了,没 release。最早流式对话如果用户中途关页面,stream 的 finally 块没释放预扣,冻结金额一直挂着。后来在 finally 里补上:有 usage 就 commit+release,没 usage 全额 release。
坑二:预估金额算法太保守。一开始预估是总token × 输出价(用最贵的输出价算所有 token),导致预扣金额虚高,用户余额经常不够。后来改成 input 和 output 各自按最高分档价分别估,更贴近实际。
坑三:并发 commit 乐观锁冲突频繁。高峰期同一个用户多个请求并发 commit,冲突率一度到 30%,用户频繁看到"并发冲突请重试"。把重试次数从 3 提到 5 后基本解决。
最后
这套设计其实没有发明什么新东西,就是把成熟的支付协议搬到了 AI 计费场景。但这个"搬运"本身是有价值的——LLM 计费的"事前不知花费"问题,支付领域早就解决了,没必要重新造轮子。
回头看几个关键决策:
- 抄 Stripe 而不是自己发明:两阶段提交是经过验证的模型,比从零设计靠谱。
- 幂等用流水表不用 Redis:复用现有表,减少中间件依赖。
- 并发用乐观锁不用悲观锁:LLM 调用耗时长,悲观锁会锁死钱包行。
- TTL 兜底:预扣有过期时间,定时清理,冻结不会无限累积。
- 折扣率快照:历史账单自包含,不受后续变更影响。
计费系统的复杂度不在"扣钱"这个动作,而在"在各种异常和并发下保证扣得准"。这套协议把异常路径(失败、中断、超时、重试、并发)都覆盖到了,才算勉强能睡个安稳觉。
配合这篇的还有乐观锁那篇和折扣链那篇,三篇连着看比较完整。