MCP 可靠性、取消与流量控制
请求 ID 只用于关联消息。它不会让副作用变得安全、停止 worker,也无法保护流免受慢消费者的影响。
类型: Build 语言: Python 前置要求: 第 13 阶段,第 09 和 13 课 预计时间: 约 120 分钟
学习目标
- 为 stdio 和 Streamable HTTP 实现正确的取消信号。
- 在取消和完成的竞态中正确决断,不在取消后发送消息。
- 区分请求取消与持久化的
tasks/cancel语义。 - 基于副作用和显式幂等键构建重试决策。
- 在保留最终响应的同时限制进度队列。
- 通过重连、重新获取和带抖动的退避恢复流。
问题背景
顺利路径掩盖了最昂贵的分布式系统 bug。
客户端调用一个工具。服务器开始工作。进度陆续到达。代理缓冲了流。客户端达到超时并断开连接。服务器一毫秒后完成。客户端使用新的 JSON-RPC ID 重试。变更被执行了两次。
每个组件在本地都表现正常,系统却在全局上失败了。
MCP 定义了消息和传输行为,但你的应用仍要负责:
- 时间预算;
- 业务幂等性;
- 有界队列;
- 重试分类;
- 持久化任务状态;
- 重连和重新获取策略。
本课把这些决策构建进一个确定性模拟器。这里没有睡眠、套接字或随机失败。你可以直接控制取消事件的顺序。一个同步线程测试会强制两个账本客户端竞争同一个幂等键。
请求取消取决于传输方式
每种传输方式的意图相同:客户端不再需要正在进行的结果。线路信号则不同。
stdio
stdio 使用一个共享的双向通道。客户端发送一条通知:
{
"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 请求。将这条路径与普通客户端请求取消分开。
取消是一场竞态
两种事件顺序都有效。
取消获胜
request starts
client sends cancellation signal
server marks request cancelled
worker reaches completion
server suppresses the response完成获胜
request starts
worker commits the result
server sends the response
cancellation arrives late
server ignores the late notification客户端也必须忽略自己已经放弃的请求的迟到响应。网络延迟意味着双方都无法证明对方先观察到了哪个事件。
mcp-reliability-race本课的 RequestCoordinator 存储一个终态。取消后,complete() 不返回响应。迟到的取消不能改变已完成的记录。
超时需要两只时钟
单个不活动计时器不够。
使用两个限制:
- 空闲超时。 请求可以多久不产生有用活动。
- 最大超时。 从请求开始算起的绝对墙上时钟预算。
进度可以重置空闲时钟,但绝不能取消最大截止时间。
start: 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 到达,最大截止时间仍会取消它。
进度是可选的。服务器可以接受进度 token 而不发出更新。绝不能因为存在 token 就把超时变成无限期。
MCP 进度值必须递增。完成或取消后,通知停止。对进度做速率限制,避免快速 worker 淹没传输层。
请求取消不是 tasks/cancel
这些机制解决的是不同的生命周期。
| 机制 | 目标 | 信号 | 成功意味着什么 |
|---|---|---|---|
| stdio 上的请求取消 | 一个正在进行的 RPC | notifications/cancelled | 客户端已放弃请求;服务器应在可行时停止 |
| HTTP 上的请求取消 | 一个正在进行的响应流 | 关闭流 | 客户端已放弃请求;服务器应在可行时停止 |
tasks/cancel | 一个持久化 Task | 普通 MCP 请求 | 服务器已确认取消意图 |
成功的 tasks/cancel 结果并不能证明 worker 已停止。Task 可能保持 working,直到某个 worker 检查点观察到该标志。工作也可能在那个检查点之前完成。
HTTP 连接关闭时,不要删除持久化任务状态。创建 Task 的原因正是其生命周期超出单个请求和单个连接。
新的 JSON-RPC ID 不是幂等性
JSON-RPC ID 用于关联请求和响应。它们不标识业务操作。
假设一个客户端以 ID 41 提交扣款,丢失响应后以 ID 42 重试。服务器看到两条不同消息。没有应用键,它无法知道它们代表同一次结账。
幂等键标识业务意图:
{
"name": "charge_account",
"arguments": {
"account": "acct-7",
"cents": 1200,
"idempotencyKey": "checkout-7"
}
}服务器存储:
- 该键;
- 操作参数的指纹;
- 已提交的结果。
相同键和相同参数会返回已存储的结果。相同键但参数不同会被拒绝。这可防止意外复用键去变更另一个业务操作。
账本边界必须原子且持久化
下面的顺序不安全:
check key
run mutation
store result两个 worker 都可能观察到键不存在,然后都执行变更。在副作用发生后、存储前发生崩溃,也会在重试时造成同样的歧义。
本课使用文件后端的 SQLite 账本。BEGIN IMMEDIATE 将键检查、模拟的业务副作用、执行计数器和存储结果串行化为一个事务。因此,使用相同键竞态的两个独立账本连接会观察到一个已提交结果和一次执行。关闭并重新打开账本后,该记录仍会保留。
每个返回值都从存储的 JSON 重建。调用方永远不会收到账本持有的可变对象,因此修改返回的字典不会破坏之后的重放结果。
模拟器的业务副作用是同一个 SQLite 事务内的收据和执行计数器。真实的付款、部署或外部 API 调用不会仅因写入本地表就变成原子操作。生产环境需要持久化的共享数据库事务、事务性发件箱,或强制执行同一幂等键的上游提供方。仅靠进程锁既不能保护多个副本,也无法在重启后存续。
重试矩阵
实现重试前先对其分类。
| 分类 | 示例 | 重试规则 |
|---|---|---|
| 安全 | 没有副作用的确定性读取 | 在理解失败边界后,使用新的 JSON-RPC ID 重试 |
| 有条件 | 带持久化幂等键的变更 | 使用相同键和完全相同的参数重试 |
| 不安全 | 没有业务去重的变更 | 不要自动重试;先对账 |
readOnlyHint 和 idempotentHint 等工具注解仍是不可信的提示。重试是否安全由应用契约和服务器实现决定。
背压是正确性的一部分
SSE 生产者生成进度的速度可能快于客户端、代理或网络的消费速度。无界队列会把缓慢转化为内存耗尽。
使用有界队列,并定义什么可以丢失。
进度可以替换。同一 token 的较晚进度值会取代较早的值。最终 JSON-RPC 响应不可替换。
本课的缓冲区应用以下策略:
- 合并同一 token 的相邻进度。
- 达到容量时丢弃最早的进度。
- 标记该流需要重新从权威来源获取。
- 保留最终响应。
- 如果保留最终响应必须丢弃另一个最终响应,则拒绝这种状态。
这是一种带显式恢复的有界丢失。静默丢失不是策略。
代理缓冲
即便服务器正确地流式传输,反向代理也可能将事件保留在缓冲区中。
对于 SSE 响应,发送:
Content-Type: text/event-stream
Cache-Control: no-cache
X-Accel-Buffering: no2026 年版 Streamable HTTP 规范建议设置 X-Accel-Buffering: no,以便兼容的代理立即交付事件。
对于安静的长连接流,定期发送一条 SSE 注释:
:客户端会忽略注释行。中间件看见流量后,不太可能关闭空闲连接。
保活不是进度。不要仅因收到了传输注释,就重置某个操作的语义空闲超时。
重连意味着重新获取
现代 Streamable HTTP 不通过 Last-Event-ID 支持可恢复的 SSE。
在 subscriptions/listen 流中断后:
- 使用新的 JSON-RPC ID 打开新的 listen 请求。
- 恢复所需的订阅过滤器。
- 从权威方法重新获取受影响的工具、资源、prompt 或 Task。
- 使用稳定标识符对应用状态去重。
- 不要仅因响应丢失就重放不安全的变更。
示例恢复计划将 sendLastEventId 显式设为 false,并列出要重新获取的资源。
防止重连羊群效应
如果 10,000 个客户端都恰好在一秒后重连,正在恢复的服务器会再次失败。
使用带抖动和上限的指数退避。本课根据客户端 ID 和尝试次数计算确定性抖动,以便测试保持可复现:
attempt 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
- 确认一个取消请求;
- 在 worker 检查点之前让 Task 保持
working; - 展示为什么确认不等于最终状态。
BoundedSseBuffer
- 在压力下合并或丢弃进度;
- 记录需要从权威来源重新获取;
- 永不丢弃最终响应。
恢复辅助工具
- 返回对代理安全的 SSE 标头和保活注释;
- 创建重连和重新获取计划;
- 通过确定性的指数退避和抖动分散重试。
实际使用
从仓库根目录运行:
cd phases/13-tools-and-protocols/29-mcp-reliability-cancellation-and-flow-control/code
python3 main.py
python3 -m unittest discover tests -v演示会运行核心竞态的双方,在临时文件后端账本中执行由事务去重的变更,使有界进度缓冲区过载,并展示持久化 Task 从已确认取消变为被 worker 观察到的取消。
交互实验
无需添加睡眠,运行四种事件顺序。
- 启动请求
A,取消它,然后调用complete()。 - 启动请求
B,完成它,然后递交取消。 - 启动请求
C,在每次空闲截止时间之前发出进度,然后越过最大截止时间。 - 通过 Streamable HTTP 启动请求
D,并关闭其响应流。
为每个场景记录:
- 请求的终态;
- 是否存在最终响应;
- 线路上放置的取消信号;
- 客户端应忽略哪个事件。
然后把 D 改为 stdio。操作完全相同,但取消信号必须改变。
实践实验
向 MutationLedger 添加一个 reserve_inventory 变更。
要求:
- 键绑定 SKU、数量、租户和操作名称。
- 使用相同键和相同参数重试时,返回第一次预留。
- 使用改变后的数量重试会失败,且不会产生另一次预留。
- 已提交但丢失响应的执行可以按键对账。
- 结果不记录任何秘密或付款数据。
- 当客户端没有提供键时,禁用自动重试。
- 添加一次模拟订阅中断,并在决定下一步之前重新获取库存记录。
- 在屏障处启动两个账本连接,并发提交相同键。断言只提交了一次预留。
- 修改第一个返回的预留对象。重放该键,并证明存储结果没有变化。
- 关闭并重新打开账本文件,然后按键对账预留。
让实验保持诚实:如果库存存放在另一个服务中,说明该服务是否接受同一个幂等键,或事务性发件箱是否将本地提交桥接到远程副作用。
交付产物
outputs/skill-mcp-reliability-reviewer.md 是一个扁平的可靠性评审 skill。向它提供一个 MCP 操作、传输方式、超时策略、重试行为、队列策略和恢复计划。它会返回竞态表、重试分类、幂等性边界、流量控制检查和失败夹具。
验证
当以下陈述均为真时,本课完成:
- stdio 取消会发送
notifications/cancelled,且不接收响应。 - Streamable HTTP 取消会关闭请求流,且不发送取消 POST。
- 先取消后完成会抑制最终响应。
- 先完成后取消会保留响应,并忽略迟到的取消。
- 进度可以重置空闲超时,但绝不能重置最大超时。
- 仅使用新的 JSON-RPC ID 会再次执行变更。
- 一个幂等键和相同参数会在并发的双连接竞态下执行一次。
- 已提交记录在重新打开后仍保留,重放会返回防御性副本。
- 修改一个返回结果不会改变存储结果。
- 有界缓冲区保持在容量内并保留最终响应。
- 重连使用新请求,不发送
Last-Event-ID,并重新获取受影响状态。 tasks/cancel确认会使 Task 在 worker 观察到它之前保持非终态。
生产环境故障模式
| 故障 | 可观测症状 | 正确响应 |
|---|---|---|
| HTTP 客户端 POST 取消通知 | 服务器和客户端对请求生命周期存在分歧 | 关闭请求的 SSE 响应流 |
| 服务器在接受取消后响应 | 客户端收到不可用的迟到结果 | 取消获胜时,停止工作并抑制后续消息 |
| 进度重置每个截止时间 | 挂起的工作永久存活 | 保留单独的绝对最大超时 |
| 将新的 RPC ID 视为去重 | 扣款、部署或删除执行两次 | 添加持久化应用幂等键 |
| 键检查和副作用相互分离 | 并发 worker 都观察到键不存在 | 原子提交键声明、副作用记录和结果 |
| 跨副本使用内存账本 | 重启或另一个 worker 忘记先前提交 | 使用共享持久化存储或上游幂等性 |
| 直接返回存储的可变结果 | 调用方的变更破坏之后的重放 | 序列化已提交结果并返回防御性副本 |
| 用改变后的参数复用键 | 一个键别名化两个业务意图 | 存储并比较参数指纹 |
| 无界进度队列 | 慢消费者导致内存持续增长 | 在边界内合并并丢弃可替换进度 |
| 在压力下丢弃最终响应 | 客户端无法知道请求结果 | 预留容量或驱逐进度,绝不驱逐最终响应 |
| 代理缓冲 SSE | 进度成批到达或在超时后到达 | 禁用缓冲并配置兼容的代理超时 |
假定存在 Last-Event-ID | 客户端从服务器不支持的状态恢复 | 使用新请求重连并重新获取 |
| 每个客户端立即重连 | 恢复过程造成另一次故障 | 使用带抖动且有上限的指数退避 |
| 将任务确认视为最终取消 | UI 显示已停止后 worker 仍在运行 | 轮询 Task,直到其达到终态 |
综合项目关联
工具生态系统综合项目应把可靠性视为可执行证据,而不是架构图中的一段文字。
要求以下产物:
- 每种传输方式各一份取消竞态记录;
- 每个暴露变更的一张重试表;
- 一份幂等键记录和不匹配夹具;
- 一份并发同键记录、重新打开检查和变更别名检查;
- 一项有界缓冲区过载结果;
- 反向代理 SSE 标头和空闲策略;
- 一份点名权威重新获取方法的重连计划;
- 当综合项目使用 Tasks 时,一条持久化 Task 取消跟踪记录。
本地进程中一次成功的请求只能证明顺利路径。当丢失响应、迟到取消、慢消费者和重连羊群都有确定性结果时,综合项目才算具备生产就绪性。
关键术语
| 术语 | 含义 |
|---|---|
| 请求取消 | 放弃一个正在进行的 MCP 请求 |
| 取消竞态 | 终态完成事件与取消事件之间的竞争 |
| 空闲超时 | 自上一次有用请求活动起的限制 |
| 最大超时 | 从请求开始算起、不受进度影响的绝对限制 |
| 幂等键 | 对一个业务意图去重的应用标识符 |
| 原子账本 | 将键声明、副作用记录和结果作为一个单元提交的持久化边界 |
| 背压 | 当生产者超过消费者速度时施加的控制 |
| 进度合并 | 用较新的权威值替换较早进度 |
| 重新获取 | 在流出现缺口后再次读取当前状态 |
| 抖动 | 故意引入的变化,用于将重试分散到不同时间 |