LLM Agent 在超时、限流、进程崩溃或 Kubernetes Job 重提时,往往会对同一次工具调用再次发起 tools/call 或等价的 function call。模型侧通常只关心「是否拿到结果」;下游 API、数据库或工单系统关心的是「副作用是否被重复执行」。MCP 规范在工具元数据里提供了 readOnlyHint、idempotentHint 等注解,但它们被明确定义为提示(hints),既不能替代服务端幂等实现,也不能作为自动重试的唯一依据。要在生产环境安全重试,需要在 Agent 运行时与工具适配层之间增加一层幂等执行层(idempotent execution layer)。
问题背景:at-least-once 是默认现实
典型 Agent 循环在以下场景会重复派发工具:
- 传输层重试:HTTP/Streamable MCP 连接在响应体未完整到达时断开,客户端按 JSON-RPC 语义重发同一
id的请求(是否安全取决于工具语义,而非协议本身)。 - 框架级重试:编排器对「工具超时」做指数退避,未区分只读查询与资金类写入。
- 检查点恢复:工作流从 checkpoint 重放时,若未记录「该
tool_call_id已成功落库」,会再次执行副作用步骤。 - 多 Worker 竞态:批处理 Agent 分片时,若租约过期与任务重领叠加,两个 Pod 可能对同一业务键各执行一次写工具。
HTTP 语义对此有清晰表述:RFC 9110 将 PUT、DELETE 及 safe 方法定义为幂等,并规定客户端不应自动重试非幂等方法,除非能证明请求语义实际上是幂等的,或能检测原始请求从未被应用。Agent 工具调用在协议上等价于「模型发起的 POST」—— 默认可重试性为否。
MCP 2025-06-18 schema 中的 ToolAnnotations 则试图在发现阶段向客户端传递风险信号(字段定义见官方 schema.json):
| 字段 | 默认值 | 规范含义(摘要) |
|---|---|---|
readOnlyHint |
false |
为 true 时表示工具不修改环境 |
destructiveHint |
true |
在 readOnlyHint == false 时才有意义;false 表示仅做追加型更新 |
idempotentHint |
false |
在 readOnlyHint == false 时才有意义;相同参数重复调用无额外环境效应 |
openWorldHint |
true |
是否与开放外部实体交互(如全网搜索 vs 封闭记忆库) |
这些注解不进入 JSON-RPC 校验路径;错误标注不会导致调用被拒绝。因此执行层必须独立实现幂等与审批策略。
可落地实现:三层结构
建议把「模型看到的工具」与「真实副作用」拆开:网关 / Tool Host 统一拦截,再委托给 MCP Server 或内部 HTTP 适配器。
1. 工具调用指纹(dedup key)
在副作用发生之前,用稳定键查询去重表。推荐指纹组成:
dedup_key = SHA-256(
tenant_id + "|" +
session_id + "|" +
tool_call_id + "|" +
tool_name + "|" +
canonical_json(arguments)
)
参数说明:
tool_call_id:优先使用 LLM 返回的 call id(OpenAItool_calls[].id、Anthropictool_use.id)。检查点重放时必须复用同一 id,否则指纹无法对齐历史。canonical_json:对 arguments 做键排序、UTF-8 规范化、浮点固定精度(若业务接受),避免{ "a":1,"b":2 }与{ "b":2,"a":1 }产生不同哈希。- 可选业务幂等键:若工具 schema 暴露
idempotency_key或client_mutation_id,应纳入哈希或作为唯一索引列,便于与 Stripe/Payment 类 API 对齐。
去重表(PostgreSQL 示例)最小字段:
| 列 | 类型 | 说明 |
|---|---|---|
dedup_key |
bytea 或 char(64) |
主键 |
status |
enum |
in_flight / completed / failed |
response_payload |
jsonb |
成功时缓存工具结果(供重试直接返回) |
created_at |
timestamptz |
插入时间 |
expires_at |
timestamptz |
TTL 到期后可清理 |
推荐 SQL 流程(伪代码级):
-- 1) 抢占:插入 in_flight,冲突则进入 2)
INSERT INTO agent_tool_dedup (dedup_key, status, expires_at)
VALUES ($1, 'in_flight', now() + interval '24 hours')
ON CONFLICT (dedup_key) DO NOTHING;
-- 2) 若未插入成功,查询现有行
SELECT status, response_payload FROM agent_tool_dedup WHERE dedup_key = $1;
-- 3) 执行真实工具后
UPDATE agent_tool_dedup
SET status = 'completed', response_payload = $2
WHERE dedup_key = $1 AND status = 'in_flight';
in_flight 租约:长耗时工具(>30s)应为 in_flight 行设置 lease_expires_at;租约过期后允许新 Worker 接管,但业务层仍需工具自身幂等(例如下游支持 Idempotency-Key 头)。
2. 与 MCP 注解联动的重试矩阵
在运行时把注解映射为策略,而非直接信任默认值(destructiveHint 默认 true、idempotentHint 默认 false):
readOnlyHint |
idempotentHint |
自动重试 | 去重表 | 人工审批 |
|---|---|---|---|---|
true |
— | 允许(仍建议缓存读结果降本) | 可选 | 否 |
false |
true |
允许,依赖去重表 + 下游幂等 | 必须 | 视 destructiveHint |
false |
false |
禁止自动重试 | 必须(防双写) | destructiveHint=true 时建议强制 |
对通过 HTTP 暴露的工具,适配器应在写请求上附加 Idempotency-Key: <dedup_key 前 32 字节 hex>(许多支付与云 API 支持该头);只读 GET 可依赖 safe 语义重试,但仍应限制次数与总超时。
3. 可观测性与审计
为每次拦截记录结构化事件(不必采集 prompt 正文):
agent.session_id、tool.name、tool_call_idmcp.read_only_hint、mcp.idempotent_hint(来自tools/list缓存)dedup.hit(miss/replay_completed/blocked_in_flight)- 下游 HTTP 状态码或 MCP
isError
OpenTelemetry 语义约定仓库已将 GenAI/MCP 相关属性迁出主 semantic-conventions 仓库独立维护;在约定稳定前,建议将上述字段挂载为 agent.* 自定义属性,并预留与 mcp.method.name 等注册属性的对齐空间。
4. 推荐默认参数
| 参数 | 建议值 | 说明 |
|---|---|---|
| 去重表 TTL | 24–72 h | 覆盖「隔夜 Job 重提 + 人工重跑」窗口 |
in_flight 租约 |
工具 P99 耗时 × 1.5,下限 60s | 防止永久占锁 |
| 自动重试次数(写工具) | 0(仅人工触发) | 除非 idempotentHint=true 且去重表已启用 |
| 自动重试次数(只读) | ≤ 3,指数退避 1s–8s | 配合总 deadline |
| 去重表清理 | 每日 batch DELETE WHERE expires_at < now() |
避免表无限增长 |
风险与边界
注解不可当作合约。 MCP schema 写明 idempotentHint 仅在 readOnlyHint == false 时有意义,且默认 false。第三方 MCP Server 可能全部留空或误标;执行层应以服务端实现的幂等为准,注解只影响 UI 与重试策略。
指纹碰撞与语义幂等不是一回事。 「相同 JSON 参数」可能对下游仍非幂等(例如 create_issue 每次创建新工单)。需要业务级 idempotency_key 或改为 update_* / upsert 工具契约。
缓存响应与部分失败。 若工具在外部系统已成功、但在返回 Agent 前进程崩溃,去重表可能停在 in_flight。租约到期后的第二次执行可能触发双写 —— 下游 API 必须支持幂等键,或采用 outbox + 状态查询(先 GET 再决定是否 POST)。
读工具的数据新鲜度。 对 readOnlyHint=true 的结果做长期缓存会导致 Agent 基于陈旧数据规划下一步;读缓存 TTL 宜短(秒级到分钟级),并与 openWorldHint 区分对待。
隐私与合规。 response_payload 可能含 PII;去重表应加密静态存储、按租户隔离,并遵守数据保留策略。调试时避免把完整参数写入日志,可只记录哈希前缀。
与模型多轮行为无关。 即使执行层完美幂等,模型仍可能在新的 tool_call_id 下再次调用同一工具(用户未确认却重复下单)。产品层需要 Human-in-the-Loop 或业务规则引擎,本文不展开。
参考来源
- Model Context Protocol — Tools(2025-06-18) — 工具定义、
annotations字段与错误模型 - MCP schema.json(
ToolAnnotations) —readOnlyHint/idempotentHint/destructiveHint的规范描述与默认值 - RFC 9110 — HTTP Semantics, Section 9.2.2 Idempotent Methods — 客户端自动重试与非幂等方法的约束
- OpenTelemetry — MCP attribute registry — MCP 相关 span 属性(部分条目已标记 deprecated,实施时需对照最新 GenAI 约定仓库)
内容声明:本文无广告投放、无付费植入。
如有事实性问题,欢迎发送勘误至 i@hotdrydog.com。