MCP 可靠性、取消与流控

Build~120 minutesPython先修要求: Phase 13, Lessons 09 and 13
请求 ID 只能关联一条消息。它不能让副作用变安全,不能停止一个工作线程,也不能保护一个流免受慢速消费者的拖累。
学习目标
  • 为 stdio 和 Streamable HTTP 分别实现正确的取消信号。
  • 解决完成与取消之间的竞态,且不在取消之后再发送任何消息。
  • 区分请求取消与持久的 tasks/cancel 语义。
  • 基于副作用和显式幂等键做出重试决策。
  • 在保留最终响应的前提下为进度队列设上界。
  • 通过重连、重新获取和带抖动的退避来恢复流。

问题

正常路径会掩盖分布式系统中最昂贵的缺陷。

客户端调用一个工具。服务器开始工作。进度不断返回。一个代理缓冲了流。客户端到达超时并断开。服务器在一毫秒后完成。客户端用新的 JSON-RPC id 重试。变更执行了两次。

每个组件在本地看都行为正常。系统却在全局上失败了。

MCP 定义了消息和传输行为,但你的应用仍然负责:

  • 时间预算;
  • 业务幂等;
  • 有界队列;
  • 重试分类;
  • 持久任务状态;
  • 重连与重新获取策略。

本课把这些决策构建成一个确定性的模拟器。没有 sleep、没有 socket,也没有随机故障。你直接控制取消事件的顺序。一个同步线程测试会强制两个账本客户端竞争同一个幂等键。

请求取消因传输方式而异

每种传输方式的意图相同:客户端不再需要某个在途结果。区别在于线上的信号不同。

stdio

stdio 使用一条共享的双向通道。客户端发送一条通知:

json{
  "jsonrpc": "2.0",
  "method": "notifications/cancelled",
  "params": {
    "requestId": 41,
    "reason": "User closed the operation"
  }
}

该通知是即发即忘的。服务器不会对它发出任何 JSON-RPC 响应。

服务器应停止工作、释放资源,并避免为已取消的请求发送响应。当请求未知、已经完成或无法安全停止时,它可以忽略取消。

格式错误、未知以及已完成请求的取消通知都会被忽略。把这些竞态变成新的错误,只会制造出更多竞态。

Streamable HTTP

现代 Streamable HTTP 为每个请求提供独立的 HTTP 响应或 SSE 响应流。客户端通过关闭该请求的响应流来取消。

不要为普通 HTTP 请求 POST 一个 notifications/cancelled。流的关闭本身就是取消信号。

服务器一旦观察到断开,就应停止工作,并且不得再为该请求发送任何消息。

服务器发起的取消范围很窄

服务器不会用 notifications/cancelled 取消任意的客户端调用。在 stdio 上,服务器发起的取消只保留给终止 subscriptions/listen 请求这一种用途。请把这条路径与普通的客户端请求取消分开处理。

取消是一场竞态

两种事件顺序都有效。

取消获胜

textrequest starts
client sends cancellation signal
server marks request cancelled
worker reaches completion
server suppresses the response

完成获胜

textrequest starts
worker commits the result
server sends the response
cancellation arrives late
server ignores the late notification

客户端还必须忽略它已经放弃的请求的迟到响应。网络延迟意味着双方都无法证明对方先观察到哪个事件。

mcp-reliability-race静态版本未包含此交互图

本课的 RequestCoordinator 只存储一个终态。取消之后 complete() 不再返回响应。迟到的取消无法改变一条已完成的记录。

超时需要两个时钟

只用一个不活动计时器是不够的。

使用两种时限:

  1. 空闲超时(idle timeout)。 请求可以没有任何有效活动的最长时间。
  2. 最大超时(maximum timeout)。 从请求开始算起的绝对墙钟时间预算。

进度可以重置空闲时钟。它绝不能移除最大截止时间。

textstart: 0 ms
progress: 400 ms
progress: 800 ms
progress: 1200 ms
idle timeout: 500 ms
maximum timeout: 2000 ms

在 1500 ms 时,请求仍然活跃,因为最新一次进度只过去了 300 ms。到了 2000 ms,即使 1999 ms 时又来了一个进度事件,最大截止时间仍会取消它。

进度是可选的。服务器可以接受一个 progress token 而从不发出更新。绝不要把 token 的存在变成无限超时。

MCP 的进度值必须递增。完成或取消之后通知停止。要对进度做速率限制,以免一个快速的工作线程淹没传输层。

