返回探索
指南VibeFix 编辑部更新于 2026年10月14日

AI 时代的用量计费:credits 积分账本的设计与对账实战

用户的 credits 不是一个余额字段,而是一本账。这篇指南为 AI SaaS 打造复式记账式积分账本:可直接抄的不可变流水表结构、幂等键 + 行锁的并发扣减、应对 token 用量不可预测的预授权/结算两阶段、退款与过期的回写规则、三方每日对账、负余额熔断,以及防薅清单。

一次 AI API 调用的扣费全流程图:幂等查验、行锁、预授权冻结、执行,成功结算或失败回滚

先划清三笔账:这篇讲的是哪一本

做 AI SaaS 计费的新手最容易犯的一个错误,是把三笔账混成一笔账。先花 30 秒把它们分开,后面所有设计都建立在这三分法上:

  • 第一笔:支付通道账——Stripe 收了多少钱。这是"钱进来"的账,归支付网关管。站内已经有一篇《Stripe 支付指南》专门讲收钱通道:checkout、webhook、dunning,本篇不再重复。
  • 第二笔:token 成本账——我调用模型花了多少钱。这是"钱出去"的账,归成本核算管。站内《token 成本账》讲的是这一端:怎么统计 prompt/completion token、怎么算毛利率。
  • 第三笔:billing 账本——用户还剩多少 credits。这是"用户手里的钱"的账。它既不等于 Stripe 收了多少(有赠送、有过期、有退款),也不等于 token 成本(中间有你的定价倍率)。本篇只讲第三笔:credits 积分账本的设计、扣减、退款回写与对账。

三笔账每月末必须互相咬合:Stripe 实收 ≈ 发出的付费 credits 面值;token 成本 ÷ 定价 ≈ 消耗的 credits;billing 账本余额 = 流水重算结果。任何一处对不上,亏的都是真金白银。下面从"为什么一个 balance 字段一定会爆雷"讲起。

1. 一个 balance 字段的三种死法

几乎所有 vibe 项目的计费都是从这一行代码开始的:

await db.user.update({
  where: { id: userId },
  data: { credits: { decrement: cost } },
});

看起来没问题,甚至 Prisma 的 decrement 是原子操作。但真实世界的爆雷从来不在这一行,而在它周围:

死法一:并发超扣。原子递减能保证数字不错乱,但保证不了"够不够扣"的判断。用户剩 100 credits,同时发起 10 个请求:每个请求先读余额(都是 100,够扣),然后各自递减 120——Prisma 不会拦你,最后余额变成 -1100。对 AI 场景这几乎是必然:用户开 5 个标签页同时点"生成",或者写了个脚本并发调你的 API。超扣的每一分都是你贴给模型厂商的真钱。

死法二:退款漏回写。用户在 Stripe 退款成功了,webhook 发到你的服务器,但处理函数里只退了钱、没把 credits 加回去——用户投诉;或者 webhook 被 Stripe 重试了三次(它会重试 3 天),你的函数没有幂等,加了三次 credits——资损。更隐蔽的是"部分退款":退了一半的钱,credits 该返多少?没有分录概念,只能拍脑袋。

死法三:月底对不上账。Stripe 说这个月收了 $12,400,你的账本说发出去 1,310,000 credits。按定价 100 credits = $1,应该对应 $13,100。差的 $700 去哪了?赠送的?过期的?退款的?超扣的?没有不可变流水,你只能对着三个数字发呆,最后认栽调账——而调账本身又没有记录,下个月更对不上。

这三种死法的共同病根是:把"余额"当成了真相,而余额只是真相的缓存。真相是每一笔变动的流水。解法是会计行业用了 500 年的老办法:复式记账。

2. 复式记账式 Ledger:余额是算出来的,不是存出来的

核心设计只有三句话:每一笔 credits 变动都是一条不可变的流水记录;每笔业务事件写一对借贷平衡的分录;余额字段只是流水的快照,丢了可以重算。

三张表,先给可直接抄的 Prisma schema:

model LedgerEntry {
  id             String   @id @default(uuid())
  userId         String
  // 同一笔业务事件的分录共享一个 txId,借贷必平衡
  txId           String
  entryType      LedgerEntryType // TOPUP | CONSUME | AUTH_HOLD | AUTH_RELEASE
                                // | SETTLE | AUTH_REVERSAL | REFUND
                                // | EXPIRE | ADJUST
  direction      Direction       // DEBIT(借) | CREDIT(贷)
  // 整数,最小单位(如 1 = 1 credit),杜绝浮点
  amount         BigInt
  // 写分录那一刻的用户可用余额快照,方便翻账
  balanceAfter   BigInt
  // 幂等键,唯一约束。同一 key 重复写入直接冲突返回
  idempotencyKey String   @unique
  // 冲销类分录指向被冲销的交易
  reversesTxId   String?
  meta           Json?    // 关联订单号、模型名、token 数等
  createdAt      DateTime @default(now())

  @@index([userId, createdAt])
  @@index([txId])
  // 注意:这个模型没有任何 update/delete 的业务代码路径
}

model CreditBalance {
  userId    String   @id
  available BigInt   @default(0) // 可用
  held      BigInt   @default(0) // 预授权冻结中
  version   Int      @default(0) // 乐观锁用(见第 3 节选型)
  updatedAt DateTime @updatedAt
}

model IdempotencyKey {
  key       String   @id // 客户端传的 Idempotency-Key
  userId    String
  status    String   // PROCESSING | SUCCEEDED | FAILED
  response  Json?    // 成功结果的缓存,重放时直接返回
  createdAt DateTime @default(now())
  expiresAt DateTime // 24 小时后可清理
}

enum LedgerEntryType {
  TOPUP
  CONSUME
  AUTH_HOLD
  AUTH_RELEASE
  SETTLE
  AUTH_REVERSAL
  REFUND
  EXPIRE
  ADJUST
}

enum Direction {
  DEBIT
  CREDIT
}

记账方向约定(只针对用户侧 billing 账本,公司侧的现金/递延收入在会计系统另记,不要混进同一张表——这也是图 2 底部红字提醒的原因):

  • 充值 TOPUP:贷记用户 credits 账户(用户多了 1000)+ 借记"预收 credits 负债" 1000。借贷平衡。
  • 消费 CONSUME:借记用户 credits 账户 100 + 贷记"已实现收入" 100。借贷平衡。
  • 退款 REFUND:贷记用户 credits 账户 100(红字冲回)+ 借记"退款支出" 100,reversesTxId 指向原消费交易。原分录一个字不改。

为什么坚持"借贷必平衡"?因为它给了你一个每夜都能跑的数学校验:SELECT txId FROM ledger_entries GROUP BY txId HAVING SUM(CASE WHEN direction='DEBIT' THEN amount ELSE -amount END) != 0——返回任何一行都说明记账代码有 bug。这比事后翻日志高效一个数量级。

3. 幂等扣减:先防重放,再防并发

扣减链路有两个敌人:重试(客户端超时重发、Stripe webhook 重发)和并发(同一用户多请求同时扣)。顺序不能反:先让重试安全,再让并发正确。

防重试:idempotency key 唯一约束。客户端每次调用生成一个 UUID(建议 UUID v7,时间有序对索引友好),放在 Idempotency-Key 请求头。服务端第一步就是 INSERT 这条 key,唯一约束冲突 → 说明处理过 → 直接返回缓存的 response。注意 key 的有效期:24 小时足够覆盖绝大多数重试风暴,过期后清理,避免表无限膨胀。

防并发:行锁选型决策表。

  • 悲观锁(SELECT … FOR UPDATE):锁住 credit_balances 的用户行,检查 available - held >= 预估费用,不够直接返回 402。适合 AI 调用场景——因为一次扣减链路里本来就要调外部模型 API,持锁时间长但正确性优先。锁等待超时设 5 秒,超时返回 429 让客户端指数退避,不要无限排队。
  • 乐观锁(version 字段):适合纯内部、无外部调用的秒级扣减(如按次计费的查询接口)。冲突时重试 3 次,3 次都冲突说明该用户被并发打爆,直接 429。

一句话选型:链路里有外部 API 调用 → 悲观锁;纯 DB 操作且 QPS 高 → 乐观锁。AI vibe 项目 99% 是前者,别为了"性能"选乐观锁,最后在对账时把省下的性能全吐回去。

扣减核心函数(TypeScript + Prisma,可直接改):

