1 秒超时,3 秒才写完:MCP 写操作的重复提交实测

先看两行日志,来自同一次工具调用:

ini 复制代码
[client  7.6s] McpError -32001: TimeoutError: The operation was aborted due to timeout
[server  9.6s] save_note 写入完成 id=n1,当前共 1 条

客户端在 7.6 秒报错退出,服务端在 9.6 秒写成功。

中间那两秒,客户端已经认定这次调用失败了,服务端还在老老实实地干活,并且干完了。

这就是所有重复写入事故的起点。用户看到"保存失败",再点一次;框架看到超时,自动重试一次;模型看到报错,换个说法再调一次。每一次重试,服务端都会认真执行一遍------因为从它的角度,每次都是新请求。

这篇不讲怎么"避免超时",讲的是怎么让重试变得无害。

先把事故复现出来

一个最小的笔记服务,save_note 支持两个参数:delayMs 用来把执行时间拉长,idempotencyKey 用来做幂等。

客户端 1 秒超时,工具执行 3 秒:

vbnet 复制代码
=== 场景一:客户端 1s 超时,服务端要 3s ===
  [catch] McpError: MCP error -32001: TimeoutError: The operation was aborted due to timeout
  等 3.5s,让服务端把活干完......
  服务端现在的数据:"共 1 条:n1  会议纪要"

客户端拿到的是异常,服务端的数据里多了一条。两边对同一件事的认知完全相反。

服务端日志把时间线摊开:

ini 复制代码
[ 7258ms] save_note 开始 title=会议纪要 delay=3000
[10259ms] save_note 延时结束,signal.aborted = false
[10259ms] save_note 写入完成 id=n1,当前共 1 条

客户端在 8258ms 就放弃了,服务端的 setTimeout 跑到 10259ms 才结束,然后写入。

这里有个很多人会误会的点:超时是客户端单方面的决定,没有任何机制会去撤销服务端正在做的事。 它就像你挂了电话,对方还在把话说完。

然后用户又点了一次

ini 复制代码
=== 场景二:用户看到失败,又点了一次「保存」(无幂等键)===
  [结果] 已保存 id=n2,当前共 2 条
  服务端现在的数据:"共 2 条:n1  会议纪要 / n2  会议纪要"

两条一模一样的笔记。从服务端的视角,这是两次合法的、独立的创建请求,它没有任何理由拒绝第二次。

而且注意:第二次调用返回的是成功。所以你的 UI 上不会有任何异常,用户也不会知道多了一条。这类问题通常不是靠报错发现的,是靠某天导数据时发现"怎么有这么多重复"。

我第一次的解法:让服务端知道客户端放弃了

MCP 的 handler 第二个参数里有个 extra,里面带 signal。看起来这就是答案:客户端放弃 → signal abort → 服务端检查一下 → 不写了。

我挂上监听,连着跑了 7 次"1 秒超时"的调用。结果:

c 复制代码
7 次超时调用,只有 1 次服务端收到了 abort 信号

而且收到的那一次,写操作照样执行完了:

ini 复制代码
[11789ms] save_note 开始 title=周报草稿 delay=3000 idem=d8c79aa7-... session=-
[12809ms] save_note 收到 abort 信号(客户端已放弃)
[14790ms] save_note 延时结束,signal.aborted = true
[14790ms] save_note 写入完成 id=n3,当前共 3 条

signal.aborted 已经是 true 了,代码还是把 notes.push() 执行了------因为我的 handler 里根本没检查它。

就算检查了也没用。真实业务里"写"是一个 await db.insert(),你只能在写之前检查一次。一旦 insert 发出去,abort 到来也拦不住已经提交的事务。

所以这条路走不通。取消信号是"通知",不是"回滚"。 它能告诉你对方走了,但不能替你把已经做的事撤回来。而在无状态传输下,它连"通知"都未必送得到------7 次里只到了 1 次。

真正的解法:幂等键

思路很朴素:客户端为"这一次业务动作"生成一个唯一键,重试时带同一个键,服务端看到键已经处理过,就不再写,直接返回上次的结果。

vbnet 复制代码
=== 场景三:带幂等键,超时后重试 ===
  客户端生成的幂等键:3d75370b-29d2-4657-b5c1-9456aba5ad31
  [catch] McpError: MCP error -32001: TimeoutError
  等 3.5s......
  服务端现在的数据:"共 3 条:... n3  周报草稿"
  现在重试(同一个 key):
  [结果] 已存在,返回原记录 id=n3(本次未重复写入)
  服务端现在的数据:"共 3 条:... n3  周报草稿"

重试没有增加,总数还是三条。注意返回的是原来那条记录的 id,不是报错------这样客户端和模型都能继续往下走,不会因为"重复提交"卡住。

工具定义上就是多一个可选参数:

css 复制代码
inputSchema: {
  title: z.string().min(1),
  content: z.string().min(1),
  idempotencyKey: z.string().optional()
    .describe("幂等键。同一个键重复提交只会写入一次,建议由客户端生成 UUID"),
}

为什么是客户端生成,不是服务端?因为只有客户端知道"这是同一次用户动作"。服务端看到的两个请求长得一模一样,它没有办法区分"重试"和"用户真的想写两条一样的"。

