AI Agent 工具重试为何重复写入?Python + SQLite 幂等键与结果回放实战

Agent 调用“创建任务”工具后超时,重试却生成了两条一样的任务。这类故障不一定是模型忘了自己做过什么:可能第一次写入已经提交,只是调用方没有收到结果。

让模型在提示词里记住“不要重复操作”,无法补上数据库提交与响应到达之间的空隙。下面把副作用与可回放结果写在同一个 SQLite 事务里,验证重复调用、参数冲突、异常回滚和两连接竞争。

一、重试要复用操作身份

operation_key 应标识一次业务操作,而非某次网络尝试。调度器应在执行前生成并持久保存它;后续重试继续使用原键。模型重新生成的 tool call ID 是否稳定,需要单独核实,不能直接当作业务幂等键。

本例用 tenant 与 operation_key 共同定位操作,另存工具名、版本和参数。相同键且参数相同,返回已有结果;相同键但参数变化,拒绝执行;真正的新操作使用新键。输入只允许非空字符串,没有提供通用 JSON 归一化算法。

二、为什么两张表要一起提交

jobs 是实际创建的本地任务,receipts 保存参数和结果。先写 jobs 再单独提交 receipts,中间失败仍然可能重复创建;先提交成功标志再写 jobs,则可能留下没有真实任务的“成功”。

本例使用 BEGIN IMMEDIATE,在同一个事务内检查旧回执、创建任务并保存结果。SQLite 同一时刻只允许一个写事务,竞争可能等待或报 busy;这不是无限并发保证。机制见 SQLite 事务文档

三、完整可运行示例

保存为 idempotent_tool_demo.py,执行 python idempotent_tool_demo.py。需要 Python 3.12 或更新版本以使用 autocommit 参数;本轮实际环境是 Python 3.13.13、SQLite 3.51.2。实验只创建临时数据库,不调用模型或外部服务。

这里显式使用 autocommit=True 加 SQL 的 BEGIN、COMMIT、ROLLBACK。不要把它与其他事务模式下的 Connection.commit() 用法混在一起,参考 Python sqlite3 事务控制

"""Local SQLite effects only; does not call an LLM or external service."""
import json
import platform
import sqlite3
import tempfile
import threading
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path


def connect(path):
    return sqlite3.connect(path, autocommit=True, timeout=5)


def setup(path):
    con = connect(path)
    try:
        con.executescript('''
            CREATE TABLE jobs(id INTEGER PRIMARY KEY, tenant TEXT NOT NULL, title TEXT NOT NULL);
            CREATE TABLE receipts(
                tenant TEXT NOT NULL, operation_key TEXT NOT NULL,
                payload TEXT NOT NULL, result TEXT NOT NULL,
                PRIMARY KEY(tenant, operation_key)
            );
        ''')
    finally:
        con.close()


def create_job(path, tenant, operation_key, title, fail_at=None):
    if not all(type(x) is str and x for x in (tenant, operation_key, title)):
        raise ValueError('non-empty strings required')
    payload = json.dumps({'tool': 'create_job', 'version': 1, 'title': title},
                         sort_keys=True, ensure_ascii=False, separators=(',', ':'))
    con = connect(path)
    try:
        con.execute('BEGIN IMMEDIATE')
        old = con.execute('SELECT payload, result FROM receipts WHERE tenant=? AND operation_key=?',
                          (tenant, operation_key)).fetchone()
        if old is not None:
            if old[0] != payload:
                raise ValueError('operation key reused with different arguments')
            con.execute('COMMIT')
            return json.loads(old[1])
        cur = con.execute('INSERT INTO jobs(tenant, title) VALUES(?, ?)', (tenant, title))
        result = {'job_id': cur.lastrowid, 'title': title}
        if fail_at == 'before_receipt':
            raise RuntimeError('injected failure before receipt')
        con.execute('INSERT INTO receipts VALUES(?, ?, ?, ?)',
                    (tenant, operation_key, payload, json.dumps(result, ensure_ascii=False)))
        con.execute('COMMIT')
        if fail_at == 'after_commit':
            raise TimeoutError('injected lost acknowledgement after commit')
        return result
    except BaseException:
        if con.in_transaction:
            con.execute('ROLLBACK')
        raise
    finally:
        con.close()


def count_jobs(path):
    con = connect(path)
    try:
        return con.execute('SELECT COUNT(*) FROM jobs').fetchone()[0]
    finally:
        con.close()


