返回文章列表

Agent 可靠执行实战(八):你点的同意,究竟批准了哪一步?

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

屏幕上写着:“从 A 组调三个名额给 B 组,是否同意?”人点了同意。Agent 随后刷新了一次计划,觉得四个更合适。执行器看见任务上的 approved=true,勤勤恳恳地调了四个。

它没有重复执行,没有用过期余额,也没有违反数据库约束。它只是把一个很具体的“可以”,理解成了一张任意填写的空白支票。

这是合成场景,不是作者亲历的线上事故。上一篇解决“计划依据是否仍然成立”,这一篇解决另一件事:执行器正在实施的决定,是否还是那份已经被确认的决定? 两个条件都可能必要,谁也不能替代谁。

资料核对日期:2026 年 10 月 16 日。下面实际运行了 Python 标准库、SQLite 事务、独立子进程退出和并发连接;名额调拨、审批决定、逻辑时刻均为合成输入。没有真人审批实验,没有模型调用,没有接入生产身份系统或真实业务 API。代码里的 grant 是可信测试夹具,不是可对外开放的“批准接口”。

1. 先界定“同意”的对象,再谈同意按钮#

一个布尔值能表示“曾经发生过某种确认”,却没有说清对象。任务可能经历重规划、工具参数补全、工作进程接管和重试。只要后续阶段还能改参数,任务级的 approved 就可能与最后的动作脱节。

本文把确认对象收缩为一个固定格式的计划:schema、src、dst、amount、expected。示例是从 A 调 3 个名额到 B,A、B 的预期版本都是 1。schema=1 明确采用这一版解释规则。不是把自然语言全文塞进散列就万事大吉,而是先规定执行器到底理解哪些字段。

一条审批记录保存六项内容:审批 ID、操作 ID、计划摘要、原始规范化计划、逻辑到期值和状态。审批 ID 标识一条批准记录;操作 ID 标识一次业务决定;计划摘要绑定决定的具体参数;状态记录这次批准还是否可用。它们各有职责。

OWASP 的交易授权指南强调让使用者确认重要交易数据,并在执行处检查授权与数据是否对应。这里借用的是“确认内容必须与执行内容一致”的设计原则,不是声称这份本地代码满足该指南全部安全要求。身份认证、可信显示通道和授权凭证都没有实现。

第一组错误对照刻意保留布尔值:approved=true 对应原先的数量 3,随后把当前计划数量改为 4。实际运行后,A=6、B=4。安全入口对同样的改动返回 approval_mismatch,A 仍为 10、B 仍为 0。

这条对照只证明“缺少内容绑定可以执行变更后的参数”。它不是一个完整的攻击服务,也没有测量用户是否真的看懂确认页面。

任务级同意会放过数量从三改为四;绑定操作 ID 和计划摘要的审批拒绝变更,并要求新决定取得新审批
原始输入批准调拨 3 个名额。错误对照执行了 4 个;绑定完整计划的入口拒绝参数变更。业务输入是合成样例。 查看大图 ↗

2. 摘要只解决一致性,不能凭空变成身份#

规范化不是随便调用一次 str。样例首先拒绝未知字段,要求不同的源、目标,要求 expected 恰好包含这两个资源。标识符限制为 1 到 64 个 ASCII 字母、数字、下划线或连字符。数量与版本为正整数,布尔值不能冒充整数;整数上界为 SQLite 有符号 64 位整数上限。

验证后使用排序键、固定分隔符的 JSON,再计算 SHA-256。键插入顺序变化不会改变摘要,但数量、目的资源或 expected 的任一变化都会改变它。execute 还把验证后的 JSON 解析为自己的快照,随后使用这个快照执行,避免验证后继续读取调用方的可变字典。

plan = json.loads(canonical(plan))
fingerprint = digest(plan)

这里的 canonical 只处理本文受限格式;Python 3.12 JSON 文档中的 sort_keys 与 separators 提供确定性输出所需的选项,不代表这段代码实现了任意 JSON 的跨语言规范化标准。我们不接受浮点金额、任意 Unicode 标识符或自由扩展字段。若将来引入单位、币种、目的账号、工具版本等有业务意义的字段,应更新契约和测试,不能让它们在绑定范围之外偷偷影响执行。

