Node.js 原生实现JSON-RPC及长进程双向通信实践

Node.js 原生实现JSON-RPC及长进程双向通信实践

问题

由于负责的业务项目中原来的架构每个操作都 spawn 一个 Python大模型的 进程,执行完就退出。当 Agent 需要人机交互(help_needed)时,进程已经结束了,submitUserResponse 没法把响应传回去。

解决思路

改成长进程,Python 启动后不退出,通过 stdin/stdout 持续通信。

协议设计

用 JSON-RPC 风格,每行一个 JSON。

请求(JS → Python stdin):

json 复制代码
{"jsonrpc":"2.0","id":"req_1","method":"get_llm_providers","params":{}}

响应(Python stdout → JS):

csharp 复制代码
[RESPONSE]{"jsonrpc":"2.0","id":"req_1","result":{...}}

事件通知(Python → JS):

csharp 复制代码
[EVENT]{"jsonrpc":"2.0","method":"help_needed","params":{"query":"需要什么帮助?"}}

加前缀 [RESPONSE][EVENT] 是为了和其他日志区分开。

Python 端实现

核心是一个死循环读 stdin:

python 复制代码
class DaemonWrapper:
    def __init__(self):
        self.wrapper = BRTWrapper()  # 复用同一个实例,状态保持
        self.running = True
        
    async def read_stdin_line(self):
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(None, sys.stdin.readline)
        
    async def run(self):
        self.send_event('ready', {'message': '守护进程已就绪'})
        
        while self.running:
            line = await self.read_stdin_line()
            if not line:  # EOF
                break
            request = json.loads(line.strip())
            asyncio.create_task(self.handle_request(request))

关键点:

  1. run_in_executor 把同步的 readline 变成异步,不阻塞事件循环
  2. create_task 处理请求,不阻塞主循环继续读 stdin
  3. BRTWrapper 实例复用,asyncio.Event 等状态都在

JS 端实现

spawn 进程后,监听 stdout 解析响应:

javascript 复制代码
async startDaemon() {
    this.daemonProcess = spawn('/bin/bash', [this.runScript, '--daemon'], {
        stdio: ['pipe', 'pipe', 'pipe']
    })
    
    this.daemonProcess.stdout.on('data', (data) => {
        this._handleDaemonOutput(data.toString())
    })
}

_handleDaemonOutput(data) {
    // 处理跨行数据
    this.inputBuffer += data
    const lines = this.inputBuffer.split('\n')
    this.inputBuffer = lines.pop() || ''
    
    for (const line of lines) {
        if (line.startsWith('[RESPONSE]')) {
            const response = JSON.parse(line.substring(10))
            this._handleDaemonResponse(response)
        } else if (line.startsWith('[EVENT]')) {
            const event = JSON.parse(line.substring(7))
            this._handleDaemonEvent(event)
        }
    }
}

发请求就是往 stdin 写:

javascript 复制代码
async _sendRequest(method, params = {}) {
    const requestId = `req_${++this.requestIdCounter}`
    
    return new Promise((resolve, reject) => {
        this.pendingRequests.set(requestId, { resolve, reject })
        
        const request = { jsonrpc: '2.0', id: requestId, method, params }
        this.daemonProcess.stdin.write(JSON.stringify(request) + '\n')
    })
}

踩坑

1. stdout 数据分片

stdout 的 data 事件不保证按行来,可能一次收到半行,也可能一次收到好几行。必须用 buffer 拼接,按 \n 切分。

2. 长任务阻塞

python的大模型任务跑起来可能几分钟,不能阻塞 stdin 读取,否则 submitUserResponse 发过来收不到。用 create_task 把长任务丢后台。

3. stdin 是同步的

Python 的 sys.stdin.readline() 是同步阻塞的,直接 await 会卡住事件循环。必须用 run_in_executor 扔到线程池。

4. 进程清理

关闭时先尝试发 shutdown 命令优雅退出,超时后 SIGTERM,再不行 SIGKILL。Windows 用 taskkill。

效果

javascript 复制代码
const client = new BundleBRTClient(bundlePath)

// 监听需要帮助事件
client.on('help_needed', async (data) => {
    const answer = await promptUser(data.query)
    await client.submitUserResponse(answer)  // 传回同一个进程
})

await client.startBRTask('...')  // 任务跑在后台
await client.getTaskStatus()          // 复用同一进程
await client.close()                  // 清理

进程启动一次,后续所有调用都是 stdin/stdout 通信,状态保持,多轮对话可以正常工作。

相关推荐
陳陈陳15 小时前
🚀 前端流式输出革命:SSE + BFF 架构从零到一实战指南
vue.js·架构·node.js
Revolution6115 小时前
第一次运行 Node.js:终端里的 JavaScript 怎样执行
后端·面试·node.js
半句唐诗15 小时前
我是如何通过 Access Token 成功发布第一个 npm 包的
前端·npm·node.js
贩卖黄昏的熊17 小时前
NestJS简明教程——异常处理和日志
javascript·node.js·nest.js
Revolution6121 小时前
Node.js 是什么:前端项目里哪些事情由它完成
前端·面试·node.js
Revolution6121 小时前
Nest.js 是什么:怎样用它写出第一个后端接口
后端·node.js·nestjs
柳林林1 天前
解决 nvm 切换 Node.js 版本时报“没有文件扩展 ‘.vbs’ 的脚本引擎”错误
node.js
To_OC1 天前
从 0 到 1:Milvus + 大模型打造私人记忆知识库
人工智能·node.js·llm
孟陬2 天前
介绍 Node.js v22 新增的 `toArray` 方法
node.js·deno·bun
卡兹墨2 天前
针对高并发、数据量大的场景,通常会考虑采用分库分表进行优化。在这篇文章,我们重点盘点一下Node.js主流ORM框架的动态分表方案 ...
node.js