返回文章列表

Agent 可靠执行实战(九):钱还没扣,预算怎么已经不够了?

芝士AI吃鱼2026年10月17日15 分钟阅读阅读统计加载中

预算只剩一美元,两个 Agent 同时站到收银台前,各要买六十美分的服务。它们都看了一眼余额,都觉得够,都下了单。没有谁算错减法,账却多出了二十美分。

这是假设场景。真正让人头疼的还在后面:第一笔请求超时了。执行器非常体贴地把六十美分退回可用额度,第二笔顺利出发。几分钟后,第一笔账单也来了。

第五篇讲“调用券”不能靠重启补满,第八篇讲“同意”必须绑定具体动作。这一篇把收银台上的问题补齐:金额不等于次数;预留不等于扣款;没收到扣款消息,也不等于没有欠款。

2026 年 10 月 17 日核对的 Composio Instant Tools 文档明确说明:Session 没有内置金额上限,异步余额检查也不是硬限额。它要求应用预留可信费用上界,并保留未查清的调用占用。本文以此为问题入口,没有调用模型,不接入 Composio,不调用付费服务,也不测量其扣费行为。以下数据库、并发进程与退出故障是真实执行;价格、账单和供应商行为全部是合成测试输入。

1. 账本先记“可能要付多少”,再记“最终付了多少”#

假设一项任务被允许使用 100 美分。我们只处理 USD 整数美分,省去浮点四舍五入,也拒绝布尔值冒充整数。真实 API 如果按百万分之一美元计价,应选足够细的固定整数单位,或者采用精确定点规则;不能先把单次价格向下取整成零,再声称预算受控。本文没有实现币种兑换、税费、价格阶梯或多币种账户。

每个调用只有三种账本状态。reserved 表示已占住额度,还没有领取派发机会;unknown 表示机会已领取,费用尚未核清;settled 表示已接收可信的最终账单。unknown 刻意覆盖“领取后尚未发出去”和“已经做完但没有回音”两种情况,因为本地数据库不能替远端作证。

定义当前占用 exposure:已结算调用使用最终费用,所有未结算调用使用事先确定的最大费用,再把它们相加。可用额度就是 cap 减去 exposure。这不是现金余额,也不是银行冻结,而是执行器自己的保守风险账。

# 未结算保留 maximum;结算后改记 charged。
def exposure(c):
    return sum(row[0] for row in c.execute(
        'SELECT CASE WHEN state="settled" THEN charged ELSE maximum END FROM call'))

上式使用 Python 整数累加,避免账本记录异常高费用后,SQLite 有界整数 SUM 溢出遮住真实异常。单项金额与 cap 都限制在 10 的 12 次方美分以内;这只是样例输入范围,不是建议给 Agent 配置如此大的预算。

假设预留 60,最终账单为 20。对账后只释放差额 40,历史已花的 20 继续占预算。再接一笔上界 80 的调用,整体占用恰好达到 100。如果把整张预留全部删掉,就把“已经花出去的 20”也从任务预算里擦掉了。

合成预算100美分;预留60后可用40;最终费用20后可用80;没有最终账单仍占60
合成金额示例,单位为 USD 美分。预留记录潜在支出,最终账单只释放未使用的差额。 查看大图 ↗

2. 上界不是模型猜出来的价格#

整个协议最容易被偷换的词,是 maximum。模型说“大概三毛钱”,不是可信上界。历史平均值、厂商介绍文字、一次成功请求的账单,也不自动成为下一次调用的最大费用。

要有严格金额约束,上界必须覆盖本次被允许的全部收费因素:工具、参数、使用量限制、币种、税费或附加费用,以及价格版本。若供应商只承诺按实际用量计费,而执行器无法限制用量,就没有可以拿来证明限额的上界。这个调用应被拒绝,或者明确转入另一个由人承担风险的流程,不能悄悄降级成“尽量别超”。

样例只从“可信执行器已经验证上界与批准”之后开始。reserve 接受 tool、arguments、maximum、quote,并把它们与固定币种一起规范化存储,绑定操作 ID。同一 ID 同一内容重复预留返回 existing;任意内容变化返回 conflict。quote 是合成报价版本标识,不是数字签名,不会自行验证价格是否真实。

这份代码没有真人审批、身份认证、供应商报价认证,也没有价格有效期实现。生产中不能把 reserve 暴露给模型,让它自己填写 maximum。若报价只在有限时间内有效,必须在实际派发路径上验证有效期;过期后不能继续沿用旧上界。第八篇的内容绑定是可组合的前置条件,但本文没有把两篇代码拼成一个完整授权系统。

