文章

JSON-RPC 协议的设计和使用

JSON-RPC 协议的设计和使用

1. 背景

最近看 MCP、LSP、A2A、ACP 这类协议时,经常能看到 JSON-RPC 的影子。

LSP 直接建立在 JSON-RPC 之上;MCP 也把 JSON-RPC 2.0 作为消息格式;A2A 虽然抽象出了不同的 transport binding,但也提供了 JSON-RPC binding。

这件事挺有意思。JSON-RPC 2.0 是 2010 年定稿的协议,规范本身很短,核心字段也就那几个。到了大模型和 Agent 时代,它反而又被频繁拿出来用。

为什么一个老协议又被拿出来复用?先把 JSON-RPC 自己讲清楚,再回头看这个选择会更自然。

这篇文章不准备讲 RPC 的完整历史,只梳理 JSON-RPC 2.0 的几个核心问题:

  • 它到底约定了什么
  • Invocation Request、Notification Request、Response 怎么区分
  • 错误和 Batch 怎么处理
  • 放到 HTTP、WebSocket、SSE、stdio 等不同传输上时要注意什么

2. 协议到底约定了什么

同样叫【协议】,IP、Protobuf、JSON-RPC 约定的东西完全不同。

协议层次约定什么
IP网络层数据包怎么在网络上找到目的地,比如分片、路由、TTL
Protobuf序列化层结构化数据怎么编码成二进制,比如 varint、wire type、字段编号
JSON-RPC应用层调用方和被调用方怎么对话,比如消息格式、交互顺序、异常处理

类比快递会更好理解:IP 约定包裹怎么运,Protobuf 约定东西怎么打包,JSON-RPC 约定两个人怎么对话。

所以 JSON-RPC 本身不关心你用 HTTP、WebSocket、stdio 还是别的传输方式。它只关心消息长什么样,一次调用要不要回复,出错时用什么格式返回。

换句话说,JSON-RPC 和 transport 不是强绑定关系。
HTTP 只是常见承载方式之一,不是 JSON-RPC 的组成部分。你完全可以把同一套 JSON-RPC 消息放在 WebSocket frame、stdio 管道,甚至自定义 TCP framing 里。

这也是它能被 LSP、MCP、A2A 这类协议复用的原因之一:传输层可以换,应用层消息模型保持稳定。

3. 报文结构

JSON-RPC 2.0 主要有两类消息:【Request】和【Response】。
Request 继续往下分,又可以分成【Invocation Request/调用请求】和【Notification Request/通知请求】。

这两个名字是本文为了方便讨论约定的术语。Invocation 和 Notification 刚好对称,对应中文里的【调用】和【通知】。

3.1 Request 对象

Request 表达的是“我要调用哪个方法、带什么参数、要不要等结果”。

所以它的字段也围绕这三件事展开:

字段类型必填说明
jsonrpcString固定值 "2.0",也是和 JSON-RPC 1.0 区分的标志
methodString要调用的方法名。以 rpc. 开头的方法名为系统保留,业务方法不要使用
paramsArray 或 Object参数。数组表示按位置传参,对象表示按名称传参
idString、Number 或 Null请求标识。有 id 就是 Invocation Request,没有 id 就是 Notification Request

这里最关键的是 id

id,代表这是一次 Invocation Request,服务端处理完必须返回 Response。 没有 id,代表这是 Notification Request,服务端不能返回 Response。

Invocation Request,按位置传参:

1
2
3
4
5
6
7
8
9
10
11
{
  "jsonrpc": "2.0",
  "method": "subtract",
  // 数组表示按位置传参
  "params": [
    42,
    23
  ],
  // 有 id,说明这是 Invocation Request,服务端需要返回 Response
  "id": 1
}

Invocation Request,按名称传参:

1
2
3
4
5
6
7
8
9
10
{
  "jsonrpc": "2.0",
  "method": "subtract",
  // 对象表示按名称传参
  "params": {
    "minuend": 42,
    "subtrahend": 23
  },
  "id": 2
}

Notification Request 没有 id,服务端不回复:

1
2
3
4
5
6
7
8
9
10
11
12
{
  "jsonrpc": "2.0",
  "method": "update",
  "params": [
    1,
    2,
    3,
    4,
    5
  ]
  // 没有 id,说明这是 Notification Request,服务端不返回 Response
}

规范允许 id 是 String、Number 或 Null,不过实际工程里一般推荐用字符串或整数。

Null 和小数都能用,但不太值得,后面讲字段约束时会展开。

3.2 Response 对象

Response 的职责只有一个:告诉客户端这次 Invocation Request 是成功了,还是失败了。

字段类型说明
jsonrpcString固定值 "2.0"
result任意类型成功时存在,值由方法自己定义
errorObject失败时存在,包含 codemessage,以及可选的 data
idString、Number 或 Null和请求的 id 一致。解析不出请求 id 时为 Null

resulterror 只能出现一个。成功就返回 result,失败就返回 error不能两个都带,也不能两个都不带

成功响应:

1
2
3
4
5
6
7
{
  "jsonrpc": "2.0",
  // 成功时只有 result,没有 error
  "result": 19,
  // id 和请求 id 保持一致
  "id": 1
}

错误响应:

1
2
3
4
5
6
7
8
9
10
{
  "jsonrpc": "2.0",
  // 失败时只有 error,没有 result
  "error": {
    "code": -32601,
    "message": "Method not found"
  },
  // id 和请求 id 保持一致
  "id": "1"
}

3.3 怎么区分消息类型

JSON-RPC 消息里没有单独的 type 字段。

它不会明晃晃写着“我是 Invocation Request”或者“我是成功响应”。接收方只能看字段组合,先判断这条消息是 Request 还是 Response,再继续往下分具体场景。

这里的第一层判断看 method:有 method 就是 Request,没有 method 就是 Response。

第二层再看 idresulterror:Request 通过 id 区分 Invocation Request 和 Notification Request;Response 通过 resulterror 区分成功和失败。

flowchart TD
    A[收到 JSON-RPC 消息] --> B{有 method 字段?}
    B -->|有| C[Request]
    B -->|没有| D[Response]

    C --> E{有 id 字段?}
    E -->|有| F[Invocation Request<br/>服务端必须回复]
    E -->|没有| G[Notification Request<br/>服务端不能回复]

    D --> H{有 result 还是 error?}
    H -->|result| I[成功 Response<br/>客户端按 id 匹配请求]
    H -->|error| J[错误 Response<br/>客户端按 id 匹配请求并处理错误]

整理成表格就是这样:

第一层判断第二层判断消息类型处理方式
methodidInvocation Request服务端处理后必须返回 Response
method没有 idNotification Request服务端处理后不能返回 Response
没有 methodresult成功 Response客户端按 id 匹配请求
没有 methoderror错误 Response客户端按 id 匹配请求,并处理错误

这个模型很朴素。
它不像 REST 那样借助 HTTP method 表达语义,也不像 gRPC 那样依赖 IDL 和生成代码。JSON-RPC 的语义基本都塞在 JSON 对象本身里。

4. 错误处理

一次调用失败以后,客户端至少要知道两件事:程序应该怎么处理,用户或者开发者应该看到什么。

所以 JSON-RPC 的 error 对象没有只放一段错误文案,而是拆成了三个字段:

字段类型必填用途
codeNumber(整数)给程序判断用。客户端可以按错误码决定重试、降级或提示
messageString给人看的简短说明,适合放进日志、toast、调试面板
data任意类型补充上下文,比如字段名、校验原因、traceId、原始异常摘要
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
  "jsonrpc": "2.0",
  "error": {
    // 给程序判断用
    "code": -32602,
    // 给人看,适合日志、toast、调试面板
    "message": "Invalid params",
    // 补充上下文,结构由双方约定
    "data": {
      "field": "name",
      "reason": "required"
    }
  },
  "id": "req-1"
}

这三个字段的分工要尽量稳定。

  • code 给程序看(if-else),值要稳定。客户端会根据它判断下一步动作,比如重试、降级、弹提示,或者只记录日志。
  • message 给人看(toast/log),适合写一句简短说明。它会出现在日志、toast、调试面板里。
  • data 放补充上下文,比如哪个字段错了、为什么校验失败、traceId 是什么。