请求取消不是 tasks/cancel

这些机制解决的是不同生命周期的问题。

机制目标信号成功的含义
stdio 上的请求取消一个在途 RPCnotifications/cancelled客户端放弃了请求;服务器在可行时应停止
HTTP 上的请求取消一个在途响应流关闭流客户端放弃了请求;服务器在可行时应停止
tasks/cancel一个持久 Task普通 MCP 请求服务器确认了取消意图

tasks/cancel 成功返回并不能证明工作线程已停止。任务可能保持 working 状态,直到某个工作线程 checkpoint 观察到该标志。工作也可能在该 checkpoint 之前就完成了。

HTTP 连接关闭时,不要清除持久任务状态。创建 Task 的理由,正是它的生命周期比一次请求、一条连接更长。

新的 JSON-RPC ID 不等于幂等

JSON-RPC id 关联请求与响应,并不标识一项业务操作。

假设客户端用 id 41 提交了一笔扣款,随后丢失了响应,再用 id 42 重试。服务器看到的是两条不同的消息。没有应用层的键,它无法知道这两者代表同一次结账。

幂等键标识的是业务意图:

json{
  "name": "charge_account",
  "arguments": {
    "account": "acct-7",
    "cents": 1200,
    "idempotencyKey": "checkout-7"
  }
}

服务器存储:

  • 该键;
  • 操作参数的指纹;
  • 已提交的结果。

相同的键和相同的参数返回已存储的结果。相同的键配不同的参数会被拒绝。这样可以防止误用同一条键去变更另一项业务操作。

账本边界必须原子且持久

下面这个顺序是不安全的:

textcheck key
run mutation
store result

两个工作线程可能都观察到键不存在,然后都执行了变更。副作用已经发生、但结果尚未写入时发生崩溃,也会在重试时造成同样的歧义。

本课使用一个由文件支撑的 SQLite 账本。BEGIN IMMEDIATE 把键检查、模拟业务副作用、执行计数器和结果存储串行化到同一个事务中。因此,两个独立的账本连接用同一个键竞争时,只会观察到一个已提交结果和一次执行。关闭并重新打开账本,记录依然保留。

每个返回值都从存储的 JSON 重建。调用方永远不会拿到账本持有的可变对象,因此修改返回的字典不会破坏后续的重放结果。

模拟器的业务副作用是同一 SQLite 事务中的回执和执行计数器。真实的支付、部署或外部 API 调用,并不会因为写入了一张本地表就变得原子。生产环境需要持久化的共享数据库事务、事务性发件箱(transactional outbox),或者由上游提供方强制执行同一个幂等键。仅有进程锁既无法保护多个副本,也扛不住一次重启。

重试矩阵

先对重试分类,再实现重试。

类别示例重试规则
安全无副作用的确定性读取在弄清失败边界后,用新的 JSON-RPC ID 重试
条件性带持久幂等键的变更使用相同的键和完全相同的参数重试
不安全无业务去重的变更不要自动重试;先对账

readOnlyHint 和 idempotentHint 这类工具注解仍然只是不可信的提示。重试安全性由应用契约和服务器实现决定。

背压是正确性的一部分

SSE 生产者的进度生成速度可能快过客户端、代理或网络的消费速度。无界队列会把「慢」变成「内存耗尽」。

使用有界队列,并明确哪些东西可以丢失。

进度是可替换的。对同一个 token,较晚的进度值会取代较早的值。最终的 JSON-RPC 响应不可替换。

本课的缓冲区执行以下策略:

  1. 合并同一 token 的相邻进度。
  2. 容量用尽时丢弃最旧的进度。
  3. 将流标记为需要重新获取权威数据。
  4. 保留最终响应。
  5. 拒绝一种状态:要保留最终响应就必须丢弃另一个最终响应。

这是有界丢失加显式恢复。静默丢失不是一种策略。

代理缓冲

服务器可以正确地流式发送,而反向代理却把事件扣在缓冲区里。

对 SSE 响应,发送:

httpContent-Type: text/event-stream
Cache-Control: no-cache
X-Accel-Buffering: no

2026 年的 Streamable HTTP 规范建议使用 X-Accel-Buffering: no,让兼容的代理立即投递事件。

对于安静的长连接流,定期发出一个 SSE 注释:

text:

客户端会忽略注释行。中间设备能看到流量,就不太可能关闭空闲连接。

