Agent 平台

「赠送额度不能提现」——这句话得写进数据结构

平台核心计费改造:余额从单一池拆成多个受限的桶,资源计价从各自为政统一为声明式框架。

平台上要计费的资源有完全不同的"形状":文本对话按 token、网络搜索按次、OCR 按页数、视频按时长。用一个"积分余额"和一套计费逻辑没法准确体现这些成本,更没法承载营销活动。这篇讲两项基础改造:把钱包从单一永久池重做为多个桶(带有效期和可用范围),以及把计价从隐式约定改成后台可配的资源价表。

单一余额的局限性

现有账户模型是这样的:每个用户一条 Account 记录,字段就一个 Balance。所有充值、赠送、活动都加到这个数字上。问题在于这个模型无法表达——也无法强制——不同金钱的用途限制和有效期。

比如月卡产品要求:每月发放 1000 点,有效期 30 天,到期清零。如果这 1000 点也落在 Balance 里,那么"到期清零"这个约束就只能靠业务代码到处判断:扣费时检查来源是否过期、统计报表时提醒用户余额即将失效。一旦业务代码有一处漏判或遗忘,就会有用户在余额本该清零的那一刻发现账户还能扣费,或者收不到过期提醒。

类似的规则还有:赠送额度不能提现(充值时不计入),活动额度只能用于特定功能(订阅用户才能用),订阅档位要按周期重置。这些规则堆到业务代码里,复杂度爆炸。

真正的解决方案是把规则落在数据结构上:改用"多桶钱包"。每一笔发放都是一个桶,带自己的 Remaining(剩余点数)和 ExpiresAt(过期时间),以及预留的 AllowedModels(可用模型白名单,先不启用)。余额变成了多个桶的求和,扣费时按优先级顺序消耗——最早过期的先扣,确保快要失效的点不会被浪费。

多桶钱包的设计

核心数据结构是一张 PointBucket 表:

ID          | UserID | Remaining | ExpiresAt      | Source       | AllowedModels
-----------|--------|-----------|----------------|--------------|---------------
1          | user-a | 500       | NULL           | topup        | (空,不限)
2          | user-a | 100       | 2026-07-27     | membership   | (空)
3          | user-a | 50        | NULL           | redeem       | (空)

User-a 的总余额就是 500 + 100 + 50 = 650 点。当他发起一个消耗 200 点的回合时:

  1. 锁定该用户存活的桶(ExpiresAt IS NULL OR ExpiresAt > now()),按 ExpiresAt 升序排列
  2. 第 2 行那个 100 点(7 天后过期)会先被消耗,变成 0;剩余还需 100 点
  3. 第 1 行那个 500 点(永久)消耗 100,变成 400
  4. 记录这次扣费从哪些桶各扣了多少,存在 UsageRecord.Allocations

为什么要记录分配?因为结算时可能多退:模型只发出了 150 个 token,实际扣费少于预期 200 点。退费时要按逆序原路退回——最后扣的(永久的)先退,保证临时点不会被复活。而且如果某个原桶已经过期了,那笔退款就跳过(点本就该没了,不用还)。

每个桶本身没有"花费"的概念,只有"剩余"。所有的花费都是透过 UsageRecord 记录的。这样的好处是:

  • 多退少补的逻辑精确而无歧义
  • 每笔支出都能精确审计追溯到来源桶
  • 临时点和永久点永远不串味
  • 防止了"用完临时点还能从永久点倒流"这类漏洞

发放与消费的原语

平台所有入账路径(充值、兑换、活动赠送、订阅月度发放)都调同一个 GrantPoints 函数:

GrantPoints(tx, userID, amount, expiresAt, source)
  • amount > 0 才会建桶;amount <= 0 无效
  • expiresAt = nil 表示永久(所有现有充值、赠送都是永久)
  • source 标记来源:topupredeemadjust(管理员调整)、membership(订阅)

消费侧是 ReserveSettle 的一对原语:

Reserve 是预扣。给定一个操作 ID、用户、估计消费点数,它会:

  1. 查存活桶、按最早过期先排序、加行级锁(FOR UPDATE
  2. 贪心从前往后扣,直到凑满估计值或余额不足
  3. UsageRecord 记录 status=reserved,包含 Allocations 分配
  4. 余额不足则报错,整个事务回滚、不写记录、不建空桶

Settle 是结算。给定操作 ID 和真实消费,它会:

  1. 幂等检查:如果记录已经 settled 或 refunded,直接返回
  2. 计算 delta = actual - reserved
  3. 如果 delta < 0(多退),按 Allocations 逆序精确退回原桶
  4. 如果 delta > 0(少补),尽力从存活桶再扣;不足仍结算(不卡死)
  5. 标记记录为 settled

为什么 settle 时"少补不足仍结算"?因为 reserve 已经用保守估计(input + maxOutputTokens)去预扣了,delta > 0 的缺口通常微小。而且结算不能卡死——已经发生的消费是既成事实,不因为后续余额不足而回滚。

并发与幂等的保证

同一用户的并发请求怎么防止双扣?方案很简单:事务内 FOR UPDATE 锁定存活桶。Postgres 会按行级锁串行化对同一用户的并发扣费。

幂等性靠 OperationID(对话回合 ID)作主键。同一操作重试 reserve,会查到已存的 UsageRecord,直接返回其 ReservedPoints,不重复扣。Settle 同理——已结的记录不再改。

过期的预留怎么处理?有一个后台对账任务,每日扫一遍超过 10 分钟还没 settle 的 reserved 记录,调 Settle(opID, 0) 让它全额退回原桶。这个任务即使失败也无碍——正确性不依赖它,它只是账务清理。

资源计价的统一框架

现在回到另一个问题:不同资源的单位不一样。对话要精确到 token 计数,网络搜索只能按次(一次搜索多少点),OCR 要按页数。长期来看,还会有 embedding 按千 token、视频按秒等等。

如果每个功能都自己定义一套计费规则,就会出现:

  • 有些用"PriceRule + token 累加"的复杂方式(对话双倍率)
  • 有些用"固定点数"(网搜 10 点/次)
  • 有些用"浮点折算"(embedding 每 1000 token 多少点)
  • 改价时无法审计,历史账单对不上

正确的做法是建一个通用资源价表 ResourcePrice,所有资源都必须先配价,消费时统一调 Charge() 接口。

表的结构是这样的:

ResourceKey  | DisplayName  | PricingType | Rate | PerUnits
-------------|--------------|-------------|------|----------
websearch    | 网络搜索     | PER_CALL    | 10   | 1
ocr          | OCR识别      | PER_UNIT    | 5    | 1
embedding    | 文本嵌入     | PER_UNIT    | 0.2  | 1000
video.gen    | 视频生成     | PER_UNIT    | 100  | 60
  • PER_CALL:每次调用固定点数。比如网搜一次 10 点,参数 units 无关
  • PER_UNIT:按单位线性计费。比如 OCR 每页 5 点(perUnits=1),embedding 每 1000 token 0.2 点(perUnits=1000

消费时调 Quote(resourceKey, units) 得到应扣点数,然后 Charge(opID, userID, resourceKey, units) 一次性扣费,包括:

  1. ResourcePrice(必须 enabled)
  2. 按公式计费:PER_CALL 时取 ceil(rate)PER_UNIT 时取 ceil(rate * units / perUnits)
  3. 用多桶钱包的 Reserve/Settle 逻辑扣分配额
  4. UsageRecordstatus=settled

这样的好处是:改价只需改 ResourcePrice 里一行。所有历史的 UsageRecord 还是记着原来的实际扣点,不会被改价影响。新的消费按新价表计算。审计时能看到每笔消费用的是什么价表。

为什么是现在

这两项改造之所以现在做,而不是等到实际需求出现,原因很直接:项目未上线、没有真实用户或活钱。这意味着:

  1. 没有历史数据需要迁移——全量重写钱包表无成本
  2. 没有活跃用户会在改造期间遇到不一致的余额计算
  3. 如果后续发现设计有漏洞,改数据结构的成本最低

一旦上线、产生真实用户和消费记录,再想重做多桶就会牵扯数据迁移、兼容旧账户、处理部分迁移失败等等复杂性。现在这两项都是"必须一次做对"的基础设施,做对的成本远小于上线后被迫改的成本。

星野的头像

星野 XINGYE

一个人维护 AI 平台的工程师。这里记录 63 篇复盘:18 份故障档案、OTA、架构演进与工作流。