更重要的是:散列不是签名。知道计划的人都能算出相同摘要。本实验没有密钥,没有批准人身份,没有租户、角色或权限策略。审批 ID 也只是数据库主键,不是持有它就获得权力的 bearer token。能直接修改数据库的人可以伪造审批;样例不防御这一攻击面。

生产实现必须先解决“谁通过哪个可信通道批准了什么,以及他是否有权批准”。本文从那条可信决定已经存在的位置开始,只检查后续执行是否仍遵守它。将 grant 暴露给不可信调用方,会直接绕过这个前提。

原始计划与摘要一起保存,是为了能够审查绑定内容,而不是让人对着一串十六进制字符确认。真正的确认界面应展示有意义的参数和后果,并由同一份受控计划生成;代码没有实现这种界面,也没有证据证明一个 UI 显示的文案必然对应这里的字节。

3. 先读回执,还是先检查审批有效期?#

假设批准在逻辑时刻 20 到期,动作在 19 已提交,回复却丢了。调用方在 25 重试。若执行器先查有效期,会返回 expired,让调用方误以为事情没有做成。

正确的顺序延续系列第一篇的思路:先查这次操作的历史结果,再决定一个新动作能否进入执行。

old = c.execute(
    'SELECT approval_id,digest,result FROM receipt WHERE op=?', (op,)
).fetchone()
if old:
    if old[:2] != (aid, fingerprint):
        return {'status': 'conflict'}
    return {'status': 'replayed', 'result': json.loads(old[2])}

同一操作 ID、同一审批 ID、同一计划,返回历史结果;同一操作 ID 换计划或换审批,返回 conflict。只有没有回执的新操作,才检查审批是否存在、是否绑定该操作与计划、是否仍为 active,以及逻辑时刻是否早于 expires。

这里把审批 ID 也纳入回执一致性,避免调用方给已经完成的决定换一张“新票”,让历史记录似乎来自另一项批准。反过来,同一计划换一个操作 ID,也不能继续使用旧审批:它绑定的是原操作,不是一张允许把相同动作重复做任意次数的许可证。

实跑的 replay_after_expiry 场景先得到 applied,之后调用 revoke 返回 consumed,再在时刻 25 重试,得到 replayed/applied。效果始终只有一条。这个结果说明已完成的动作不会因为过期而变成“未执行”,不说明审批仍然有效,更不说明资源现在保持当初状态。

“先读历史”也不意味着生产系统可以绕过访问控制泄露结果。真实服务仍要验证请求者能否读取该回执,必要时只返回允许其查看的结果。本地可信输入实验没有实现这层权限。

4. 撤销与消费必须在同一条写入秩序里#

另一组错误对照更隐蔽。子进程先读到 active,释放连接,输出 READY。父进程确认它到达这个点,提交撤销,再发送 GO。子进程沿用之前的检查,照常调拨。

最终数据库中,审批是 revoked,业务效果却是一条,A=7、B=3。这不是撤销来得太晚:父进程的撤销确实先提交了。错误入口只是在真正写入时没有再检查审批。

安全入口把以下步骤放在同一 SQLite 文件的 BEGIN IMMEDIATE 事务中:读取回执;检查审批绑定与状态;消费审批;检查业务版本与余额;写入业务效果和终态回执。revoke 同样使用 BEGIN IMMEDIATE。它们遵守同一数据库的写入排序。

SQLite 事务文档说明,同一数据库同时只有一个写事务,BEGIN IMMEDIATE 会尝试立即开始写事务。本例据此避免检查与修改之间插入另一笔撤销。它并没有把任意外部请求变成可回滚事务。

安全对照让子进程停在执行入口外,父进程撤销后再放行。入口实际读到 revoked,拒绝执行,A=10、B=0。两组都使用 READY/GO 管道握手,不靠 sleep 猜测竞争窗口。