Keepalive 不是进度。不要仅仅因为到达一条传输层注释,就重置某个操作在语义上的空闲超时。

重连意味着重新获取

现代 Streamable HTTP 不支持通过 Last-Event-ID 续传 SSE。

subscriptions/listen 流断开之后:

  1. 用新的 JSON-RPC id 发起一个新的 listen 请求。
  2. 恢复所需的订阅过滤器。
  3. 通过权威方法重新获取受影响的工具、资源、prompt 或 Task。
  4. 按稳定标识符对应用状态去重。
  5. 不要仅仅因为响应丢失就重放一次不安全的变更。

示例恢复计划显式地把 sendLastEventId 设为 false,并列出需要重新获取的资源。

防止重连风暴

如果一万个客户端在同一秒重连,刚刚恢复的服务器会再次倒下。

使用带抖动和上限的指数退避。本课根据客户端 id 和尝试次数计算确定性抖动,以便测试可复现:

textattempt 0: up to 250 ms
attempt 1: up to 500 ms
attempt 2: up to 1000 ms
...
cap: 8000 ms

生产环境可以使用密码学安全随机数或运行时随机数。不变量是分布,而不是某个具体公式。

构建它

code/main.py 构建五个小型可靠性组件。

RequestCoordinator

  • 以空闲和最大两种截止时间启动一个在途请求;
  • 发出单调递增的进度通知;
  • 产生正确的 stdio 或 HTTP 取消信号;
  • 忽略无效的取消通知;
  • 把取消与完成的终态竞态显式化;
  • 把服务器发起的取消保留给 stdio 订阅。

MutationLedger

  • 证明在没有业务键时,两个不同的 JSON-RPC id 会执行两次变更;
  • 用文件支撑的 SQLite 事务完成键检查、模拟副作用、执行计数和结果提交;
  • 在独立的账本连接之间,对同一幂等键下参数相同的请求去重;
  • 拒绝同一个键搭配不同参数的使用;
  • 返回防御性副本,并在重新打开后保留已提交记录。

DurableTaskService

  • 确认一个取消请求;
  • 让任务保持 working,直到工作线程到达 checkpoint;
  • 演示为什么「确认」不等于最终状态。

BoundedSseBuffer

  • 在压力下合并或丢弃进度;
  • 记录需要重新获取权威数据;
  • 永不丢弃最终响应。

恢复辅助函数

  • 返回对代理安全的 SSE 头和 keepalive 注释;
  • 生成重连与重新获取计划;
  • 用确定性的指数退避加抖动来分散重试。

使用它

在仓库根目录下执行:

bashcd phases/13-tools-and-protocols/29-mcp-reliability-cancellation-and-flow-control/code
python3 main.py
python3 -m unittest discover tests -v

演示会运行核心竞态的两侧,在一个临时的文件型账本中执行一次事务去重的变更,让一个有界进度缓冲区过载,并展示一个持久 Task 如何从「已确认取消」走到「工作线程观察到取消」。

交互实验

在不添加 sleep 的情况下运行四种事件顺序。

  1. 启动请求 A,取消它,然后调用 complete()。
  2. 启动请求 B,完成它,然后投递取消。
  3. 启动请求 C,在每个空闲截止时间之前发出进度,然后越过最大截止时间。
  4. 通过 Streamable HTTP 启动请求 D,并关闭它的响应流。

记录每个场景的:

  • 请求的终态;
  • 是否存在最终响应;
  • 线上实际放置的取消信号;
  • 客户端应当忽略哪个事件。

然后把 D 改成 stdio。操作完全相同,但取消信号必须改变。

实践练习

向 MutationLedger 添加一个 reserve_inventory 变更。

要求:

  1. 键绑定 SKU、数量、租户和操作名称。
  2. 用相同键和相同参数重试时,返回第一次的预留结果。
  3. 数量发生变化后重试会失败,且不会产生第二次预留。
  4. 已经提交但丢失响应的执行,可以按键进行对账。
  5. 结果中不记录任何秘密数据或支付数据。
  6. 客户端没有提供键时,禁用自动重试。
  7. 添加一次模拟的订阅断开,并在决定下一步之前重新获取库存记录。
  8. 在屏障处启动两个账本连接并并发提交同一个键。断言只提交了一次预留。
  9. 修改第一次返回的预留对象。重放该键,证明存储的结果没有改变。
  10. 关闭并重新打开账本文件,然后按键对账该预留。

