> ## Content Index
> Fetch the complete content index at: https://www.hubwiz.com/blog/llms.txt
> Use this file to discover other available public pages before exploring further.

# Agent 上线复盘：重启失忆、重复退款、超时当失败
- URL: https://www.hubwiz.com/blog/agent-restart-replay-idempotency-retro/
- Published: 2026-10-22T16:04:42.000Z
- Updated: 2026-10-22T16:06:39.000Z
- Description: 重启后忘了自己在干什么，重试又把邮件发了两遍。三份生产复盘讲清楚：Agent 的可靠性问题不在推理，而在"决定"和"做成"之间。
- Author: admin
- Tags: AI Agent, 可靠性, 幂等, 生产实践

**一句话结论**：Agent 上线后最难处理的事故，几乎都不在推理层。模型想得很对、参数也没错，但**进程在"决定"和"做成"之间死了一次**——于是它要么忘了自己干到哪（失忆），要么把已经发生的事再做一遍（既视感）。三份一手复盘给出的答案不是"更聪明的重试"，而是三个更朴素的东西：**把运行写下来、记调用而不是记回答、把"不知道"当"发生过"处理**。

**披露**：本文编译自三篇公开实践记录——Anis Meziani《Amnesia and déjà vu: how our AI agents failed after every restart (and how we fixed it)》（dev.to，2026-10-13）与《Idempotency keys for AI agents: why hashing the arguments doesn't work》（2026-10-21），以及 Dimitris Kyrkos《Your agent's retry bug isn't an AI problem. It's a distributed-systems problem with an LLM on top.》（2026-10-19）。前两篇作者在做开源 Agent 运行时 ApowerB（thaink²，Apache 2.0），文中含项目介绍；第三篇是概念性分析，作者声明场景为示意而非真实事故。本文未做复测，代码与数字以原文为准。

---

## 一、三份复盘，同一个缺口

把三篇放在一起看，它们指向同一个位置：**「模型决定要做什么」和「世界真的被改变了」之间，有一段时间窗**。窗口里可能发生的事有四类，而大多数框架只处理了一类：

- 请求没发出去 → 安全重试
- 请求到了被拒绝 → 安全重试
- 请求到了、成功了、**响应丢了** → 重试就是重复执行
- 执行到一半进程被杀 → 连"做没做"都不知道

只有前两类可以盲目重试。第三类会产生两次退款、两封邮件；第四类连判断依据都没有。下面按三篇文章的顺序，先讲"怎么发现"，再讲"怎么修"。

## 二、事故一：一天重启 8 次，每次从零开始

ApowerB 团队做内部审计时，有个数字跳了出来：**一个部署平均每天重启约 8 次**。这本身很正常——每次发布重启、K8s 挪 Pod、节点被 drain，健康集群里进程本来就一直在生灭。

问题在于：**每次重启之后，所有会话都没了**。用户可能正跟 Agent 做到一半，下一条消息落在一个"从没见过他"的 Agent 上。

根因是一个默认值。他们建在 Google 的 Agent Development Kit（ADK）上，每个会话（session）是一串事件：用户说了什么、模型答了什么、调了哪个工具、返回了什么。**这串事件就是 Agent 的短期记忆**。而如果没配"会话存哪儿"，ADK 会退回**内存 session service**——作者的原话："基本上就是一个 Python dict。笔记本上完美，进程一重启就蒸发。"

第二种失忆更像段子：他们的可视化工作流编排器（画布上串 Agent，前一个的输出喂给后一个）**原本跑在浏览器里**。浏览器就是编排器——所以关掉标签页，工作流就……停在半路，且没有任何"它停在哪"的记录。

第三种最根本：整个产品里**只有一条路能重试失败的运行**——收进来的邮件，因为 webhook 队列留了 payload 和尝试次数。其余全部（画布发起的、定时的、API 调的）**失败后什么都不留**。工作流路由器的状态甚至就存在一个 Python dict 里，流一结束就清空。

