Skip to content

预算预留 ​

实验性项目

AgentPay 尚未经过独立安全审计,请勿用于生产环境的真实资金。本页示例均基于测试网和测试代币。

一句话结论 ​

预算检查和预算扣减必须是同一个不可分割的动作。 先检查、再付款的做法,在并发场景下一定会超支。AgentPay 的做法是"先预留,再付款,最后确认或释放"。

一个具体的故事 ​

你给 Agent 设置了 24 小时预算 10 USDC。某一时刻,它同时发出两个请求,每个 6 USDC:

时间请求 A请求 B
t1读取剩余预算:10读取剩余预算:10
t210 ≥ 6,通过10 ≥ 6,通过
t3付款 6付款 6
结果合计支出 12,超出预算 2

两个请求都"检查过"了,但都是基于同一个过时的数字。这在 Agent 场景里很常见:一个任务会并行调用多个工具,多个 Agent 也可能共用同一份预算。

问题出在 t1 到 t3 之间的空隙:检查和扣减不是同一步。

几种常见做法,为什么不够 ​

做法一:先检查,再付款 ​

就是上面的故事。检查与付款之间存在窗口,并发请求会同时穿过检查。

做法二:付款成功后再扣预算 ​

text
检查 → 付款 → 付款成功后扣减

窗口更大了:从检查到扣减之间,包含了整个付款过程,期间所有并发请求看到的都是旧的预算。

做法三:在内存里用一个计数器 ​

单进程内可以用锁保护,但 Agent 往往是多进程、多实例运行的,或者进程会重启。内存里的数字既不共享,也不持久,重启之后预算就"回满"了。

做法四:请求时就扣减,失败了不归还 ​

这能防止超支,但会产生另一个问题:请求失败、被拒绝或超时之后,预算一直被占用,慢慢地把额度"漏光",Agent 变得无法工作。预留出去的额度必须有明确的去向。

AgentPay 的做法:预留、提交、释放 ​

把一次付款对预算的影响拆成三个动作:

text
        检查额度 + 扣减可用额度      ← 同一个原子操作
              │
           预留成功
              │
   ┌──────────┼──────────┐
   ▼          ▼          ▼
 付款成功    明确失败    结果未知
   │          │          │
 提交确认    释放归还    保持预留
(正式计入)(额度恢复)(不动,等待查清)

第一步:预留(Reserve)。 检查剩余额度,并把这笔金额划出来,两件事在同一个数据库事务里完成。事务内别的请求看不到中间状态。

第二步:付款。 只有预留成功之后,才会进入签名环节。预留失败就直接拒绝,不会产生任何签名。

第三步:提交或释放。

  • 付款确认成功 → 提交(Commit),预留变成正式支出。
  • 明确失败,例如被上游以客户端错误拒绝 → 释放(Release),额度归还。
  • 结果未知,例如超时 → 保持预留不动,等查清之后再决定。这一点与 超时不等于失败 一致。

回到开头的故事 ​

text
请求 A:预留 6  → 成功,剩余 4
请求 B:预留 6  → 剩余 4 < 6,直接拒绝

只有一个请求能通过,不会超支。

它是怎么保证"原子"的 ​

在 AgentPay 里,有两个地方会做预留,原理相同,只是底层实现不同:

位置作用实现方式
买方本地防火墙限制 Agent 自己的支出SQLite 事务,写入前先独占锁定数据库
网关的平台钱包限制租户的虚拟钱包扣费PostgreSQL 行级锁,同一个钱包的请求严格排队,不同钱包互不影响

钱包路径上的核心 SQL 是一条带条件的更新:

sql
UPDATE ap_wallet
SET available_cache = available_cache - :amount,
    held_cache      = held_cache + :amount
WHERE wallet_id = :wallet_id
  AND available_cache >= :amount;
-- 受影响行数为 0:余额不足,拒绝

"检查余额"和"扣减余额"在同一条语句里,数据库保证它们不会被别的请求打断。网关的并发测试里,用 100 个并发请求争抢有限余额,断言可用余额不会透支成负数。

滚动 24 小时预算 ​

预算窗口用的是滚动窗口,而不是"每天零点重置":

  • 固定日窗口:23:59 花光 10 USDC,00:01 又可以再花 10 USDC,两分钟内花了 20。
  • 滚动窗口:任何时刻,往前数 24 小时内的累计支出都不得超过上限。

滚动窗口能避免在日期边界上被"钻空子"。

预留也会过期 ​

为了防止上游一直不返回、预留永远占着额度,钱包预留带有超时时间(默认为上游超时再加 60 秒)。到期后由恢复流程统一处理,不会无限期占用。

配置时的注意事项 ​

一个私钥对应一个账本。 如果用同一个私钥启动多个 Agent 进程,却让每个进程使用各自独立的本地账本,每个进程都有一份完整的预算,实际总额就是预算乘以进程数。同一个钱包应当共用同一个账本;需要多实例共享预算的场景,使用网关的平台钱包模式更合适。

金额只用整数。 所有金额以最小单位的整数表示(USDC 为 6 位小数),数据库里也有整数类型的约束,不使用浮点数,避免累加之后出现精度误差。

拿不准时拒绝。 数据库锁等待超时、事务冲突等异常,一律按拒绝处理,宁可少付,不可多付。

常见问题 ​

Q:预留了但没付款,额度会不会一直被占着? 不会。明确失败会立即释放;结果未知的会保持预留,直到查清或到期,由恢复流程处理。

Q:预算用完之后,Agent 会怎样? 后续付款会被直接拒绝,并给出明确的原因,不会产生签名。你可以调整上限,或等滚动窗口内的支出滑出。

Q:并发很高时,行级锁会不会变成瓶颈? 同一个钱包的预留会排队,但这个事务很短:查询、更新、写入记录后立刻提交,不会在持锁期间等待上游返回。不同钱包之间互不阻塞。

Q:为什么单笔上限和预算要分开设置? 它们防的是不同的风险:单笔上限防止一次性付出过大的金额(例如价格被篡改),预算防止许多笔小额累积成大额(例如死循环)。

下一步 ​

深入阅读 ​

实验性项目,未经独立安全审计,请勿用于生产资金。