接下来是 code 的取值问题。

JSON-RPC 2.0 把 -32768-32000 这一段留给协议层错误和服务端错误使用,其中几个标准错误码已经固定下来:

错误码含义触发场景
-32700Parse errorJSON 格式非法,解析失败
-32600Invalid RequestJSON 合法,但不是有效的 Request 对象
-32601Method not found方法不存在
-32602Invalid params参数类型或值不合法
-32603Internal error服务端内部错误
-32000-32099Server error预留给实现自定义服务端错误

应用层业务错误最好避开这段保留区间。比如余额不足、权限不足、资源不存在,可以用双方约定的业务错误码,但不要和协议层错误码混在一起。

这样排障时一眼就能看出来:到底是 JSON-RPC 协议层没读懂,还是业务方法读懂了但执行失败。

5. Batch 请求

Batch 解决的是“多次调用怎么一起发”的问题。

如果客户端连续发很多个 Request,每个都单独走一轮传输,开销会比较碎。JSON-RPC 给了一个简单办法:把多个 Request 放进一个数组里,一次发给服务端。

但 Batch 只是传输上的打包,不是事务,也不是有序队列。数组里的每个 Request 仍然按自己的 id 独立匹配 Response。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
[
  {
    // Invocation Request,会有对应 Response
    "jsonrpc": "2.0",
    "method": "sum",
    "params": [
      1,
      2,
      4
    ],
    "id": "1"
  },
  {
    // Notification Request,没有 id,不会产生 Response
    "jsonrpc": "2.0",
    "method": "notify_hello",
    "params": [
      7
    ]
  },
  {
    // Invocation Request,会有对应 Response
    "jsonrpc": "2.0",
    "method": "subtract",
    "params": [
      42,
      23
    ],
    "id": "2"
  }
]

服务端只需要对 Invocation Request 返回 Response。中间那条 Notification Request 不返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[
  {
    // 对应 id = 1 的 Invocation Request
    "jsonrpc": "2.0",
    "result": 7,
    "id": "1"
  },
  {
    // 对应 id = 2 的 Invocation Request
    "jsonrpc": "2.0",
    "result": 19,
    "id": "2"
  }
]

理解了这一点,Batch 的几个约束就比较自然了:

  • 服务端可以并发处理,Response 顺序不保证和 Request 顺序一致
  • 客户端必须靠 id 匹配响应,不能靠数组下标
  • Batch 里如果全是 Notification Request,服务端什么都不返回,不能返回空数组 []
  • 如果整个 Batch 的 JSON 都解析失败,返回单个 Parse error Response,而不是数组

这个设计其实很工程化。它只减少传输次数,不改变单个请求的语义。

6. 传输层怎么放

JSON-RPC 规范本身是传输无关的。
它不规定必须基于 HTTP,也不规定请求一定要走哪个端口、哪个 path、哪个 header。

但真正落地时,transport binding 必须解决一个问题:消息边界在哪里?

JSON 对象本身没有天然边界。你在文件、管道、TCP 流里连续写两个 JSON 对象,接收方并不知道第一个对象在哪里结束,第二个对象从哪里开始。

所以不同传输会有不同的封装方式。

6.1 HTTP 请求响应

最简单的做法,是把一个 JSON-RPC Request 放进一次 HTTP 请求体里,再把 Response 放进 HTTP 响应体里。

优点是简单,防火墙友好,调试也方便。
缺点也明显:天然是客户端主动请求,服务端想主动推送就比较别扭。

很多需要双向交互的场景,会在 HTTP 之外再配 SSE 或 WebSocket。

6.2 WebSocket 长连接

WebSocket 有帧边界,每个 frame 可以承载一条 JSON-RPC 消息。
这样一来,消息边界问题就交给 WebSocket 解决了。

如果某一帧里的 JSON 格式坏了,服务端可以返回 Parse error 或直接记录错误,然后继续读下一帧。至少不会像裸 TCP 流那样,一条消息坏了之后很难判断后面从哪里恢复。

