返回文章列表

Agent 可靠执行实战(一):工具干完了,回执丢了怎么办?

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

Agent 替你建了一张工单。工具那边写入成功,回执却没回来。进程重启后,屏幕上只有一行失败记录。

这时最顺手的动作是再试一次。于是同一个问题,有了两张工单。为了不重单,把“已完成”提前写入日志?下一次恰好在写日志之后、调用工具之前崩溃,屏幕上就会出现一种更安静的事故:任务显示完成,工单根本不存在。

这是一个假设场景,不是作者的客户事故。但后面的实验真的会终止子进程,再从数据库恢复。我们不让模型表演“我记住了”,而是检查一个更基础的问题:当调用方不知道工具是否已经生效,它凭什么决定重试?

本篇开启「Agent 可靠执行实战」。RAG 系列关注回答证据,执行系列关注工具改变外部状态后的责任边界。两者可以共同构成应用验收,但不能互相替代。资料核对日期:2026 年 10 月 6 日。

1. 先说清楚这次到底运行了什么#

实验只使用 Python 标准库和 SQLite,业务动作是一条合成工单记录。发送端与接收端使用两个独立数据库;接收端由真实子进程执行,在指定边界使用 os._exit 退出。恢复时启动新进程,重新读取已提交的状态。没有调用大模型,没有真实工单系统、支付接口、客户数据或公网业务请求。

这是一组故障机制实验,不是生产可用性、吞吐量或费用测试。实际测到的是:给定这些事务边界与故障点,数据库最终有几条记录,调用方能知道什么。业务内容是合成的,进程退出、SQLite 提交与恢复查询是真实运行的。

本地文件数据库仍有假设:存储保持可读、操作系统与 SQLite 遵守其事务约定,幂等记录没有被提前删除。os._exit 模拟进程突然结束,不等于拔电、磁盘丢写、文件系统损坏或跨机房灾难。本实验实际启用 WAL 模式与 synchronous=FULL,以 WAL 提交和恢复规则为准;atomic commit 文档主要解释回滚日志模式,也列出底层存储假设,不能把两种实现混称为同一条写入路径,更不能从本篇几个测试推导“任何硬件故障都安全”。

2. 两本账之间,没有一个共同的提交按钮#

发送端的账回答:“这项任务需要执行吗?我收到结果了吗?”接收端的账回答:“这个请求是否已经改变业务状态?”

在自己的数据库里写入 done,不会顺带提交对方的业务事务。反过来,对方已经提交,也不会保证回复一定抵达调用方。它们之间的空隙,正是丢单与重单的来源。

所谓 durable outbox,先解决的是意图不丢:把需要执行的动作持久化,后续工作进程从这份记录取任务。它不自动解决接收端的重复副作用。如果一个重试会再次建单,outbox 可以把“必达”推得更近,也可以把重复发送做得更稳定。

接收端需要另外一个约定:调用方为一次业务意图提供稳定的请求 ID;相同 ID、相同参数重放,返回原来的结果,不再新建业务记录。相同 ID 却携带不同参数,必须作为冲突拒绝,不能悄悄沿用旧结果。

AWS 关于可安全重试的幂等 API 的说明强调调用方表达意图的标识,以及同一标识下参数发生变化的处理。不能仅仅对参数做哈希:两次内容相同的真实需求,也可能应当各建一张工单。

发送端持久化意图与接收端事务去重是两种保障;提交后的回执可能丢失
两端各有事务边界。发送端重试时,接收端必须识别同一个业务意图。 查看大图 ↗

3. 把幂等记录和业务动作放进同一个事务#

接收端至少需要保存请求 ID、规范化后的参数或其摘要,以及成功结果。关键不在字段名,而在提交顺序:检查旧请求、写业务记录、保存可重放的结果,必须共享一个事务边界。

如果先插入工单,再单独插入去重记录,中间崩溃仍然会重单。如果先保存“见过此 ID”,却没有把工单写入同一个事务,中间崩溃又会漏单。把一张新表叫作 idempotency 并不会消除这道缝。

本实验用 SQLite 的显式事务把接收端这几步绑定在一起。写事务的竞争由数据库负责串行化,唯一键承担最终约束。SQLite 事务文档说明 BEGIN IMMEDIATE 会立即开始写事务,遇到其他写事务时可能返回忙错误。忙错误是需要处理的竞争结果,不是许可应用绕开唯一约束再写一遍。