> 他们的总结值得抄下来：**缺的不是可观测性，是输入。** 日志、trace、token 数都在，失败看得一清二楚——但没法重做，因为不知道当初让它做什么。

修法四条，成本都不高：

1. **把会话搬进 Postgres**：`session_service_uri` 指过去，长期记忆同样落库，并**按 Agent + 用户隔离**（一个用户的记忆不会漏给另一个）。
2. **管住连接池**：默认每 worker 最多 15 条（SQLAlchemy 5 + 10 overflow），在共享库上很快堆起来；他们压到 **5**。
3. **给事件表设保留期**：ADK 的库版 session service **没有保留策略**，每回合一行完整 JSON，放着不管会慢慢吃掉数据库；现在每天删 **90 天前**的事件，会话壳保留、只丢旧回合细节。
4. **编排器搬出浏览器**：改到服务端，用 ADK 自己的 Workflow 原语（串行、扇出、汇合），每步发 `step_start` / `step_complete` / `step_error`，结果在 `finally` 里落库。注意——**关标签页仍然会取消运行**（这是故意的，取消会传播给子工作流与重试等待，不让任何东西在后台无人看管地跑）；变的是**运行不再消失**：它被记成 `cancelled`，带着输入，可以重放。

## 三、事故三：超时被当成"没做"

Dimitris 的文章从一个"非常普通"的场景开始（他明确说是示意，不是真实事故）：Agent 处理工单，判断客户该退款，调 `POST /refunds`。支付服务处理完了、写了账、开始回响应；网络抖了一下，Agent 的 HTTP 客户端超时抛错。

**在 Agent 看来调用失败；在支付服务看来已经成功。** Agent 的循环看到错误，最自然的动作就是再试一次——客户收到两笔退款。

模型推理没有问题。它选对了动作、给对了参数。改进提示词、换更大模型、加更强的规划器，都救不了这个场景，因为**失败发生在推理结束之后**。

他列了五种失效模式，其中三种和后文直接相关：

- **把超时当"否"**：框架通常把工具错误统一包成"工具失败了，你决定下一步"，于是三种完全不同的情况被塌成一种（没到 / 被拒 / 成功但响应丢了）。
- **没有幂等键**：有键的话，第二次退款请求会变成返回原结果的空操作。关键是**键必须由应用在第一次尝试之前生成**，并在同一意图的每次重试里复用。**模型生成、或每次重试重新生成，都不保护任何东西**。
- **状态只存在于上下文窗口**：如果"我已经试过退款 4812"只记在对话历史里，崩溃、上下文截断、worker 重启都会让它消失。
- **没有"结果未知"状态**：多数动作模型只有成功/失败两个终态。可靠实现至少要四个：`submitted`（发出去了，还没答复）、`confirmed`（下游确认了）、`failed`（下游明确拒绝）、**`unknown`（说不清）**——而 `unknown` 必须**阻断自动重试**并触发对账。

他的重构很干脆：**Agent 工具调用本质是 RPC，只是由 LLM 来选择调哪个过程**。所以 RPC 的老规矩全部适用：幂等、持久化意图日志、显式不确定性、对账。分工则是：**模型提出动作，应用拥有执行状态**（键、意图记录、状态、重试策略）。"再试一次"对读操作是合理默认；对任何改变状态的操作，只有在你确知第一次没落地、或下游保证不会重复应用时，才是安全的。

## 四、第二轮复盘：默认拒绝卡在"跑到一半"

ApowerB 的第一版修法非常保守：**只要这次运行调用过任何工具，就拒绝自动重放**——读操作也一样。理由是推理纯度不可信：打在限流或有状态 API 上的 `GET` 不是免费的；"纯查询"顺手写一条审计行就不纯了；第三方 MCP 服务器自称只读，你没法验证。

保守的代价马上就来了。他们的邮件活动技能在发送前有人工闸门（起草完必须显式批准），批准后循环给收件人逐个发。**如果 Pod 在第 40 封时死掉**：重放被拒——正确，但没用，剩下 60 个人永远收不到；强行重放——前 40 个人收到两封。

