MarkZ

前言

管道里出现 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 这份规范:规定远程调用的消息长什么样(methodparamsid,回来用同一个 idresulterror),并且写明自己与传输无关。自研 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 就不能省。

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,父进程也不对每一行回包。typetextstep 只是举例,不是 stream-json 标准字段。某一行必须回答时,对象里得另有配对键,例如 Claude 的 control_request / request_id

stream-json:两根管子,一行一个 JSON

图 2:一行进去,多行出来。图里的 typetext 只是举例,不是 stream-json 的标准字段。对象里写什么、要不要回,分帧层都不管。

某一行必须回答时,对象里得另有配对键。那是叠在流上面的协议。JSON-RPC 也可以按行写在 STDIO 上,那是 RPC 跑在 JSON Lines 上,不是 stream-json 变成了 RPC。

三、对照

JSON-RPCstream-json
它是什么远程方法调用的信封一行一个 JSON 的分帧
必须有的东西method;要回包时还要有 id换行;每一行本身是合法 JSON
对方必须回吗id 就必须回同一个 id不必
典型样子一问一答写一行,读出很多行
管道规范不管。STDIO 上是两根管子,可同时收发同样是 stdin / stdout 两根管子

四、产品里怎么判断这一行要不要回

两边都可以一边跑一边往外倒 JSON 行。不能因此认为大家都用 type 来决定要不要 request。

主路径在倒什么怎么判断要不要回这些名字从哪来
Codex app-serverJSON-RPC 通知和请求,一行一个对象id + method 就要回同一个 id;只有 method 是 Notification,不要回JSON-RPC 2.0 规定“无 id = 通知”;具体 method 名来自官方 ServerNotification / ServerRequest Schema
Claude Code stream-jsontype 的事件对象多数 type 不用回;control_request 才要按 request_idAnthropic 的事件信封和控制协议,不是 JSON-RPC

产品里的分类字段也是协议的一部分,不是接入方和 Agent 临时口头约定“哪些主题算请求”。Claude 的 type、Codex 的 method,都写在各自官方信封里;接入方按字段解析。type: control_request 的含义是:这一行不是普通事件,必须按 request_idcontrol_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,方向不同,管子也不同。

参考资料


相关笔记