这套协议的撤销语义很窄,也很清楚:撤销先提交,就能挡住尚未被消费的新执行;执行先提交,撤销只能看到 consumed,不能撤回已经发生的效果。如果执行正在持有写事务,撤销要等待。它不是中断信号,更不是“按下取消键就回滚一切”。需要抵消效果时,应提出另一项业务决定,重新检查条件与授权,而不是把 consumed 改回 active。

一个本地 SQLite 事务内先读回执,再检查并消费审批,最后写入业务结果和回执;提交前退出全部回滚,提交后退出只重放
消费的对象是一次已获批决定的处理机会。业务拒绝同样留下终态;撤销不逆转已提交的效果。 查看大图 ↗

5. 单次消费,不等于保证成功一次#

这里有一个需要主动选择的产品语义:批准内容匹配,但业务版本已经变了,要不要把审批退回来?

本文选择不退。审批通过入口检查后,该决定就被处理一次。若业务状态 stale、资源缺失、余额不足或整数溢出,仍消费审批并保存对应终态回执。重复提交原决定只会取回原来的拒绝,不会因为外部状态后来变化就突然执行。

这能避免一个危险的漂移:用户批准的是当时那份计划,系统却把审批长期挂着,等到某天条件凑齐了再悄悄执行。代价是保守,有些暂时失败也必须重新决策。如果产品要求“在期限内等待条件满足”,应把这种等待语义写进确认内容,并设计相应状态机;不能让它偶然从重试逻辑中长出来。

样例明确分开两层结果:

  • 审批入口拒绝:approval_missing、approval_mismatch、approval_revoked、approval_expired 等,不消费审批,不写终态回执
  • 已获准处理的业务终态:completed 包裹 applied、stale、insufficient、missing_resource 或 overflow;审批被消费,回执保存
  • 历史结果:replayed 包裹原来的业务终态,可能是成功,也可能是拒绝
  • 同一操作身份的冲突:conflict,不替换已有记录

入口拒绝不保存回执是一项有意的接口选择。因此请求先因审批不存在被拒,后来可信流程创建了正确审批,同一操作可以进入处理;但不能把第一次入口拒绝说成一个已持久化的最终决定。生产系统可能需要另建审计或限流记录,本文没有实现。格式错误抛出 ValueError,数据库锁超时、SQL 异常则向上传播,均不伪装成 completed。

stale_replan 场景展示了完整分界:先把 B 从 0 改成 2,版本从 1 变成 2;旧计划匹配审批,却因状态变化得到 stale,审批消费。把 expected 中 B 改成 2,继续使用 job-1,得到 conflict。换成 job-2、仍拿旧审批,得到 approval_mismatch。只有可信夹具为新计划与 job-2 建立另一条审批,才调拨成功,最终 A=7、B=5。

实验中的新审批由测试代码显式生成,并不表示 Agent 可以自动代表人批准。重规划后的动作可能看起来差不多,但它是新的决定。怎样少打扰用户,是确认界面和授权范围设计的问题,不能通过偷偷复用旧审批解决。

6. 消费后进程没了,批准会不会白白丢掉?#

如果先把审批标成 consumed 并提交,再另开事务执行业务,进程可能倒在中间:批准没了,事情没做。反过来,先提交业务,最后才消费审批,也会留下重复使用窗口。

因此样例把消费、业务结果和回执放进同一个本地事务。独立子进程分别在三个位置调用 os._exit;父进程重新连接数据库检查结果,而不是信任子进程的最后一行日志。

真实退出点退出码恢复后审批效果 / 回执行数原请求重试
已消费,未处理业务81active0 / 0completed/applied
业务与回执已写,未提交82active0 / 0completed/applied
已提交,未回复83consumed1 / 1replayed/applied

前两行的未提交事务整体回滚;第三行保留已提交结果。另一个测试用触发器阻止回执插入,检查资源、消费与效果是否一并回滚。它防止代码在 SQL 错误后留下“业务成功,审批状态却没跟上”的半成品。

并发测试由八个线程、各自独立连接,提交十六次完全相同的请求。实际结果为一次 completed、十五次 replayed,效果与回执各一条。这里的分母是 16 次本地入口调用,不是 16 个用户、16 次独立审批或 16 个分布式节点。也不报告性能提升、吞吐或真实服务延迟。

