# Agent 可靠执行实战（六）：去重记录删了，旧请求会不会复活？

> 用 Python 标准库、SQLite 和真实子进程实验，复现幂等记录清理后的重复执行，比较结果墓碑与代次封口，验证原子清理、迟到请求和崩溃恢复，并说明封口会拒绝合法旧任务的代价。

- 作者：芝士AI吃鱼
- 发布日期：2026-10-14
- 主题：Agent、幂等、数据生命周期、SQLite
- HTML 正文：[https://ai-knowledgepoints.cn/blog/agent-reliability-06-idempotency-retention](https://ai-knowledgepoints.cn/blog/agent-reliability-06-idempotency-retention)
- Markdown 永久链接：[https://ai-knowledgepoints.cn/blog/agent-reliability-06-idempotency-retention/index.html.md](https://ai-knowledgepoints.cn/blog/agent-reliability-06-idempotency-retention/index.html.md)

引用本文时，请注明作者与 HTML 正文链接。Markdown 版本用于机器阅读，与 HTML 正文共享同一内容来源。

## 正文

清理任务删掉了一批老回执。数据库瘦了，值班面板也很安静。过了一会儿，一个在队列里躺了很久的请求终于醒来。接收端查了查：“没有见过。”于是，同一张工单又建了一次。

这是本篇的假设场景，不是作者经历过的线上事故。问题却可以真实复现：**过去没做过，与做过但忘了，在一张空表面前长得一模一样。**

[第一篇](https://ai-knowledgepoints.cn/blog/agent-reliability-01-outbox-idempotency)把去重记录与业务效果放进同一个事务，[第五篇](https://ai-knowledgepoints.cn/blog/agent-reliability-05-durable-dispatch-reservations)把派发额度保存在跨进程账本里。它们都需要历史状态。今天不再增加一层重试，而是追问这本历史账什么时候可以变薄。

资料核对日期：2026 年 10 月 14 日。以下业务内容是合成工单；SQLite 提交、子进程退出、管道控制的迟到请求及重新查询是真实执行。没有调用大模型、支付接口或真实工单系统，也没有运行生产 TTL、网络延迟和容量测试。

## 1. 幂等有保留边界，不能从“支持”两个字推导永久有效

先看一个具体契约。[Stripe 的幂等请求文档](https://docs.stripe.com/api/idempotent_requests)明确区分 API v1 与 API v2；其中 v1 说明，键至少保留 24 小时后可以自动清理，原键被清理后再次使用会生成新请求。同键参数校验也依赖其保存的信息。

这里引用的是文档约定，没有实际调用 Stripe，更不是在评测支付安全。不同 API、版本与服务商的规则不能互相套用。值得提炼的工程问题是：调用方承诺多久之内重试，接收方承诺多久之内记得，排队与恢复会不会跨过两者之间的缝。

“任务最长运行十分钟”也不等于“十分钟后所有旧请求都消失”。暂停的进程、离线重放、消息积压与恢复旧快照都可能延长到达时间。即使删除操作准确遵守 TTL，TTL 仍然只说明记录的年龄，并没有证明旧请求已经不存在。

本例不模拟现实时间。实验中的清理是一个显式函数调用，表示保留策略已经决定删记录；我们只检验删除前后会发生什么，不声称测到了某个服务的过期时间。

## 2. 两种东西可以分别过期：答案与“我见过你”

回执里通常不只有一个键，还带着返回值。返回值很大时，可以先让答案过期，继续保留一条较小的记录：命名空间、代次、操作 ID，以及参数摘要。这类保留记录在本文称为墓碑。

我们的 receipt 表把 result 设为 NULL 后，同键同参数返回 result_expired；它不会创建第二张工单，也不会凭空恢复已清掉的结果。同键不同参数仍返回 conflict。状态名称刻意没有叫 success：这个入口已经不能提供原始回执，调用方需要独立的业务查询或对账。

代码中的 result 实际只保存一个效果 ID，不是大响应正文；因此实验验证的是状态语义，不是节省了多少存储。业务 effect 表仍保留合成工单，我们也没有用它做隐藏的去重兜底。

```python
if old:
    if old[0] != digest:
        return {'status': 'conflict'}
    return {
        'status': 'replayed' if old[1] is not None else 'result_expired',
        'effect': old[1],
    }
```

摘要基于精确 UTF-8 字符串的 SHA-256。它不是通用 JSON 规范化方案，也不是认证签名。真实接口必须明确哪些参数参与比较、规范化如何版本化，以及碰撞与权限的处理；本实验按摘要未发生碰撞来讨论。

墓碑的限制很直接：每个旧操作仍占一行。清掉大答案，可以降低每条记录的负担，但并没有把无限操作压成有限状态。若接下来把墓碑也删了，最初那个“没做过还是忘了”的问题就回来了。

![删除全部回执会丢失重复识别；保留墓碑可以拒绝重复但不能返回原结果；代次封口用范围拒绝换取旧回执清理](https://ai-knowledgepoints.cn/images/articles/agent-reliability-06/retention-choices.svg)

这不是三个等价的缓存方案。它们分别保留了不同的行为承诺，也分别付出不同代价。

[查看竖版大图](https://ai-knowledgepoints.cn/images/articles/agent-reliability-06/retention-choices-mobile.svg)

## 3. 想删掉整批历史，先改变旧请求的准入规则

如果业务允许把旧批次彻底关闭，可以用一个范围边界替代这批逐条回执。每个命名空间保存 closed_through，意思是这个代次及以前的请求都不再接收。

假设边界从 0 推到 3，代次 1、2、3 的请求一律返回 closed。无论它有没有做过、参数是否变化、去重行是否还在，都不能重新创建效果。代次 4 及以后才继续查 receipt，并按原来的事务幂等规则执行。

这项规则不是“已经完成的旧任务不要重试”，而是更强、也更粗的承诺：**这个范围内，连从未到达的合法新工作也不要了。** 因此封口必须是业务协议中的终止决定，不能由一个只懂磁盘占用的清理脚本擅自作出。

本例中的 epoch 是批次范围，不是[第二篇](https://ai-knowledgepoints.cn/blog/agent-reliability-02-leases-fencing)的工作进程接管代次；closed_through 也不是墙钟 deadline。相同字段名称不能替代协议含义。这里没有实现生产代次分配器、租户身份认证或封口审批。

样例只接受受信任测试调用者传入的命名空间与正整数代次。真实系统若允许任意调用者把 1 改成 999，就等于让请求自己宣布“我不属于旧批次”。必须由可信任务状态决定批次，并校验该业务意图与批次的绑定。下面的代码没有提供这层防伪能力。

## 4. 封口、清理与业务写入必须在同一道门里排队

先删除 receipt，稍后再写 closed_through，会留下一个窗口：旧请求已经查不到回执，却还没有被边界拒绝。只检查边界然后释放事务，再做业务写入，也会留下窗口：请求检查时合法，清理完成后却继续产生效果。

实验使用同一个 SQLite 数据库。execute 在 BEGIN IMMEDIATE 内读取边界、检查回执、插入业务效果与回执；close_and_collect 在同类写事务内提高边界、删除旧回执。业务写入不能绕过这个入口。

```python
with connection(path) as c, transaction(c):
    row = c.execute(
        'SELECT closed_through FROM boundary WHERE namespace=?',
        (namespace,),
    ).fetchone()
    if epoch <= row[0]:
        return {'status': 'closed'}
    # 在同一事务内继续查回执、写效果、写回执
```

封口代码也不把“更新成功”与“清理成功”分成两次提交：

```python
c.execute(
    'UPDATE boundary SET closed_through=? WHERE namespace=?',
    (through, namespace),
)
c.execute(
    'DELETE FROM receipt WHERE namespace=? AND epoch<=?',
    (namespace, through),
)
```

[SQLite 的事务文档](https://www.sqlite.org/lang_transaction.html)说明，同一时刻只有一个写事务，BEGIN IMMEDIATE 从开始就申请写事务。因此这里有两个可解释的顺序：旧操作先提交，随后封口删除其回执但保留业务效果；或者封口先提交，旧操作看到边界后被拒绝。已经提交的效果不会被封口撤销。

本例使用 WAL 与 synchronous=FULL。[WAL 文档](https://www.sqlite.org/wal.html)限定共享内存协作等使用条件，不能把本机数据库实验直接搬到网络文件系统，或推导跨服务原子性。若业务动作是外部邮件或远端 API，这个 SQLite 事务管不到那里；需要接收端提供相应协议，不能靠本地一条边界记录代替。

## 5. 让旧进程真的等一等，再决定放它进来

实验驱动启动独立 Python 进程。子进程输出 READY，停在管道输入；父进程收到确认后执行清理，再写入 GO，让子进程进入 execute。没有用 sleep 猜测“它应该已经停住了”。十五秒超时只是测试卡死保护，不是业务期限或测量值。

第一组是故意错误的对照：先创建工单，再只删 receipt，最后放行旧请求。实测晚到请求返回 created，effect 表从一行变成两行。这证实“请求 ID 没变”本身不构成去重，接收端还得保有对应状态或拒绝规则。

第二组先封口并清理，再放行同一请求。实测返回 closed，原始效果保留一条。随后发送一个从未出现过、但属于旧代次的操作，同样返回 closed。最后发送代次 2 的新操作，才新增一条效果。这组最终效果数是二，来自旧批次的原工单和新批次的新工单，不是旧请求重放成功。

墓碑组则得到另一种结果：清掉 result 后，同键同参数返回 result_expired，改变参数返回 conflict，效果仍只有一条。把“不能返回结果”和“允许再执行”分开，才不会让缓存回收顺手改了业务语义。

![业务执行和封口清理都在同一数据库写事务内排序；先执行时保留已有效果，先封口时拒绝迟到写入](https://ai-knowledgepoints.cn/images/articles/agent-reliability-06/atomic-close.svg)

封口约束的是之后的接收行为，不会倒带已经提交的业务效果。事务外的外部动作不在这个保证里。

[查看竖版大图](https://ai-knowledgepoints.cn/images/articles/agent-reliability-06/atomic-close-mobile.svg)

## 6. 清理进程自己崩溃，不能留下一半规则

我们用 os._exit 真正退出子进程，分别停在边界更新后、回执删除后但提交前、封口事务提交后但回复前。重新打开数据库，不看进程最后说了什么，只看持久状态。

| 故障点 | 恢复后边界 | 旧回执行数 | 旧请求恢复行为 |
| --- | --- | --- | --- |
| 更新边界后、提交前退出 | 0 | 1 | replayed，不新增效果 |
| 删除回执后、提交前退出 | 0 | 1 | replayed，不新增效果 |
| 封口事务提交后、回复前退出 | 1 | 0 | closed，不新增效果 |

前两行回滚整个未提交事务；最后一行保留整个已提交事务。这里没有出现“回执没了，边界还是旧的”的半成品。补充测试用 SQLite 触发器故意拒绝删除，验证边界更新也一起回滚；触发器是测试故障注入，不是生产组件。

同样的检查也施加到业务事务：效果插入后、回执插入前退出，恢复后效果数为零；两者提交后、回复前退出，恢复重试返回 replayed，效果数仍为一。这样才能确认用于清理实验的基础去重入口本身没有先漏掉原子性。

这些是进程级故障与列出的事务边界，不是断电、磁盘丢写、损坏恢复或跨节点一致性测试。八线程、十六次同键调用的额外测试使用各自数据库连接，结果为一次 created、十五次 replayed；它检查一个并发用例，不代表穷尽所有调度，也不是吞吐基准。

## 7. 换批次能通过，不代表应该替旧任务换批次

测试还故意做了一次危险的“恢复”：代次 1 的 work-1 已有一个效果，封口后，把同名操作换到代次 2 再发。实测新增第二条效果。

实现的键是 namespace、epoch、op 三元组。换 epoch 就换了键，代码没有跨代次的全局业务去重。若一个动作必须跨批次保持唯一，需要稳定业务键、业务表唯一约束或其他可审计的终态协议。不能为了躲开 closed 自动生成新批次，也不能把这种改键行为包装成安全重试。

命名空间也一样。它用于隔离不同范围；测试中 demo 的封口不影响 other。生产里它应来自经过验证的身份与资源绑定，不能让调用者通过改命名空间逃避限制。即使使用哈希摘要，也不会自动获得身份认证。

另一个危险来源是恢复旧备份。若 closed_through 从 3 回到 0，已经关闭的请求会重新获准。代码拒绝普通接口把边界调小，也验证再次 init 不会重置已有边界；但它无法阻止运维恢复整份旧数据库。恢复协议必须把边界单调性当作业务状态的一部分，不能只把它看成可重建缓存。

封口还改变了查询能力：旧回执被删除后，仅凭 closed 无法知道某个操作以前是否执行。本文保留的业务表可用于后续对账，但没有实现完整查询服务；调用方不能把 closed 解释成“肯定没发生”。[第三篇的 unknown 对账边界](https://ai-knowledgepoints.cn/blog/agent-reliability-03-unknown-reconciliation)仍然适用。

## 8. 重跑十组场景，再检查你愿意失去什么

下载五个文件到同一空目录：[retention.py](https://ai-knowledgepoints.cn/examples/agent-reliability-06/retention.py)、[test_retention.py](https://ai-knowledgepoints.cn/examples/agent-reliability-06/test_retention.py)、[run_experiment.py](https://ai-knowledgepoints.cn/examples/agent-reliability-06/run_experiment.py)、[results.json](https://ai-knowledgepoints.cn/examples/agent-reliability-06/results.json)、[README.md](https://ai-knowledgepoints.cn/examples/agent-reliability-06/README.md)。使用 Python 3.10 或更新版本，无第三方依赖：

```bash
python3 test_retention.py
python3 run_experiment.py --check
```

本次运行环境为 Linux、Python 3.12.14、SQLite 3.53.1。30 项 unittest 全部通过；10 组具名场景重新创建临时数据库、执行真实进程，再与完整 JSON 比较通过。结果文件不包含随机目录、PID 或耗时，保留可比较的状态、退出码与效果数量。管道等待采用 selectors，本次未验证 Windows 管道兼容性。

这里减少的是逐操作 receipt 行，不是宣称整个系统占用恒定：业务 effect 行仍增长，命名空间边界也必须持久保留。没有测量磁盘回收量，没有实现 VACUUM、分布式垃圾回收、真实时钟保留窗口或备份恢复协议。封口范围的业务批准、身份认证、并发隔离与数据保留要求，都需要真实项目单独落实。

对需要长期接受旧任务的系统，永久或长期保留墓碑可能更合适。对允许明确结束一个批次的系统，范围封口可以替代那一批去重行。对只有第三方接口、无法控制接收协议的系统，只能遵守对方保留契约，在窗口之外查询或对账，不能照搬本例 SQL 取得并不存在的保障。

清理历史不是单纯的存储优化。删掉答案，意味着无法再从这个入口重现答案；删掉识别记录，可能意味着重新执行；以范围拒绝代替逐条记忆，则意味着连合法的迟到工作也一并拒绝。真正需要批准的，是这个行为变化，而不只是“数据库终于小了一点”。

继续讨论可查看[知识星球](https://ai-knowledgepoints.cn/planet)，或通过[公众号入口](https://ai-knowledgepoints.cn/about#wechat)关注更新。本文延续 Agent 执行系列；RAG 第六篇仍等待真实生成输出与可审查细粒度证据，不用本实验代替。

本文及原创图解、样例代码由 AI 辅助制作，并经过代码执行与内容核验。合成业务与本地事务实验不构成生产系统可靠性承诺。
