前言
管道里出现 JSON,并不等于双方在调远程方法。
JSON-RPC 和 stream-json 经常被放在一起谈,因为它们都可以跑在 STDIO 上,肉眼看都是一行行的 JSON。差别在于各自约定了什么:
- JSON-RPC 约定:这是一次远程调用,响应用哪个
id对回去。 - stream-json 约定:读到换行,就解析一个完整 JSON 对象。
前者是调用语义,后者是分帧。
可以叠成三层,不要挤成一件事:
| 层 | 干什么 | 例子 |
|---|---|---|
| 传输 | 已经切好的字节怎么送到对端 | TCP、HTTP、STDIO、WebSocket。Netty 是传输和 I/O 的框架,不是 TCP 协议本身 |
| 分帧 | 连续字节里一条消息从哪切到哪 | 长度前缀、魔数;stream-json / JSON Lines 用换行切开 |
| 调用信封 | 这是不是一次远程调用、用什么键回 | 自研 RPC 字段;或 JSON-RPC 的 method + id;或 Claude 的 type + request_id |
JSON-RPC 是协议,但不是传输协议。它是 JSON-RPC 2.0 这份规范:规定远程调用的消息长什么样(method、params、id,回来用同一个 id 带 result 或 error),并且写明自己与传输无关。自研 RPC 通常自己定义这一层信封;JSON-RPC 是同一层的公开规范,载荷用 JSON。Netty 不会“认识 JSON-RPC”:对象先序列化成字节,Netty 再把字节送走。传输协议(如 TCP)负责把这些字节送到对端,不负责把对象变成字节。gRPC、Thrift 也是 RPC,但编码和信封是它们自己的。stream-json 连信封都不规定,对象里写什么、要不要回,它都不管。
读完应能回答四件事:JSON-RPC 的 id 是干什么的;stream-json 的“流式”指什么;为什么两者可以叠在一起却不能互相替代;产品里一边倒事件时,怎么判断这一行要不要回。
一、JSON-RPC:用 JSON 调远程方法
RPC 的动作很简单:这边请对方执行一个过程,那边做完把结果送回来。跨进程之后,配对不能再靠函数栈,必须写进消息。
JSON-RPC 2.0 把这次动作写成一个 JSON 对象,并且声明自己与传输无关。同一组对象可以放进 HTTP body、WebSocket 文本帧,或一行一行写进管道。它通用的是调用信封,不是某一种网线或进程管道。
四个字段
| 字段 | 作用 |
|---|---|
jsonrpc | 版本,必须是 "2.0" |
method | 要调用的过程名 |
params | 参数,可以是数组或对象,也可以省略 |
id | 这一次调用的配对号,由发出请求的那一方生成 |
规范里的减法例子:
{"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}服务端算完,必须带回同一个 id:
{"jsonrpc": "2.0", "result": 19, "id": 1}失败则带 error,不能和 result 同时出现:
{"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "1"}有 id 才是调用,没有 id 是通知
带 id 的 Request,对方必须回复,并且把 id 原样抄回去。并发时全靠这个号区分“是哪一次”。
不带 id 的是 Notification。对方可以执行,但不得回复,调用方也收不到错误:
{"jsonrpc": "2.0", "method": "update", "params": [1, 2, 3, 4, 5]}产品方言可以省略 "jsonrpc": "2.0",但只要还按 JSON-RPC 配对,id 就不能省。
图 1:客户端生成 id = 1,服务端执行后原样带回。管道能不能同时收发,是传输的事;这一次调用仍然靠 id 对上。
二、stream-json:一行一个 JSON
stream-json 不是另一种 RPC。它是产品对 JSON Lines 的叫法,也叫 NDJSON、JSONL。Claude Code 的 --input-format=stream-json / --output-format=stream-json 指的就是它。
格式只有三条:UTF-8;每一行是一个完整 JSON 值;行结束符是 \n。
它解决什么问题
一个普通 JSON 数组是一份完整文档,解析器通常要先拿到从 [ 到 ] 的全部字节,才知道数组已经结束。管道还在写、最后一个 ] 还没到,就不能当一份合法 JSON 去解析:
[{"event":"start"},{"event":"done"}]JSON Lines 没有外层 [...],行与行之间也没有逗号。读到换行就可以解析这一行,下一行还没到也没关系:
{"event":"start"}
{"event":"done"}这就是这里的“流式”:记录可以边产生边读取。 它不是半个 JSON 慢慢拼,也不是自动拥有请求–响应。少写 [] 只是外形;真正换来的是每一行都已经结束,所以还可以:
- 边到边处理:进度、日志、助手输出可以立刻显示,不必等任务全部结束。
- 内存按行算:解析完一行就可以丢掉,不必把整次数组装进内存。
- 直接追加:发送方再写一行就行,不用回头改数组末尾的逗号和
]。 - 坏一行不一定整份作废:某一行坏了可以跳过;数组文档缺一个括号,整份都解析失败。
- Unix 工具能直接用:
tail -f、按行grep、管道拆分流都按行工作,不用先当一份 JSON 文档解析。
这些好处都来自同一件事:行与行之间没有必须成对的括号,也没有行间逗号。代价是整份内容不再是一个合法 JSON 文档,不能拿普通 JSON.parse 一次吃完。
进程之间怎么走
用在父进程和子进程之间时,通常是两根管子,不是两个线程去读同一个 .jsonl 文件:
父进程 --stdin--> 子进程
父进程 <--stdout-- 子进程父进程往 stdin 写一行用户消息。子进程不必用同一个号回包,它可以往 stdout 陆续写很多行:进度、输出、结束。父进程按行读,来一行处理一行。stdin 和 stdout 互不堵塞,可以同时写、同时读。
一次往来可以是这样。父进程写入 stdin 的只有一行:
{"type":"user","text":"列出当前目录"}子进程随后往 stdout 写出三行,每一行都已经结束,父进程读到 \n 就可以解析,不必等最后一行:
{"type":"progress","step":"start"}
{"type":"output","text":"src/"}
{"type":"done"}和上一节的 subtract 对比:那里是 id=1 的请求配 id=1 的结果,一问一答。这里没有 id,父进程也不对每一行回包。type、text、step 只是举例,不是 stream-json 标准字段。某一行必须回答时,对象里得另有配对键,例如 Claude 的 control_request / request_id。
图 2:一行进去,多行出来。图里的 type、text 只是举例,不是 stream-json 的标准字段。对象里写什么、要不要回,分帧层都不管。
某一行必须回答时,对象里得另有配对键。那是叠在流上面的协议。JSON-RPC 也可以按行写在 STDIO 上,那是 RPC 跑在 JSON Lines 上,不是 stream-json 变成了 RPC。
三、对照
| JSON-RPC | stream-json | |
|---|---|---|
| 它是什么 | 远程方法调用的信封 | 一行一个 JSON 的分帧 |
| 必须有的东西 | method;要回包时还要有 id | 换行;每一行本身是合法 JSON |
| 对方必须回吗 | 有 id 就必须回同一个 id | 不必 |
| 典型样子 | 一问一答 | 写一行,读出很多行 |
| 管道 | 规范不管。STDIO 上是两根管子,可同时收发 | 同样是 stdin / stdout 两根管子 |
四、产品里怎么判断这一行要不要回
两边都可以一边跑一边往外倒 JSON 行。不能因此认为大家都用 type 来决定要不要 request。
| 主路径在倒什么 | 怎么判断要不要回 | 这些名字从哪来 | |
|---|---|---|---|
| Codex app-server | JSON-RPC 通知和请求,一行一个对象 | 有 id + method 就要回同一个 id;只有 method 是 Notification,不要回 | JSON-RPC 2.0 规定“无 id = 通知”;具体 method 名来自官方 ServerNotification / ServerRequest Schema |
| Claude Code stream-json | 带 type 的事件对象 | 多数 type 不用回;control_request 才要按 request_id 回 | Anthropic 的事件信封和控制协议,不是 JSON-RPC |
产品里的分类字段也是协议的一部分,不是接入方和 Agent 临时口头约定“哪些主题算请求”。Claude 的 type、Codex 的 method,都写在各自官方信封里;接入方按字段解析。type: control_request 的含义是:这一行不是普通事件,必须按 request_id 回 control_response。
stream-json 替代不了 JSON-RPC。Claude 用它,是因为主路径是事件流,不是每次只做一个远程函数调用;要回包时另叠了 control protocol。Codex 主路径更像会话上的一次次调用,信封直接用 JSON-RPC,事件则做成没有 id 的官方 Notification。
五、常见误解
把 stream-json 当成 JSON-RPC。 两边都可以是一行一个 JSON。有没有 id、承不承诺回包,才决定这是不是一次调用。
以为必须一来一回,所以是半双工。 半双工是对讲机:你说完对方才能说。STDIO 的 stdin 和 stdout 是两根管子,两边可以同时写。JSON-RPC 里“这一次调用要对回去”,不等于整条连接被锁成一问一答。通知不用回;多个带 id 的请求可以同时在途。
以为两个线程在读同一个 JSONL 文件。 stream-json 通常连文件都没有。父进程写 stdin,子进程写 stdout,方向不同,管子也不同。
评论