下载代码中的事务包装器如下。接收端在这个上下文里写工单与收据,退出上下文才提交;故障注入位于提交之前或之后,而不是靠捕获异常假装进程崩溃。

@contextmanager
def transaction(connection):
    connection.execute("BEGIN IMMEDIATE")
    try:
        yield
        connection.execute("COMMIT")
    except BaseException:
        if connection.in_transaction:
            connection.execute("ROLLBACK")
        raise

os._exit 不经过这里的异常清理。因此,提交前故障场景真正依赖 SQLite 在重启后的事务恢复,而不是依赖 except 帮忙撤销。只有 BEGIN IMMEDIATE 本身失败时,调用方才从事务尚未开始的状态收到错误。

参数的规范化同样是协议。JSON 对象字段顺序可以被统一,字段值、操作类型和所属业务范围则不能被随便忽略。生产系统还需要定义租户、权限主体与幂等键作用域;本文单一合成业务空间没有实现真实多租户授权,不能直接拿请求 ID 当作访问控制。

只有接收端成功提交后,回复才能表示“已完成”。回复丢了,调用方仍然可以携带原 ID 再请求:接收端找到旧结果,返回同一个工单 ID。这里没有让网络变可靠,只是让重复消息不再必然产生重复工单。

4. 让进程真的死在那道缝里#

本次运行环境为 Python 3.12.14、SQLite 3.53.1。run_experiment.py 的 16 个具名场景全部执行并通过内置断言,结果保存在可下载的 results.json。下表摘出三种策略的关键对照:

策略与注入位置恢复后的工单数发送端最终状态说明
普通调用,接收端提交后丢确认,再次发送2done重复副作用已经发生
发送前先记 done,随后在发送前退出0done本地完成状态掩盖了漏单
outbox + 接收端事务去重,提交后丢确认1done用原 ID 取得已提交结果

三行分别对应 naive_lost_ack、mark_before_send_loses_task、idempotent_lost_ack。第一行与第三行中,接收端在 COMMIT 后以退出码 73 结束,发送端没有拿到可接受的成功回执;第二行则是发送端提前记 done 后退出,根本没有调用接收端。第三行的恢复没有删除旧工单、没有人工修数据库,也没有给重试换一个新 ID。

注意比较单位:同一个业务意图在恢复后留下的工单数。这里的数字不是统计概率,也不是某个架构的长期故障率。故障点由测试主动指定,测试就是要把平时难撞到的边界撞出来。

发送端在落库前退出,没有已接受的持久任务可恢复。这时上游需要用稳定意图标识重新提交。落库后、发出前退出,任务还在,可以继续发;接收端提交后退出,调用方没有回执,但原 ID 重放可以取回同一个结果;发送端记录结果后退出,恢复只读取完成状态,不再创建业务动作。

还有一个容易写错的边界:after_response_write 已把完整回包 flush 到管道,接收端随后虽然以 73 退出,发送端仍可验证结果并完成,本次尝试数为 1。不能只看子进程退出码就丢掉已经收到的有效业务回执。与之不同,after_response_before_ack 是发送端拿到回包后、尚未落库时自身退出,恢复仍需按原 ID 重放。

“发送端没有结果”和“接收端没有执行”是两个不同命题。错误处理最容易在这里偷换概念。日志中的 timeout,只能证明等待没有按预期完成,不能证明对方没干活。

5. 两个工作进程同时醒来,又怎么办#

持久队列通常不止一个工作进程。两个进程同时看到 pending,都去发消息,即使最后工单没有重复,调用量和外部压力也可能翻倍。

因此发送端也需要原子领取:从可领取状态转为处理中时,只能有一个当前工作进程拿到任务。它缓解重复调度,但不能替代接收端去重,因为领取之后仍然可能发生“已经发送,尚未保存回复”的退出。

实验启动 8 个子进程竞争同一条意图,只有 1 个成功领取;另启动 8 个接收端子进程处理相同请求,最终只有 1 条工单、1 条去重记录,所有返回值相同。

发送端还给每次领取分配递增的尝试编号。保存确认时,必须匹配当前编号与处理中状态;测试中的旧编号确认被拒绝。这仅保护发送端这行记录不被旧尝试覆盖,并不能阻止一个已经失去租约的进程继续调用外部工具。

时间采用显式逻辑刻度,租约长度为 10,恢复刻度为 11。它让边界测试可重复,不是在测试现实中的时钟同步、长暂停或网络分区。对接收端同一请求的参数修改也会被拒绝,原工单保持不变。

