侧边栏壁纸
博主头像
一笑痕

仙人之下我无敌,
仙人之上一换一。

  • 累计撰写 52 篇文章
  • 累计收到 7 条评论

用 stdio 和 JSON-RPC 把工具拆成独立进程

2026-9-15 / 0 评论 / 17 阅读

用 stdio 和 JSON-RPC 把工具拆成独立进程

工具越加越多的时候,先踩到的是依赖:本机那个 agent 主进程里,为了三个工具装了 playwright 和 pandas,结果其中一个包一升级,整个循环连启动都起不来,而当时真正要用的其实只是读一个文件。另一件事是权限,低权限工具和高权限工具住在同一个进程里,谁碰过什么就说不清楚。

所以干脆把工具拆出去了,协议选了最省事的那种。下面几张图是这套东西在本机跑出来的形状,脚本在 tools/verify-stdio-jsonrpc.mjs,服务端在 tools/verify-jsonrpc-server.mjs

图 1 · 三条管道各走各的

agent 主进程
   │
   │  spawn(process.execPath, [serverPath])
   │  stdio: ['pipe', 'pipe', 'inherit']
   ▼
工具服务子进程
   ├─ stdin   ◄── 一行一个 JSON-RPC 请求
   ├─ stdout  ──► 一行一个 JSON-RPC 响应(只放协议)
   └─ stderr  ──► 日志(父进程消费,或者 inherit 直接进终端)

图上这个形状对应协议里只约定了三件事:消息以换行分隔,一行一个 JSON;请求带 id,响应把同一个 id 带回来,这样一条连接上能同时挂多个请求;stdout 只放协议内容,日志全部走 stderr。这三条落到代码里就是下面这些:

import { createInterface } from 'node:readline';
import { readFileSync, statSync } from 'node:fs';

const TOOLS = {
  wordcount: {
    description: '统计文件里的字符数、中文字符数和字节数',
    inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
    run: ({ path }) => {
      const text = readFileSync(path, 'utf8');
      const cjk = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
      return { path, chars: text.length, cjk, bytes: statSync(path).size };
    },
  },
};

async function handle(req) {
  const { id, method, params } = req;
  if (method === 'initialize') {
    return { jsonrpc: '2.0', id, result: { protocolVersion: '0.1', serverInfo: { name: 'demo-tools' } } };
  }
  if (method === 'tools/list') {
    return {
      jsonrpc: '2.0',
      id,
      result: {
        tools: Object.entries(TOOLS).map(([name, t]) => ({
          name, description: t.description, inputSchema: t.inputSchema,
        })),
      },
    };
  }
  if (method === 'tools/call') {
    const t = TOOLS[params?.name];
    if (!t) return { jsonrpc: '2.0', id, error: { code: -32602, message: `未知工具: ${params?.name}` } };
    try {
      return {
        jsonrpc: '2.0', id,
        result: { content: [{ type: 'text', text: JSON.stringify(t.run(params.arguments ?? {})) }] },
      };
    } catch (e) {
      return { jsonrpc: '2.0', id, result: { isError: true, content: [{ type: 'text', text: e.message }] } };
    }
  }
  return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } };
}

const rl = createInterface({ input: process.stdin });
rl.on('line', async line => {
  if (!line.trim()) return;
  let req;
  try {
    req = JSON.parse(line);
  } catch (e) {
    process.stdout.write(JSON.stringify({
      jsonrpc: '2.0', id: null, error: { code: -32700, message: `Parse error: ${e.message}` },
    }) + '\n');
    return;
  }
  process.stdout.write(JSON.stringify(await handle(req)) + '\n');
});

图 2 · 一次握手的真实往返

客户端把它 spawn 起来,依次走握手,然后列工具,再调一次工具,输出是这样:

initialize  → {"protocolVersion":"0.1","serverInfo":{"name":"demo-tools"}}
tools/list  → wordcount, read_lines
tools/call  → "{\"path\":\"/Users/yxh/blog/写作规范.md\",\"chars\":2228,\"cjk\":991,\"bytes\":4535}"

wordcount 那行值得看一眼:2228 个字符,其中 991 个是汉字,占 44%,字节数 4535 是字符数的两倍。工具里是真去 readFileSync 了,读的是子进程的路径,主进程不需要有这个文件。把它摊成时序就是:

父进程                                        子进程
  │  {"jsonrpc":"2.0","id":1,"method":"initialize"}
  ├─────────────────────────────────────────────────►│
  │  ◄── {"protocolVersion":"0.1","serverInfo":{"name":"demo-tools"}}
  │
  │  {"jsonrpc":"2.0","id":2,"method":"tools/list"}
  ├─────────────────────────────────────────────────►│
  │  ◄── wordcount, read_lines
  │
  │  {"jsonrpc":"2.0","id":3,"method":"tools/call"}
  ├─────────────────────────────────────────────────►│
  │  ◄── "{\"path\":\"/Users/yxh/blog/写作规范.md\",\"chars\":2228,\"cjk\":991,\"bytes\":4535}"

三条消息共用一条连接,靠 id 分清谁是谁,这就是握手阶段做完之后循环往复的那个动作。

图 3 · 坑一:一个 chunk 不等于一条消息

stdout 交付的是字节流,没有消息边界
   {"jsonrpc":...}\n{"jsonrpc":...}\n    ← 服务端一次 write,怎么切随管道的意思

客户端唯一正确的姿势
   chunk ──► buf += chunk ──► buf.indexOf('\n') ──► 切一行 ──► JSON.parse ──► 按 msg.id 找 pending

在服务端加了个开关,把一条响应分两次 write,中间隔 40ms,模拟被切开的场景:

用例:服务端把一条响应拆成两个 chunk
按行缓冲的客户端 → {"protocolVersion":"0.1","serverInfo":{"name":"demo-tools"}}
每个 chunk 直接 parse 的客户端 →
   解析失败 SyntaxError: Unterminated string in JSON at position 12 (line 1 column 13)
   解析失败 SyntaxError: Unexpected non-whitespace character after JSON at position 3

两个报错都好认,一个是字符串没收尾,一个是 JSON 后面还有多余内容。真正的麻烦是这个 bug 在本地经常不出现:消息小,一次 write 正好是一个 chunk,测一百次都是对的,等到工具返回一个大结果才炸。客户端的写法只有一种是对的,先攒进 buffer,再按换行切:

child.stdout.setEncoding('utf8');
let buf = '';
child.stdout.on('data', chunk => {
  buf += chunk;                     // 关键:先攒起来,不能假设一个 chunk 就是一条消息
  let idx;
  while ((idx = buf.indexOf('\n')) !== -1) {
    const line = buf.slice(0, idx);
    buf = buf.slice(idx + 1);
    if (!line.trim()) continue;
    let msg;
    try {
      msg = JSON.parse(line);
    } catch (e) {
      console.warn('stdout 上有一行不是 JSON:', line.slice(0, 60));
      continue;
    }
    if (msg.id !== undefined && pending.has(msg.id)) {
      const { resolve, reject } = pending.get(msg.id);
      pending.delete(msg.id);
      msg.error ? reject(new Error(`${msg.error.code} ${msg.error.message}`)) : resolve(msg.result);
    }
  }
});

图 4 · 坑二:stdout 上多一行日志

服务端顺手 console.log 一行
   ├─► 按行缓冲的客户端     那行不是 JSON,记下来,循环继续 ──► 没崩
   └─► 每个 chunk 直接 parse 报错 ×2,第二条恰好是一整条响应 ──► 崩

在服务端加了个 --noisy 开关,模拟刚上手最容易干的事,启动时打一行日志:

用例:服务端顺手 console.log 了一句
按行缓冲的客户端 → {"protocolVersion":"0.1","serverInfo":{"name":"demo-tools"}}
  它顺手记下的杂音: ["stdout 上有一行不是 JSON: server 启动中,加载了 2 个工具"]
   每个 chunk 直接 parse → 解析失败 SyntaxError: Unexpected token 's', "server 启动中"... is not valid JSON
   每个 chunk 直接 parse → 解析成功 id=1 有结果

按行缓冲的客户端没崩,它把那行不是 JSON 的东西记下来了,这算兜住了。换个写法就是两条报错,第二条恰好是一整条响应。所以服务端里连 console.warn 都别用,要打日志就 process.stderr.write,或者挂到 stderr 的 logger 上。