> 结论一句话：**运行不是合适的单位，那封邮件才是。**

## 五、为什么"从第 3 步接着跑"不成立

面对重复执行，第一反应是"别从头来，从断点续跑"。传统工作流引擎可以这么干，因为步骤固定且确定：第 3 步永远是第 3 步。

**对 LLM Agent，没有"第 3 步"。** 那个"计划"只存在于那一次运行的推理里；再跑一次，模型可能走另一条路、换一个工具顺序、甚至决定要一个上次没用过的工具。你没法在跳过已做调用的前提下重放一个 Agent，因为**你没法重放导致那些调用的推理**。

所以团队换了问题：不再问「**怎么续跑**」，而问「**再开一次安全吗**」。回答这个问题只需要一条信息：**这次运行碰过外部世界没有？**

## 六、实现：一张表 + 一个"记调用不记回答"的决定

**每次运行开跑就写一行**，字段不多但每个都有用：

| 字段                                 | 作用                                              |
| ---------------------------------- | ----------------------------------------------- |
| run\_id / trigger / owner\_id      | 谁、因为什么触发（workflow / schedule / chat / webhook）  |
| config                             | **输入。没有输入就没有重放**                                |
| input\_file\_path                  | 带文件的运行，**开跑就把文件复制到磁盘**——只存指针的话，用户删了文件，重放就永远做不成了 |
| status / attempts / error\_message | running / success / error / cancelled           |
| replay\_of                         | 这是一次重放，指向原始运行（**永远不覆盖历史**）                      |
| tools\_executed                    | 下文的核心                                           |

然后是整套设计里最关键的一行代码注释：统计工具调用时，**算调用（`functionCall`），不算回答**。原因作者写在 docstring 里——「一个中途崩掉的工具，可能已经产生了副作用，却从没来得及回答」。如果只统计"有回答的工具"，你会得出"什么都没发生"，然后把它再发一遍。

崩溃后响应丢了，拿不到事件列表怎么办？**因为会话已经落在 Postgres 里了**，可以直接读会话、从中抽出工具调用。有个细节：聊天会话包含整段对话，里面有**之前几轮**调过的工具，那不是本次运行的副作用，所以只看**用户最后一条消息之后**发生的事。

这也解释了为什么"失忆"和"既视感"是同一个问题：**失忆不只丢掉对话，它还毁掉了你判断"能不能重来"所需的证据**。

## 七、三个状态：`[]`、`[...]`、`NULL`

`tools_executed` 只有三种取值，其中两个的差别就是整套安全模型：

- `[]`：我们查过了，**没有工具被调用** → 安全重放
- `["send_email", "update_crm"]`：查过了，这些调过 → 重放会重复它们
- `NULL`：**没查成**（比如会话读不出来）→ 不知道

> 把"不知道"当成"大概没事"非常诱人。我们的处理是当成"大概有事"，因为**错的代价不对称**：拒绝一次安全的重放，成本是点一下；放过一次不安全的重放，成本是客户收到第二封邮件。

## 八、重放规则表

所有重放走同一个端点 `POST /runs/{run_id}/replay`，规则一张表说完：

| 原始运行的状态     | 请求重放时                          |
| ----------- | ------------------------------ |
| 成功过         | **拒绝**。副作用已经发生                 |
| 还在跑         | **拒绝**。等它落定                    |
| 失败，且确知没调过工具 | **重放**。没有要重复的东西                |
| 失败，但调过工具    | **拒绝**，并列出调过哪些工具               |
| 失败，且判断不了    | **拒绝**。**unknown is not none** |

拒绝不是静默的，而是 **HTTP 409 + 一句人能照着办的话**：「这次运行失败前是否调用过工具无法确认，重放可能重复副作用。确实要跑，请传 `force=true`。」`force=true` 是**故意留的**——有时候重复一次正好是你想要的，但那应该是读过这句话的人来决定，而不是凌晨三点的自动重试循环。

另外三条规矩同样值得抄：

