跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客GitHub 精选镜像AI 生图工具UI配色美学关于
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
编程语言Agent RuntimeRetryIdempotencyTool Runtime生产事故

《Agent Runtime 工程化》Agent 生产事故 #001:Retry 为什么会导致重复下单?

Agent 生产事故 001:Retry 为什么会导致重复下单? 凌晨两点,客服群里开始有人截图。 同一个用户,同一个购物车,出现了两张订单。金额一样,收货地址一样,创建时间差 18 秒。业务同学第一反应是接口重复提交。后端第一反应是前端按

陈堂会发布于 —2 浏览
《Agent Runtime 工程化》Agent 生产事故 #001:Retry 为什么会导致重复下单?

Agent 生产事故 #001:Retry 为什么会导致重复下单?

凌晨两点,客服群里开始有人截图。

同一个用户,同一个购物车,出现了两张订单。金额一样,收货地址一样,创建时间差 18 秒。业务同学第一反应是接口重复提交。后端第一反应是前端按钮没防抖。Agent 团队看了 trace,沉默了几分钟。

案发链路是这样的:

用户:帮我把购物车里的东西下单
  ↓
Agent 调用 create_order(cartId=998)
  ↓
HTTP client 30 秒超时
  ↓
Tool Runtime 返回 TIMEOUT
  ↓
Agent 推理:订单创建失败,重试一次
  ↓
再次调用 create_order(cartId=998)
  ↓
两张订单

这不是模型'突然发疯'。它只是按你给它的观察继续行动。

你告诉它:工具超时了。你没有告诉它:这个请求可能已经到达服务端,也可能已经创建成功,只是响应没回来。你也没有给它 idempotency key,没有 tool ledger,没有恢复查询,没有把这次失败标成 SIDE_EFFECT_UNCERTAIN。

所以它做了一个很像人的判断:失败了,再试一次。

坏就坏在这里。

Retry 本来是可靠性手段

在普通后端系统里,retry 很常见。

网络抖一下,重试。上游 503,退避重试。模型 provider 短暂限流,等几秒再请求。读缓存失败,换节点。大多数工程师都写过这种代码:

async function retry<T>(fn: () => Promise<T>, maxAttempts = 3) {
  let lastError: unknown;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;
      await sleep(500 * attempt);
    }
  }

  throw lastError;
}

这段代码在很多地方没问题。

但它有一个隐藏前提:你重试的操作要么没有副作用,要么有办法识别'这是同一个业务请求'。

HTTP 规范里,GET、HEAD、PUT、DELETE 这类方法被定义为幂等方法。注意,幂等不等于'没有副作用',而是同一个请求执行多次,预期效果和执行一次相同。GET 理论上安全,PUT 通常可以重复设置同一个资源状态,DELETE 重复删除同一个资源也应该得到相同结果。

POST 就麻烦得多。创建订单、发邮件、扣款、发券、创建 issue、合并 PR、执行数据库脚本,很多都是 POST 风格的动作。重复一次,世界真的会多一个东西。

Agent Tool Runtime 的坑在于:模型看不见这些语义。它只看见一个工具名和一段 observation。

如果 observation 只有:

{ "ok": false, "error": "timeout" }

模型很可能继续尝试。它甚至会觉得自己很负责。

真正危险的是'结果未知'

很多人把失败分成成功和失败。

生产系统不是这样。生产系统里最难处理的是第三种状态:不知道。

请求没发出去:可以重试
请求失败且无副作用:可以重试
请求成功:不能重试
请求发出去了,但响应丢了:不知道

订单事故通常发生在最后一种。

你的 HTTP client 超时,只说明客户端没在规定时间内收到响应,不说明服务端没处理。也许服务端已经写入订单表,只是返回慢了。也许下游支付系统已经拿到请求。也许消息已经发进队列。

这时候,如果 runtime 把它归类成普通 TIMEOUT,Agent 就会按'失败可重试'处理。更准确的分类应该是:

type ToolErrorCode =
  | "INVALID_ARGUMENTS"
  | "NOT_FOUND"
  | "PERMISSION_DENIED"
  | "TIMEOUT"
  | "TOOL_CRASH"
  | "OUTPUT_TOO_LARGE"
  | "SIDE_EFFECT_UNCERTAIN";

SIDE_EFFECT_UNCERTAIN 这个名字不好看,但很有用。

它告诉模型和 runtime:别急着重放。先确认外部世界发生了什么。

Stripe 和 AWS 早就把坑踩透了

Stripe 的 API 文档里建议对创建或更新请求使用 idempotency key。客户端第一次请求生成一个唯一 key,网络错误后用同一个 key 重试。服务端看到同一个 key,就返回第一次请求的结果,而不是再创建一个对象。

AWS Builders Library 也专门讲过'让重试安全'的问题。核心不是'遇到错误就重试',而是让服务端能识别客户端意图:这次 retry 是同一个业务动作,还是用户真的发起了第二个动作。

