AI 技术
#MCP#JSON-RPC#批量请求#并发#客户端实现

MCP 的 JSON-RPC 批量请求:响应乱序时客户端为何会配对错乱

批量发送 MCP 请求时,服务端可以并发处理并按任意顺序返回响应。本文从一次并行读取多个资源的客户端场景出发,解释 JSON-RPC 2.0 的 id 配对规则、服务端并发导致乱序的机制、id 重复或缺失时错配的具体表现,并给出校验点、可观测指标与逐条请求方案的取舍。

一次并行读取多个资源的批量调用

假设一个 MCP 客户端要在一次对话准备阶段同时取回三样东西:两个资源模板实例的正文,以及一份工具列表。为了少发几次网络往返,客户端把三个请求装进一个 JSON 数组,一次发给服务端,并给它们分配了 id 1、2、3。

服务端返回的数组里,第一个元素是 id 为 2 的资源正文,第二个是 id 为 3 的工具列表,第三个才是 id 为 1 的资源正文。如果客户端按数组下标把第 0 个响应当成第 0 个请求的结果,它会把资源 B 的正文塞进资源 A 的上下文,把工具列表当成资源正文交给上层。工具调用可能因此选错方法,模型也可能基于错误资料作答,而日志里看不到任何报错。

这种错配不是服务端实现有 bug。JSON-RPC 2.0 规范明确允许批量调用并发处理、乱序返回,配对责任在客户端。下面把这个规则、服务端为什么乱序、以及客户端该在哪里设防讲清楚。

规范怎么规定批量调用的顺序

JSON-RPC 2.0 规范对批量调用的描述很直接:客户端可以发送一个包含多个请求对象的数组;服务端可以并发地、以任意顺序和任意并行宽度处理这些请求;响应对象可以任意顺序地放在返回数组里;客户端应该基于每个响应对象中的 id 成员来匹配对应的请求对象。

这里有两个容易混淆的点。

第一,id 的语义是“关联上下文”。规范规定,请求中如果包含 id,服务端必须在响应中回复相同的值。这个成员存在的唯一目的,就是让客户端把响应和请求对上。它不承担排序职责,也不保证单调递增。

第二,没有 id 的请求是通知(Notification),服务端必须不回复,批量请求里的通知同样如此。这意味着返回数组的长度通常小于请求数组的长度,按下标配对从长度上就已经不成立。规范给出的批量示例里,一个包含通知和无效请求的批次,返回的响应数量与请求数量并不相等。

资料 4 提到“规范未强制要求顺序,但建议保持请求与响应顺序一致”。这是该文的实现建议,不是规范义务。把建议当成保证,正是客户端写出按下标取值的根源。

服务端并发处理为什么会打乱顺序

批量请求到达服务端后,常见的处理方式是把数组里的每个元素分派给工作队列,由多个执行单元并行处理,全部完成后再把结果收集成数组返回。三个请求的处理耗时不同,完成顺序自然不同。

以贯穿场景为例:id 1 的资源读取要查磁盘或对象存储,耗时较长;id 2 的资源命中缓存,很快返回;id 3 的工具列表是内存中的静态数据,几乎立即完成。如果服务端按完成顺序追加结果,返回数组就是 2、3、1。

flowchart TD
    A[客户端组装批量数组 id 1 2 3] --> B[服务端解析数组]
    B --> C1[分派 id 1 资源读取]
    B --> C2[分派 id 2 缓存资源]
    B --> C3[分派 id 3 工具列表]
    C2 --> D[完成 写入结果槽]
    C3 --> D
    C1 --> D
    D --> E[按完成顺序收集响应数组]
    E --> F[客户端按 id 建索引配对]
    F --> G{id 全部唯一且齐全}
    G -->|是| H[正确分发到各调用方]
    G -->|否| I[错配 丢弃 或超时]

图中关键转折在“按完成顺序收集”这一步。只要服务端不做额外的重排,返回顺序就由各请求的实际耗时决定,而耗时又受缓存命中、下游服务响应、锁竞争影响,同一批次两次发送都可能得到不同顺序。客户端不能把某次观察到的顺序当成稳定契约。