这些实验验证进程退出和 SQLite 恢复,不验证机器断电、磁盘损坏、备份回退或多节点故障。回执与审批没有清理协议;若将来清理,仍须遵守第六篇讨论的迟到请求边界,不能删除历史后随意复用审批 ID 或操作 ID。

7. 到期、跨服务和审批界面:三个不能偷换的边界#

先说到期。本例 now 是测试提供的可信逻辑时刻,判断规则是 now 小于 expires;恰好相等就拒绝。它用于重现到期分支,不是系统真实时间。锁等待期间 now 不自动推进,因此代码不保证“取得锁时仍未过期”,更不保证“提交发生在真实截止时间之前”。

真实系统需要明确批准的时限约束的是准入、派发还是业务提交,并在相应权威边界采样可信时间。若只检查一次准入时间,执行很久后完成可能仍符合该契约;若要求提交前仍有效,则需要更强的执行协议。不能让浏览器或 Agent 任意传入 now,再宣称实现了安全有效期。

再说跨服务。本例名额、审批和回执在同一个 SQLite 文件内。如果在事务里调用一个发送邮件接口,邮件不会因为 SQLite 回滚而收回;如果先消费审批,再派发到远端,进程崩溃也会重新引入“批准已用,远端状态未知”的问题。需要结合接收端幂等与第三篇的 unknown 对账,设计持久派发与审批消费之间的边界,不能直接搬用本地原子性结论。

最后说展示。一份精确的摘要只能绑定已被编码的内容。若用户看见“发给项目组”,执行参数却是一百个外部联系人,摘要正确也无济于事。重要信息必须在确认之前解析为可审查的对象;别名解析、隐含默认值、附件内容、递归执行范围以及工具版本,都可能让“看见的计划”和“执行的计划”分离。

本例只支持明确源、目标、数量和双资源版本,不证明自然语言意图已被完整捕获。它也没有防御恶意提示注入、实现审批者登录、隔离不可信 Agent,或建立防篡改审计链。把这些边界写出来,是为了让一个小而可验证的机制保持诚实,而不是把它包装成万能的人在回路方案。

8. 复现:先看错误对照,再检查你的协议#

下载五个文件到同一个空目录:approval.py、test_approval.py、run_experiment.py、results.json、README.md。只需要 Python 3.10 或更新版本,无第三方依赖:

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

本次实跑为 Linux、Python 3.12.14、SQLite 3.53.1,27 项 unittest 通过,12 组具名场景重建临时数据库后与完整 results.json 一致。输出保存状态、退出码、数量、版本、效果与回执数,不混入随机目录、PID 或耗时。两组 unsafe 场景分别展示布尔批准与事务外撤销检查,其余十组覆盖安全入口与恢复。实验文件保留明确标识,不被安全 execute 调用。

样例使用 SQLite WAL 与 synchronous=FULL。子进程等待的 15 秒只是防挂死看门狗,不是性能指标;管道就绪检查在本次 Linux 环境验证,未做 Windows 管道兼容测试。所有文件都可独立下载使用,不需要获取会员资料或连接外部模型。

检查自己的协议时,可以沿着一个问题追到底:从用户看到的参数,到审批存下的计划,到执行器实际使用的输入,再到失败后回放的结果,这是不是同一项决定?哪里允许重新解释,哪里允许重新生成,哪里需要人再次确认?

一个可靠的执行器不应把“用户点过同意”当作万能前提。它应当能说清楚:同意的是哪份计划,这次机会是否已经用过,变化之后该由谁重新决定。

系列讨论可查看知识星球,或通过公众号入口关注更新。RAG 第六篇仍等待真实生成输出与细粒度审计条件,本实验不替代模型评测。

本文、原创图解与样例代码由 AI 辅助制作,并经过执行与内容核验。本地可信审批记录实验不构成生产认证、授权或跨服务原子性保证。

AI 实践

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

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

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

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

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

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

芝士AI吃鱼

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

更多文章