Day 16 / 共 20 天 · 第 4 周 平台底座

Credits 积分计费

AutoGPT Platform 与经典最大的区别——它是计量收费 SaaS。今天深入计费:两阶段扣费、原子 SQL 保余额正确、Stripe 充值闭环。这是平台的"钱袋子"。

📍 你在整门课的位置 · 第 4 周 平台化与收官
D15 WebSocket D16 Credits 计费 D17 OAuth D18 REST API
💡 今天的类比世界观:两阶段扣费 = 餐厅的"押金 + 结账" 平台的钱袋子,像下馆子:预扣 charge_usage = 先押一笔押金 / 冻结额度(保证你付得起);对账 charge_reconciled_usage = 按实际吃了多少结账,多退少补原子 SQL 扣费 = 收银机保证余额绝不算错(哪怕很多单同时结);Stripe = 充值通道自动充值 = 余额低了自动续。今天都用"押金 + 结账"来想。

👶 小白:为什么要"先预扣、后对账"这么麻烦?执行完按实际用量一次性扣不行吗?

👨‍🏫 老师:不行,会出两种事故。① 透支:不先扣,用户余额只剩 1 元却跑了 100 元的活,钱花在上游了追不回。② 并发算错:同一用户十个任务同时结账,不用原子 SQL 会把余额算乱(各扣各的、少扣了)。所以先预扣挡住付不起的,实际用量出来再对账多退少补,原子 SQL 保证并发下余额永远准。这就是"计量收费 SaaS"的严谨之处。

L01

计费全景

用户余额存 UserBalance 表,每笔变动记一条 CreditTransaction(充值 TOP_UP / 赠送 GRANT / 消费 USAGE)。Day 09 学了"每个 Block 值多少 credit",今天学"执行时怎么扣、怎么充"。

计费账本 = 余额 + 流水(每笔变动都记账) UserBalance 余额 = 所有流水之和 充值 TOP_UP(+) Stripe 付款 → 加余额 赠送 GRANT(+) 新用户赠额度 消费 USAGE(−) 跑 Block → 扣余额
充值/赠送让余额增加、消费让余额减少;每一笔变动都记一条流水(可审计、可对账)。任何时刻"余额 = 所有流水之和"。
计费系统 = 一个"账本" 它就像你的手机预付卡话费:卡里有个余额,充值加钱、打电话上网扣钱,运营商还给你一份账单流水记着每一笔——AutoGPT 的计费一模一样。核心就是一个账本:余额(现在有多少钱)+ 流水(每笔进出记录)。充值 → 加余额、记一条 TOP_UP 流水;跑 Block → 扣余额、记一条 USAGE 流水。任何时刻余额 = 所有流水之和。金融系统的铁律:每一分钱的变动都要有流水记录(可审计、可对账)。Day 09 的价格表 + 今天的扣费/充值 = 完整的计费闭环。
L02

预扣 charge_usage

节点运行charge_usage()executor/billing.py:118)按 pre-flight 估算扣费:

remaining_balance = db_client.spend_credits(
    user_id=..., cost=cost,
    metadata=UsageTransactionMetadata(graph_exec_id=..., block=block.name, ...))
# 动态计费 block(pre-flight=0)会检查余额必须为正,否则抛 InsufficientBalanceError
读法:执行前先按估算扣一笔(Day 09 的静态成本准、动态成本用历史均值估)。动态 Block 即使预扣 0,也要检查"余额为正才放行"——防止用户零余额还白嫖真实 API 开销。
L03

对账 charge_reconciled_usage

Block 跑完拿到真实用量,charge_reconciled_usage()billing.py:200)算 delta = 真实 - 预扣:

delta = post_flight - pre_flight
if delta == 0: return
# delta > 0 → 补扣(fail_insufficient_credits=False,允许扣成负余额!)
# delta < 0 → 退款(比如 token 实际用得少)
spend_credits(..., cost=delta, fail_insufficient_credits=False)
📝 举个例子:多退少补的具体数字 跑一个 LLM Block,预扣按估算扣了 10 credit。跑完拿到真实用量:
· 情况 A(用多了):真实 13 → delta = 13−10 = +3再补扣 3(允许扣成负余额)。
· 情况 B(用少了):真实 7 → delta = 7−10 = −3退回 3
· 情况 C(估准了):真实 10 → delta = 0return,不动账。最终用户被扣的永远是真实用量。
读法:Day 05/09 讲的"预扣+对账"在这兑现——执行后按真实用量多退少补。注意补扣时 fail_insufficient_credits=False允许扣成负余额!因为服务已经消耗了(平台已付钱给供应商),宁可记用户欠款,也不能漏账。
"允许负余额"是什么财务考量? 预扣时估少了,执行后发现真实成本更高,要补扣——但用户余额可能已经不够补了。如果这时"余额不足就不扣",平台就白干了(钱花出去了却没收回)。所以补扣强制执行、允许扣成负数——记为用户欠款,下次充值时先还。这是"服务已消耗必须记账"的财务正确性优先于"余额不能为负"的直觉。金融系统里,正确性(不漏账)压倒一切。
L04

原子 SQL 扣费(最关键)

