AI 技术
#MCP#SSE#Streamable HTTP#断线重连#事件重放

MCP Streamable HTTP 的 Last-Event-ID 重放:断线后事件为什么会重复或丢失

远程 MCP Server 用 SSE 推送工具进度时,客户端断线重连会带上 Last-Event-ID 请求续传。本文解释事件 ID 如何标记流位置、服务端如何从缓冲区重放,并分析 ID 非单调、缓冲区驱逐和无状态部署导致重复或丢失的条件,给出可落地的校验点与观测指标。

一次断线重连暴露的问题

设想一个部署在公网上的远程 MCP Server,负责执行耗时较长的数据导出工具。客户端通过 POST 发起 tools/call 后,服务端返回 text/event-stream,先推送若干条进度通知,最后推送工具结果。用户在导出进行到一半时切换了 Wi-Fi,TCP 连接断开,客户端随后自动重连。重连后,客户端收到的进度条从 30% 跳回 10%,或者干脆从 30% 直接跳到 100%,中间的 20% 永远没有出现。

这两种表现对应两个不同故障:重复和丢失。它们都发生在“客户端告诉服务端自己看到了哪里”这一步。MCP 的 Streamable HTTP 传输允许服务端给 SSE 事件附加 id,客户端重连时用 Last-Event-ID 请求头声明最后收到的事件 ID,服务端据此重放后续事件。规范把是否支持续传定为可选项(MAY),这意味着大量实现只做了“能连上”,没有做“接得上”。

这篇文章围绕上面这个导出工具场景,拆开事件 ID 的语义、服务端缓冲区的生命周期和重连时的匹配逻辑,说明重复与丢失分别在什么条件下发生,以及怎样用校验点和指标把它们定位出来。

事件 ID 标记的是流位置,不是消息序号

SSE 标准里,事件流由若干字段行组成,id: 行设置事件 ID,data: 行携带负载。浏览器端的 EventSource 会维护一个“最后事件 ID 字符串”,每次收到带 id 的事件就更新它。连接断开后自动重连时,这个值会放进 Last-Event-ID 请求头。这是 SSE 协议层的行为,与 MCP 无关。

MCP 规范在此基础上加了一条约束:如果服务端给 SSE 事件附加了 id,该 ID 必须在同一会话的所有流之间全局唯一;未启用会话管理时,则必须在同一客户端的所有流之间唯一。这条约束针对的正是“客户端只记得一个字符串”这一事实。客户端无法区分这个 ID 来自哪条流,服务端必须保证任意一个 ID 都能唯一定位到一条流上的一个位置。

工程上通常把事件 ID 设计成“流标识 + 单调递增序号”的组合,例如 stream-7f3a:42。流标识让服务端在重连时找到正确的缓冲区,序号让服务端知道从哪一条之后开始重放。只用一个全局自增整数也能满足唯一性,但服务端需要额外维护“这个 ID 属于哪条流”的映射。

这里有一个容易忽略的细节:SSE 的 id 字段在解析时是字符串,比较时也是字符串。如果服务端用 10 和 9 这样的十进制字符串,客户端只会原样回传,不会做数值比较。服务端若在重放逻辑里把 ID 转成整数再比较,需要自己保证转换前后语义一致。

重连时服务端要做的三步匹配

客户端发起重连时,按 MCP 规范应使用 HTTP GET 请求 MCP 端点,并带上 Last-Event-ID 头。服务端收到这个请求后,需要完成三件事,缺一件就会出问题。

第一,识别这条重连属于哪条流。如果事件 ID 里编码了流标识,直接解析即可;如果只有序号,就要靠会话 ID 或其他请求头辅助。识别失败时,服务端只能开一条新流,从当前位置继续推送,断线期间的事件全部丢失。

第二,在缓冲区里定位 Last-Event-ID 对应的位置。缓冲区是按流组织的已发送事件列表,每条记录包含事件 ID 和完整的 SSE 负载。服务端找到匹配项后,把它之后的所有事件按原顺序重新发送。如果找不到匹配项,说明该事件已被驱逐或从未存在,服务端无法判断客户端缺了哪些内容。

第三,决定无法重放时的行为。MCP 规范没有规定服务端必须报错,也没有规定必须补发快照。工程上常见三种选择:返回错误让客户端重新发起整个请求;从缓冲区最旧的事件开始重放,接受重复;直接开新流,接受丢失。三种选择对应不同的正确性语义,必须和客户端约定清楚。

下面的流程图展示这个场景中一次完整的断线与重连过程,包括缓冲区命中与未命中两条分支。