async function chargeUser(userId: string, idemKey: string, estCost: bigint) {
  return await db.$transaction(async (tx) => {
    // 1. 幂等:key 已存在则直接返回
    const existing = await tx.idempotencyKey.findUnique({ where: { key: idemKey } });
    if (existing?.status === 'SUCCEEDED') return existing.response;
    if (existing?.status === 'PROCESSING') throw new ConflictError('请求处理中,请稍后查询');

    await tx.idempotencyKey.create({
      data: { key: idemKey, userId, status: 'PROCESSING',
              expiresAt: new Date(Date.now() + 86400_000) },
    });

    // 2. 行锁 + 余额检查(冻结额度也算占用)
    const bal = await tx.$queryRaw<CreditBalance[]>`
      SELECT * FROM "CreditBalance" WHERE "userId" = ${userId} FOR UPDATE`;
    const row = bal[0];
    if (!row || row.available - row.held < estCost) {
      throw new InsufficientCreditsError('余额不足');
    }
    // 3. 写预授权分录 + 冻结(见第 4 节两阶段)
    // ... auth hold entries ...
    return { ok: true };
  }, { timeout: 8000 });
}

注意事务超时设 8 秒:行锁 + 写分录必须在一次事务里,超时让它整体回滚,不要出现"锁住了但分录没写"的半吊子状态。

4. 预授权 / 结算两阶段:一次 AI 调用的完整扣费

AI 计费和电商下单有一个本质区别:调用前你不知道这次到底花多少。用户 prompt 200 token,但模型可能返回 200 token 也可能返回 8000 token。两种天真做法都有坑:先按上限全扣、调用完再退差价——退款路径一多就乱;调用完再扣——并发下超扣,且调用失败时模型厂商照样收了你的钱。

正确姿势是酒店/租车行业用了几十年的预授权 + 结算:先冻结一个预估额度,执行完按实际用量多退少补。下图是完整流程,每一步都对应 ledger 里的一对分录:

一次 AI API 调用的扣费全流程:幂等查验、行锁、预授权冻结、执行、成功结算或失败回滚

图 1:一次 AI API 调用的扣费全流程。图中 7 步与本节代码流程完全对应:幂等键防重放、行锁防并发超扣;失败路径一律走"追加冲销分录"回滚,绝不 UPDATE 或删除已写分录。

预估额度的算法:预估 = promptTokens × 输入单价 + maxTokens × 输出单价 × 安全系数 1.2。安全系数 1.2 是经验值:覆盖分词差异和价格表滞后,又不至于冻结太多让用户觉得"钱被吞了"。前端展示时要诚实:可用余额 = available - held,冻结中的额度明确标出来,否则客服工单会教你做人。

三个边缘情况必须写进代码,而不是"以后再说":

  • 流式输出中途断连:按已返回的 token 结算(写 SETTLE 分录),释放剩余冻结(写 AUTH_RELEASE 分录)。不要因为"没跑完"就不扣——模型厂商按实际 token 收了你的费。
  • 调用超时/失败:写 AUTH_REVERSAL 分录全额释放冻结。注意区分"模型返回了错误"和"网络超时未知状态":后者先释放冻结,但打上 meta.needsReview=true,对账 job 会复核该笔调用在模型厂商侧是否实际计费。
  • 并发预授权总额超限:第 3 个并发请求发现 available - held 不够时,直接 402,不要排队等前面的释放——AI 调用动辄几十秒,排队等于雪崩。

5. 退款、过期与补偿:回写规则

充值和消费是" happy path",真正的账本质量体现在逆向流程。规则只有一条:任何逆向变动都是追加一对冲销分录,绝不修改原分录、绝不直接 UPDATE 余额。下图把正常消费和退款回写放在一起对照:

复式记账借贷对照:正常消费借用户账户贷已实现收入,退款回写贷用户账户借退款支出并冲销原交易

图 2:复式记账借贷对照。左:正常消费 100 credits,借用户账户、贷已实现收入;右:退款回写 100 credits,贷用户账户、借退款支出,reverses_tx_id 指回原交易。两组分录各自借贷平衡,原分录一个字不改。

退款回写 SOP:Stripe 的 charge.refunded webhook 到达 → 以 Stripe event id 为 idempotencyKey(天然幂等,覆盖 3 天重试)→ 写 REFUND 分录对(贷用户 / 借退款支出)→ 更新余额快照。部分退款按比例返还 credits:退 50% 的钱就返 50% 对应的 credits,不要整笔返还——否则用户"买 1000 退 500,得 1000 credits 只花一半钱"。

