计费这事把人逼疯了-我们抄了Stripe的两阶段协议来扣LLM的钱
计费这事把人逼疯了我们抄了 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 CommitStripe 的 PaymentIntent 也是这个思路下单时不知道最终金额先 authorize 冻结卡片额度发货后正式 capture。LLM 计费的场景简直一模一样用户发起对话 下单事前不知道最终花费 不知道要 capture 多少调用前要校验花得起 需要 authorize 冻结调用后按实际 token 结算多退少补 capture release所以我们干脆抄了这套协议叫reserve-commit-release。协议长什么样状态机很简单reserve(amount) ┌─────────┐ ─────────────────→ ┌──────────┐ │ (none) │ │ RESERVED │ 已冻结待结算 └─────────┘ └──────────┘ │ │ commit(actual) │ │ release() ┌─────────────────┘ │ ▼ ▼ ┌───────────┐ ┌──────────┐ │ COMMITTED │ │ RELEASED │ 退多余冻结 └───────────┘ └──────────┘三个动作动作干啥钱包怎么变reserve(amount)预扣按预估最大花费冻结星源值 - amount冻结 amountcommit(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(rollbackForException.class)publicMapString,Objectreserve(LonguserId,StringreservationId,StringidempotencyKey,BigDecimalamount,...){// 1. 幂等同一个 reservation_id 来了第二次直接返回上次结果WalletReservationexistingbaseMapper.selectOne(newLambdaQueryWrapperWalletReservation().eq(WalletReservation::getReservationId,reservationId));if(existing!nullRESERVED.equals(existing.getStatus())){returnMap.of(reservation_id,existing.getReservationId());// 幂等返回}// 2. 余额校验 乐观锁扣减最多 3 次for(intattempt0;attempt3;attempt){WalletwalletwalletMapper.selectOne(...);// 读最新含 versionif(wallet.getStarCredits().compareTo(amount)0){thrownewBusinessException(402,星源值余额不足);// 事前拦截}wallet.setStarCredits(wallet.getStarCredits().subtract(amount));wallet.setFrozen(wallet.getFrozen().add(amount));introwswalletMapper.updateById(wallet);// 自动带 WHERE version?if(rows0)break;// CAS 成功if(attempt2)thrownewBusinessException(并发冲突请重试);// 失败了重读最新值重算重试}// 3. 写预扣记录WalletReservationreservationnewWalletReservation();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 是整个协议里设计最讲究的。它要支持增量多次 commitAgent 多步骤场景还要处理折扣和预扣不够用的情况publicMapString,ObjectcommitIncremental(StringreservationId,BigDecimalcommitAmount,StringidempotencyKey,StringmodelName,...){// 1. 幂等查 ledger 有没有同 idempotency_keyReservationLedgerexistingledgerMapper.selectOne(...idempotencyKey...);if(existing!null)returnMap.of(committed,existing.getAmount());// 2. 校验状态必须是 RESERVEDWalletReservationreservation...;if(!RESERVED.equals(reservation.getStatus())){/* 状态机校验 */}if(LocalDateTime.now().isAfter(reservation.getExpiresAt())){thrownewBusinessException(预扣已过期);// TTL 兜底}// 3. 折扣计算四级优先级链另写了一篇BigDecimaldiscountRateuserDiscountService.getDiscountRate(userId,modelName);BigDecimaldiscountedcommitAmount.multiply(discountRate).setScale(4,HALF_UP);// 4. 智能扣减折后金额优先从 frozen 扣超出部分从 star_credits 扣BigDecimalremainingFrozenreservation.getAmount().subtract(reservation.getCommittedAmount());BigDecimalfromFrozen,fromCredits;if(discounted.compareTo(remainingFrozen)0){fromFrozendiscounted;// 预扣够用全从冻结扣fromCreditsBigDecimal.ZERO;}else{fromFrozenremainingFrozen;// 吃光冻结fromCreditsdiscounted.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(fixedRate120_000)// 每 2 分钟扫一次publicvoidcleanupExpiredReservations(){ListWalletReservationexpiredbaseMapper.selectList(newLambdaQueryWrapperWalletReservation().eq(WalletReservation::getStatus,RESERVED).lt(WalletReservation::getExpiresAt,LocalDateTime.now()));for(WalletReservationr:expired){releaseWithReason(r.getReservationId(),预扣超时自动释放);}}这个定时任务是最后的安全网——哪怕应用层所有逻辑都漏了 releaseTTL 到期也会自动退。这样冻结金额不会无限累积。三重保证扣费不多不少这套协议怎么保证不重复扣、不漏扣靠三重机制第一重业务级幂等idempotency_key每个请求带唯一 key同一个 key 调 N 次只扣一次# Python 网关侧reservation_idfllm_{db_user_id}_{uuid4().hex[:16]}idempotency_keyfcommit_{reservation_id}# 每次都唯一Java 侧每个动作先查 ledger 有没有同 key有就直接返回原结果。这样网络重试不会导致重复扣。第二重乐观锁CAS 重试钱包表有 version 字段updateById自动带WHERE version?。并发扣减时谁先成功谁的 version1后到的发现 version 变了就失败重试。我那篇乐观锁的博客专门讲了这块。第三重状态机约束RESERVED → COMMITTED / RELEASED / EXPIRED每个操作都校验当前状态。比如 commit 只允许 RESERVED 状态已经是 COMMITTED 的直接返回幂等结果不会重复 commit。三重叠加基本能做到扣费永远准确。上线至今没出过扣多扣少的客诉。踩过的坑这套协议也是迭代出来的踩了几个坑才长这样。坑一流式调用中断了没 release。最早流式对话如果用户中途关页面stream 的 finally 块没释放预扣冻结金额一直挂着。后来在 finally 里补上有 usage 就 commitrelease没 usage 全额 release。坑二预估金额算法太保守。一开始预估是总token × 输出价用最贵的输出价算所有 token导致预扣金额虚高用户余额经常不够。后来改成 input 和 output 各自按最高分档价分别估更贴近实际。坑三并发 commit 乐观锁冲突频繁。高峰期同一个用户多个请求并发 commit冲突率一度到 30%用户频繁看到并发冲突请重试。把重试次数从 3 提到 5 后基本解决。最后这套设计其实没有发明什么新东西就是把成熟的支付协议搬到了 AI 计费场景。但这个搬运本身是有价值的——LLM 计费的事前不知花费问题支付领域早就解决了没必要重新造轮子。回头看几个关键决策抄 Stripe 而不是自己发明两阶段提交是经过验证的模型比从零设计靠谱。幂等用流水表不用 Redis复用现有表减少中间件依赖。并发用乐观锁不用悲观锁LLM 调用耗时长悲观锁会锁死钱包行。TTL 兜底预扣有过期时间定时清理冻结不会无限累积。折扣率快照历史账单自包含不受后续变更影响。计费系统的复杂度不在扣钱这个动作而在在各种异常和并发下保证扣得准。这套协议把异常路径失败、中断、超时、重试、并发都覆盖到了才算勉强能睡个安稳觉。配合这篇的还有乐观锁那篇和折扣链那篇三篇连着看比较完整。