中文 English

AI Agent 明明只执行一次,为什么服务器却重启了两遍?我让 Worker 在最危险的 1 毫秒崩溃

发布时间: 2026-08-23 · 阅读量 --
AI Agent Idempotency 架构 平台工程 分布式 可观测性 工程实践

先说结论

用户只点了一次,队列却可能把任务交给 Worker 两次;Worker 只「想」执行一次,外部世界却可能真的收到两次重启、两封邮件甚至两笔扣款。最危险的窗口发生在:副作用已经成功,Worker 还没来得及保存 COMPLETED 就崩溃。 队列看不见外部世界,只能按「至少一次投递」重新交付。解决办法不是相信某个消息队列能魔法般提供端到端 Exactly-once,而是把持久任务状态机、稳定的幂等键、接收端唯一约束、租约、fencing token、Transactional Outbox、对账与补偿组合起来。

我写了一个只用 Python 标准库和本地 SQLite 的零网络实验。它先复现「一次意图、两次副作用」,再证明两次投递只产生一次安全副作用;随后演示 Worker 接管、旧 Worker 拒写、Outbox 在两处崩溃后恢复,以及不支持幂等的接收端如何补偿。全部是合成数据,不访问模型、服务器、凭据或第三方服务,最终 7/7 项验收通过。Windows 11、Ubuntu 26.04 和 macOS 26 的一键脚本均随文提供。

原创封面:一次请求为何变成两次重启,以及幂等接收端怎样拦住第二次副作用。

图 1:本文原创封面。标题中的「服务器重启」在实验中是本地数据库里的合成副作用,没有连接或操作任何真实设备。

1. 问题背景:用户的一次点击,不等于系统的一次投递

AI Platform 旗舰架构 中,我把 Worker 崩溃放进了生产验收,但那篇文章只展示了结果,没有把最危险的一毫秒完全拆开。这篇专门补上这块拼图。

先想象一家餐厅。你把写着「一份炒饭」的小票交给服务员,服务员送进后厨。厨师把炒饭做好放到出餐口,正准备在小票上盖「已完成」,突然停电了。来电后,系统只看到一张没有盖章的小票,于是又打印一张交给另一位厨师。第二位厨师并不知道第一份饭已经在出餐口,只能再炒一份。

用户会说:「我只点了一次。」队列会说:「我保证小票至少送到一次。」两句话都没错,但桌上真的出现了两份炒饭。

把炒饭换成业务动作,问题就不再可爱:

很多团队把这些叫作「模型重复调用」。但模型可能只生成了一次工具意图,真正重复的是持久任务的投递与副作用协议。如果只盯 Prompt 和聊天记录,很容易修错地方。

原创时间线:副作用成功与 COMPLETED 落库之间,就是最危险的崩溃窗口。

图 2:Worker 已经越过外部世界的「不可撤销线」,本地任务记录却仍停在 RUNNING。进程可能只消失一毫秒,但不确定性会一直留到有人对账。

2. 问题表现:为什么日志里每一行都像是「正常重试」

典型现场长这样:任务表中只有一个 task_id,第一次 Worker 领取任务,外部接口返回成功;Worker 随后失去进程、连接或电源,没有写入完成状态。租约到期后,第二个 Worker 领取同一任务,再次调用相同工具,最后顺利写入 COMPLETED

如果日志只记录「开始、失败、重试、完成」,运维人员看到的甚至是一条漂亮的恢复链:第一次失败,第二次成功。可用户看到的是两次动作。任务状态成功,不等于业务副作用只发生一次。

我在实验的朴素路径中故意把崩溃点放在 action=ACCEPTED 之后、COMPLETED 之前。第一次投递产生一次合成重启,恢复后第二次投递又产生一次:

真实实验输出:一次意图经两次投递产生两个朴素副作用。

图 3:真实本地实验输出截图。delivery=1 的副作用已被接受,随后注入崩溃;队列重投后 delivery=2 再次被接受,最终 observed_side_effects=2。实验为零网络合成逻辑。

这个窗口不只来自进程真的崩溃,还可能来自:

它们的共同点是:系统无法用一条本地布尔值同时证明「我记住了」和「外部世界只做了一次」。

3. 问题根因:把投递次数、执行尝试和业务结果混成了一个概念

3.1 At-least-once 保证的是小票会送到,不是菜只做一份

可靠队列通常宁可重复,也不愿丢失。消息没有按时 ACK、消费者掉线或租约到期,Broker 就再次投递。这叫 at-least-once delivery(至少一次投递)。它保证一条消息最终有机会被处理,却从未承诺业务动作只执行一次。