让练习保持诚实:如果库存存在于另一个服务中,请说明该服务是否接受同一个幂等键,或者是否由事务性发件箱在本地提交与远程副作用之间搭桥。

交付产物

outputs/skill-mcp-reliability-reviewer.md 是一份扁平化的可靠性评审 skill。给它一个 MCP 操作、传输方式、超时策略、重试行为、队列策略和恢复计划,它会返回竞态表、重试分类、幂等边界、流控检查项和故障夹具。

验证它

当以下陈述都成立时,本课才算完成:

  • stdio 取消发送 notifications/cancelled,且不接收任何响应。
  • Streamable HTTP 取消关闭请求流,并且不发送取消 POST。
  • 先取消后完成时,最终响应被抑制。
  • 先完成后取消时,响应保留,迟到的取消被忽略。
  • 进度可以重置空闲超时,但永远不能重置最大超时。
  • 仅更换新的 JSON-RPC id,变更会再次执行。
  • 在双连接并发竞态下,一个幂等键加相同参数只执行一次。
  • 已提交记录在重新打开后仍然存在,重放返回防御性副本。
  • 修改任意一次返回的结果都不会改变已存储的结果。
  • 有界缓冲区保持在容量之内,并保留最终响应。
  • 重连使用新请求,不发送 Last-Event-ID,并重新获取受影响状态。
  • tasks/cancel 的确认会让任务保持非终止状态,直到工作线程观察到它。

生产环境故障模式

故障可观察症状正确响应
HTTP 客户端 POST 取消通知服务器与客户端对请求生命周期理解不一致关闭该请求的 SSE 响应流
服务器在已接受的取消之后仍然响应客户端收到无法使用的迟到结果取消获胜时停止工作并抑制后续消息
进度会重置每一个截止时间卡住的工作永远存活保留独立的绝对最大超时
把新的 RPC id 当作去重扣款、部署或删除执行两次增加持久化的应用层幂等键
键检查与副作用分离并发工作线程都观察到键不存在原子地提交键声明、副作用记录和结果
跨副本使用内存账本重启或另一个工作线程忘掉此前的提交使用共享持久存储或上游幂等
直接返回存储的可变结果调用方的修改破坏后续重放序列化已提交结果并返回防御性副本
同一个键搭配了变更后的参数一个键别名了两个业务意图存储并比较参数指纹
无界的进度队列遇到慢消费者时内存持续上升在界内合并并丢弃可替换的进度
压力下丢弃最终响应客户端无法得知请求结果预留容量或驱逐进度,绝不丢最终响应
代理缓冲 SSE进度成批到达或超时之后才到关闭缓冲并配置兼容的代理超时
假定支持 Last-Event-ID客户端从服务器并不支持的状态恢复用新请求重连并重新获取
每个客户端都立即重连恢复过程制造了第二次故障使用带抖动、有上限的指数退避
把任务确认当作最终取消界面显示已停止,但工作线程仍在运行轮询 Task 直到进入终止状态

项目关联

工具生态项目应把可靠性当作可执行证据,而不是架构图里的一段文字。

要求提供以下产物:

  • 每种传输一份取消竞态执行记录;
  • 为每一个对外暴露的变更提供重试表;
  • 一份幂等键记录和一个不匹配的故障夹具;
  • 一份同键并发记录、一次重新打开检查,以及一次变更别名检查;
  • 一份有界缓冲区过载结果;
  • 反向代理的 SSE 头和空闲策略;
  • 一份重连计划,其中指名权威的重新获取方法;
  • 当项目使用 Task 时,提供一份持久 Task 的取消轨迹。

本地进程里的绿色请求只能证明正常路径。当丢失的响应、迟到的取消、慢速消费者和重连风暴都有确定性结果时,项目才算达到生产就绪。

关键术语

术语含义
请求取消放弃一个在途的 MCP 请求
取消竞态终态完成事件与取消事件之间的竞争
空闲超时距上一次有效请求活动的时间上限
最大超时从请求开始计算的绝对上限,不受进度影响
幂等键用于对一项业务意图去重的应用层标识符
原子账本把键声明、副作用记录和结果作为一个单元持久提交的边界
背压生产者速度超过消费者时施加的控制
进度合并用更新的权威值替换较早的进度
重新获取在流出现缺口后重新读取当前状态
抖动有意引入的变化,用于把重试分散到不同时间

延伸阅读

“Stay hungry, stay foolish.”

— Steve Jobs

esc
↑↓切换↵打开esc关闭