flowchart TD
    A[客户端 POST tools/call] --> B[服务端开 SSE 流]
    B --> C[推送进度事件 id=stream-7f3a:1]
    C --> D[推送进度事件 id=stream-7f3a:2]
    D --> E[网络断开]
    E --> F[客户端 GET 并带 Last-Event-ID: stream-7f3a:2]
    F --> G{缓冲区中是否存在该 ID}
    G -->|存在| H[重放 id 之后的事件]
    H --> I[继续推送新事件直至结果]
    G -->|不存在| J{服务端策略}
    J -->|报错| K[客户端重新发起 tools/call]
    J -->|从最旧重放| L[客户端收到重复事件]
    J -->|开新流| M[客户端缺失中间事件]

图中的关键转折点是判断框 缓冲区中是否存在该 ID。它决定了后续是正常续传,还是进入三种降级路径之一。

重复从哪来:ID 非单调与重放起点偏移

重复事件最直接的原因是重放起点算错。服务端在缓冲区里找到 Last-Event-ID 后,应当从它的下一条开始发送。如果实现里用了“大于等于”而不是“大于”,客户端会再收到一遍最后那条事件。对于进度通知,重复一条通常无害;对于“文件已写入”这类有副作用的通知,重复就可能触发两次下游动作。

更隐蔽的重复来自事件 ID 非单调。假设服务端把事件 ID 设计成时间戳加随机后缀,或者用数据库自增主键但多个工作协程并发写入,缓冲区里的顺序就可能与 ID 的字典序不一致。客户端回传的 Last-Event-ID 是它最后收到的那个字符串,服务端按 ID 排序查找位置,找到的可能是缓冲区中间某处,于是把它之后的事件全部重发,其中一部分客户端已经处理过。

还有一种情况发生在多流并发时。MCP 规范要求服务端把每条 JSON-RPC 消息只发在一条流上,不得广播。但一个客户端可能同时保持多条 SSE 流,例如一条用于工具进度,一条用于服务端主动发起的请求。如果服务端给不同流分配了相同的事件 ID 前缀,客户端在流 A 上记录的 Last-Event-ID 被用于流 B 的重连,服务端在流 B 的缓冲区里找不到,或者错误地匹配到同名前缀的另一条记录,重放结果就不可预期。规范要求 ID 在会话内全局唯一,正是为了排除这种歧义。

判断重复是否由 ID 非单调引起,可以看一个信号:客户端收到的重复事件,其 ID 在时间上早于 Last-Event-ID 之后本应出现的事件。换句话说,重放序列不是原序列的连续子串。

丢失从哪来:缓冲区驱逐与无状态部署

丢失的根源是服务端在重连发生时已经不具备重放所需的信息。

缓冲区驱逐是最常见的一种。服务端为了控制内存,通常给每个会话的缓冲区设上限,例如保留最近 100 条事件或最近 5 分钟。导出工具如果推送频率高,断线 30 秒就可能产生超过上限的事件,客户端回传的 Last-Event-ID 已被挤出缓冲区。此时服务端知道“这个 ID 我不认识”,但不知道客户端到底缺了多少。

无状态部署让问题更彻底。MCP 的 Streamable HTTP 允许服务端作为独立进程处理多个连接,但规范并未要求服务端在内存里保存会话状态。如果服务端部署在多个实例后面,且没有共享存储,客户端重连时被负载均衡器分到另一个实例,那个实例的缓冲区里根本没有这条流。即使事件 ID 全局唯一,新实例也无从重放。

规范对这种情况留了余地:服务端可以给 SSE 事件附加 id,但这是可选的;服务端可以把流做成可续传的,这也是可选的。一个只实现最小功能的 Server 完全可能既不附加 ID,也不保留缓冲区。客户端此时带上 Last-Event-ID,服务端直接忽略,开一条新流。从协议角度看这没有违规,从用户角度看就是进度丢失。

还有一种丢失不发生在重连瞬间,而发生在断线期间。如果服务端在客户端断开后继续执行工具,并把事件写入缓冲区,重连时可以补发。如果服务端在检测到连接关闭后就终止了工具执行,那么重连后客户端拿到的是一个已经结束或从未开始的结果,中间事件自然不存在。MCP 规范明确说断开连接不应被解释为客户端取消请求,取消要通过 CancelledNotification 显式发出。服务端是否遵守这一点,直接决定断线期间有没有事件可补。

与 JSON 单次响应的对比

MCP 允许服务端对 tools/call 直接返回 application/json 的单个 JSON 对象,而不是开 SSE 流。两种方式在断线场景下的行为差异很大,选择哪一种取决于工具是否需要中间进度。

