预算预留
实验性项目
AgentPay 尚未经过独立安全审计,请勿用于生产环境的真实资金。本页示例均基于测试网和测试代币。
一句话结论
预算检查和预算扣减必须是同一个不可分割的动作。 先检查、再付款的做法,在并发场景下一定会超支。AgentPay 的做法是"先预留,再付款,最后确认或释放"。
一个具体的故事
你给 Agent 设置了 24 小时预算 10 USDC。某一时刻,它同时发出两个请求,每个 6 USDC:
| 时间 | 请求 A | 请求 B |
|---|---|---|
| t1 | 读取剩余预算:10 | 读取剩余预算:10 |
| t2 | 10 ≥ 6,通过 | 10 ≥ 6,通过 |
| t3 | 付款 6 | 付款 6 |
| 结果 | 合计支出 12,超出预算 2 |
两个请求都"检查过"了,但都是基于同一个过时的数字。这在 Agent 场景里很常见:一个任务会并行调用多个工具,多个 Agent 也可能共用同一份预算。
问题出在 t1 到 t3 之间的空隙:检查和扣减不是同一步。
几种常见做法,为什么不够
做法一:先检查,再付款
就是上面的故事。检查与付款之间存在窗口,并发请求会同时穿过检查。
做法二:付款成功后再扣预算
检查 → 付款 → 付款成功后扣减窗口更大了:从检查到扣减之间,包含了整个付款过程,期间所有并发请求看到的都是旧的预算。
做法三:在内存里用一个计数器
单进程内可以用锁保护,但 Agent 往往是多进程、多实例运行的,或者进程会重启。内存里的数字既不共享,也不持久,重启之后预算就"回满"了。
做法四:请求时就扣减,失败了不归还
这能防止超支,但会产生另一个问题:请求失败、被拒绝或超时之后,预算一直被占用,慢慢地把额度"漏光",Agent 变得无法工作。预留出去的额度必须有明确的去向。
AgentPay 的做法:预留、提交、释放
把一次付款对预算的影响拆成三个动作:
检查额度 + 扣减可用额度 ← 同一个原子操作
│
预留成功
│
┌──────────┼──────────┐
▼ ▼ ▼
付款成功 明确失败 结果未知
│ │ │
提交确认 释放归还 保持预留
(正式计入)(额度恢复)(不动,等待查清)第一步:预留(Reserve)。 检查剩余额度,并把这笔金额划出来,两件事在同一个数据库事务里完成。事务内别的请求看不到中间状态。
第二步:付款。 只有预留成功之后,才会进入签名环节。预留失败就直接拒绝,不会产生任何签名。
第三步:提交或释放。
- 付款确认成功 → 提交(Commit),预留变成正式支出。
- 明确失败,例如被上游以客户端错误拒绝 → 释放(Release),额度归还。
- 结果未知,例如超时 → 保持预留不动,等查清之后再决定。这一点与 超时不等于失败 一致。
回到开头的故事
请求 A:预留 6 → 成功,剩余 4
请求 B:预留 6 → 剩余 4 < 6,直接拒绝只有一个请求能通过,不会超支。
它是怎么保证"原子"的
在 AgentPay 里,有两个地方会做预留,原理相同,只是底层实现不同:
| 位置 | 作用 | 实现方式 |
|---|---|---|
| 买方本地防火墙 | 限制 Agent 自己的支出 | SQLite 事务,写入前先独占锁定数据库 |
| 网关的平台钱包 | 限制租户的虚拟钱包扣费 | PostgreSQL 行级锁,同一个钱包的请求严格排队,不同钱包互不影响 |
钱包路径上的核心 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:为什么单笔上限和预算要分开设置? 它们防的是不同的风险:单笔上限防止一次性付出过大的金额(例如价格被篡改),预算防止许多笔小额累积成大额(例如死循环)。
下一步
- 想理解"结果未知时为什么保持预留":超时不等于失败
- 想看到预算拦截的实际效果:配置策略并观察拦截
- 想了解钱包的冻结、确认、释放:虚拟钱包与链上支付:双轨架构