arguments 限定为字符串键和值的字典,不假装实现任意 JSON 的跨语言规范化。执行器保存完整规范化内容与 SHA-256 摘要;摘要用来检查一致性,不证明批准者身份。一个数据库对应一个预算范围,操作 ID、账单证据 ID 也在该范围内解释。把同一任务写进两个独立数据库,会获得两份预算,代码不会替你跨库合账。

3. “够不够”和“占住它”必须同时发生#

错误方案的结构很朴素:先读 exposure,判断足够,稍后写一条预留。并发的两个工作进程都能在写入前看到旧数字。即使每条 INSERT 都是事务,组合起来仍然会穿透预算。

安全入口在同一个 BEGIN IMMEDIATE 事务里读取现有操作、检查冻结标记、计算占用,并插入预留。按照 SQLite 事务文档,单库只有一个同时进行的写事务;IMMEDIATE 在事务起点尝试取得写事务,而不是等到最后插入时才升级。锁等待超时会报错,不能把数据库错误解释成“额度充足”。

with transaction(path) as c:
    # 先处理同一操作的重放与冲突,再检查新的准入。
    cap, frozen = c.execute('SELECT cap,frozen FROM wallet').fetchone()
    if frozen:
        return 'frozen'
    if exposure(c) + maximum > cap:
        return 'budget_exceeded'
    # 在这个事务里插入 reserved;提交以后才能领取派发机会。

实际并发实验启动 8 个独立进程,在 Barrier 同步后各申请 30 美分。cap 为 100。运行结果是 3 次 reserved、5 次 budget_exceeded,占用 90。赢家的操作 ID 取决于调度,不应该写成固定黄金结果;results.json 只固定结果计数与总占用。这不是吞吐测试,没有延迟结论。

另一个 unsafe_check_then_spend 对照只记录确定性的错误交错:两次读到 100,各花 60,总计 120。它是合成调度算例,没有把两笔真实外部扣款伪装成实验。真正执行的进程竞争发生在安全账本上。

4. 先把派发机会用掉,才能伸手叫供应商#

仅有预留还不够。若每次恢复都能拿着同一张预留再次调用供应商,它可能承担多个收费动作,原来的单次上界就失效了。

claim 在事务里检查完整意图仍然相同且状态是 reserved,然后先把状态改成 unknown,提交后才返回参数快照。返回快照的执行器最多派发一次;再次 claim 返回 None。样例没有 SDK 重试,也没有任何真实网络调用。生产适配器如果在一个 claim 后偷偷重试三次,每次都可能收费,这个上界证明就不成立。

这里的保守代价很明显:进程在 claim 提交后、网络发送前崩溃,额度仍被占住。恢复进程不能因为“本地没有发出记录”就退款。一次领取不能拿到两次发送机会,但一次领取也可能一次都没发出去。

本实验用真实子进程的 os._exit(73) 在六个位置突然退出。reserve 提交前退出,数据库里没有预留,占用 0;提交后退出,占用 60。claim 提交前退出,恢复后仍能领取;提交后退出,恢复后不能再领,占用仍为 60。对账提交前退出,仍保留 60;对账提交后退出,已变成最终费用 20,重复对账返回 replayed。

这些是单机进程退出和数据库事务恢复证据,不是主机断电、文件系统损坏、网络分区或跨区域容灾实验。账本采用 WAL 与 synchronous=FULL,配置语义参考 SQLite synchronous 文档,不能把配置名当成完成了所有持久性测试。

预留提交后单次领取派发机会,unknown保留全额,可信最终账单到达后对账;超上界账单记录真实金额并冻结
本地提交不能和供应商扣款原子完成。缺回音保留占用,异常账单保留真相。 查看大图 ↗

5. 对账入口比退款按钮更需要谨慎#

reconcile 接收 charged 与 evidence。这里的 evidence 只是可信账单校验器提供的测试标识,代码没有验证签名、供应商账号、币种或账单完整性。把外部 HTTP 响应里的一个数字不加校验地塞进来,会直接破坏协议。

必须核实的是这次调用的最终、完整、不可变费用。第一条部分账单 20 并不能证明之后没有另一条 40;日志里没有费用字段,也不能证明免费。本文不处理后续调账、退款或分批收费。一旦业务存在这些情况,就需要账单版本、累计收费与重新预留规则,不能复用“结算一次就结束”的状态机。

