最近在集中补几门老课:JSON-RPC、SSE、stdio。
说它们老,是真老。JSON-RPC 2.0 定稿于 2010 年,SSE 2006 年就有了标准,stdio 更是跟 Unix 同岁——比互联网还年长。
但说实话,这些年我几乎没正眼看过它们——各有各的理由:
- JSON-RPC:从没见过这东西——RPC 还有这种协议?
- SSE:印象一直停留在「过时的推送方案」——明明有更强的双向 WebSocket,为什么还要用它?
- stdio:做业务开发的,谁会专门去学标准输入输出。
直到 AI Agent 时代,MCP 把它们翻出来当了通信底座,我才不得不回头补课。
这篇就是补课的学习记录:三个东西分别是什么、怎么配合、为什么在今天重新有了舞台。
一、JSON-RPC:它到底是什么?
本质
先说一个事实:它今天无处不在——MCP、A2A、AG-UI、ACP,这些当红的 Agent 相关协议,场景各异,却不约而同地选了 JSON-RPC 当消息底座。
至于它到底是什么,名字已经说了一半:一个用 JSON 承载消息的 RPC 协议。
所以得先聊 RPC。RPC(Remote Procedure Call,远程过程调用)的核心想法朴素得很:让调用远端服务,像调用本地函数一样自然。客户端说「帮我执行 method X,参数是这些」,服务端回「结果在这儿」,或者「出错了,错误码是这个」——体验上和调本地函数没什么区别,只不过这件事发生在另一个进程、或者另一台机器上。
JSON-RPC 就是这条路线上的一个具体做法:把一次调用的意图——方法名、参数、配对用的序号——打包成 JSON 消息,交给传输层去送。
主要的示例格式
请求就是个四件套:
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": { "name": "read_file", "arguments": { "path": "/tmp/a.txt" } },
"id": 1
}
| 字段 | 必须 | 含义 |
|---|---|---|
jsonrpc |
必须 | 固定字符串 "2.0",表明协议版本 |
method |
必须 | 要调用的方法名 |
params |
可选 | 参数,数组(位置参数)或对象(具名参数) |
id |
可选 | 调用序号,用来把响应和请求对上号 |
响应走「成功」和「失败」两条路——result 和 error 互斥,必须二选一(2.0 的硬规矩);id 永远原样带回来:
// 成功
{ "jsonrpc": "2.0", "result": { "content": "文件内容..." }, "id": 1 }
// 失败
{ "jsonrpc": "2.0", "error": { "code": -32601, "message": "Method not found" }, "id": 1 }
还有个我后来才品出味道的设计——通知(notification):请求里不带 id,意思就是「我说一声,你不用回」。服务端绝对不能回——回了反而是违规。
它跟「RPC」是什么关系?
RPC 不是某一个协议的名字,而是一大类思路。从早期的 CORBA、Java RMI,到今天的 gRPC,都是这个家族的成员。JSON-RPC 也在其中——只是它走的路,和这些「前辈」们不太一样。
作为一家子,它们的哲学是一致的——都把一次远程调用,封装成一个「方法名 + 参数 + 配对标识」的消息体:
- 调用模型一致:方法名 + 参数 → 结果;
- 都要解决「请求与响应的配对」问题——这就是
id存在的原因; - 都有结构化的错误传递机制。
而差异,恰恰是 JSON-RPC 的立身之本——它只规定消息格式,不规定怎么传。家族里的其他成员往往把传输也定死(gRPC 绑 HTTP/2 + Protobuf,CORBA 有自己的 IIOP),JSON-RPC 却把传输层完全留白:走 HTTP、WebSocket、TCP,还是 stdio,都行。
💡 打个比方:gRPC 是「塞进标准集装箱、外面刷好编码」,JSON-RPC 是「白纸黑字写清楚」。后者的克制,恰恰是它在 AI 时代被选中的原因——第二节就会应验。
那和 REST 比呢?
REST 的哲学核心就一句话:操作某个资源。落到 HTTP 上,它的典型形态是「路径 + 四个标准方法的组合」——同一个路径,配不同的动词,就是对它做不同的操作;参数要么拼在 URL 后面,要么塞进 Body——「操作什么」由 URL 说了算,「怎么操作」由动词说了算:
POST /api/users ← 创建用户(参数在 Body)
GET /api/users/1 ← 查看用户 1
DELETE /api/users/1 ← 删除用户 1
JSON-RPC 完全不是这个玩法。它的请求永远发往根路径——要调哪个方法、传什么参数,全部写在 Body 里:
POST / ← 永远就这一个端点
{"jsonrpc":"2.0","method":"users.create","params":{"name":"foo"},"id":1}
{"jsonrpc":"2.0","method":"users.get","params":{"id":1},"id":2}
看出来了吗——「方法名 + 参数全在 Body 里」,这跟传统 RPC 是一脉相承的:调用一个方法,而不是操作一个资源。这是它和 REST 最根本的分野。
再对照得细一点:
| JSON-RPC | REST | |
|---|---|---|
| 核心概念 | 调用一个方法 | 操作一个资源 |
| URL 怎么用 | 通常就一个端点,靠 method 区分 | 每个资源一个路径 |
| 风格 | 函数调用,像本地调函数 | 数据操作,靠 HTTP 动词 |
| 适合 | 内部服务、工具调用、双向通信 | 对外开放的 API |
说白了:想「像调本地函数一样调远端」,JSON-RPC 最省事;想「操作资源」,REST 更自然。JSON-RPC 选了前者——RPC 家族的哲学,它一样没丢。
二、SSE:它怎么跟 JSON-RPC 走到一起?
先说说 SSE 是什么
SSE(Server-Sent Events)是个「服务端推送」的浏览器标准:客户端发一个普通 GET 请求,这条连接就挂着不关,服务端沿着响应流随时写数据——格式朴素得可爱,就是一行行 data: xxx 文本。
但有个细节得掰清楚:它和 WebSocket 的「推送」,不是一回事。WebSocket 是全双工——连接建好,谁都能开口;SSE 是单工——客户端先敲门,服务端才好开口。
这标准 2006 年就有了,却一直被 WebSocket 盖着。人家双向、能力更强——SSE 站在旁边,像个没什么存在感的配角。
那为什么还有场景选它?答案有点反直觉:WebSocket 的「强」,本身就是入场费。
它要先做协议升级(Upgrade: websocket),很多企业网关和代理直接就把这类连接拦了;SSE 只是一次普通的 HTTP 响应,防火墙看它,跟看网页请求没区别。
而且能力过剩也是成本:MCP 这类场景,需要的只是服务端能推消息——客户端发消息,走普通 POST 就够了。为一半的需求背上整套双向协议,不划算。
所以这次重翻规范时有点意外:这个老东西,比我印象中干净得多。
结合起来发生了什么
回头看第一节讲的 notification(服务端有话想说),但在「纯 HTTP 一问一答」模式下,服务端根本没有说话的时机。SSE 恰好补上这块——客户端先开一条常驻连接,让服务端拿到了持续说话的通道。于是两件事同时成立:
- 服务端可以随时发消息:进度、日志、通知,不必再等下一次请求;
- 一个请求,多个消息:在响应前后可以夹带一串 notification(比如
progress: 10、progress: 60),最后才用一条带id的 result 收尾——变相实现了流式输出。
这就是 MCP 远程传输(Streamable HTTP)的核心思路:一个端点、一个 POST;普通请求直接回 JSON,需要流式时切换成 SSE。
老实说,看完这套设计我有点服气——两个「过气」的东西,拼出了一个恰好够用的方案。
现在有哪些典型应用
目前我接触到的主要是两类:MCP 的远程传输(上面说的);以及各类 AI 应用的流式输出——你看到的那些「边生成边出字」的聊天界面,底下大多是 SSE 在干活。
实践里也踩过坑,挑两个最有代表性的分享:
- JSON 必须压成一行——SSE 的
data:遇到裸换行就分段了,所以 pretty-print 会直接破坏协议(和 stdio 的纪律异曲同工,后面还会见到它); - EventSource 不能带自定义请求头——标准 API 没法加
Authorization,很多实现被逼着用 query 参数传令牌。
三、stdio:古老,但在本地恰到好处
JSON-RPC 规定了「消息长什么样」;至于消息怎么流动,传统答案是 HTTP——「HTTP + JSON-RPC」这对组合,用了很多年。
但在本地场景,有人把传输层换掉了:从 HTTP 换成一条进程管道,消息一个字都不用改。换上来的这套就是 stdio——比 HTTP 老得多,却成了 MCP 本地传输的默认选择。还记得第一节说的「传输层自由」吗?这就是它兑现价值的地方。
原理:一根管道
原理简单得不像个技术:让两个进程用一根双向管道对话。客户端把服务端拉成子进程,往 stdin 写请求,从 stdout 读响应,stderr 拿来打日志。
有多老?它跟 Unix 一起诞生——1970 年代定下的设计,比在座的所有用户都老。
理解它最好的方式,是亲手在 shell 里模拟一次:
# 把一条 JSON-RPC 请求写进 server 的 stdin,从 stdout 读回响应
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | ./my-mcp-server
两条纪律,不写进代码就永远踩坑,顺手记下:
- 换行分帧:stdio 是字节流,没有消息边界。规范规定一条消息占一行、不能内嵌换行——所以 pretty-print 在这里同样是"拆协议";
- stdout 绝对不能打日志:规范原文是 server MUST NOT 往 stdout 写任何非协议消息。一个随手
fmt.Println就可能让客户端解析失败。日志要走 stderr——Go 里log.Println默认就是 stderr,而 Python 的print()默认 stdout,尤其危险。
为什么在本地场景,它优于 HTTP?
这是这次补课里我想得最久的一个问题。「HTTP + JSON-RPC」明明也跑得通,为什么到了本地场景,MCP 和很多本地工具要换成 stdio?我的理解是四点:
- 零配置:不用分配端口、不用握手、不用证书——进程启动的那一刻,通道就已经存在了;
- 天然隔离:每个 server 一个进程,崩了互不影响;权限边界就是操作系统的进程边界;
- 生命周期绑定:进程停,服务停,不会留孤儿端口和僵尸服务;
- 无网络暴露:没有端口,天然规避了一整类网络攻击面——这在"让 AI 调用本地工具"的场景里,是很实在的安全考量。
再加上不走网络栈、没有 TCP/HTTP 开销的天然性能优势,就不难理解 MCP 的本地传输为什么选了它:一行配置拉起一个工具进程,即插即用。于是今天的 MCP 形成了「本地 stdio + 远程 Streamable HTTP」的双轨格局——一个覆盖本机进程,一个覆盖跨网络。
四、写在最后
回头看这三个老东西,气质出奇地一致:简单、够用、好穿透。它们都不是当年最强的那个——JSON-RPC 打不过 gRPC 的体系化,SSE 打不过 WebSocket 的双向,stdio 更是「老掉牙」的进程通信方式。
但 AI Agent 时代重新需要它们,恰恰是因为它们朴素:协议简单到不用文档就能实现,通道皮实到什么环境都能跑起来。
技术选型从来不是「谁先进谁赢」,而是「谁恰好合适谁赢」。