工具已经在数据库里创建了任务,响应却在返回途中丢失。聊天界面只显示超时,用户再次点击"创建",模型又发起一次工具调用。数据库里该有一条任务,还是两条?
上一篇讨论MCP Apps如何检查返回数据的结构。这篇把时间往前移到执行边界:结果还没送达时,怎样避免一次业务意图被重复执行。核心是稳定的操作标识、持久化结果和明确的事务边界。
一、超时留下的是未知结果
一次写工具调用至少经过三个阶段:请求到达服务端,业务修改提交,响应到达调用方。超时可能发生在任意两个阶段之间。
| 发生位置 | 服务端可能的状态 | 调用方看到的状态 |
|---|---|---|
| 请求尚未到达 | 没有修改 | 超时 |
| 执行尚未提交 | 可能回滚,也可能继续执行 | 超时 |
| 已提交,响应丢失 | 修改已经存在 | 超时 |
同一种界面错误对应不同事实,所以"超时→重新执行"不足以保护写操作。应该保留原业务操作的身份,并允许查询或重复取得同一次操作的结果。
MCP取消通知也不能充当数据库回滚证明。规范允许接收方在请求已经完成或不能取消时忽略取消通知,取消还可能晚于处理完成到达。应用只能根据服务端确认的状态展示"已取消";只发送通知时,应展示"已请求取消"。MCP取消规范
二、请求编号与业务操作编号分开
JSON-RPC请求编号服务于一次请求与响应的对应。业务上的operation_id服务于"这几次尝试属于同一件事"。重试可以发起新的协议请求,同时携带原来的业务操作编号。
json
{
"jsonrpc": "2.0",
"id": 102,
"method": "tools/call",
"params": {
"name": "create_task",
"arguments": {
"operation_id": "op-001",
"title": "检查布局"
}
}
}
operation_id是本应用定义的工具参数,不是MCP自动提供的通用去重功能。宿主在用户确认一次创建意图时生成并保存它;网络重试继续用它;用户明确要求再创建一个相同标题的任务时生成新编号。
编号不能每次重试都重建,否则每次都像新操作。也不能简单使用标题作为编号,否则用户无法合法创建两个同名任务。
幂等提示不等于实现保证
MCP的idempotentHint说明相同参数重复调用是否应产生额外环境影响,规范将它定义为提示;来自不可信服务端的提示不能作为决策保证。需要先把工具行为实现正确,再声明对应提示。ToolAnnotations定义

三、把业务修改和结果收据放进同一个事务
本例只创建当前SQLite数据库里的任务。两张表分别保存业务数据和操作收据,收据包含身份范围、操作编号、规范化参数及原始结果。
处理顺序是:开启写事务→查收据→存在则比较参数并回放结果→不存在则插入任务与收据→一起提交。
为什么参数也要保存?如果第一次op-001创建"检查布局",第二次却要求创建"删除旧数据",直接返回第一次结果会掩盖调用错误。相同身份范围与操作编号必须绑定相同参数,否则拒绝。
为什么收据和任务一起提交?如果先写任务、后写收据,中间崩溃会留下"任务存在但没有去重凭据"的窗口。重试再创建就会重复。两个写入共享事务,提交前失败时一起回滚,提交后则能找到结果。
SQLite同一时刻只有一个写事务。本例用BEGIN IMMEDIATE先取得写事务,再检查收据,避免两个并发写者都先判断不存在。竞争者可能等待,也可能因超时得到busy错误;此时仍应保留原操作编号,不把等待失败解释成成功。SQLite事务说明

