【随笔】MCP工具重试:任务已经创建,响应丢了怎么办

工具已经在数据库里创建了任务,响应却在返回途中丢失。聊天界面只显示超时,用户再次点击"创建",模型又发起一次工具调用。数据库里该有一条任务,还是两条?

上一篇讨论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调用就宣称完成跨系统去重。

可以先在本地事务里保存业务意图与待发送记录,再由后台发送;但后台发送仍可能遇到"外部已接收、确认丢失"的窗口。外部服务需要支持自己的幂等键,或提供按业务编号查询的能力。没有这些能力时,结果未知需要对账,不能无限自动重试。

收据保留期是契约的一部分

删除收据之后,旧编号再次到达可能重新执行。服务端应规定允许重试的期限,客户端应保存创建时间;过期请求应明确拒绝或进入查询流程。不能只为了节省存储随意清理收据,却继续声称永久去重。

授权每次都要检查

命中收据不能绕过当前身份和访问控制。返回历史结果前仍须确认调用方有权访问这次操作;操作编号也不应被当作访问凭证。

六、宿主和服务端怎样分工

宿主保存稳定操作编号、展示执行中与结果未知、阻止无意义的重复点击,并在可重试条件下携带原编号重试。服务端负责验证参数和权限、原子提交、保存并回放结果。禁用按钮改善体验,数据库约束承担跨标签页、多进程与断网重试的正确性。

遇到失败时可以按下面顺序判断:

  1. 参数冲突或权限拒绝:停止自动重试,展示明确错误。
  2. 请求超时或连接中断:保留操作编号,将结果标记为未知,先查询或按契约重试。
  3. 已取得完成收据:展示原结果,不创建新的业务操作。
  4. 服务端明确确认未提交:允许使用原编号再次尝试。

重试次数、退避和总截止时间仍需受限。去重解决重复影响,不能解决服务一直不可用的问题。

七、🧠 思维导图

#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;} 写工具重试
稳定身份
一次业务意图
重试复用编号
参数冲突拒绝
原子提交
业务与收据同事务
完成结果回放
提交前回滚
保证边界
外部服务另行去重
收据保留期限
每次验证授权

八、总结

总结要点

超时表示调用方没有得到确定结果,不能据此断定业务未执行。取消通知同样不能替代提交状态。

操作收据让重试得到原结果。稳定编号、参数绑定和同一事务共同封住本地业务修改与去重记录之间的空隙。

保证边界必须跟着副作用走。本地事务不能覆盖远端系统;外部幂等支持、查询与对账决定跨系统重试能做到什么。

下一篇随笔继续看工具任务的执行状态:当操作需要较长时间时,如何让"处理中、完成、失败"可查询,并避免轮询结果误导界面。

👉 如果你觉得这篇文章对你有所帮助,欢迎点赞、收藏、分享!😊

相关推荐
wuyk5552 小时前
Spider-第10讲-SQLite持久化与去重
数据库·爬虫·sqlite
乌暮3 小时前
Spring AOP 与 Spring 事务:用大白话彻底理解原理与实践
java·数据库·spring boot·后端·sql·学习·spring
小此方4 小时前
Re:Mysql数据库基础篇(十二):什么是事务、事务解决了什么问题、事务的四大属性、事务的操作方式
数据库·mysql
flash俊杰5 小时前
v0.5-01 让桌面 AI 助手记得住:预算组装、递归摘要、长期记忆与评估基线
数据库·安全·架构
2501_933923256 小时前
redis:string类型常见命令
数据库·redis·缓存
byte轻骑兵7 小时前
时序数据库选型指南:Apache IoTDB 社区版实践与踩坑
数据库·人工智能·apache·时序数据库·iotdb
xrlfreedom7 小时前
MCP 面试高频考点:远程 Tool 调用的认证、授权与最小权限怎么落地?
prompts·mcp·最小权限与审计日志·streamable http 传输
黄连木笛8 小时前
Redis Insight 汉化教程:Windows / macOS / Linux 设置中文界面(附繁體、教程汉化、关闭自动更新)
数据库·redis·缓存
nice_lcj5208 小时前
MySQL 存储引擎入门:InnoDB 和 MyISAM 到底怎么选
java·数据库·mysql·面试·innodb·存储引擎·myisam