- **重放是一次新运行**，指向原运行（`replay_of`），不覆盖历史。重放再失败，也能和原失败区分开。
- **重放在全新会话里开始**。原会话可能留着半截交换；对聊天来说那是用户自己的对话，重放没资格往里写。
- **绝不重放"差不多"**。输入文件如果已经不在了，直接 **410 Gone**——没有它就跑，会产出一个"你以为在复现、实际已经变了"的运行。

## 九、被 K8s 杀掉的运行，会永远"运行中"

最后一个坑很隐蔽：运行靠 `finally` 标记结束，但 **K8s 杀 Pod 时 `finally` 不执行**，那行状态就永远是 `running`——而"还在跑"的运行永远不许重放。

修法是读运行时就**对账**：状态是 `running` 且超过 **6 小时**没有完成记录，就改成 `error`，消息写明「6 小时内没有完成记录，实际结果未知」。`WHERE status = 'running'` 这个条件让多副本同时对账也安全（只有第一次写入生效）；6 小时刻意留得很宽，避免误杀真正长跑的任务。对账之后，它走**和其它失败完全一样的安全规则**——如果那封邮件已经发过，就不会再发。

**什么还会自动重试**（也就是说，"保守"不等于"什么都不自动"）：

- **空模型回合**：按定义不含任何工具调用，重问一次不可能重复副作用，重试一次再走兜底。
- **事件触发的运行**：走持久化队列，每个事件处理一次，崩了由别的 worker 接手，重试退避且到上限停止。

作者还诚实地留了个脚注：ADK 每次请求都会把用户消息追加进会话，所以空回合重试会让那条消息在历史里出现**两次**。目前罕见且无害，但在长期会话的观察名单上。

## 十、为什么 `hash(参数)` 做幂等键不行

第二篇复盘是被读者追问出来的：既然"默认拒绝"会卡在半路，为什么不把安全判断**下推到工具**，每个有副作用的工具自己算幂等键？

教科书做法是 `sha256(json(args))`，运行时见过这个键就跳过。**对普通程序成立，对 LLM 悄悄失效，因为参数本身不稳定。**

第一次调用：

```json
{"to":"anna@acme.com","subject":"Following up on your request","body":"Hi Anna, following up on the quote you asked for..."}

```

同输入、同 Agent 的重放：

```json
{"to":"anna@acme.com","subject":"Your quote","body":"Hello Anna, I'm getting back to you about your quote..."}

```

主题变了、正文变了、哈希变了。账本从没见过这个键——**Anna 收到两封，而你的幂等层报告一切正常**。作者把读者那句话往下推了一层：「第 3 步不是稳定坐标」，**参数也不是**。

规则是：**键只能由稳定意图构成，绝不用生成内容。**

| 参数种类     | 例子                                | 进键吗    |
| -------- | --------------------------------- | ------ |
| 外部世界给的身份 | 事件 id、消息 id、会话 id、记录 id、规范化后的收件地址 | **进**  |
| 从输入确定性派生 | 上传文件内容的 SHA-256                   | **进**  |
| 模型生成     | 正文、主题、摘要、任何自由文本                   | **永不** |

键要回答的是「**这件事、为这个原因、对这个对象，我做过没有**」，不是「这些字节我发过没有」。对邮件活动就是 `key = (触发事件, 工具, 收件人)`：重放可以整封重写，Anna 的键不变，于是跳过她。

## 十一、已经在生产跑的两处

这不是纸上设计，只是还没推广到全部工具：

1. **Webhook：键就是对方给的身份**。微软 Graph 重投通知时，`(subscription_id, resource_id)` 上的唯一索引直接挡掉重复插入，消息只处理一次。**键里没有任何生成内容**。
2. **BI 数据集：键从内容派生**。Agent 导入文件成数据集时，ID 不是随机的，而是 `uuid5(owner | org_id | project_id | folder | name | sheet | sha256(文件字节))`。同一个文件跑两次只有一个数据集；改一个字节就是新的。**注意键里没有"显示名"**，因为那是格式化文本。