过期政策决策表(这是最容易被 solo 团队忽略的一块):

  • 永不过期 = 资产负债表上的黑洞。会计上未使用的 credits 是你的负债(deferred revenue),无限累积会让每一轮融资的尽调都多一页解释。更现实的是:三年前注册的僵尸账号还躺着 500 credits,你不敢删,不删就是负债。
  • 推荐策略:促销/赠送 credits——30 天或 90 天过期,先进先出扣减;付费购买 credits——12 个月滚动过期(每次充值刷新全部付费余额的过期时钟,实现简单,用户也好理解)。过期时写 EXPIRE 分录(借用户 / 贷过期收益),并在到期前 7 天和 1 天各发一封提醒邮件——这封邮件的打开率通常高得惊人,顺带是召回 dormant 用户的好机会。

补偿调整:客服手动补 credits(如故障致歉)必须走 ADJUST 分录,meta.reason 必填、操作人必填,每月审计抽查。禁止任何"后台改一下余额"的操作——今天为客服开的后门,明天就是资损的入口。

6. 月末对账:让三笔账互相咬合

账本写得再漂亮,不对账等于没写。对账 job 每天凌晨跑一次(别等到月末),三组校验:

对账一:Stripe 实收 vs 发出的付费 credits。把 Stripe 当日 charge.succeeded 金额汇总(扣除 charge.refunded),按你的定价换算成应发 credits,与 ledger 当日 entryType=TOPUP AND meta.source='stripe' 的发放数对比。差异超过 0.5% 直接告警——常见根因是 webhook 丢事件(服务器 500 那几分钟)。

-- 对账一:某日 Stripe 充值 vs ledger 发放(PostgreSQL)
WITH stripe AS (
  SELECT date_trunc('day', created)::date AS d,
         SUM(amount_captured - amount_refunded) AS cents
  FROM stripe_charges_local  -- 你落库的 webhook 明细
  WHERE created >= now() - interval '1 day'
  GROUP BY 1
),
ledger AS (
  SELECT date_trunc('day', "createdAt")::date AS d,
         SUM(amount)::bigint AS credits
  FROM "LedgerEntry"
  WHERE "entryType" = 'TOPUP' AND "meta"->>'source' = 'stripe'
    AND "createdAt" >= now() - interval '1 day'
  GROUP BY 1
)
SELECT s.d, s.cents, l.credits,
       (l.credits - s.cents * 10) AS diff_credits -- 假设 $1 = 1000 credits,按你定价改
FROM stripe s FULL JOIN ledger l USING (d)
WHERE abs(l.credits - s.cents * 10) > s.cents;

对账二:token 成本 vs 消耗的 credits。把模型厂商账单(OpenAI/Anthropic 的 invoice 按 token 计)乘以你的定价倍率,换算成"应收 credits",与 ledger 当日 CONSUME+SETTLE 总额对比。毛利率单日波动超过 5 个点,八成是定价表没跟上模型降价(或者某个新模型的输出 token 定价配错了)——这是真金白银的漏水。

对账三:余额快照 vs 流水重算。SUM(ledger 贷 - 借) == credit_balances.available + held,按用户抽查 + 全表汇总双跑。差 1 个 credits 就告警——快照漂移说明有代码绕过了 ledger 直接改余额,这是 P0 级别的代码事故。

差异分级处理:P0(钱对不上:对账一/二差异)→ 暂停自动充值发放、人工介入,当日必须闭环;P1(快照漂移)→ 用流水重算快照并锁定相关代码路径;P2(四舍五入、时区边界)→ 记差异台账,月度复核。所有差异处理本身也要写 ADJUST 分录——调账也要留痕。

7. 负余额熔断:亏可以亏,但不能无声地亏

即使设计再严密,负余额还是会出现:结算时实际费用超过了预授权(流式输出超长)、退款时对应的消费早已用完、并发窗口期的极限竞态。关键不是"杜绝负余额",而是让负余额第一时间尖叫。

  • 熔断线:available < 0 立刻触发——该用户所有新请求返回 402 + 明确错误码 INSUFFICIENT_CREDITS(不要返回 500,不要让用户猜)。已在执行的请求允许跑完并正常结算,避免"扣了模型费但没收用户钱"和"收了钱但掐断服务"两种最坏情况同时发生。
  • 升级策略:负余额用户 24 小时内未充值,账号降级为只读(可导出数据、不可新开任务)——留得青山在;运营后台提供"核销坏账"按钮,走 ADJUST 分录把负余额清零,reason 必填。
  • 熔断阈值:建议单用户负余额上限 -500 credits 或单用户日消费上限。超过阈值的负余额自动升级为 P0 告警——正常用户到不了这里,能到这里的不是 bug 就是黑产。