幂等键绑在哪一层,决定它有没有用

生成时机是最容易做错的地方。下面这段是典型的错法:

vbnet 复制代码
for (const call of msg.tool_calls) {
  await client.callTool({
    name: call.function.name,
    arguments: { ...args, idempotencyKey: randomUUID() },  // 每次调用都是新键
  });
}

键绑在"每次工具调用"上,重试时当然是新的一个------等于没加。而且这种写法特别隐蔽,因为代码看起来完全正常。

正确的绑定层级是"用户的这一次业务动作":

javascript 复制代码
// 用户点「保存」的那一刻生成一次,存在这次动作的上下文里
const actionKey = randomUUID();

async function saveWithRetry(args) {
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      return await client.callTool({
        name: "save_note",
        arguments: { ...args, idempotencyKey: actionKey },  // 三次重试共用同一个键
      });
    } catch (e: any) {
      if (e.code !== -32001) throw e;
      await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
    }
  }
  throw new Error("重试三次仍失败");
}

三层重试里,只有"用户又点了一次"是新的业务动作(该生成新键),另外两层------SDK 自动重试、模型自主重试------都必须复用旧键。

判断标准一句话:键应该在"用户意图产生的那一刻"生成,而不是在"请求发出的那一刻"生成。

顺带记一个反例:tool_call_id 不能当幂等键。模型重试时会生成新的 id,而你的写操作可能已经成功了。它能标识"同一次模型决策",不能标识"同一次业务动作"。

还有一个工程细节:幂等记录不能永久存。常见做法是留 24 小时,或者按业务容忍度定(转账类要留久一点,日志类几小时就够)。留太长,存储成本上去;留太短,慢重试会漏过去。

但幂等键加错了,照样重复

我第一版是这样写的,看起来很合理:

javascript 复制代码
// 1. 先查
if (idemIndex.has(key)) return existing;

// 2. 写库
await new Promise((r) => setTimeout(r, dbLatencyMs));  // 真实场景是 await db.insert()
notes.push(note);

// 3. 写完再登记
idemIndex.set(key, note.id);

串行重试没问题。并发呢?两个请求同时到,都查了一遍,都没查到,然后都去写。实测:

ini 复制代码
同一个幂等键并发两次
  A: 已保存 id=n1
  B: 已保存 id=n2
  数据: 共 2 条:n1 并发写入 / n2 并发写入

同一个幂等键,写了两条。

原因就是那三步之间的 await。第 2 步一让出事件循环,第二个请求就挤进来把第 1 步重做了一遍------那时登记还没发生。

这在真实场景里不是极端情况:超时重试和用户手点,本来就容易撞在一起。

修法:把"占位"变成同步操作

关键不是"查完再写",而是写之前就把位置占住 ,而且占位本身不能有 await:

typescript 复制代码
const inflight = new Map<string, Promise<string>>();

async ({ title, content, idempotencyKey, dbLatencyMs }) => {
  // 已完成过
  if (idemIndex.has(idempotencyKey)) {
    return { content: [{ type: "text", text: `已存在 id=${idemIndex.get(idempotencyKey)}` }] };
  }

  // 正在写:等同一个 promise,不写第二次
  const running = inflight.get(idempotencyKey);
  if (running) {
    const id = await running;
    return { content: [{ type: "text", text: `已存在 id=${id}(等到了进行中的那次)` }] };
  }

  // 登记 promise 是同步的,中间没有 await,第二个请求一定看得见
  const job = (async () => {
    await new Promise((r) => setTimeout(r, dbLatencyMs));  // 写库
    const note = { id: `n${++seq}`, title, content };
    notes.push(note);
    idemIndex.set(idempotencyKey, note.id);
    return note.id;
  })();
  inflight.set(idempotencyKey, job);

  try {
    return { content: [{ type: "text", text: `已保存 id=${await job}` }] };
  } finally {
    inflight.delete(idempotencyKey);
  }
}

实测:

ini 复制代码
并发两次
  A: 已保存 id=n1 | B: 已存在 id=n1(等到了进行中的那次)
  串行重试: 已存在 id=n1
  数据: 共 1 条:n1 并发写入

并发和串行都只有一条。

第二个坑:这张表放错位置,等于没做

上面那个 inflight 我第一次是写在 createServer() 里面的。

结果和前一个版本一样,还是重复。

原因在第六篇讲过:无状态传输下,每个 HTTP 请求都会新建一个 McpServer 实例。 createServer() 里的 Map,每个请求各一份------两个并发请求各自持有一张空表,互相看不见。

javascript 复制代码
// 错:每个请求一份
function createServer() {
  const inflight = new Map();
}

// 对:进程级共享
const inflight = new Map();

这和 notes、idemIndex 必须放在模块级是同一个道理。幂等状态属于存储层,不属于请求。 在多实例部署时,这个 Map 还得换成 Redis 或数据库的唯一索引------否则请求被负载均衡打到两台机器上,两边各写一条。