相反,at-most-once 选择「可能丢,但不主动重发」。对于只读缓存刷新也许可接受,对付款、审批、发布和基础设施操作通常不可接受。工程上的常见选择仍是 at-least-once,再让业务层把重复投递变成同一个结果。

3.2 「Exactly-once」必须先问:在哪个边界里?

某些数据库或流处理系统能在自己的日志、事务或消费—生产链内提供 exactly-once semantics。但一个 Agent 任务往往跨越队列、任务数据库、HTTP 工具、邮件平台、支付系统和真实设备。它们没有共享事务协调器,也不可能一起原子提交。

因此,「队列开启 Exactly-once」这句话不够完整。你必须继续问:

  1. Broker 内的消息只消费一次,还是外部接口只产生一次副作用?
  2. Worker 在外部成功后、ACK 前崩溃怎么办?
  3. 外部接口超时,结果到底是失败还是成功但响应丢了?
  4. 去重记录保存多久,晚到的重试还认不认识旧键?

端到端更现实的目标是:允许任务重复到达,但同一业务意图的已接受副作用最多一份;无法确定时不盲重试,而是进入查询、对账或补偿。

3.3 聊天记录不能承担业务状态

模型回复「已经重启」只是文本,不能代替事实记录。可靠任务至少要分清 PENDINGLEASED/RUNNINGCOMPLETEDUNKNOWNRECONCILING。聊天窗口关掉、上下文被压缩或模型换代,任务仍应能恢复。

原创任务状态机:不确定结果必须进入 UNKNOWN 与 RECONCILING,而不是假装失败后直接重试。

图 4:简化状态机。生产系统还会加入 WAITING_APPROVALFAILED_RETRYABLEFAILED_TERMINALCANCEL_REQUESTEDCANCELLEDCOMPENSATING;关键是每条边都有前置条件和版本。

这也呼应 12-Factor Agents 的原则:执行状态和业务状态必须统一,Agent 才能暂停、恢复和审计;把状态藏在 Prompt 里,只是在把数据库问题伪装成语言问题。

4. 第一层解法:为「业务意图」生成稳定幂等键

幂等的意思不是「禁止重试」,而是同一个请求重复执行,得到与执行一次相同的业务结果。最直观的比喻是快递单号:同一箱货在分拣中心扫描两次,系统可以记录两次扫描,但仓库只能发一箱。

一个实用幂等键通常绑定:

可以对规范化 JSON 做哈希:

canonical = json.dumps({
    "tenant": tenant_id,
    "task": task_id,
    "action": tool_name,
    "arguments": normalized_arguments,
    "version": "v1",
}, sort_keys=True, separators=(",", ":"))
key = hashlib.sha256(canonical.encode("utf-8")).hexdigest()

不要把 attempt、当前时间、随机 nonce 或每次变化的 trace ID 放进键里,否则每次重试都会变成一个「新快递单号」,去重形同虚设。也不要仅用工具名作为键,否则今天和明天的两个合法重启会互相吞掉。

4.1 唯一约束必须靠近副作用接收端

只在 Worker 内存里放 already_done = true 没用,进程死亡后它也消失。只在本地任务库中提前插入「已处理」同样危险:如果标记成功、外部调用尚未发生就崩溃,恢复进程可能因为命中标记而永远漏做。

真正有价值的是让产生副作用的一方接受幂等键,并用唯一约束原子地返回第一次结果。可以是支付平台支持的请求键、订单表上的业务唯一索引、对象存储的条件写、资源版本的 Compare-And-Swap,或工具适配器中的去重收件箱(Inbox)。

本地实验把 SQLite 表当成合成接收端,idempotency_key 是主键。第一次请求插入结果后崩溃,第二次请求仍带同一个键,因此不是再次执行,而是取回第一次结果:

真实实验输出:两次投递使用同一幂等键,第二次被去重。

图 5:真实本地实验输出截图。两次 delivery 的 key 完全相同;第一次 EXECUTED,第二次 DEDUPLICATED,最终副作用为 1,任务才写成 COMPLETED

4.2 幂等记录不是永久垃圾桶

幂等表至少保存 key、请求摘要、状态、结果摘要、首次时间、完成时间和过期策略。同一个 key 携带不同参数时应返回冲突,而不是偷偷复用旧结果。保留期必须覆盖队列最大重试时间、人工恢复窗口和消息可能滞留的最长时间;涉及支付、库存或审计时,通常还要遵守业务对账周期。