图 5 · 坑三:两类失败分两条路

tools/call
   ├─ 请求本身有问题(工具名不存在)──► 协议层 error: {code:-32602}      → 客户端记日志,当 bug 查
   └─ 工具内部抛错(文件不存在)    ──► result.isError: true + 错误原文   → 给模型看,模型可以换参数重试

这个区分是这套协议里最值得记的一处,调不存在的工具和工具内部抛错走完全不同的路:

未知工具   → 走协议层 error: -32602 未知工具: nope
文件不存在 → 走 result.isError: true / ENOENT: no such file or directory, open '/tmp/没有这个文件.txt'

区别在于谁会看到它。协议层 error 的意思是「这个请求处理不了」,客户端该把它记成日志当 bug 看。isError 的意思是工具执行失败,错误原文放在 content 里,那是要给模型看的输入,模型可以换个路径重试。早先的写法是把两者都塞进协议 error,结果工具一失败模型就完全失明,它只知道「调用出错了」,拿不到 ENOENT 这种能用来改参数的信息。

图 6 · 坑四:stderr 没人读会怎样

让子进程先往 stderr 写 2MB(管道缓冲区 64KB),写完才回一次请求
   ├─► Node 子进程:process.stderr.write 是异步写
   │      数据排在子进程内存里 ──► 父进程不读,也照样收到响应
   └─► Python 子进程:sys.stderr 是阻塞写
          管道满了就卡在 write 上 ──► 父进程不读,永远收不到响应

我原来的判断是都会卡死,实测打脸了:

【Node 子进程】读 stderr = true   → 收到响应 / 消费 2097152 字节 / 子进程 RSS 涨 288KB
【Node 子进程】读 stderr = false  → 收到响应 / 消费 0 字节 / 子进程 RSS 涨 304KB
【Python 子进程】读 stderr = true  → 收到响应 / 消费 2097152 字节
【Python 子进程】读 stderr = false → 什么都没收到(2 秒内零响应)

Node 写的子进程没人读 stderr 也照样把响应回过来,RSS 只涨了 304KB。原因是 process.stderr.write 到管道是异步的,数据排在子进程的内存里,没人读它就拿内存当缓冲区。Python 写的子进程一个字都没回,因为 sys.stderr 是阻塞写,管道满了就卡在 write 上,后面的活全都不干。

现在很多工具服务端是 Python 写的,所以父进程漏读 stderr 就是个会挂死的 bug,而且症状是「卡住不回」,跟死循环、跟模型不响应都很像,很难查。两个修法:父进程老老实实消费 stderr,或者把子进程的 stderr 设成 inherit,日志直接进终端。

const child = spawn(process.execPath, [serverPath], {
  stdio: ['pipe', 'pipe', 'inherit'],   // stdin/stdout 走协议,stderr 直接继承
});

图 7 · 拆不拆的判断

要不要把工具拆到独立进程里跑
   ├─ 它要 import 一个不该装进主进程的重包   ──► 拆
   ├─ 它要碰的东西不该让主进程有权限碰       ──► 拆
   └─ 三五个工具、只在本机跑、依赖也干净       ──► 别拆

满足前两条里的一条就拆,都不满足就留在主进程里。多一层进程要处理管道生命周期和僵尸进程,还得想清楚超时了怎么取消,第一次拆的时候忘了 kill 子进程,跑了几十次之后 ps 里拉出一排没退出的 node。

图 8 · 这套东西离 MCP 还有多远

这里实现的等价物                        真实 MCP 规范
  ─────────────────────               ──────────────────────
  一行一个 JSON-RPC 请求/响应          能力协商
  写死的 protocolVersion '0.1'         通知消息
  没有能力列表                         更细的握手流程

这台机器上没有配好的 MCP 客户端,所以上面这套是按「行分隔 JSON-RPC」自己写的等价物,并不是 MCP 的实现。真实 MCP 规范里的能力协商和通知消息这些细节比这里复杂,没有逐条验证过。这里的 initialize 只是回一个写死的 protocolVersion 字符串,严格说连握手都算不上。要真做兼容,去看官方规范,别拿它当参考实现。

    🤞 分享