生产系统中的租约失效还涉及旧工作进程复活:新进程接管后,旧进程是否还能覆盖结果?需要任务版本、条件更新或 fencing 等机制来约束,不能只加一个过期时间就宣告完成。本篇的崩溃恢复场景在旧进程已退出后重启,另有发送端旧编号确认的单独断言;跨主机租约与暂停后复活的完整协议留给下一篇,在有可复现交错测试之后再讨论。

6. 接收端不配合时,要敢于留下 unknown#

如果工具只是一个不支持幂等键的外部接口,本地 outbox 管不住对方的业务事务。你可以保存调用前后日志,但无法靠本地事务把远端写入包进来。

这时“失败就重试”和“失败就当成功”都可能错。一个诚实的状态是 unknown:操作可能已经发生,当前证据不足以决定是否再做一次。若工具支持按业务 ID 查询,可以先对账;若不支持,就需要人工核验或经过明确授权的补偿流程。

实验额外放置一个独立的合成外部工单账本,故意不提供幂等协议。它仍然是本地模拟接收方,不是联网的第三方服务。故障分别注入在副作用发生前与发生后:

注入时机实验观察者看到的外部工单数发送端恢复状态自动重试
外部副作用前退出0unknown否
外部副作用后退出1unknown否

这里故意保留两种不同的真实结果、相同的调用方认知。测试程序有权限读取两边账本,所以能给出 0 和 1;发送端的恢复逻辑没有拿这份全知视角作弊。再额外强制绕过停止策略重放,反例留下了 2 条工单。

unknown 不是把工作扔掉。它应当保留意图、参数、尝试记录、可对账标识与下一步责任人,并阻止后台无限重试。所谓补偿也不是时间倒流:删掉重复工单也许可行,已发出的邮件无法让所有收件人忘记,外部动作的撤销必须按具体业务判断。

有接收端幂等协议时按原ID重放;无协议且结果不明时先对账或人工核验
恢复策略取决于工具契约,不取决于 Agent 有多自信。 查看大图 ↗

7. 下载、运行,再检查哪些结论没有被证明#

完整文件:执行实现 reliability.py、实验驱动 run_experiment.py、边界测试 test_reliability.py、实际运行结果 results.json、运行说明 README.md。五个文件放在同一空目录,使用 Python 3.10 或更新版本,无需安装第三方包:

python3 test_reliability.py
python3 run_experiment.py --check results.json --output reproduced.json

第一条执行 31 个测试:除真实数据库行为外,子进程超时和启动中途失败的清理使用 mock 验证,不能当作额外的真实网络故障实验。

第二条命令在临时目录重新创建数据库、执行全部场景,先断言行为,再比较整个结果对象;不是把已有 JSON 原样打印出来。成功后写出 reproduced.json,可以继续比较原始字节。临时实验数据随运行结束清理,不涉及真实业务数据。

建议先阅读结果 JSON,再看测试逐项验证的故障点。如果改动实现,重新运行完整测试并生成新的结果,不要只复制本文数字。不同 Python 或 SQLite 版本的环境信息可以变化,业务不变量则应当保持。

这里验证的“只留一条工单”,限定在接收端 SQLite 的同一个事务、稳定请求 ID、参数冲突检查和保留的去重记录之内。它没有证明任意外部工具的 exactly-once,也没有测试断电、跨地域复制、恶意客户端、访问控制或无限时间后的重放。幂等记录一旦清理,旧请求能否再次到达,需要另行定义保留窗口与过期策略。

最值得带走的不是某个重试装饰器,而是一张边界清楚的执行记录:意图在哪里落地,业务在哪里提交,结果在哪里保存,哪一步退出以后只能说“不知道”。Agent 可以负责决定调用什么工具;工具是否已经造成变化,最终还得让可查询的业务事实说话。

下一篇计划研究多工作进程接管时的租约与旧写入隔离;只有实验和边界验证齐备后发布。RAG 系列继续保留真实生成输出与细粒度支持标签的验证要求,不用合成回答代替缺失的模型实测。

相关阅读:断点恢复的责任边界;RAG 评测实战:引用审计。进一步讨论可查看知识星球,或通过公众号入口关注更新。

本文及原创图解、样例代码由 AI 辅助制作,并经过代码执行与内容核验。合成业务数据仅用于故障机制实验;不构成生产系统可靠性承诺。

AI 实践

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

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

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

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

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

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

芝士AI吃鱼

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

更多文章