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


