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 表达的是“我要调用哪个方法、带什么参数、要不要等结果”。
所以它的字段也围绕这三件事展开:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
jsonrpc | String | 是 | 固定值 "2.0",也是和 JSON-RPC 1.0 区分的标志 |
method | String | 是 | 要调用的方法名。以 rpc. 开头的方法名为系统保留,业务方法不要使用 |
params | Array 或 Object | 否 | 参数。数组表示按位置传参,对象表示按名称传参 |
id | String、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 是成功了,还是失败了。
| 字段 | 类型 | 说明 |
|---|---|---|
jsonrpc | String | 固定值 "2.0" |
result | 任意类型 | 成功时存在,值由方法自己定义 |
error | Object | 失败时存在,包含 code、message,以及可选的 data |
id | String、Number 或 Null | 和请求的 id 一致。解析不出请求 id 时为 Null |
result 和 error 只能出现一个。成功就返回 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。
第二层再看 id、result、error:Request 通过 id 区分 Invocation Request 和 Notification Request;Response 通过 result 和 error 区分成功和失败。
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 匹配请求并处理错误]
整理成表格就是这样:
| 第一层判断 | 第二层判断 | 消息类型 | 处理方式 |
|---|---|---|---|
有 method | 有 id | Invocation Request | 服务端处理后必须返回 Response |
有 method | 没有 id | Notification Request | 服务端处理后不能返回 Response |
没有 method | 有 result | 成功 Response | 客户端按 id 匹配请求 |
没有 method | 有 error | 错误 Response | 客户端按 id 匹配请求,并处理错误 |
这个模型很朴素。
它不像 REST 那样借助 HTTP method 表达语义,也不像 gRPC 那样依赖 IDL 和生成代码。JSON-RPC 的语义基本都塞在 JSON 对象本身里。
4. 错误处理
一次调用失败以后,客户端至少要知道两件事:程序应该怎么处理,用户或者开发者应该看到什么。
所以 JSON-RPC 的 error 对象没有只放一段错误文案,而是拆成了三个字段:
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
code | Number(整数) | 是 | 给程序判断用。客户端可以按错误码决定重试、降级或提示 |
message | String | 是 | 给人看的简短说明,适合放进日志、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 这一段留给协议层错误和服务端错误使用,其中几个标准错误码已经固定下来:
| 错误码 | 含义 | 触发场景 |
|---|---|---|
-32700 | Parse error | JSON 格式非法,解析失败 |
-32600 | Invalid Request | JSON 合法,但不是有效的 Request 对象 |
-32601 | Method not found | 方法不存在 |
-32602 | Invalid params | 参数类型或值不合法 |
-32603 | Internal error | 服务端内部错误 |
-32000 到 -32099 | Server 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 的边界
这里有个容易绕的地方。
规范同时有两条规则:
- Notification Request 不应该收到 Response
- 解析失败时应该返回 Parse error,且
id为null
如果客户端原本想发一条 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-Id、Authorization、X-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 语义的传输上,就需要自己约定放在 _meta、params,或者另行定义协议层扩展规则。不要默认所有 JSON-RPC 框架都会保留或透传扩展字段。
8. 总结
JSON-RPC 2.0 的设计很克制:一个版本字段、一个方法名、一个参数字段、一个请求 id,再加上成功和失败两种响应形态。
它的核心结论可以压成几条:
method决定消息是不是 Request,id决定 Request 是 Invocation Request 还是 Notification Request。result和error互斥,成功只返回result,失败只返回error。- Batch 只是把多个请求放在一次传输里,不改变单个请求的匹配规则。
- JSON-RPC 不定义传输层,真正落地时必须处理消息边界。
回到开头的问题,很多 AI 工具协议复用 JSON-RPC,主要看中的不是性能,而是简单、可读、跨语言、好调试。LLM 推理和工具调用本来就慢,JSON 编解码通常不是主要矛盾;排查 Agent 调用链时,能直接看到 method、params、result、error,反而很实用。
当然,高吞吐、强类型、低延迟的内部服务调用,gRPC 仍然更合适。JSON-RPC 的优势在于克制:它没有试图包办一切,只把“怎么调用、怎么返回、怎么报错”这件事讲清楚。
以上是笔者对 JSON-RPC 2.0 的主要理解。如有错误,欢迎指正。