6.3 SSE 单向流

SSE 是 HTTP 长连接上的服务端到客户端单向流,常见格式如下:

1
2
data: {"jsonrpc":"2.0","method":"onLog","params":{"msg":"hello"}}

它的几个规则很适合承载服务端事件:

  • 一行一个 field: value
  • 空行表示一个事件结束
  • data 字段承载消息内容,多行 data 会拼接
  • event 字段可以区分事件类型
  • id 字段可以辅助断线重连
  • : 开头的行是注释,常用于心跳

SSE 是单向的,所以如果 JSON-RPC 需要双向通信,通常要配合 HTTP POST:客户端用 POST 发请求,服务端用 SSE 返回响应或推送通知。

MCP 早期的 HTTP+SSE 传输,以及后来的 Streamable HTTP,都能看到这个思路:请求走 HTTP,服务端消息通过流式响应返回。

6.4 stdio 和管道

LSP、MCP 本地进程通信里经常会用 stdio。

stdio 本质上是字节流,没有消息边界,所以需要额外 framing。LSP 的做法是使用类似 HTTP header 的 Content-Length

1
2
3
Content-Length: 78

{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{}}}

先读 header,拿到 body 长度,再按长度读取 JSON 内容。
这套办法很朴素,但很好用。语言服务器和编辑器之间就是靠这个模型稳定通信的。

7. 使用时容易忽略的细节

规范短,不代表坑少。

JSON-RPC 的很多约束都藏在字段类型里。实现时如果只按“能解析成 JSON 就行”来处理,很容易把非法 Request 当成正常调用继续往下传。

下面这些点不算复杂,但最好一开始就写进校验逻辑里。

7.1 Parse error 和 Notification Request 的边界

这里有个容易绕的地方。

规范同时有两条规则:

  1. Notification Request 不应该收到 Response
  2. 解析失败时应该返回 Parse error,且 idnull

如果客户端原本想发一条 Notification Request,但发出去的内容连 JSON 都不是,服务端在解析阶段就已经失败了。它还没看到 method,也不知道有没有 id,自然无法判断这条消息是不是 Notification Request。

这种情况下,服务端会返回一个 id: null 的 Parse error。

1
2
3
4
5
6
7
8
9
{
  "jsonrpc": "2.0",
  "error": {
    "code": -32700,
    "message": "Parse error"
  },
  // 请求解析失败,服务端拿不到原始请求 id,只能返回 null
  "id": null
}

客户端怎么处理?

通常就是用 id 去待响应请求表里匹配。匹配不到,打日志后丢弃即可。Notification Request 的语义本来就是“不关心结果”,这里不要为了它再设计一套复杂状态机。

7.2 method 必须是 String

method 的值必须是字符串,传数字不是有效 Request。

1
2
3
4
5
6
7
{
  "jsonrpc": "2.0",
  // 无效:method 必须是字符串
  "method": 1,
  "params": "bar",
  "id": 1
}

合理的错误响应是:

1
2
3
4
5
6
7
8
9
{
  "jsonrpc": "2.0",
  "error": {
    "code": -32600,
    "message": "Invalid Request"
  },
  // 请求对象无效时,不一定能安全使用原请求 id
  "id": null
}

7.3 params 只能是 Array 或 Object

规范里说 params 必须是 Structured value。
在 JSON 规范里,Structured value 指 Array 或 Object,不包含 String、Number、Boolean 这些原始类型。

1
2
3
4
5
6
7
{
  "jsonrpc": "2.0",
  "method": "subtract",
  // 无效:params 只能是 Array 或 Object
  "params": "bar",
  "id": 1
}

这类请求应该按 Invalid Request 或 Invalid params 处理。具体落在哪个错误码上,不同实现可能会有差异,关键是不要把字符串参数当成合法的 params 结构。

7.4 rpc. 前缀是系统保留的

rpc. 开头的方法名是协议保留的,业务方法不要使用。

1
2
3
4
5
6
{
  "jsonrpc": "2.0",
  // 无效:rpc. 前缀是协议保留命名空间
  "method": "rpc.discover",
  "id": 1
}

业务方法建议使用自己的命名空间,比如:

1
2
3
4
5
6
{
  "jsonrpc": "2.0",
  // 推荐:业务方法使用自己的命名空间
  "method": "user.create",
  "id": 1
}

7.5 id 不建议用 Null 或小数

规范允许 id 为 Null,但不建议这么做。

原因很简单:解析失败时,服务端会用 id: null 表示“我不知道请求 id 是什么”。如果正常请求也用 Null,就会产生二义性:这个 null 到底是业务请求本身的 id,还是解析失败时的兜底值?

小数也不建议用。
JSON Number 落到不同语言里可能变成浮点数,3.1 这种值存在精度问题。请求 id 本来就是匹配用的,不要给自己找麻烦。

推荐做法是用字符串或整数。

1
2
3
4
5
6
7
8
9
10
11
12
{
  "jsonrpc": "2.0",
  "method": "get",
  // 推荐:整数 id
  "id": 1
}
{
  "jsonrpc": "2.0",
  "method": "get",
  // 推荐:字符串 id
  "id": "req-42"
}

7.6 jsonrpc 必须恰好是 "2.0"

jsonrpc 是区分 JSON-RPC 1.0 和 2.0 的字段,值必须是字符串 "2.0"

下面这些都不对:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
  // 无效:jsonrpc 必须是字符串 "2.0"
  "jsonrpc": 2.0,
  "method": "get",
  "id": 1
}
{
  // 无效:不能省略小版本
  "jsonrpc": "2",
  "method": "get",
  "id": 1
}
{
  // 无效:不是 JSON-RPC 2.0 规范定义的版本值
  "jsonrpc": "2.0.0",
  "method": "get",
  "id": 1
}

7.7 扩展字段要提前约定

很多协议会把元数据放在传输层里。比如 HTTP 里常见的 X-Request-IdAuthorizationX-Tenant-Id,它们不属于业务参数,但对鉴权、追踪、排障很有用。

JSON-RPC 2.0 规范本身没有规定这类元数据应该放在哪里,也没有定义 _meta 字段。但很多工程实践里会加类似扩展字段,用来携带 traceId、userId、tenantId 这类信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
{
  "jsonrpc": "2.0",
  "method": "user.get",
  "params": {
    "id": 123
  },
  "id": "req-1",
  // 非 JSON-RPC 2.0 标准字段,必须由双方提前约定
  "_meta": {
    "traceId": "abc123",
    "userId": "u-456"
  }
}

这类字段能不能用,取决于双方实现是否约定。

如果 JSON-RPC 是跑在 HTTP 上,元数据也可以继续放在 HTTP header 里。

如果跑在 stdio、WebSocket 这类没有同款 header 语义的传输上,就需要自己约定放在 _metaparams,或者另行定义协议层扩展规则。不要默认所有 JSON-RPC 框架都会保留或透传扩展字段。

8. 总结

JSON-RPC 2.0 的设计很克制:一个版本字段、一个方法名、一个参数字段、一个请求 id,再加上成功和失败两种响应形态。

它的核心结论可以压成几条:

  1. method 决定消息是不是 Request,id 决定 Request 是 Invocation Request 还是 Notification Request。
  2. resulterror 互斥,成功只返回 result,失败只返回 error
  3. Batch 只是把多个请求放在一次传输里,不改变单个请求的匹配规则。
  4. JSON-RPC 不定义传输层,真正落地时必须处理消息边界。

回到开头的问题,很多 AI 工具协议复用 JSON-RPC,主要看中的不是性能,而是简单、可读、跨语言、好调试。LLM 推理和工具调用本来就慢,JSON 编解码通常不是主要矛盾;排查 Agent 调用链时,能直接看到 method、params、result、error,反而很实用。

当然,高吞吐、强类型、低延迟的内部服务调用,gRPC 仍然更合适。JSON-RPC 的优势在于克制:它没有试图包办一切,只把“怎么调用、怎么返回、怎么报错”这件事讲清楚。

以上是笔者对 JSON-RPC 2.0 的主要理解。如有错误,欢迎指正。

9. 参考

本文由作者按照 CC BY 4.0 进行授权