反模式警告:不要在代码里写"余额不足时自动赠送 100 credits 让流程跑通"。这个"善意"会绕过所有风控,让黑产脚本无限续杯——而你的模型账单会替你记住每一杯。

8. 防薅 checklist:credits 是黑产眼中的现金

credits 在黑产眼里和现金没有区别:注册送 500 credits 的 AI 应用,等于"注册送 $5 的 API 调用额度"。上线前把这张清单过一遍:

  • 批量注册识别:同一 IP / 设备指纹 24 小时内注册超过 5 个账号,新账号的赠送 credits 自动降为 0 或进入人工审核。误伤率可控:真实用户不会一天注册 5 个号。
  • 邀请奖励延迟到账:邀请送的 credits 延迟 7 天到账,且被邀请人必须有真实消费行为。否则就是左手倒右手的无限套娃。
  • 试用池与付费池分离:两种 credits 分开记账(meta.pool='trial'|'paid'),扣减时试用池先扣。试用池过期快、规则严,不污染付费池的对账。
  • 用量突增告警:单用户日消耗超过其 30 天均值 10 倍,或单 key 多 IP 高频调用,直接冻结 key 并告警。正常用户的用量曲线是平滑的,垂直拉升的 99% 是脚本。
  • 不可转让、不可提现:写进用户协议,也写进代码——转账接口从一开始就不要提供。提供转账之日,就是 credits 二级市场开张之时。

9. 上线前清单

  • LedgerEntry 表确认没有任何业务代码执行 UPDATE / DELETE(grep 一遍,有就删)。
  • 每种 entryType 至少有一条测试分录,且 tx 内借贷平衡校验通过。
  • 并发测试:同一用户 50 并发扣减,最终余额 = 初始 - 50×单价,一个 credits 都不能多扣少扣。
  • 幂等测试:同一 Idempotency-Key 连发 10 次,只扣一次费,10 次返回相同结果。
  • Stripe webhook 测试模式跑全:charge.succeeded、charge.refunded、charge.dispute 全链路,event id 幂等生效。
  • 对账 job 在 staging 跑 7 天,三组校验零 P0 差异。
  • 过期提醒邮件模板就绪(7 天 / 1 天两次)。
  • 负余额熔断在 staging 实际触发过一次,告警通道确认有人看。
  • 运营后台的"手动调整"走 ADJUST 分录,reason 必填,已做权限控制。

结语:账本是信任的机器

用户把钱换成你系统里的一串数字,这件事建立在信任上:他信你扣得对、退得回、算得清。复式记账、幂等扣减、预授权结算、退款回写、每日对账——这五件套听起来"重",但每一件对应的都是一个真实发生过的资损事故。对于 solo 团队,我的建议是:第一天就把 ledger_entries 表建起来,哪怕一开始只有 TOPUP 和 CONSUME 两种类型。表结构是便宜的,重构账本是昂贵的——等你有 10 万用户时再想补流水,神仙也救不回来。

延伸阅读:收钱通道看站内《Stripe 支付指南》,成本核算看《token 成本账》——三篇连起来,就是 AI SaaS 完整的"钱"的链路。

阅读 0评论 0

评论 (0)

ME
0/1000
评论加载中...
浏览项目广场发布你的项目

相关文章

OIDC/SAML 登录流程时序图:用户、应用(SP)、企业 IdP 三方八步往返,蓝色 OIDC 路径、紫色 SAML 路径对照
指南
企业客户问“支持 SSO 吗”:vibe 项目从单用户到 B2B 团队版的升级实战

企业客户安全问卷 5 必答项拆解:OIDC/SAML 决策表与 SAML 启动阈值公式;org→team→member 三级数据模型与可直接抄的 Prisma schema;SP 发起的登录流程、JIT 自动建账号、域名认领、成员移除即时失效;SCIM 取舍、12 项转正验收清单与问卷应答模板;SSO 定价策略。

支付与变现团队协作后端工程
审批状态机与时序示意图:待审批经通过、拒绝、超时三条分支流转,最终统一归档为已审计,时序覆盖发起、通知、确认、执行、回写全链路
指南
别让 Agent 直接删库:vibe 项目高风险操作的“人审”审批流设计

Agent 越自主,破坏半径越大。给删除、退款、群发、权限变更这类操作加一道“人点头才执行”的闸,是成本最低的保险。这篇指南给你风险×可逆性四象限分级、审批单最小表结构、默认拒绝的超时策略、审计留痕和紧急通道,一个周末就能跑起来。

开发工作流AI 编程实践安全与隐私