为什么 Tool 必须支持 Idempotency?
上一篇讲 retry 时,我留了一个坑:
不是所有 Tool Call 都能重试。
今天把这个坑填上。
Agent Runtime 里,工具是否幂等,不是一个可有可无的注释。它决定三件事:
能不能 retry
能不能 resume
能不能 replay
如果工具没有幂等语义,Agent 一旦遇到超时、崩溃、断网、用户刷新页面,就会陷入一种尴尬状态:不知道该继续,还是该停;不知道上一步有没有发生副作用;不知道重放会不会把事故扩大。
这不是理论问题。
重复下单、重复扣款、重复发邮件、重复创建 issue、重复提交 PR,都是这个问题的变体。
幂等到底是什么意思
HTTP RFC 9110 里对幂等方法有一个经典定义:同一个请求执行一次或多次,预期效果相同。
注意,不是'没有副作用'。
DELETE 可以是幂等的。第一次删除资源,第二次还是删除同一个资源,最终状态都是资源不存在。
POST 通常不天然幂等。你创建订单两次,世界上就多了两张订单。
Stripe 的 idempotent requests 文档讲得很实际:客户端为创建或更新请求生成一个 idempotency key,网络错误后用同一个 key 重试,服务端返回第一次请求的结果,而不是重复执行。AWS Builders Library 也强调,安全重试的核心是服务端能识别客户端意图:这是同一个业务动作,还是用户真的发起了第二个动作。
Agent Tool 也一样。
模型不懂这些业务语义。Runtime 必须懂。
ToolDefinition 里要声明幂等性
工具定义至少要多几个字段:
type Idempotency = "idempotent" | "conditional" | "non_idempotent";
type ReplayPolicy = "auto" | "ask" | "never";
type ToolDefinition = {
name: string;
riskLevel: "read" | "write" | "network" | "install" | "destructive";
idempotency: Idempotency;
replayPolicy: ReplayPolicy;
sideEffects: string[];
};
这不是文档装饰。
Runtime 会用它决定:
- 工具超时后能不能 retry。
- 进程崩溃后能不能 replay。
- checkpoint 恢复后是否需要用户确认。
- trace 里如何标记风险。
- eval 里是否要检查'禁止重复执行'。
如果一个工具没有写 idempotency,默认应该按 non_idempotent 处理。
保守一点没坏处。
三类工具,三种策略
第一类:天然幂等。
read_file
list_files
search_code
get_order_status
pure_compute
这些工具重复执行,通常不会改变外部状态。它们可以 replayPolicy: "auto"。
第二类:条件幂等。
write_file
apply_patch
update_config
run_command("npm test")
它们是否能重放,要看条件。
写文件要检查 base hash。应用 patch 要检查 patch 是否已经应用。npm test 理论上是只读,但测试脚本可能写 coverage、cache、snapshot。命令工具尤其不能只看工具名。
第三类:非幂等。
send_email
create_order
charge_customer
create_issue
merge_pull_request
npm publish
run_migration
这些默认不能自动重放。要么需要业务级 idempotency key,要么需要人工确认,要么只能走补偿动作。
命令工具最容易骗人
很多 Agent 项目会有一个 run_command。
这工具不能简单标成幂等或非幂等。
run_command("ls src") 幂等
run_command("npm test") 条件幂等
run_command("npm install") 有副作用
run_command("npm publish") 非幂等
run_command("rm -rf dist") 破坏性
run_command("prisma migrate deploy") 非幂等
所以 run_command 的幂等性要在计划阶段按参数分类。
function classifyCommandIdempotency(command: string): {
idempotency: Idempotency;
replayPolicy: ReplayPolicy;
reason: string;
} {
if (/^(pwd|ls|find|rg|cat|sed|wc)\b/.test(command)) {
return {
idempotency: "idempotent",
replayPolicy: "auto",
reason: "只读命令",
};
}
if (/^npm test\b|^pnpm test\b|^yarn test\b/.test(command)) {
return {
idempotency: "conditional",
replayPolicy: "ask",
reason: "测试命令可能写 coverage、cache 或 snapshot",
};
}
return {
idempotency: "non_idempotent",
replayPolicy: "never",
reason: "命令可能产生不可恢复副作用",
};
}
不要把 shell 当普通函数。
shell 是副作用入口。
Tool Ledger 要记录幂等性
工具执行前就要记账。
type ToolLedgerEntry = {
callId: string;
stepId: string;
toolName: string;
inputHash: string;
status:
| "planned"
| "approved"
| "started"
| "succeeded"
| "failed"
| "uncertain";
idempotency: "idempotent" | "conditional" | "non_idempotent";
replayPolicy: "auto" | "ask" | "never";
idempotencyKey?: string;
sideEffects: string[];
startedAt?: string;
finishedAt?: string;
};
最重要的是 started 和 uncertain。
如果工具请求已经发出,但响应没回来,状态不能写成普通失败。应该写:
status = uncertain
恢复时看到 uncertain,再结合 idempotency 判断:
idempotent + auto:可以重放
conditional + ask:先检查条件或问用户
non_idempotent + never:禁止自动重放
没有 ledger,就没有恢复。
Idempotency Key 不能交给模型自由发挥
创建订单、扣款、创建 issue 这类工具,往往需要 idempotency key。
这个 key 不应该由模型随便编。
模型可以说明业务意图,但 key 的生成应该在 runtime 或业务服务里完成。
function buildIdempotencyKey(input: {
userId: string;
runId: string;
toolName: string;
businessObjectId: string;
}) {
return [
"agent",
input.userId,
input.runId,
input.toolName,
input.businessObjectId,
].join(":");
}
这里有取舍。
如果 key 绑定 runId,同一个 run 内 retry 安全,但跨 run 不会被当作同一业务动作。
如果 key 绑定用户和购物车,用户刷新后仍能恢复同一个下单动作,但要小心用户真的想再下一单。
幂等 key 是业务设计,不是随机字符串。
条件幂等要有检查函数
写文件工具可以设计成条件幂等。
执行前记录 base hash:
type FileWritePlan = {
path: string;
beforeHash: string;
afterHash: string;
patchRef: string;
};
恢复时检查:
async function canReplayFileWrite(plan: FileWritePlan) {
const currentHash = await hashFile(plan.path);
if (currentHash === plan.afterHash) {
return { ok: true, action: "already_applied" as const };
}
if (currentHash === plan.beforeHash) {
return { ok: true, action: "apply_again" as const };
}
return {
ok: false,
action: "conflict" as const,
reason: "文件已被用户或其他工具修改",
};
}
这才叫条件幂等。
没有 base hash 和 after hash,你只能靠感觉判断能不能重放。
MCP 工具也要重新分类
MCP Server 暴露的工具描述里可能写着 safe、read only、search。
Runtime 不能直接相信。
外部工具进入本地 Tool Registry 时,要重新分类:
function classifyMcpTool(tool: {
name: string;
description?: string;
inputSchema: unknown;
}) {
const text = `${tool.name} ${tool.description ?? ""}`.toLowerCase();
if (/read|list|search|get/.test(text)) {
return { idempotency: "idempotent", replayPolicy: "auto" };
}
if (/create|send|charge|delete|publish|merge|write/.test(text)) {
return { idempotency: "non_idempotent", replayPolicy: "never" };
}
return { idempotency: "conditional", replayPolicy: "ask" };
}
真实实现还要看 server trust level、resource scope、用户授权、组织策略。
协议让工具更容易接入,不代表工具更安全。
给模型看的错误要明确
非幂等工具超时,不能只返回:
{ "error": "timeout" }
应该返回:
{
"ok": false,
"code": "SIDE_EFFECT_UNCERTAIN",
"content": "create_issue 请求已发出,但响应超时。该工具可能已经创建 issue,不能自动重试。请先调用 get_issue_by_idempotency_key 或让用户确认。",
"metadata": {
"retryable": false,
"idempotency": "non_idempotent"
}
}
模型看到这条 observation,才知道下一步要查状态,不是继续创建。
Eval 要覆盖幂等性
幂等性不是写完类型就完事。
Eval 里要测:
read_file timeout 后允许 retry。
create_order timeout 后禁止 retry,返回 SIDE_EFFECT_UNCERTAIN。
write_file base hash 一致时可 replay。
write_file 文件已变更时进入 conflict。
PERMISSION_DENIED 后不可重放。
MCP create_issue 工具默认 non_idempotent。
run_command("ls") 可自动 replay。
run_command("npm publish") 禁止 replay。
这些 case 最好用 mock tool 做。不要拿真实支付、真实邮件、真实 issue tracker 测。
Runtime 的危险行为要能在本地复现。
最后
Agent 能不能恢复,很多时候取决于工具能不能安全重放。
能安全重放,就可以 retry、resume、replay。
不能安全重放,就要查状态、问用户、走补偿,或者直接停。
幂等性不是后端老概念的复读。到了 Agent Runtime 里,它变成工具系统的基础字段。
每个工具都要回答:
重复执行一次,会发生什么?
答不上来,就不要自动重试。
参考资料
- HTTP Semantics RFC 9110: Idempotent methods
- Stripe Docs: Idempotent requests
- AWS Builders Library: Making retries safe with idempotent APIs
- Google Cloud Docs: Retry strategy
- Model Context Protocol Docs: Tools