坑也随之而来，而且很有普适性：**用户把那个数据集删了怎么办**？软删除会留着行，确定性 ID 仍被占用，插入冲突。他们捕获 `IntegrityError`，回退到随机新 ID。教训是——**幂等键会比它指向的东西活得更久**，账本必须能区分「已经做过」和「做过、然后被撤销」。

## 十二、"构造上只读"才算只读

既然不可信"声明"，就只信"做不到"。他们目前只对三个工具放心标为可重放，因为保证写在工具代码里，而不是写在旁边的标志位上：

- **Oracle**：开 `SET TRANSACTION READ ONLY`，结束回滚
- **MongoDB**：拒绝含写阶段的聚合管道（`$out`、`$merge`）
- **Redshift**：执行前校验 SQL 是只读

## 十三、正在收敛的设计（未落地）

作者明确标注这部分是**提议不是已上线**，四条：

1. **工具声明自己的效果，默认按最坏情况算**：`effect="none"`（构造上只读）/ `effect="idempotent"` 并给一个从稳定意图算 key 的函数 / **不声明就当不安全**（所有第三方工具都属于这类）。
2. **效果账本 + 唯一约束**：幂等工具执行前先查账本，命中就返回存下的结果、不再执行；没命中就执行再记账。跟 webhook 用的同一个技巧，用到每个副作用上。
3. **未声明保持默认拒绝**。声明 `idempotent` 是"一个工具一个工具地挣来"自动重放权。
4. **下游支持就透传**：像 Stripe 的 `Idempotency-Key` 头，键要一路送到供应商，这样即使崩在"调完 API"和"写账本"之间，也能在对方那边去重。

这样那个死在第 40 封的活动就能干净重放：**40 次账本命中、60 次真发、没人收到两封。**

## 十四、他自己也还没答好的四个问题

1. **每个工具的键里到底放哪些字段**：邮件是 `(事件, 收件人)`；"把摘要发到 Slack"是 `(事件, 频道)` 吗？如果同一个 Agent 合法地要给同一个人、同一事件发两封不同的邮件呢？
2. **重放走了另一条分支怎么办**：模型第二次可能决定发给另一批人。按对象建的键能处理重叠，但"第一次发了、第二次漏了"的人怎么算？
3. **账本记多久**：永远记住是错的（存储成本，而且"下个月再发一封"是合法需求）；记太短又重开重复窗口。
4. **副作用与写账本之间的崩溃窗口**：供应商不支持原生幂等时，这个缝一直在。"先记意图、后记确认"加对账，够不够？

评论区还补了两刀，作者接受了：**时间桶要从触发源取**（用自然日会翻车——23:59 崩、00:01 重放，键就变了）；**按效果建键，不按决策建键**（第一次发了 Anna、第二次跳过她，"对 Anna 这件事"已经做过，所以她仍应被跳过）。至于账本"记多久"，他的答案不是 TTL，而是**每个键存状态**（pending / done / revoked），这样"产物被删掉后要合法重做"也能表达。

## 十五、上线前自检 9 条

- \[ \] 会话存在内存以外的地方吗？
- \[ \] 长期记忆是按 Agent **和**用户分别隔离的吗？
- \[ \] 每次运行是否在**开跑前**就写下、且**带着输入**？
- \[ \] 输入文件是复制了，还是只存了指针？
- \[ \] 记录的是工具**调用**（包括那些从没回答的），还是只记回答？
- \[ \] 有没有把"不知道"当"不安全"处理？
- \[ \] 重放是不是一次**指向原运行的新运行**、且跑在新会话里？
- \[ \] 被中途杀掉的运行，会被对账成 error，还是永远卡在 running？
- \[ \] 旧事件会清理、旧消息里的大 payload 会剥掉吗？

顺带说一个容易忽略的成本项：ADK 每回合都把**完整对话历史**发给模型。一个把 PDF 转成图片再读的工具，每页 base64 大约 **10 万–15 万 token**；五轮之后，同一张图被重复付费了五次。他们的做法是每回合调模型前用回调把**除最新一条之外**所有消息里的大 base64 剥掉，换成 `<stripped: N chars>`——模型已经看过那张图了，从下一轮起，**它自己关于这张图的文字就是事实来源**。