对于 IN_PROGRESS 记录,恢复者不能永远等待,也不能直接重做。它要结合租约、接收端查询和结果状态,决定继续等、接管、查询还是进入 UNKNOWN

5. 第二层解法:租约负责接管,Fencing Token 负责拒绝「复活的旧 Worker」

租约像图书馆借书证:Worker A 在一段时间内拥有任务;它按心跳续租,超过期限后 Worker B 可以接管。但借书证过期不会让 Worker A 的进程原地消失。它可能只是网络卡住、系统休眠或长时间暂停,恢复后仍继续执行。

如果只有租约,A 和 B 会在短时间内都认为自己可以写。租约解决「谁应该工作」,fencing token 解决「接收端现在还认谁」。 每次接管都获得单调递增的代数,例如 41、42。接收端记住见过的最大代数,任何小于它的写入都拒绝。

原创图:新 Worker 拿到更大的 fencing token,接收端拒绝旧 token 的迟到写入。

图 6:真正的门卫在接收端。仅仅告诉旧 Worker「你的租约过期了」不够,因为它可能听不见;资源本身必须执行条件写。

实验使用逻辑时钟避免依赖墙上时间:A 在 t=100 取得 token 1,租约到 105t=106 时 B 接管并取得 token 2。B 的写入先被接受,随后 A 带 token 1 醒来,接收端直接返回 REJECTED_STALE

真实实验输出:租约接管后旧 Worker 的迟到副作用被 fencing token 拒绝。

图 7:真实本地实验输出截图。注意 worker-a / worker-b 只是合成角色标签,不是计算机名。最终只有当前所有者能条件完成任务。

生产实现要注意三点:租约获取与 token 增长必须原子;时间判断应由同一个可信存储完成,避免各主机时钟漂移;接收端必须真的比较 token。只在日志里打印一个 token,并不会产生保护作用。

6. 第三层解法:Transactional Outbox 解决「业务已提交,消息却没发出去」

另一个常见缝隙发生在任务数据库与消息系统之间:业务状态已经提交,Worker 正要发布事件时崩溃。若先发消息后写业务,又会反过来出现「消息已发、业务没提交」。

Transactional Outbox 的做法像餐厅收银:订单与「需要通知后厨」的小票在同一本账里一次写入。独立 Dispatcher 不断扫描未发送的小票,发给下游后再标记。这样业务提交不会永久丢消息。

原创图:业务行和 Outbox 行在同一事务中提交,Dispatcher 可以安全重试。

图 8:Outbox 解决「不丢」,下游按 event_id 去重解决「不重复」。两者不是二选一。

实验连续在两个地方注入故障:先在本地事务提交后、发布前崩溃;Dispatcher 恢复并把事件送到下游,又在下游接受后、sent 标记前崩溃。第二次 dispatch 必然再次发送,但下游的唯一 event_id 把它去重:

真实实验输出:Outbox 在两处崩溃后恢复,下游最终只有一个副作用。

图 9:真实本地实验输出截图。两次 dispatch、一个 downstream effect。Outbox Dispatcher 自身仍是 at-least-once,因此下游 Inbox/幂等键不可省略。

Outbox 也不是万能分布式事务。它无法让不支持查询、幂等或撤销的外部动作自动变安全;它保证本地事实与待发送事件不分家,端到端仍要设计接收协议。

7. 外部系统不支持幂等怎么办:先查,再对账,最后才补偿

有些旧系统没有 idempotency key,也没有条件版本;请求超时后还无法查询结果。这时最危险的动作是「看到超时就原样再来一次」。超时只说明你没收到答案,不说明对方没做。

合理顺序是:

  1. 用业务查询接口按订单号、资源版本或时间窗口确认结果;
  2. 能人工判断时将任务转为 UNKNOWN / RECONCILING,暂停自动重试;
  3. 如果已经发生重复且动作可逆,执行有审计记录的补偿;
  4. 对不可逆动作采用人工介入、额度限制和更严格的前置审批;
  5. 推动接收端增加幂等键或条件写,而不是永远在调用方猜。

实验模拟一个不支持幂等键的预约端:第一次已创建,但 ACK 丢失;盲重试又创建一条。对账发现两条 ACTIVE,补偿取消重复项,最后只留一条:

真实实验输出:不支持幂等的接收端出现重复,随后对账并补偿。

图 10:真实本地实验输出截图。补偿是止损方案,不是 Exactly-once。取消邮件不能让收件人「没看见」,退款也不等于用户没有受影响,因此优先建设接收端幂等。