MCP 的传输层不改变这个结论。stdio 传输按行分隔 JSON-RPC 消息,Streamable HTTP 传输允许服务端用 SSE 流陆续发送消息。资料 2 说明,服务端在 SSE 流上可以先发送与本次请求相关的其他 JSON-RPC 请求和通知,再发送响应。也就是说,客户端从流里读到的消息序列本身就不保证与发送顺序对应。

客户端按位置配对的三种错配形态

按下标配对在三种情况下会出错,表现各不相同。

(1)纯乱序

请求和响应的 id 集合完全一致,只是顺序不同。客户端把第 i 个响应当成第 i 个请求的结果。这类错配最隐蔽:类型检查可能通过,因为资源正文和工具列表都是结构化数据,字段名不同但反序列化不报错。错误会延迟到上层使用数据时才暴露,比如模型引用了不存在的资源。

(2)id 重复

客户端生成 id 时用了时间戳、自增计数器重置、或从多个并发调用方各自取号,导致同一批次里出现两个 1。服务端按规范回复两个 id 为 1 的响应,客户端按 id 建索引时后一个覆盖前一个,两个调用方拿到同一份结果,另一个请求的结果丢失。规范要求 id 由客户端建立,但未规定服务端必须拒绝重复 id,因此这类问题通常不会产生协议错误。

(3)id 缺失或为 null

服务端在解析请求失败时,可能返回 id 为 null 的错误响应,规范对此有明确规定:检测不到请求 id 时,响应中的 id 必须为 null。批量数组里混入一个格式错误的元素时,返回数组里就会出现一个 null id 的响应。客户端如果按 id 建索引,这个响应无处安放;如果按下标配对,它会被错配给某个正常请求,把解析错误伪装成该请求的失败。

还有一种情况是服务端实现缺陷:把某个请求的响应 id 写错或漏写。此时客户端按 id 查找会失败,必须显式处理“找不到对应请求的响应”,而不是静默丢弃。

客户端应该在哪里设校验点

配对逻辑应集中在一处,输入是“已发送请求的 id 到调用方回调的映射”,输出是“每个响应的归属”。建议设置以下校验点。

发送前:为每个请求分配批次内唯一的 id,并在本地登记 id → 请求元数据。id 用字符串或整数均可,规范允许 String、Number 或 NULL,但明确不建议用 null,也不建议用带小数的数值,因为小数在二进制浮点里可能无法精确表示,跨语言比较时容易出问题。

接收时:先校验 jsonrpc 字段为 "2.0",再检查响应是否同时含有 result 和 error。规范要求二者必须且只能出现一个,同时出现说明服务端实现有问题。

配对时:用响应 id 查本地映射。查到则交付并删除映射项;查不到则记录一条“未知 id 响应”指标,并检查是否为 null id 的解析错误。

收尾时:批次结束后,本地映射里剩余的 id 就是没有收到响应的请求。它们可能因服务端崩溃、连接中断或通知语义被丢弃。客户端应对这些请求触发超时或重试,不能无限等待。

下面的伪代码只表达状态变化,不依赖任何具体 SDK:

pending = {}                     # id -> 调用方回调
for req in batch:
    assert req.id not in pending # 批次内唯一
    pending[req.id] = req.caller
send(batch)

for resp in responses:
    if resp.id is null:
        metrics.inc("batch.null_id_response")
        continue
    caller = pending.pop(resp.id, None)
    if caller is None:
        metrics.inc("batch.unknown_id_response")
        continue
    caller.deliver(resp.result if has_result else resp.error)

for id, caller in pending:
    metrics.inc("batch.missing_response")
    caller.fail_with_timeout()

这里的关键状态是 pending 字典。它同时承担配对和完整性检查两个职责:pop 成功说明配对成功,批次结束后字典非空说明有响应缺失。按下标配对的实现没有这个结构,也就失去了发现缺失的能力。

与逐条请求方案的对比