四、完整示例:响应丢失后回放同一个结果
以下示例在Python 3.12.7、SQLite 3.45.3下运行通过。代码只用Python标准库,保存为demo.py后运行python demo.py。owner在真实服务端必须取自经过认证的上下文,不能信任模型传来的用户名称。示例用固定值模拟该上下文。
python
import json
from contextlib import closing
import sqlite3
import tempfile
from pathlib import Path
def initialize(path):
with closing(sqlite3.connect(path)) as db:
db.executescript('''
CREATE TABLE tasks(id INTEGER PRIMARY KEY, owner TEXT, title TEXT);
CREATE TABLE receipts(
owner TEXT NOT NULL, operation_id TEXT NOT NULL,
payload TEXT NOT NULL, result TEXT NOT NULL,
PRIMARY KEY(owner, operation_id)
);
''')
def create_task(path, owner, operation_id, title, fail_before_commit=False):
# owner must come from authenticated server context, not model arguments.
if not operation_id or not isinstance(title, str) or not title.strip():
raise ValueError('invalid input')
payload = json.dumps({'tool': 'create_task_v1', 'title': title},
ensure_ascii=False, sort_keys=True)
db = sqlite3.connect(path, timeout=5, isolation_level=None)
try:
db.execute('BEGIN IMMEDIATE')
row = db.execute('SELECT payload,result FROM receipts '
'WHERE owner=? AND operation_id=?',
(owner, operation_id)).fetchone()
if row:
if row[0] != payload:
raise ValueError('operation_id reused with different arguments')
db.execute('COMMIT')
return json.loads(row[1])
task_id = db.execute('INSERT INTO tasks(owner,title) VALUES (?,?)',
(owner, title)).lastrowid
result = {'task_id': task_id, 'title': title}
db.execute('INSERT INTO receipts VALUES (?,?,?,?)',
(owner, operation_id, payload,
json.dumps(result, ensure_ascii=False)))
if fail_before_commit:
raise RuntimeError('injected before commit')
db.execute('COMMIT')
return result
except BaseException:
if db.in_transaction:
db.execute('ROLLBACK')
raise
finally:
db.close()
def count_tasks(path):
with closing(sqlite3.connect(path)) as db:
return db.execute('SELECT count(*) FROM tasks').fetchone()[0]
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'demo.sqlite'
initialize(path)
# Simulate a successful commit whose response never reaches the caller.
original = create_task(path, 'alice', 'op-001', '检查布局')
retried = create_task(path, 'alice', 'op-001', '检查布局')
assert original == retried and count_tasks(path) == 1
print('lost response + retry: tasks=1, same result=True')
try:
create_task(path, 'alice', 'op-001', '修改过的标题')
except ValueError:
print('same key + changed arguments: rejected')
else:
raise AssertionError('conflict was accepted')
try:
create_task(path, 'alice', 'op-002', '检查键盘', True)
except RuntimeError:
pass
assert count_tasks(path) == 1
create_task(path, 'alice', 'op-002', '检查键盘')
assert count_tasks(path) == 2
print('failure before commit + retry: tasks=2')
create_task(path, 'bob', 'op-001', '检查布局')
assert count_tasks(path) == 3
print('different authenticated owner: tasks=3')
模拟"响应丢失"的方式是:第一次函数已经返回,但调用方丢弃这次结果,再以相同参数调用。第二次重新打开数据库连接,依靠数据库收据回放,不依赖进程内字典。
text
lost response + retry: tasks=1, same result=True
same key + changed arguments: rejected
failure before commit + retry: tasks=2
different authenticated owner: tasks=3
四组检查分别验证:提交后重试没有增加任务;参数改变被拒绝;提交前异常回滚后能够重试;不同认证主体的编号互不混淆。这里的故障注入模拟事务边界,不是实际断电或网络压测。
五、这份保证到哪里结束
外部副作用不在本地事务里
如果工具还要发邮件、调用支付接口或写另一套服务,本地SQLite回滚无法撤回已发送的请求。不要把上面代码套上一个HTTP调用就宣称完成跨系统去重。
可以先在本地事务里保存业务意图与待发送记录,再由后台发送;但后台发送仍可能遇到"外部已接收、确认丢失"的窗口。外部服务需要支持自己的幂等键,或提供按业务编号查询的能力。没有这些能力时,结果未知需要对账,不能无限自动重试。
收据保留期是契约的一部分
删除收据之后,旧编号再次到达可能重新执行。服务端应规定允许重试的期限,客户端应保存创建时间;过期请求应明确拒绝或进入查询流程。不能只为了节省存储随意清理收据,却继续声称永久去重。
授权每次都要检查
命中收据不能绕过当前身份和访问控制。返回历史结果前仍须确认调用方有权访问这次操作;操作编号也不应被当作访问凭证。
六、宿主和服务端怎样分工
宿主保存稳定操作编号、展示执行中与结果未知、阻止无意义的重复点击,并在可重试条件下携带原编号重试。服务端负责验证参数和权限、原子提交、保存并回放结果。禁用按钮改善体验,数据库约束承担跨标签页、多进程与断网重试的正确性。
遇到失败时可以按下面顺序判断:
- 参数冲突或权限拒绝:停止自动重试,展示明确错误。
- 请求超时或连接中断:保留操作编号,将结果标记为未知,先查询或按契约重试。
- 已取得完成收据:展示原结果,不创建新的业务操作。
- 服务端明确确认未提交:允许使用原编号再次尝试。
重试次数、退避和总截止时间仍需受限。去重解决重复影响,不能解决服务一直不可用的问题。
七、🧠 思维导图
#mermaid-svg-P20V5lCGTp9zAsV2{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-P20V5lCGTp9zAsV2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-P20V5lCGTp9zAsV2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-P20V5lCGTp9zAsV2 .error-icon{fill:#552222;}#mermaid-svg-P20V5lCGTp9zAsV2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-P20V5lCGTp9zAsV2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-P20V5lCGTp9zAsV2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-P20V5lCGTp9zAsV2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-P20V5lCGTp9zAsV2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-P20V5lCGTp9zAsV2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-P20V5lCGTp9zAsV2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-P20V5lCGTp9zAsV2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-P20V5lCGTp9zAsV2 .marker.cross{stroke:#333333;}#mermaid-svg-P20V5lCGTp9zAsV2 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-P20V5lCGTp9zAsV2 p{margin:0;}#mermaid-svg-P20V5lCGTp9zAsV2 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-P20V5lCGTp9zAsV2 .cluster-label text{fill:#333;}#mermaid-svg-P20V5lCGTp9zAsV2 .cluster-label span{color:#333;}#mermaid-svg-P20V5lCGTp9zAsV2 .cluster-label span p{background-color:transparent;}#mermaid-svg-P20V5lCGTp9zAsV2 .label text,#mermaid-svg-P20V5lCGTp9zAsV2 span{fill:#333;color:#333;}#mermaid-svg-P20V5lCGTp9zAsV2 .node rect,#mermaid-svg-P20V5lCGTp9zAsV2 .node circle,#mermaid-svg-P20V5lCGTp9zAsV2 .node ellipse,#mermaid-svg-P20V5lCGTp9zAsV2 .node polygon,#mermaid-svg-P20V5lCGTp9zAsV2 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-P20V5lCGTp9zAsV2 .rough-node .label text,#mermaid-svg-P20V5lCGTp9zAsV2 .node .label text,#mermaid-svg-P20V5lCGTp9zAsV2 .image-shape .label,#mermaid-svg-P20V5lCGTp9zAsV2 .icon-shape .label{text-anchor:middle;}#mermaid-svg-P20V5lCGTp9zAsV2 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-P20V5lCGTp9zAsV2 .rough-node .label,#mermaid-svg-P20V5lCGTp9zAsV2 .node .label,#mermaid-svg-P20V5lCGTp9zAsV2 .image-shape .label,#mermaid-svg-P20V5lCGTp9zAsV2 .icon-shape .label{text-align:center;}#mermaid-svg-P20V5lCGTp9zAsV2 .node.clickable{cursor:pointer;}#mermaid-svg-P20V5lCGTp9zAsV2 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-P20V5lCGTp9zAsV2 .arrowheadPath{fill:#333333;}#mermaid-svg-P20V5lCGTp9zAsV2 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-P20V5lCGTp9zAsV2 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-P20V5lCGTp9zAsV2 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-P20V5lCGTp9zAsV2 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-P20V5lCGTp9zAsV2 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-P20V5lCGTp9zAsV2 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-P20V5lCGTp9zAsV2 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-P20V5lCGTp9zAsV2 .cluster text{fill:#333;}#mermaid-svg-P20V5lCGTp9zAsV2 .cluster span{color:#333;}#mermaid-svg-P20V5lCGTp9zAsV2 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-P20V5lCGTp9zAsV2 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-P20V5lCGTp9zAsV2 rect.text{fill:none;stroke-width:0;}#mermaid-svg-P20V5lCGTp9zAsV2 .icon-shape,#mermaid-svg-P20V5lCGTp9zAsV2 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-P20V5lCGTp9zAsV2 .icon-shape p,#mermaid-svg-P20V5lCGTp9zAsV2 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-P20V5lCGTp9zAsV2 .icon-shape .label rect,#mermaid-svg-P20V5lCGTp9zAsV2 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-P20V5lCGTp9zAsV2 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-P20V5lCGTp9zAsV2 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-P20V5lCGTp9zAsV2 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 写工具重试
稳定身份
一次业务意图
重试复用编号
参数冲突拒绝
原子提交
业务与收据同事务
完成结果回放
提交前回滚
保证边界
外部服务另行去重
收据保留期限
每次验证授权
八、总结
总结要点
超时表示调用方没有得到确定结果,不能据此断定业务未执行。取消通知同样不能替代提交状态。
操作收据让重试得到原结果。稳定编号、参数绑定和同一事务共同封住本地业务修改与去重记录之间的空隙。
保证边界必须跟着副作用走。本地事务不能覆盖远端系统;外部幂等支持、查询与对账决定跨系统重试能做到什么。
下一篇随笔继续看工具任务的执行状态:当操作需要较长时间时,如何让"处理中、完成、失败"可查询,并避免轮询结果误导界面。
👉 如果你觉得这篇文章对你有所帮助,欢迎点赞、收藏、分享!😊