8. 一套可落地的生产执行协议

把上面拼起来,一次高风险工具调用可以按以下流程运行:

  1. 入口验证用户、租户和权限,为业务意图生成稳定 intent_id
  2. 模型只提出结构化 tool call,不直接执行;工具机制可先阅读 Tool Calling 与 MCP
  3. 策略引擎规范化参数、计算风险,必要时生成绑定参数与版本的批准记录;
  4. 事务内创建任务和 Outbox 事件,任务进入 PENDING
  5. Worker 原子获取租约与递增 fencing token,进入 RUNNING
  6. 由平台生成稳定 idempotency key,不相信模型临时编一个;
  7. 工具适配器把 key、fence、超时和最小权限凭据送到接收端;
  8. 若明确成功,保存结果摘要并条件转换为 COMPLETED
  9. 若明确可重试失败,按退避与预算重试,仍复用同一个 key;
  10. 若结果不确定,进入 UNKNOWN,查询、对账或补偿,禁止盲重试;
  11. 全链路写入 trace、审计和指标,但不记录 Secret 与原始敏感结果。

关键是把三个 ID 分开:intent_id 表示用户想做的一件事;attempt_id 表示某一次执行尝试;idempotency_key 表示哪些尝试必须收敛为同一个业务结果。把 attempt 当 intent,会让每次重试都变成新动作。

9. 可观测性:重复投递可以增长,重复副作用必须永远是零

传统监控经常只看队列消费成功率、任务完成率和重试次数。这些指标无法证明外部世界没有重复。至少需要同时观察:

每条证据至少串起 task、intent、attempt、幂等键摘要、工具版本、fence、租约所有者的逻辑 ID、结果来源与 trace ID。不要用完整请求参数或真实机器名换取「看起来详细」的日志。

真实实验指标截图:投递次数、副作用数、拒绝与补偿结果能同时核对。

图 11:真实本地 metrics.json 输出截图。朴素路径 2 次副作用,安全路径 1 次;Outbox 下游 1 次;网络调用与真实凭据均为 0。

最重要的 SLO 不是「没有重试」,而是:重复投递允许发生,重复已接受副作用必须为 0;不确定结果必须在规定时间内被对账;旧 Worker 的迟到写入必须被拒绝。

10. 一键实验:Windows 11、Ubuntu 26.04、macOS 26

完整实验由 worker_crash_lab.py 提供,只使用 sqlite3hashlibjson 和文件 API。它不会访问网络、读取环境变量、加载凭据或调用真实工具。详细边界见 README

10.1 Windows 11 PowerShell

下载 Run-Windows11.ps1 与 Python 脚本到同一目录,然后执行:

Set-ExecutionPolicy -Scope Process Bypass
.\Run-Windows11.ps1

脚本优先使用 Windows Python Launcher 的 py -3,否则使用 python.exe;不会自动安装软件或请求第三方服务。

10.2 Ubuntu 26.04

下载 run-ubuntu-26.04.sh 与 Python 脚本到同一目录:

chmod +x run-ubuntu-26.04.sh
./run-ubuntu-26.04.sh

10.3 macOS 26

下载 run-macos-26.sh 与 Python 脚本到同一目录:

chmod +x run-macos-26.sh
./run-macos-26.sh

三套入口都运行同一份标准库实验,并强制检查 7 行 [PASS]result=PASS--clean 只允许删除名称为 lab-outputreference-run 的结果目录,避免把清理范围放大。

10.4 人工自动执行

如果不使用入口脚本,也可以人工运行:

python3 worker_crash_lab.py --clean

然后打开 lab-output/results/06-acceptance-summary.txt,确认 7/7;再检查 参考运行 metrics.jsonnaive_side_effects=2safe_side_effects=1downstream_effects=1network_calls=0。朴素场景出现 [EXPECTED RISK] 不是实验失败,而是先证明故障确实可复现。

10.5 Agent 自动配置与验收

AGENT-PROMPT.md 交给可以执行本机命令的 Agent。它要求 Agent 先读脚本、确认安全边界,再选择当前系统入口;失败时保留证据并解释,禁止改写验收文件制造 PASS。

可直接使用这段短指令:

读取当前目录的 README、Python 实验和平台脚本;确认零网络、零凭据且只写 lab-output。按 Windows 11、Ubuntu 26.04 或 macOS 26 的对应入口运行。要求验收 7/7,并核对朴素副作用为 2、安全副作用和下游副作用都为 1;失败时停止并报告真实根因,不修改验收输出。