批量请求的收益是减少传输次数。在 Streamable HTTP 传输下,资料 2 规定客户端发送的每条 JSON-RPC 消息必须是新的 HTTP POST 请求,批量数组能把这多次 POST 合并成一次。代价是客户端要自己承担配对、超时和部分失败的处理。

维度批量请求逐条请求
传输次数一次发送多个请求,减少往返每个请求一次往返
响应顺序规范允许乱序,客户端必须按 id 配对每条响应天然对应唯一请求
部分失败处理需按 id 分辨哪个失败,其余结果仍可用失败隔离在单条请求内
超时语义批次级超时,慢请求拖住整批每条独立超时,可单独重试
实现复杂度需要 id 分配、映射表、缺失检测调用与响应一一对应,逻辑简单
服务端并发压力一次涌入多个请求,可能挤占资源请求分散,压力更平滑
适用场景多个独立小请求、延迟敏感请求间有依赖、需要精细重试

工程上通常的做法是:批量只用于彼此独立、可并行、单个请求耗时相近的读取类调用;对写入类工具调用或依赖前一步结果的请求,用逐条发送。批量批次也不宜过大,否则一个慢请求会让整批的完成时间被拉长,抵消减少往返带来的收益。

可观测指标与故障定位

配对错误往往不抛异常,只能靠指标发现。建议至少观察以下几项。

  • batch.size:每批请求数量分布。批次异常增大时,乱序概率和慢请求拖累都会上升。
  • batch.response_count_mismatch:返回响应数(扣除 null id)与预期响应数不一致的次数。
  • batch.unknown_id_response:收到无法匹配的 id 的响应数。非零说明客户端 id 分配或服务端回写 id 有问题。
  • batch.missing_response:批次结束时仍未收到响应的请求数。持续非零要检查服务端是否把某些请求当成了通知。
  • batch.pair_latency:从发送到每个 id 完成配对的时间分布。某个 id 的配对延迟远高于同批其他请求,说明该请求是乱序的尾部。
  • batch.null_id_response:null id 响应数。出现即意味着批次里有请求在解析阶段就失败了。

定位时先看 unknown_id_response 和 missing_response 是否成对出现。两者同时非零,常见原因是客户端 id 生成重复,导致一个响应被重复消费、另一个请求永远等不到结果。只有 null_id_response 非零,则回到请求体本身,检查是否有元素不是合法的请求对象。

前提不成立时方案如何退化

按 id 配对依赖三个前提,任何一个不成立都会退化。

id 在批次内唯一。如果客户端从多个并发协程共享一个自增计数器却忘了加锁,或重试时复用了旧 id,唯一性就被破坏。此时配对表会覆盖,表现为结果串台。

服务端正确回写 id。规范要求服务端回复相同的 id,但实现缺陷确实存在。客户端对未知 id 的响应只能记录并丢弃,无法还原它原本属于哪个请求。

批次有明确的结束边界。批量请求需要一个“全部响应已到”的判定,否则缺失检测无从谈起。在 SSE 流上,这个边界可能是流关闭,也可能是客户端自己设定的批次超时。资料 2 指出,连接断开不应被解释为客户端取消请求,取消需要显式发送取消通知。因此客户端不能把流断开当成批次正常结束,而应按超时处理未完成的请求。

还有一个边界值得注意:如果批次里全是通知,规范规定服务端不返回任何结果,且必须不能返回空数组。客户端此时不应等待响应,否则会一直挂到超时。

尚未解决的问题

规范没有规定服务端必须检测并拒绝重复 id,也没有规定批量响应的最大数量或批次超时。不同 MCP 服务端实现对这些边界的处理并不一致,客户端只能按最保守的假设编写配对逻辑:不信任顺序、不信任数量、不信任 id 一定回写正确。把这三条写进客户端,比在服务端逐个修补更可控。

资料来源

  1. JSON-RPC 2.0 Specification
  2. MCP Specification (2025-06-18) - Transports
  3. (译) JSON-RPC 2.0 规范(中文版) - wiki . leozvc
  4. MCP 通信消息格式之 JSON-RPC 2.0 协议 - 曾左 - 博客园