维度SSE 流式响应JSON 单次响应
断线时的中间状态可能已推送部分事件,需要重放才能对齐没有中间状态,请求要么完成要么未完成
重复风险重放起点算错或 ID 非单调时出现不存在事件级重复
丢失风险缓冲区驱逐或无状态实例导致无法补发请求失败后由客户端整体重试
服务端状态需要按流保存缓冲区,可选但常见无需保存流状态
客户端复杂度需处理事件 ID、重连、去重只需处理请求成功或失败
适用场景长耗时工具、需要进度反馈短耗时工具、结果不可分
内存成本随事件数量和保留时长增长仅持有最终结果
可观测性可观察事件级延迟与重放次数只能观察请求级延迟与错误率

这张表的核心判断是:SSE 换来了进度可见性,代价是引入了需要服务端保存状态的续传问题。如果工具执行时间在秒级以内,JSON 单次响应让客户端用一次重试解决失败,比维护事件缓冲区简单得多。如果工具要跑几分钟,用户需要看到进度,SSE 几乎是唯一选择,此时必须把重放语义设计清楚。

一个折中做法是让工具结果本身幂等,并在 SSE 流中只推送可丢弃的进度通知,最终结果通过一次独立的 tools/call 或资源读取获取。这样即使进度事件重复或丢失,也不影响最终结果的正确性。代价是客户端需要多一次往返。

校验点与可观测指标

要在生产环境定位重复或丢失,需要在客户端和服务端各埋几个校验点。

客户端侧,记录每次收到事件的 ID 和接收时间,维护一个已处理 ID 的集合。重连发生后,检查重放序列的第一个 ID 是否紧接在 Last-Event-ID 之后。如果不是,说明服务端重放起点偏移。同时统计重连次数、每次重连携带的 Last-Event-ID、重连后收到的事件数量。如果某次重连后事件数量为零但工具仍在运行,说明服务端没有可重放的内容。

服务端侧,记录每条流的创建时间、事件总数、缓冲区当前水位、被驱逐的最旧事件 ID。重连请求到达时,记录 Last-Event-ID 是否在缓冲区中命中。命中率是一个直接指标:命中率持续偏低,说明缓冲区太小或客户端重连太慢。还要记录重连请求被路由到了哪个实例,以及该实例是否持有对应会话。

以下指标值得放进监控面板:

  • 重连命中率:Last-Event-ID 在缓冲区中找到的比例。
  • 重放事件数分布:每次重连补发多少条事件,异常大可能意味着客户端长时间离线。
  • 重复事件率:客户端按 ID 去重时丢弃的比例。
  • 缓冲区驱逐速率:单位时间内被挤出的事件数。
  • 跨实例重连比例:重连请求落到非原实例的比例,用于判断是否需要共享缓冲区。

这些指标里,重连命中率最能区分“设计问题”和“容量问题”。命中率低但缓冲区水位不高,通常是事件 ID 设计或流识别出了问题;命中率低且驱逐速率高,则是保留窗口不够。

部署边界与仍未解决的问题

要让重放可靠,服务端需要满足几个前提:事件 ID 在会话内全局唯一且单调;缓冲区按流组织并能按 ID 定位;重连请求能路由到持有缓冲区的实例,或者缓冲区放在共享存储里;服务端在客户端断开后继续执行工具并把事件写入缓冲区。任何一个前提不成立,重放就会退化为重复、丢失或直接开新流。

多实例部署下,把缓冲区放进共享存储会引入新的延迟和一致性问题:事件写入共享存储后才能被重连请求读到,这中间有一个窗口。如果客户端在写入完成前重连,仍然可能命中失败。另一种做法是用一致性哈希把同一会话的请求固定到同一实例,但这在实例扩缩容时会失效。

MCP 规范把续传定为可选,意味着客户端不能假设所有服务端都支持。客户端在重连后如果发现事件序列不连续,应当有能力重新发起整个工具调用,而不是继续拼接不完整的结果。这要求工具本身可重入,或者至少能识别重复执行。

还有一个开放问题:当服务端无法重放时,协议没有定义一种标准方式让服务端告诉客户端“你缺了一段”。客户端只能从事件内容推断,例如进度从 30% 跳到 70%。如果事件负载里带有累计状态,客户端可以据此判断是否连续;如果没有,就只能依赖应用层约定。这使得重放的正确性在很大程度上取决于实现者是否愿意多写一些状态,而规范本身并不强制。

资料来源

  1. MCP Specification - Streamable HTTP Transport
  2. MCP Specification - Transports (2025-03-26)
  3. WHATWG HTML Standard - Server-Sent Events
  4. What Is MCP? The Model Context Protocol Explained (2026) | The AI Rankings