Agent 自动化像请一个执行力很强的实习生做实验:要给它实验台、边界和验收表,而不是只说「帮我证明幂等没问题」。

11. 验收结果:我到底证明了什么,又没有证明什么

原创验收矩阵:允许重复投递,不允许重复业务副作用。

图 12:发布闸门应检查业务不变量,而不只是 HTTP 200、进程存活或队列清空。

本次参考运行使用本地 Python 3 标准库,七项全部通过:危险窗口可复现;两次安全投递只有一次副作用;旧 Worker 被 fencing 拒绝;Outbox 两次 dispatch 只有一次下游效果;补偿后只剩一条有效预约;安全任务进入终态;隐私与网络边界为零。

真实实验输出:七项发布闸门全部 PASS。

图 13:真实本地实验输出截图,7/7 PASS。截图中的 Python 版本仅代表这次参考运行;实验没有声称替代真实队列、外部 API、并发压力或生产故障演练。

它证明的是协议逻辑和验收思路,不证明 SQLite 就是生产队列,也不证明所有远端工具都能被一个本地事务包住。真正上线前还要做多进程并发、真实 Broker 重投、数据库故障转移、长时间网络分区、接收端限流、幂等记录过期、人工审批超时和灾备恢复测试。

12. Q&A

Q1:队列承诺 Exactly-once,我还需要幂等键吗?

需要。先看承诺覆盖哪一段。Broker 内的消费/生产事务不等于邮件、支付、设备或 HTTP API 只产生一次副作用。只要外部世界不在同一个事务边界里,接收端幂等或业务唯一约束仍然需要。

Q2:为什么不在执行前把任务写成 COMPLETED?

那会把「重复」变成「丢失」。如果先写完成再调用工具,并在调用前崩溃,系统会永远认为事情做完了。正确做法是显式记录进行中状态,并依赖接收端幂等、查询和恢复协议。

Q3:幂等键由模型生成可以吗?

不建议。模型可能改变格式、漏字段或在重试时生成新值。平台应在确定性代码中根据已验证身份、业务意图和规范化参数生成;模型只能提供业务参数候选。

Q4:只给任务表加唯一约束够不够?

不够。任务表只能阻止重复创建任务,无法证明远端副作用是否执行。唯一约束要尽量靠近真正产生副作用的位置,或者由远端系统原生支持请求键与结果查询。

Q5:租约到期后直接杀死旧 Worker 不就行了?

在网络分区或进程暂停时,控制面未必能立即杀死它;即使发出终止信号,已经在途的请求也可能到达。Fencing token 让接收端根据代数拒绝旧写,保护不依赖旧 Worker 是否听话。

Q6:所有工具都需要同样严格的幂等协议吗?

风险不同。纯读取可以缓存与去重,但通常没有外部写副作用;可覆盖写、可追加写、不可逆写的策略应逐级变严。发通知与转账都可能重复,但影响和补偿能力完全不同。

Q7:失败后补偿就等于没发生过吗?

不是。退款可能产生手续费,取消邮件无法抹掉已读内容,第二次重启造成的中断也不能被「反向重启」消除。补偿是业务上的修正动作,必须单独审计和衡量,不应包装成 Exactly-once。

Q8:幂等记录可以保留多久?

至少覆盖队列最长滞留、退避重试、人工恢复和灾备重放窗口,再结合业务对账与法规要求。过早删除会让晚到重试重新执行;永久保存则带来容量和隐私问题,所以要按动作风险分层。

Q9:取消任务能阻止正在执行的副作用吗?

只有在外部动作尚未越过提交点且协议支持取消时可以。平台要区分 CANCEL_REQUESTEDCANCELLED;如果结果已经不确定,应进入对账,而不是向用户承诺「已取消」。

Q10:上线前最少要注入哪些故障?

至少包括副作用后崩溃、ACK 丢失、租约过期后旧 Worker 复活、数据库提交不确定、Outbox 发布后未标 sent、下游超时但实际成功、幂等键冲突与过期。每次都要同时核对任务状态和接收端副作用数量。

13. 继续阅读与参考资料

如果把本文放回完整 AI Platform,可以从 架构实践阅读地图 继续;旗舰总图见 AI Platform 生产参考架构,工具边界见 Tool Calling 与 MCP,状态与控制流原则见 12-Factor Agents

进一步参考:

最后把结论压成一句发布闸门:系统可以多看几次同一张小票,但厨房、仓库、银行和生产环境只能认同一个业务意图;如果暂时无法证明,就停下来查账,不能靠重试赌运气。

本文阅读量 --