当一个产品消耗 AI 时,我最先搭的永远是额度。在 profile 表里放一个整数,每次生成减一,就这么跑起来。我在一个转录应用、一个图像生成 SaaS,还有我起项目用的 boilerplate 里都这么做过。三次最后都到了同一个地方:带小数的、以分为单位的余额,加上按 API key 的消费上限。
这条路每次都一样,所以我已经知道该从哪一步开始了。
为什么额度一开始总显得够用
因为在每次操作成本恒定的前提下,它确实够用。一张图就是一张图。每天免费一个额度,订阅送二十个,check 就是一行:
if (!profile || profile.subCredits < estimatedCost) {
return NextResponse.json({ error: "Insufficient credits" }, { status: 402 });
}
在落地页上不用解释就能懂。而且作为整数很方便:没有舍入,用户看到"你还剩 12",不会再多问。
这个模型开始不够用的第一个信号不是技术性的,而是价格上的。在一个转录项目里我按字符收费,上线两周后我把每字符的价格提到了供应商成本的六倍,此前我算了英语一句话平均多少字符(约 75)、西班牙语多少(约 90),以及每 2 美元的包能出多少句。这个计算你一开始不会做。你会在已经收到供应商账单、发现利润率不是你以为的那个时候才做。
真实成本一进来就会崩的地方
它在产品有多个模型的那一刻就崩了。如果用户可以在便宜的转录模型和精确的模型之间选,一个额度就不再有意义。要么你把贵的按便宜的收,亏钱;要么全按贵的收,便宜那个就没意义了。
有两个额度池的时候它又会崩一次。在那个图像 SaaS 里,我在订阅额度之上加了每日免费额度,当天就不得不修复"什么都生成不了":检查逻辑还是只看 subCredits。修复就是比较前把两个池加起来。
const totalAvailableCredits = (profile?.dailyCredits || 0) + (profile?.subCredits || 0);
读起来显而易见。但当新的池是在一个提交里加的、而检查住在另一个文件里时,就不是了。
余额解决了额度解决不了的什么
余额是一个以分为单位的数字,按每次操作的真实成本扣减。这样就可以在同一个账户里混合不同的模型、供应商和单位(音频分钟数和文本 token)。
我没预料到的细节:整数分不够。用便宜模型做一次短转录的成本是几分之一分,用 integer 的话每次调用都舍入到零,余额永远不会降。我不得不把这些列迁移成浮点数:
ALTER TABLE "usage_logs" ALTER COLUMN "cost_cents" SET DATA TYPE real;
ALTER TABLE "user_billing" ALTER COLUMN "balance_cents" SET DATA TYPE real;
ALTER TABLE "api_keys" ALTER COLUMN "spend_limit_cents" SET DATA TYPE real;
是的,钱用 real。对一个只扣减和充值的预付余额来说,这对我是划算的:舍入误差跟因为截断而丢掉整笔金额相比无关紧要。如果我是事后出账,我会用最小单位的整数。
余额也改变了定价:从"一个套餐"变成三个(每月 4,99、14,99 和 49,99,以转录小时数和 token 表示),外加 5、10 和 25 欧元的单次充值。内部都是一回事:加分。
极端情况:同一个产品里三套计费模型
在那个转录应用里,我一度同时有三样东西,而且不是全都活了下来:
- 余额。 剩下多少。如果是零或更少,什么都不提供。
- 用户的月度上限。 一个
monthly_limit_cents字段,默认 4,99,可在账户里编辑。 - 按 API key 的消费上限。
spend_limit_cents加一个周期(daily、monthly、total),对照该 key 的使用日志之和。
用户的月度上限我删掉了。ALTER TABLE "user_billing" DROP COLUMN "monthly_limit_cents",然后干掉选择器、server action 和五种语言的翻译。它什么都没带来:预付余额本身就是月度上限。一个充了 20 欧元的用户不需要再说一遍他不想花超过 20 欧元。这只是多一个要解释的界面和多一个要检查的条件。
留下来的是按 key 的上限,因为那里确实有真实场景:一把塞进脚本里、然后失控的 key。还有一个我吃过亏才学到的细节:比较之前必须按 id 从数据库重新读一遍这把 key,哪怕你已经从认证里拿到了对象。否则你会拿一个过期的 spendLimitCents 去服务请求。
这个 guard 要放在所有端点上,不只是花钱的那些。列模型和 preset 的端点也带着它,返回 429。如果有人余额为零,就让整个 API 都对他说不,而不只是贵的那部分。
我现在在第一天怎么决定
如果产品只有一种成本固定的操作,用额度。如果有多个模型,或者成本取决于输入大小,那从第一个提交起就用带小数的、以分为单位的余额。之后再迁移,代价是一个下午改 schema,加上一个月的邮件解释用户的额度现在变成欧元了。
还有一件事,与模型无关:结账的折扣券要放在一个常量里并且要测试。有一天我上午把 couponId 从"减 100 欧元"改成"减 100%",下午又改了回来。两个提交,同一个文件,一行。
额度是一个伪装成技术决策的营销决策。余额正好相反。
