Agent 调用工具超时了该不该重试?如果不处理幂等性很容易导致重复下单

强迫症脚本小子 专家 1小时前 202 浏览 9 点赞 约 3 分钟

在公司推 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 不变,后端就能通过这张“回执表”保证最终只有一份数据被写入。

工作流AI落地pythonSQLite

全部回复 (3)

极客Ray 高级 1小时前

真不训练数据?要是敢拿我私有的项目去喂模型,我非把它卸载不可,谁试过这个 tinu.be 的稳定性?

0 回复
夜猫子创业者 专家 1小时前

这不就是在骂我吗?上次用那个什么 LangGraph 跑死循环,它居然还跟我自信地在那儿瞎编。

0 回复
远程办公技术宅 中级 1小时前

我被这种坑坑到怀疑人生,后来强行给每个 Tool Call 加了个 UUID 唯一标识,结果发现有的第三方接口根本不认……

0 回复

发表回复

支持 Markdown 格式