## 十六、边界

- 这是**一个团队在一个运行时上的实践**（ApowerB / ADK / Postgres / K8s），不是框架级标准；换成别的 Agent 框架，默认值不同，坑的形状也不同。
- 目前做到的是**安全重放**（要么因为无害而重开，要么带着解释拒绝），**不是真正的续跑**（复用已执行工具的结果、中途接手）。作者自己说这是下一道坎。
- 键的设计部分**尚未落地**，四个开放问题没有公认答案；评论区方案同样未经大规模验证。
- 第三篇的场景是示意的，不是事故记录；本文引用的"数字"里，8 次/天、40/100 封、6 小时、90 天、10–15 万 token 都来自前两篇的一手复盘。

作者给的三句话，比任何架构图都好记：

> **默认值是给 demo 写的。** 内存会话在第一次重启前完美无缺。检查每一个"存状态"的默认值。 **没查过，就别信"什么都没发生"。** 不知道不等于没有。 **一句人能看懂的拒绝，胜过一次聪明的自动重试。** 用户会原谅"这封邮件已经发过了，确定再来一次？"，不会原谅第二封邮件。

---

**相关阅读**：[Agent / MCP / 自动化完全指南](https://www.hubwiz.com/blog/agent-stack/) ｜ [Agent 审计：行动者不能当记录者](https://www.hubwiz.com/blog/agent-audit-logs-witness-suspect/) ｜ [Agent 安全事故九起与六项控制](https://www.hubwiz.com/blog/agent-security-nine-incidents-six-controls/) ｜ [Bash 拒绝规则为什么拦不住：4 次 shell 变量绕过](https://www.hubwiz.com/blog/claude-code-bash-deny-rule-bypass/) ｜ [Agent 说"做完了"，数据库说没有](https://www.hubwiz.com/blog/agent-delivery-verification-gates/)

**Sources**

- Anis Meziani, *Amnesia and déjà vu: how our AI agents failed after every restart (and how we fixed it)*, dev.to, 2026-10-13：[https://dev.to/anis\_meziani\_52aab42304a8/amnesia-and-deja-vu-how-our-ai-agents-failed-after-every-restart-and-how-we-fixed-it-5c80](https://dev.to/anis%5Fmeziani%5F52aab42304a8/amnesia-and-deja-vu-how-our-ai-agents-failed-after-every-restart-and-how-we-fixed-it-5c80?ref=hubwiz.com)
- Anis Meziani, *Idempotency keys for AI agents: why hashing the arguments doesn't work*, dev.to, 2026-10-21：[https://dev.to/anis\_meziani\_52aab42304a8/idempotency-keys-for-ai-agents-why-hashing-the-arguments-doesnt-work-cap](https://dev.to/anis%5Fmeziani%5F52aab42304a8/idempotency-keys-for-ai-agents-why-hashing-the-arguments-doesnt-work-cap?ref=hubwiz.com)
- Dimitris Kyrkos, *Your agent's retry bug isn't an AI problem. It's a distributed-systems problem with an LLM on top.*, dev.to, 2026-10-19：[https://dev.to/cyclopt\_dimitrisk/your-agents-retry-bug-isnt-an-ai-problem-its-a-distributed-systems-problem-with-an-llm-on-top-1i6k](https://dev.to/cyclopt%5Fdimitrisk/your-agents-retry-bug-isnt-an-ai-problem-its-a-distributed-systems-problem-with-an-llm-on-top-1i6k?ref=hubwiz.com)
- 项目代码：[https://github.com/apowerb/apowerb](https://github.com/apowerb/apowerb?ref=hubwiz.com)（Apache 2.0）

*本文由 hubwiz 编译整理，转载请注明出处。*

> **这是「Agent / MCP」专题的一部分。**完整地图见：[Agent / MCP / 自动化完全指南](https://www.hubwiz.com/blog/agent-stack/)