这个经验搬到 Agent 里,结论很直接:

有副作用的 tool call,必须有业务级幂等语义。

不是随便 hash 一下参数就完事。

create_order(cartId=998) 这类工具,比较靠谱的 idempotency key 应该和用户、购物车、业务意图、run id 或用户确认动作绑定。例如:

function buildOrderIdempotencyKey(input: {
  userId: string;
  cartId: string;
  runId: string;
  approvedAt: string;
}) {
  return `order:${input.userId}:${input.cartId}:${input.runId}:${input.approvedAt}`;
}

如果用户真的想再下一单,那应该生成新的业务意图和新的 key。否则 retry 就必须复用旧 key。

这里有个细节:不能只让模型自己生成 key。key 的生成应该在 runtime 或业务服务里完成。模型可以说明意图,不能负责幂等一致性。

Tool Ledger:Agent 的操作账本

我在《Agent Runtime 工程化》里反复强调 tool ledger,因为它能解决很多'恢复后到底发生了什么'的问题。

工具执行前后都要记账:

type ToolLedgerEntry = {
  callId: string;
  runId: string;
  stepId: string;
  toolName: string;
  inputHash: string;
  idempotencyKey?: string;
  status:
    | "planned"
    | "approved"
    | "started"
    | "succeeded"
    | "failed"
    | "uncertain";
  riskLevel: "read" | "write" | "network" | "destructive";
  idempotency: "idempotent" | "conditional" | "non_idempotent";
  replayPolicy: "auto" | "ask" | "never";
  sideEffects: string[];
  startedAt?: string;
  finishedAt?: string;
  resultRef?: string;
};

注意 started 和 uncertain。

很多 demo 只在工具执行完成后记录结果。这样一旦进程在等待响应时崩了,账本里就像什么都没发生。恢复时 Agent 重新规划,可能又执行一次同样的工具。

正确做法是:

工具计划生成后:记录 planned
用户审批后:记录 approved
开始调用外部 API 前:记录 started
收到明确成功:记录 succeeded
收到明确失败且无副作用:记录 failed
超时、断连、进程崩溃:记录 uncertain

只要进入 uncertain,runtime 就不能自动重放非幂等工具。

Retry 策略应该跟工具定义绑在一起

不要写一个全局 retry,然后所有工具套进去。

每个工具都应该声明自己的幂等性和重放策略:

type ToolDefinition = {
  name: string;
  riskLevel: "read" | "write" | "network" | "destructive";
  idempotency: "idempotent" | "conditional" | "non_idempotent";
  replayPolicy: "auto" | "ask" | "never";
  retryPolicy?: {
    maxAttempts: number;
    backoffMs: number;
    retryableErrors: string[];
  };
};

然后 runtime 再决定能不能重试:

function shouldRetryTool(ctx: {
  idempotency: "idempotent" | "conditional" | "non_idempotent";
  errorCode: string;
  attempt: number;
  maxAttempts: number;
}) {
  if (ctx.attempt >= ctx.maxAttempts) return false;
  if (ctx.idempotency === "non_idempotent") return false;

  return ["RATE_LIMIT", "TRANSIENT_NETWORK", "SERVER_ERROR"].includes(
    ctx.errorCode
  );
}

这段逻辑不高级,但它比'所有错误都 retry 三次'靠谱太多。

更关键的是,conditional 要求额外条件。比如写文件工具可以在 base hash 一致时重放同一个 patch;创建订单工具可以在 idempotency key 存在时重放;发邮件工具如果没有服务端去重,默认就不该自动重放。

给模型看的 observation 要讲人话

很多工具失败信息写得像底层异常:

ECONNRESET

模型看到这个,很难知道下一步该干什么。

更好的 observation 应该把工程语义写清楚:

{
  "ok": false,
  "code": "SIDE_EFFECT_UNCERTAIN",
  "content": "create_order 请求已发出,但客户端在等待响应时超时。该工具可能已经创建订单,不能自动重试。请先调用 get_order_by_idempotency_key 或 query_orders_by_cart 确认状态。",
  "metadata": {
    "toolName": "create_order",
    "idempotencyKey": "order:user_42:cart_998:run_abc",
    "retryable": false
  }
}

这才是给 Agent 的好观察。

它不是把异常包装得更漂亮,而是告诉下一步应该怎么安全地做。

事故复盘:这次重复下单本来怎么避免

回到开头那个事故。

如果 runtime 设计得更完整,链路应该长这样:

用户确认下单
  ↓
Runtime 生成 idempotency key
  ↓
Tool Ledger 记录 create_order: planned
  ↓
权限/业务确认通过
  ↓
Tool Ledger 记录 approved
  ↓
开始请求订单服务
  ↓
Tool Ledger 记录 started
  ↓
HTTP timeout
  ↓