顺便说一句,这也是为什么我更推荐"数据库唯一索引"而不是内存 Map:唯一约束冲突即跳过(ON CONFLICT DO NOTHING)是原子的,天然不受并发和实例数影响,而内存 Map 只在单进程单实例下成立。

哪些工具必须幂等

不是所有工具都要。判断标准只有一个:重复执行一次,会不会产生用户能感知的后果。

类型 例子 要不要幂等
读 search_notes、get_note 不用,重试无害
幂等写 set_status(id, "done")、update_title 不用,天然幂等
追加写 save_note、add_comment 必须,会多一条
外部副作用 发邮件、发消息、下单、扣款、建 PR 必须,而且最贵

第三、四类里,最容易被忽略的是"发消息"和"下单"------笔记多一条还能删,消息发出去两条就撤不回来了。

还有一个隐蔽的:模型自己也会重试 。它拿到 -32001 的时候并不知道服务端已经执行了,很可能原样再调一次------这一条我没有用真实模型验证,是从"模型看不到服务端状态"推出来的,但它是这一层里唯一你控制不了的。只能靠服务端幂等兜住。

三个动作

动作一:所有"追加写"和"外部副作用"的工具,加一个 idempotencyKey 参数。

描述里写清楚"同一个键只执行一次",让模型知道有这么个东西。客户端在发起一次业务动作时生成 UUID,重试时复用------注意是复用,不是每次调用都新建。这一条做错,幂等就完全失效了。

动作二:服务端先占位,再写,最后落结果。

占位必须是原子的(Map.set、Redis 的 SETNX、数据库唯一索引),中间不能有 await。如果用了数据库,直接上唯一索引,别自己维护内存表。

动作三:把幂等状态放在存储层,不放请求层。

内存 Map 只在单进程单实例下成立。一旦多实例,必须换成 Redis 或数据库唯一约束。

一个不要做的动作:别指望 abort 信号能帮你回滚。 它送不送得到都不一定(实测 7 次里只到 1 次),送到了也拦不住已经开始的写操作。

一个反方观点

有人会说:那就把超时设长一点,别让它超时。

这个思路在"工具本身就慢"的场景下是对的------如果你的写操作稳定要 3 秒,客户端超时给 10 秒,问题确实少很多。但它解决不了三种情况:网络抖动导致的连接断开(跟超时设置无关)、用户手动刷新、以及模型在报错后的自主重试。

而且超时设长了,失败时的等待时间也变长。用户体验是用"等更久"换"更少重复",不是免费的。

幂等是兜底,不是优化。 它的价值不在于让正常路径更快,而在于让异常路径不至于留下脏数据。

总结

  • 超时是客户端单方面的决定,服务端不会被通知"别干了",实测里客户端放弃后服务端又跑了 2 秒才写完
  • 一次超时 + 一次重试 = 两条重复记录,而第二次返回的是成功,UI 上没有任何异常
  • 取消信号不可靠:7 次超时只有 1 次送达,送达了也不中断 handler,signal.aborted=true 之后照样写入了
  • 幂等键能解决问题,但"先查 → await 写 → 再登记"这种写法在并发下会重复,实测同键写了两条
  • 正解是写之前同步占位、共享同一个 promise;实测并发和串行都只有一条
  • 幂等状态必须放进程级或存储层,写在 createServer() 里等于每个请求一张空表
  • 追加写和外部副作用必须幂等;读和"设置型"写不用

适用边界:如果你的工具全是读操作,这篇整篇都可以跳过。如果你的写操作天然幂等(比如"把状态改成已完成"),也不用加键------重复执行的结果一样。真正需要这套东西的,只有"每次执行都会新增一条"和"会发到外部世界"这两类。另外,个人自用、一天几十次调用、重复了手动删一下也不心疼的场景,加幂等的成本可能高于收益;但只要涉及钱、消息、或者多人协作,就别省这一步。

相关推荐
dyxal26 分钟前
FireRedVAD 深度解析:小红书开源的工业级语音活动检测“小钢炮”
人工智能·开源·语音识别
加宝的起点雷达1 小时前
40 条候选里 38 条来自同一个源,主卡整屏重样
人工智能·算法·创业
KylinLab1 小时前
TRAE Bot 上手实测:任务级模型固定 DeepSeek-V4.1-Flash,九步流程只留两步工件
程序员
紧跟先前的步伐1 小时前
Go AI Agent 实战 28|Agent 的预算、超时与停止条件
开发语言·人工智能·后端·ai·golang
李剑一1 小时前
她是世界上第一位程序员,写下了人类第一行代码,但却因偏执的信任数学计算而早逝
程序员
蓝星科学1 小时前
AI Agent 说「已完成」,凭什么信——ChipCode 的证据链与产物门禁设计
人工智能
田里的水稻1 小时前
FA_CNN卷积神经网络二十六---从全连接到卷积PyTorch
人工智能·pytorch·cnn
艺杯羹1 小时前
MCP本地服务集成避坑:从Stdio管道僵死到双向JSON-RPC稳健通信
人工智能·python·ai·系统架构·大语言模型·ai编程·mcp
武子康1 小时前
h3.c 的两个权重槽,怎样把内存压力换成 SSD 等待
c语言·人工智能·agent