🤔 痛点:为什么不能"先读余额、算一算、再写回去"? 你可能觉得扣费很简单:读出余额 100 → 减去 60 → 写回 40。但如果两次执行同时扣同一个用户,两边都读到 100、都算出 40、都写 40——结果扣了两次却只少了 60。钱凭空少扣了,账对不上。这在金融系统是零容忍的灾难。
💡 本质:把"读+算+写+记流水"锁成一个不可分割的原子动作 解决竞态的办法不是加应用层的锁那么简单,而是把整个操作压进一条 SQL:数据库用 FOR UPDATE 行锁保证"同一时刻只有一个扣费能碰这一行余额",其他扣费排队等待。读、算、写、插流水在这条 SQL 内一气呵成——要么全成功、要么全回滚。这就是"原子性",也是所有正确的金融扣费的地基。

所有余额变动走 _add_transaction()data/credit.py:501),用一整条 PostgreSQL CTE + FOR UPDATE 行锁保证原子性(:583):

# 一条 SQL 里原子地:
# (a) 锁住并读取余额(FOR UPDATE)
# (b) 更新 UserBalance(带上溢/下溢保护)
# (c) 插入 CreditTransaction 流水
# 余额不足时 WHERE ... balance + $2 >= 0 让更新落空 → 抛 InsufficientBalanceError
为什么扣费必须原子? 想象两个执行同时扣同一用户的钱。如果"读余额→计算→写余额"分三步,可能:都读到余额 100,都算"扣 60 后剩 40",都写 40——实际扣了 120 却只显示扣了 60(丢了 60 的账)。这在金融系统是灾难。用一条 SQL(CTE + FOR UPDATE 行锁)把"读+算+写+记流水"锁成一个原子操作——同一时刻只有一个扣费能进行,杜绝竞态。源码注释明确:"金融操作正确性优先于性能,宁可接受 10-50ms 行锁延迟"。钱的事,慢一点也要对。
L05

Stripe 充值闭环

充值三步(data/credit.py):

  1. top_up_intent():1123):调 stripe.checkout.Session.create() 生成支付页,插一条 is_active=False 的 TOP_UP 流水(未生效)。
  2. 用户付款完成 → Stripe 回调 → fulfill_checkout():1199):若 payment_status="paid"_enable_transaction() 激活那条流水、真正加到余额。
  3. Webhook 入口 stripe_webhook()api/features/v1.py:1381):construct_event() 验签 + 事件去重(防 Stripe 重试重复入账)。
为什么充值要"先记未生效、付款后才激活"? 因为"创建支付会话"和"用户真的付了钱"是两个时刻(用户可能创建了会话但没付)。先记一条 is_active=False 的流水(占位、待确认),等 Stripe 确认"钱到账了"(webhook 回调)才激活加余额。这保证"只有真付了钱才加余额"。验签 + 事件去重防两类攻击/问题:验签防伪造回调(别人假装 Stripe 说"用户付款了")、去重防 Stripe 重试导致重复加钱。和钱有关的外部回调,验签和幂等(去重)是必须的。
L06

自动充值

spend_credits()credit.py:739)扣完后检查是否触发自动充值:757):余额低于 auto_top_up.threshold 就自动走 Stripe 补钱。还有余额预警 handle_low_balance()billing.py:455,跌破阈值发邮件+Discord)。

自动充值解决什么? 对"持续运行"的 Agent(Day 14 的定时任务),你不希望半夜余额耗尽、任务失败。自动充值 = "余额低于阈值就自动刷卡补钱"——保证 Agent 不断电。这对"托管的、无人值守的 Agent"很重要。配合余额预警(快没钱了先通知你),用户体验友好。计量 SaaS 的贴心设计:既要收费,也要保证用户的 Agent 不因缺钱意外中断。
L07

平台成本追踪(内部盈亏)

executor/cost_tracking.py给平台自己算成本用的(不是给用户扣费)。当 Block 用平台系统凭据(Day 17)时,log_system_credential_cost():200)记录这次调用真实消耗了平台多少钱。

"向用户收费"和"平台自己的成本"是两笔账 向用户收费(前面讲的 credit 扣费):按 Day 09 价格表(含 1.5x 毛利)向用户收。平台成本(这里):平台用自己的 API key 替用户调服务,真实花了多少钱(供应商价)。两者之差就是平台毛利。成本追踪让平台能算清"这个 Block/这个用户,我赚了还是亏了"——运营/定价决策的数据基础。它是 best-effort 异步写入,绝不影响 Block 执行。一个认真的 SaaS 必须同时记"收入账"和"成本账"。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • 计费账本由什么组成?(余额 + 流水)
  • 预扣 + 对账为什么?补扣为什么允许负余额?
  • 扣费为什么必须原子(CTE+行锁)?
  • Stripe 充值为什么"先记未生效、付款后激活"?验签/去重防什么?
  • 自动充值解决什么?平台成本追踪和用户扣费什么关系?

✋ 动手

P=autogpt_platform/backend/backend
grep -n 'def charge_usage\|def charge_reconciled_usage' $P/executor/billing.py
sed -n '501,660p' $P/data/credit.py | head -50       # _add_transaction 原子 SQL
grep -n 'def top_up_intent\|def fulfill_checkout\|def spend_credits' $P/data/credit.py
明天预告 · Day 17集成与凭证 OAuth(平台视角)——系统凭据 vs 用户凭据、OAuth handler、凭证管理器加锁刷新、执行时注入。Day 08 从 Block 视角看过,今天从平台管理视角。
← Day 15 WebSocket Day 17 · 集成凭证 →