def must_raise(kind, call):
    try:
        call()
    except kind:
        return
    raise AssertionError(f'expected {kind.__name__}')


def main():
    print(f'Python {platform.python_version()} / SQLite {sqlite3.sqlite_version}')
    with tempfile.TemporaryDirectory() as tmp:
        path = Path(tmp) / 'demo.sqlite3'
        setup(path)
        first = create_job(path, 'team-A', 'op-1', '整理采集报告')
        assert count_jobs(path) == 1
        print('PASS first call: one job')
        assert create_job(path, 'team-A', 'op-1', '整理采集报告') == first
        assert count_jobs(path) == 1
        print('PASS retry through new connection: same receipt, still one job')
        must_raise(ValueError, lambda: create_job(path, 'team-A', 'op-1', '改了内容'))
        assert count_jobs(path) == 1
        print('PASS same key with different arguments: rejected')
        must_raise(RuntimeError, lambda: create_job(path, 'team-A', 'op-2', '第二个任务', 'before_receipt'))
        assert count_jobs(path) == 1
        print('PASS failure before receipt: effect rolled back')
        create_job(path, 'team-A', 'op-2', '第二个任务')
        assert count_jobs(path) == 2
        print('PASS retry after rollback: one new job')
        must_raise(TimeoutError, lambda: create_job(path, 'team-A', 'op-3', '第三个任务', 'after_commit'))
        assert count_jobs(path) == 3
        recovered = create_job(path, 'team-A', 'op-3', '第三个任务')
        assert recovered['job_id'] == 3 and count_jobs(path) == 3
        print('PASS lost acknowledgement: committed result replayed')
        create_job(path, 'team-B', 'op-1', '整理采集报告')
        assert count_jobs(path) == 4
        print('PASS separate tenant scope: independent operation')
        barrier = threading.Barrier(2)
        def contender():
            barrier.wait(timeout=5)
            return create_job(path, 'team-A', 'op-4', '并发任务')
        with ThreadPoolExecutor(max_workers=2) as pool:
            futures = [pool.submit(contender) for _ in range(2)]
            results = [f.result(timeout=10) for f in futures]
        assert results[0] == results[1] and count_jobs(path) == 5
        print('PASS two concurrent connections: one effect, same result')
    print('8 checks passed')


if __name__ == '__main__':
    main()

2026-09-12 的实际输出:

Python 3.13.13 / SQLite 3.51.2
PASS first call: one job
PASS retry through new connection: same receipt, still one job
PASS same key with different arguments: rejected
PASS failure before receipt: effect rolled back
PASS retry after rollback: one new job
PASS lost acknowledgement: committed result replayed
PASS separate tenant scope: independent operation
PASS two concurrent connections: one effect, same result
8 checks passed

四、从结果里看三种失败状态

第一种是回执写入前发生异常。事务回滚后没有残留任务,使用同一键重试才创建一条新记录。

第二种是提交完成后,调用方没有拿到结果。代码用提交后的 TimeoutError 模拟这种“确认丢失”;下一次连接查到回执,返回原 job_id,任务总数不变。这是故障注入,没有真的断网或杀进程。

第三种是两个连接同时拿相同键执行。本轮用两线程、各自独立连接竞争,得到相同结果,最终只增加一条任务。这证明本轮小实验成立,不是吞吐压测,也没有覆盖所有锁超时场景。

五、外部副作用不能被这段代码包住

如果工具改为发送邮件、调用第三方接口或写另一套数据库,本地 ROLLBACK 无法撤回这些动作。把外部请求塞进事务,不会自动获得端到端的“恰好一次”。

接外部系统时,应检查对方是否接受稳定幂等键、是否能按操作 ID 查询结果,以及保留多长时间。确认超时后先查已有状态;只有能明确区分“未执行”和“已执行”时,才能制定安全重试策略。无法确定的操作要保留待核对状态,不能直接标成失败再创建一个新操作。

收据清理也属于业务设计:如果过早删除,迟到重试就可能再次执行。多租户场景还必须从可信登录上下文取得 tenant,并在结果回放前检查当前调用者的访问权;示例没有实现鉴权系统。

本文由 AI 辅助起草与校核,8 项检查已在上述本地环境执行。示例验证同一 SQLite 数据库内的原子提交与结果回放,不宣称解决了跨服务事务、断电恢复或真实模型工具调用的全部问题。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值