Agent 调用工具超时了该不该重试?如果不处理幂等性很容易导致重复下单
在公司推 AI Agent 落地的时候,最头疼的不是模型不聪明,而是工具调用(Tool Call)的不可靠性。最典型的情况就是:Agent 调用了一个 create_report_job("weekly") 这种写操作,结果由于网络抖动或服务响应慢,Runner 这边直接报超时了。
这时候如果直接让 Runner 重试,风险极大。因为这个 Job 可能已经在数据库里提交了,只是确认回执(Acknowledgment)没传回来。如果你盲目重试,就会在队列里塞进两个一模一样的任务;但如果你不重试,而 Worker 其实在 commit 之前就挂了,那这个任务就彻底丢了。
解决这个问题的核心不是靠猜测,而是要给每一次“意图操作”绑定一个唯一的身份标识(Identity),并且让后端服务强制执行这个标识的唯一性,而不是简单地依赖超时机制。
为了验证这个逻辑,我写了一个基于 Python 和 SQLite 的小实验,专门模拟在 commit 之前和之后进程突然崩溃的情况。这个实验不涉及复杂的 AI 模型或 HTTP 请求,直接用合成的数据库行来模拟 Job。
缺失确认回执会导致两种截然不同的状态
在这个实验逻辑里,Worker 在同一个事务中写入 Job 和一张回执表(Receipts)。回执表将操作 Key、请求的报告内容以及生成的 Job ID 关联起来。
当进行第二次尝试时,逻辑如下:
- 如果 Key 和报告内容一致,直接返回之前记录的 Job ID。
- 如果 Key 一致但报告内容变了,直接拒绝请求。
- 如果是一个新 Key,则被视为一次全新的操作。
这套规则是代码层面实现的,并不是所有工具 API 都能默认提供这种保证。
我运行了几个测试用例,结果如下:
- 第一次调用在 commit 前停止 → 使用相同 Key 和报告重试: Job 数量从 0 变到 1(成功创建)。
- 第一次调用在 commit 后但回执前停止 → 使用相同 Key 和报告重试: Job 数量维持在 1(命中回执,不会重复创建)。
- 第一次调用在 commit 后但回执前停止 → 使用新 Key 重试: Job 数量从 1 变到 2(产生了重复 Job)。
- 第一次调用在 commit 后但回执前停止 → 使用相同 Key 但改了报告内容重试: Job 数量维持在 1,且请求被拒绝。
- 第一次正常完成 → 故意使用新 Key 创建第二个 Job: Job 数量从 1 变到 2。
这里最关键的是第二和第三行。即使调用者都没有收到回执,只要保持 Key 不变,就能复用结果;一旦换了 Key,就会导致重复创建。另外,如果用户确实想跑两份周报,就得给两个不同的 Key,否则仅靠对 Payload 做 Hash 可能会把合法的重复请求给拦截了。
验证崩溃边界的实操代码
你可以把下面的代码保存为 retry-lab.py 然后运行 python retry-lab.py。它只用了标准库,通过启动本地子进程来模拟 Worker 突然消失的情况,不依赖任何外部服务或凭据。
注意:代码里的 os._exit() 是为了强制模拟进程崩溃,跳过 Python 的常规清理流程,从而精准还原“无回执”的现场。
import os
import sqlite3
import subprocess
import sys
import tempfile
from pathlib import Path
def create_job(database, key, report, fault):
db = sqlite3.connect(database, isolation_level=None)
try:
db.execute("BEGIN IMMEDIATE")
receipt = db.execute(
"SELECT report, job_id FROM receipts WHERE key = ?", (key,)
).fetchone()
if receipt:
if receipt[0] != report:
raise ValueError("key_payload_conflict")
job_id = receipt[1]
else:
job_id = db.execute(
"INSERT INTO jobs(report) VALUES (?)", (report,)
).lastrowid
db.execute("INSERT INTO receipts VALUES (?, ?, ?)", (key, report, job_id))
if fault == "before_commit":
os._exit(17) # 模拟在 commit 前崩溃,无回执
db.execute("COMMIT")
if fault == "after_commit":
os._exit(18) # 模拟在 commit 后、回执前崩溃
return job_id
finally:
db.close()
def run_lab():
# 案例定义:名称, 崩溃点, 第二次尝试的Key, 第二次尝试的Payload, 预期Job数量变化, 预期返回码
cases = [
("before commit / same key", "before_commit", "op-a", "weekly", (0, 1), 0),
("after commit / same key", "after_commit", "op-a", "weekly", (1, 1), 0),
("after commit / new key", "after_commit", "op-b", "weekly", (1, 2), 0),
("after commit / changed payload", "after_commit", "op-a", "daily", (1, 1), 20),
("two deliberate jobs / new key", "none", "op-c", "weekly", (1, 2), 0),
]
# 此处省略部分运行逻辑,实际执行需配合 subprocess 启动上述 create_job
在实际业务中,我建议在 Agent 调用写操作工具时,由 Runner 层生成一个唯一请求 ID 传给后端。这样无论超时重试多少次,只要 ID 不变,后端就能通过这张“回执表”保证最终只有一份数据被写入。
真不训练数据?要是敢拿我私有的项目去喂模型,我非把它卸载不可,谁试过这个 tinu.be 的稳定性?