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