在这个受限前提下,对账只允许 unknown 转 settled。同一操作、同一最终金额、同一 evidence 再来一次,返回 replayed,不再次释放差额;修改金额或证据返回 conflict。不同操作不能共用同一个 evidence。尚未 claim 的 reserved 不能用一张零元账单直接退款,因为它仍有未消费的派发机会;若以后需要取消,必须把取消和派发资格的撤销做成同一个原子决定,本例故意不提供取消退款入口。

最终确认零费用是允许的,但“确认零”与“什么也没查到”完全不同。测试中 verified_zero_bill 将占用降为 0;unknown_keeps_maximum 保留 60,并拒绝另一笔 60 的重试申请。后者可能显得浪费,可如果真实费用尚未查清,这份浪费换来的正是保守边界。

没有自动按 TTL 释放占用,是有意为之。时间过去十分钟,只能说明十分钟过去了,不能说明供应商不再补账。保留过多 unknown 会让任务停止接单,这是需要运维对账的可用性问题,不能靠把账删掉来伪装修复。

6. 一条超额账单,能戳破什么保证#

若已结算费用不超过各自预留上界,每次新增预留只在新 exposure 不超过 cap 时提交,那么协议可以归纳地维持 exposure 不超过 cap:领取不改占用,正常结算只减小或保持占用,新预留则先做原子检查。

进一步想把它解释成“外部最终费用也不超预算”,还需要所有收费都经过唯一入口、每个领取最多产生一个受界定的收费动作、供应商遵守上界、账单校验正确且最终费用不再变化。本地不变量和供应商收费保证不是同一件事。

因此实验专门保留一个破坏前提的反例:预留 60,供应商夹具却报最终费用 120。reconcile 返回 bound_breached,把真实的 120 写进账本,并冻结新的预留和尚未领取的调用。remaining 变成 -20。不能把 charged 截断到 60,否则面板会绿得很漂亮,欠款却被藏起来。

冻结也无法撤回已经返回给执行器的派发机会。已有调用可能仍在进行,后续账单仍要继续进入对账;测试验证冻结后仍接受另一项已领取调用的正常账单。它不会自动解冻,也没有自动退款动作。需要人工调查上界来源、适配器重试和供应商计费契约,再决定如何恢复。

如果你没有可信上界,或者无法把所有收费路径收进一个执行器,就不要把这个应用账本宣传成硬消费上限。可以做告警、软预算、人工批准,或者选择真正提供强制限额的服务,但这些都是另一种明确承诺。

7. 下载后,先看那个不太好看的结果#

五个文件放入同一空目录即可运行:budget.py、test_budget.py、run_experiment.py、results.json、README.md。需要 Python 3.10 或更新版本,无第三方依赖、模型密钥或真实付款。

python3 test_budget.py
python3 run_experiment.py --check
python3 run_experiment.py --output reproduced.json

本次实跑环境为 Linux、Python 3.12.14、SQLite 3.53.1。34 项 unittest 通过,12 组具名场景与完整 results.json 重放一致。测试覆盖整数输入、意图冲突、重复领取、重复结算、证据重用、正常释放、零费用、冻结、真实并发预留和六个进程退出点。Barrier 与进程等待超时只用于防挂死,不是服务性能指标;未做 Windows 验证。

建议先打开 provider_bound_violation:它有一笔超过预算的真实账本记录,当然,账单本身仍是合成输入。一个值得信赖的控制系统,应该在前提被打破时明确承认失败,而不是偷偷修整数字,好让所有断言继续好看。

收银台最重要的能力,不是每次都答应“余额够”。是能说清这笔钱为什么占着、谁有证据把它释放,以及哪一张账单已经超出了原来的承诺。

系列讨论可查看知识星球,或通过公众号入口关注后续。本文、原创图解与样例代码由 AI 辅助制作,并经过执行与内容核验;不构成生产支付、认证、供应商限额或跨服务原子性实现。

AI 实践

想把本文的问题继续做深一点?

知识星球里会继续整理相关案例、工程约束和问题讨论;具体内容以当前社区页面为准。

AI 实践知识星球加入海报,包含可扫描二维码
芝士AI吃鱼公众号二维码

关注「芝士AI吃鱼」公众号持续更新

在这里,我用「人话」和「漫画」拆解 AI 技术。公众号更新会同步整理到本站,方便继续阅读、检索和订阅。

点击二维码可放大,使用微信扫码关注。

芝士AI吃鱼

持续记录 AI 原理和工程实践

更多文章