Agent 会花钱、写库、发邮件时,怎样把失败变成可回滚事务?试试 agent_acid 的补偿式护栏
把 AI Agent 接到支付、数据库、邮件或第三方 API 后,风险不再只是“回答错了”。一个多步骤任务可能已经创建账号、写入订单,随后才在扣款或校验环节失败。传统的单次工具调用校验也有盲区:每笔 400 美元都低于 500 美元限额,不代表同一会话连续三笔就应该放行。
agent_acid 是一个早期 Python 项目,尝试把这类 Agent 操作组织成带补偿动作的事务,并把规则分成单步护栏与会话级护栏。它并不是数据库事务的替代品,更准确地说,它为原本缺乏统一撤销协议的外部副作用提供一层应用级编排。
先接受一个边界:这是补偿,不是魔法回滚
项目的核心抽象是 ReversibleTool:每个工具同时声明 execute 和 compensate。执行引擎将成功步骤的输入与返回结果写入 TransactionContext.history;后续步骤抛异常,或护栏抛出 GuardrailViolation 时,引擎按后进先出顺序调用已记录步骤的补偿函数。
这和数据库中由存储引擎保障的原子事务不同。支付退款、删除远程资源、撤销邮件或回滚 SaaS API 调用,都可能失败、延迟或根本不支持。因此“可回滚”首先是接口设计契约:团队必须为每种有副作用的操作实现可验证、尽量幂等的反向动作,并留好审计记录与人工处置路径。
还有一个特别重要的实现细节:当前代码是在 execute 之后记录该步骤并运行护栏。也就是说,超过额度的调用可能已经抵达真实服务,随后由补偿函数退款或删除。它适合处理“已发生后必须尽快补偿”的流程;如果绝不能让请求离开进程,例如硬性支付额度或生产删除权限,仍应在工具入口或下游 API 网关做前置授权,不能只依赖这个项目的后置检查。
用双层限额处理“拆单绕过”
下面的示例沿用项目公开 API,但把支付服务与退款服务留作需要自行接入的边界。单步规则限制每次金额,会话规则则累加同一工具在当前事务中的结果:第三笔 400 让累计金额超过 1,000 时触发异常,执行器会对历史中已记录的步骤倒序补偿。
from agent_acid.core import ReversibleTool, TransactionContext, AgentTransactionEngine
from agent_acid.guardrails import max_value, cumulative_max
charge = ReversibleTool(
name="charge_card",
description="向客户扣款",
execute=lambda data: payment_api.charge(data),
compensate=lambda data, result: payment_api.refund(result["charge_id"]),
guardrails=[max_value("amount", limit=500)],
stateful_guardrails=[
cumulative_max("charge_card", "amount", session_limit=1000)
],
)
engine = AgentTransactionEngine()
ctx = TransactionContext()
engine.execute_plan(ctx, [
(charge, {"user_id": "u1", "amount": 400}),
(charge, {"user_id": "u1", "amount": 400}),
(charge, {"user_id": "u1", "amount": 400}),
])
这里的关键不是金额本身,而是规则的观察范围。max_value 面向单次结果;cumulative_max 遍历当前 ctx.history,计算相同工具名和字段名的累计值。前两笔请求各自合规,第三笔才暴露跨步骤意图。这是应对拆分金额、分段导出数据、逐步提升权限等问题时必须补上的状态维度。
实际接入时,把补偿设计放在提示词之前
要把这套模式用于生产流程,可以从小而明确的闭环开始。
- 枚举副作用与补偿能力。 为创建用户、占用库存、写入工单、上传文件等操作逐项定义反向动作。没有可靠反向 API 的工具,不应假装可回滚;可改成先创建草稿、设置短期保留期,或进入人工审批。
- 让补偿幂等。 网络重试、进程中断和人工介入都会让撤销逻辑被再次调用。退款函数应能根据稳定的交易 ID 判断“已退款”,删除函数应能把“资源已不存在”当作可接受终态,而不是把二次执行变成新事故。
- 把校验拆成前置与后置。 请求参数中的角色、金额、目标资源和允许字段宜在
execute前拒绝;依赖远端返回值的校验,例如结果中的金额、邮箱格式或状态码,可放到返回后检查并触发补偿。两层都需要,不能混为一谈。 - 把事务边界与会话边界说清楚。
TransactionContext保存的是一段执行计划的历史。若 Agent 在不同任务、不同进程或不同用户会话中重建上下文,累计规则也会重新开始。跨会话预算、速率限制和权限额度仍应由持久化账本或策略服务负责。 - 为补偿失败准备升级路径。 项目会记录补偿时的异常,但不会自动解决它。生产实现至少应将原始动作、补偿参数、失败原因和关联 ID 写入可查询日志,并把无法补偿的记录投递给告警或人工队列。
失败模式:补偿链路也需要被当作产品功能
补偿式事务最容易被误解为“失败时调用一次反向 API 就结束”。真实系统里,失败路径往往比正常路径更复杂。网络超时可能发生在支付已受理、但客户端尚未拿到 charge_id 的时刻;同一个退款请求可能因重试被发送两次;创建资源的动作可能成功,补偿删除却因权限变更失败。若这些情况没有被建模,所谓回滚只会把问题从主流程转移到一串难以追踪的残局。
因此,接入 ReversibleTool 前应为每个工具写一张失败矩阵:执行前失败、执行已提交但响应丢失、护栏拒绝、补偿成功、补偿超时、补偿明确失败,各自要保留哪些关联 ID、是否允许重试、何时转人工。外部 API 若支持幂等键,应将事务 ID 或步骤 ID 传入;若支持查询接口,补偿前先按稳定 ID 查询当前状态,避免把“不确定是否成功”误判为“没有发生”。
对于不可逆操作,例如发送已投递的邮件、向外部系统发送 webhook、触发物理设备,较安全的策略通常是把“最终生效”拆到最后一步:先写草稿、先生成待确认请求,经过授权或延迟窗口后再提交。这样即使引擎只能补偿本地状态,也不会把无法收回的影响放在事务中段。
此外,补偿顺序并不自动等于业务正确性。引擎采用 LIFO,适合“先创建账号、再创建其附属资源”这类依赖关系;但如果两个远端系统存在异步复制,删除顺序、重试间隔与最终一致性检查可能需要由业务层明确编排。把 compensate 写成一个无副作用的函数看似简单,却最容易在生产里失去可观测性:至少要记录原动作引用、补偿请求引用、返回状态、执行次数及操作者。
它验证了什么,尚未承诺什么
仓库的 tests/test_core.py 覆盖了五类核心行为:正常提交、执行异常后的反向补偿、单步护栏失败、累计护栏拦截拆单,以及额度内累计值正常通过。本次按仓库说明在隔离环境中运行该测试文件,结果为 5 passed。这能验证示例引擎的控制流,却不等同于支付 API、数据库或邮件系统上的端到端可靠性证明。
项目当前在 README 中标为 early-stage,GitHub 仓库也没有可由 API 或根目录 LICENSE 文件确认的许可证声明。因此,评估时应把它视为一个可阅读、可实验的实现样本,而不是已完成合规审查的通用依赖。尤其在金融、医疗或权限管理场景,补偿动作、审计留存、身份授权与最终一致性的责任仍在接入方。
何时值得采用这套思路
如果 Agent 只读文档或生成代码,撤销事务未必是首要问题;但它一旦开始串联多个外部写操作,单纯依赖“模型会遵守提示”就不够了。agent_acid 的价值在于把两个工程问题明确摆到接口上:每个动作怎样补偿,以及限制是否要跨步骤累计。
最实用的落地方式不是立即把所有工具改造为可撤销,而是选择一条高风险、步骤少的工作流,例如“建客户记录 → 发优惠券 → 发起扣款”,先为每一步定义幂等补偿和前置权限检查,再把测试中的失败路径变成集成测试。即使最终不采用该项目,这种以补偿、状态和边界为中心的设计也能让 Agent 自动化更接近可运营的系统。