Agent 上线复盘:重启失忆、重复退款、超时当失败
重启后忘了自己在干什么,重试又把邮件发了两遍。三份生产复盘讲清楚: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 数都在,失败看得一清二楚——但没法重做,因为不知道当初让它做什么。
修法四条,成本都不高:
- 把会话搬进 Postgres:
session_service_uri指过去,长期记忆同样落库,并按 Agent + 用户隔离(一个用户的记忆不会漏给另一个)。 - 管住连接池:默认每 worker 最多 15 条(SQLAlchemy 5 + 10 overflow),在共享库上很快堆起来;他们压到 5。
- 给事件表设保留期:ADK 的库版 session service 没有保留策略,每回合一行完整 JSON,放着不管会慢慢吃掉数据库;现在每天删 90 天前的事件,会话壳保留、只丢旧回合细节。
- 编排器搬出浏览器:改到服务端,用 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 悄悄失效,因为参数本身不稳定。
第一次调用:
{"to":"anna@acme.com","subject":"Following up on your request","body":"Hi Anna, following up on the quote you asked for..."}
同输入、同 Agent 的重放:
{"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 的键不变,于是跳过她。
十一、已经在生产跑的两处
这不是纸上设计,只是还没推广到全部工具:
- Webhook:键就是对方给的身份。微软 Graph 重投通知时,
(subscription_id, resource_id)上的唯一索引直接挡掉重复插入,消息只处理一次。键里没有任何生成内容。 - 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 是只读
十三、正在收敛的设计(未落地)
作者明确标注这部分是提议不是已上线,四条:
- 工具声明自己的效果,默认按最坏情况算:
effect="none"(构造上只读)/effect="idempotent"并给一个从稳定意图算 key 的函数 / 不声明就当不安全(所有第三方工具都属于这类)。 - 效果账本 + 唯一约束:幂等工具执行前先查账本,命中就返回存下的结果、不再执行;没命中就执行再记账。跟 webhook 用的同一个技巧,用到每个副作用上。
- 未声明保持默认拒绝。声明
idempotent是"一个工具一个工具地挣来"自动重放权。 - 下游支持就透传:像 Stripe 的
Idempotency-Key头,键要一路送到供应商,这样即使崩在"调完 API"和"写账本"之间,也能在对方那边去重。
这样那个死在第 40 封的活动就能干净重放:40 次账本命中、60 次真发、没人收到两封。
十四、他自己也还没答好的四个问题
- 每个工具的键里到底放哪些字段:邮件是
(事件, 收件人);"把摘要发到 Slack"是(事件, 频道)吗?如果同一个 Agent 合法地要给同一个人、同一事件发两封不同的邮件呢? - 重放走了另一条分支怎么办:模型第二次可能决定发给另一批人。按对象建的键能处理重叠,但"第一次发了、第二次漏了"的人怎么算?
- 账本记多久:永远记住是错的(存储成本,而且"下个月再发一封"是合法需求);记太短又重开重复窗口。
- 副作用与写账本之间的崩溃窗口:供应商不支持原生幂等时,这个缝一直在。"先记意图、后记确认"加对账,够不够?
评论区还补了两刀,作者接受了:时间桶要从触发源取(用自然日会翻车——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 / 自动化完全指南 | Agent 审计:行动者不能当记录者 | Agent 安全事故九起与六项控制 | Bash 拒绝规则为什么拦不住:4 次 shell 变量绕过 | Agent 说"做完了",数据库说没有
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
- 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
- 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://github.com/apowerb/apowerb(Apache 2.0)
本文由 hubwiz 编译整理,转载请注明出处。
这是「Agent / MCP」专题的一部分。完整地图见:Agent / MCP / 自动化完全指南