Tool Ledger 记录 uncertain
  ↓
Runtime 禁止自动重试 create_order
  ↓
Agent 调用 query_order_by_idempotency_key
  ↓
如果订单已存在:返回订单
如果订单不存在:请求用户确认后再处理

注意,这里不是'不重试'。是'先确认,再决定'。

这就是 Agent Runtime 和普通工具调用 demo 的分水岭。

你可以直接检查现有 Agent 项目

拿你们现在的 Agent 项目,对照下面这几个问题:

  • 工具有没有声明 idempotency?
  • 写入类工具有没有 idempotencyKey?
  • 工具执行开始前有没有落 ledger?
  • 超时后有没有 SIDE_EFFECT_UNCERTAIN?
  • 恢复执行时会不会重放上一次写操作?
  • 模型看到的 observation 有没有告诉它'不能自动重试'?
  • trace 里能不能查到同一个 input hash 是否执行过?
  • eval 里有没有覆盖'非幂等工具超时后禁止重放'?

如果这些问题都答不上来,retry 迟早会变成事故制造机。

最后

Agent 时代会把很多老问题重新暴露一遍。幂等、重试、超时、账本、恢复,这些分布式系统里的老概念,不会因为外面套了一层 LLM 就消失。

相反,模型会让这些问题更容易被触发。

因为它太积极了。它看到失败就想修。看到超时就想再试。看到工具报错就想换个参数继续冲。

Runtime 的职责,就是在它冲出去之前问一句:

这一步能不能重放?

参考资料

  • AWS Builders Library: Making retries safe with idempotent APIs
  • Stripe Docs: Idempotent requests
  • HTTP Semantics RFC 9110: Idempotent methods
  • Stack Overflow: Is Idempotency really necessary for preventing duplicate payments
  • GitHub issue: Idempotency protection / duplicate order discussion

推荐阅读

  • 一次 Agent 任务为什么能烧掉几十次 LLM 调用?
  • 一个 Production Agent Runtime 到底需要什么?
  • Agent Memory 越做越乱,本质上是 Runtime 问题

目录

  1. Agent 生产事故 #001:Retry 为什么会导致重复下单?
  2. Retry 本来是可靠性手段
  3. 真正危险的是“结果未知”
  4. Stripe 和 AWS 早就把坑踩透了
  5. Tool Ledger:Agent 的操作账本
  6. Retry 策略应该跟工具定义绑在一起
  7. 给模型看的 observation 要讲人话
  8. 事故复盘:这次重复下单本来怎么避免
  9. 你可以直接检查现有 Agent 项目
  10. 最后
  11. 参考资料
  12. 推荐阅读
  • 免费图片AI生成工具免费生成了解详情
  • Magick API 一键接入全球大模型注册送1000万token查看
  • 免费图片视频在线生成30秒,将你的创意变成现实开始设计
  • X/Twitter免费视频下载器免登陆无限额度免费视频解析下载了解详情
  • 100+免费在线小游戏爽一把
极客日志微信公众号二维码

微信扫一扫,关注极客日志

微信公众号「极客日志V2」,在微信中扫描左侧二维码关注。展示文案:极客日志V2 zeeklog

更多推荐文章

查看全部
  • 论文阅读--Agent AI 探索多模态交互的前沿领域(一)
  • OpenCode 开源 AI 编程助手使用教程
  • Linux diff 与 patch 命令实战指南
  • 网络安全工程师职业定义、核心技能与认证体系详解
  • Python 为何如此流行?深度解析其核心优势与应用场景
  • 大疆无人机如何导出日志并解析
  • 前端 WebSocket 通信实战与最佳实践
  • OpenClaw 进阶教程:记忆系统、定时任务、多模型与子代理解析
  • Python 爬虫实战指南:从基础请求到分布式框架
  • Kubernetes Python 客户端实战教程
  • Java IO 核心:BufferedReader、BufferedWriter、PrintStream 与 PrintWriter 详解
  • AI 辅助编程工具:GitHub Copilot 安装与使用指南
  • 基于 Rokid AR 眼镜的聚会游戏助手开发实践
  • 百瑞互联 BR8654A02 蓝牙 6.0 SOC 芯片规格介绍
  • Windows 7 安装 Python 3.9+ 配置指南
  • 前端加密:常用方式与使用示例
  • GitHub Copilot 学生认证教程(2026 版)
  • openclaw-termux:在 Android 上部署 OpenClaw AI Gateway
  • OpenClaw 浏览器控制终极方案 - 让 AI 助手随时控制你的浏览器:
  • Gazebo 机器人三维物理仿真平台

相关免费在线工具

  • Base64 字符串编码/解码

    将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online

  • Base64 文件转换器

    将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online

  • Markdown转HTML

    将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online

  • HTML转Markdown

    将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online

  • JSON 压缩

    通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online

  • JSON美化和格式化

    将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online