手写 AI 编程 Agent 的命令执行工具:我被 child_process 坑出来的实战经验

前阵子折腾 AI 编程 Agent 的 Demo,想实现个很简单的效果:跟大模型说一句「用 vite 建一个 React 的 todo 项目」,它就能自动把命令跑完、项目建好。本来以为很简单,不就是调用 Node 的 child_process 跑个命令么,结果前前后后卡了我两天。要么命令悄无声息没反应,要么直接报找不到命令,Windows 和 Mac 上表现还不一样。踩完一圈坑我才发现,这个看起来最基础的 API,细节里全是门道。今天就顺着我当时踩坑的思路,把 child_process 写命令执行工具的完整过程捋一遍,省得你们再走弯路。

先说说为啥 Agent 非得用 child_process

我最开始其实也疑惑过,直接在 Node 里调命令不行吗,为啥非要整个子进程出来?后来想明白了,Node 本身是单线程跑 JS 的,主进程要处理大模型调用、对话逻辑、状态管理这些事,总不能卡着等一条命令执行完吧?而且 bash 命令本质就是系统级的独立进程,跟 Node 进程本来就不是一回事,硬塞到主线程里反而会把整个 Agent 堵死。

你可以这么理解:Agent 主进程就像办公室里的项目经理,只管做决策、调度任务;跑命令这种外勤杂活,交给专门的同事(子进程)去干。同事出去跑任务的时候,项目经理该干嘛干嘛,不会被卡住,同事干完了通过专属通道(IPC 进程间通信)回传结果就行。

像 Cursor、Trae 这类 AI 编程工具,底层的命令执行能力本质都是这么实现的。大模型输出要执行的指令,Agent 封装一层调用 child_process 开子进程跑命令,跑完把结果返回给大模型,再继续下一步操作。整个自动化的基础,就是这个看起来不起眼的子进程工具。

第一个坑:整串命令直接塞,必炸

我最开始写的时候图省事,直接把终端里敲的整串命令丢给了 spawn:

javascript 复制代码
// 错误写法,当时坑了我快一小时
import { spawn } from 'node:child_process';
const client = spawn('npm init vite react-todo-app --template react-ts');

一跑直接报错,说找不到这个命令。当时我第一反应是环境变量的问题,反复确认 node 有没有在 PATH 里,甚至重装了一遍 node,结果屁用没有。后来静下心去翻 Node 官方文档,才看见 spawn 的参数说明第一句就写着:第一个参数是要运行的可执行文件名称,第二个才是参数数组。

说白了,spawn 默认不会帮你解析命令字符串,它真的会去系统里找一个叫「npm init vite ...」的程序,那当然找不到。正确的做法是把命令拆成两部分:命令本身 + 参数数组。比如上面那条命令,npm 是命令,后面的都是参数。我写了个简单的拆分方式,日常用足够了:

javascript 复制代码
const command = 'npm init vite react-todo-app --template react-ts';
// 第一项是命令,剩下的全是参数
const [cmd, ...args] = command.split(' ');

注意这种按空格拆分的方式只适合简单命令,如果参数本身带空格(比如文件路径有空格),会拆错。复杂场景建议自己做参数解析,或者用 shell 模式(后面会讲)。

第二个坑:命令跑了,控制台啥都没有

好不容易把参数拆对了,再跑代码,不报错了,但也没任何输出,程序就僵在那不动。我盯着控制台看了三分钟,一点动静都没有,以为是网络慢 npm 卡了,差点把进程杀了重跑。后来突然反应过来:子进程是不是有自己的输出通道?

去查了 stdio 参数才搞懂,默认情况下,子进程的标准输入、输出、错误流都是独立的「管道」,和父进程的控制台不连通。也就是说,子进程该跑还是在跑,但它的日志不会自动打到你的终端上,你也没法给它传输入。

解决办法很简单,加个 stdio: 'inherit',让子进程直接继承父进程的输入输出:

javascript 复制代码
const client = spawn(cmd, args, {
    cwd: process.cwd(), // 子进程的工作目录,默认继承父进程
    // 注意这一行,卡了我一下午
    stdio: 'inherit'
});

加上之后再跑,npm init 的交互提示瞬间就出来了,跟我手动在终端敲命令的效果一模一样。

聊聊 stdio 的三个常用值

  • pipe:默认值,父子进程通过管道独立通信,需要手动监听 data 事件拿数据,适合后台静默执行
  • inherit:子进程直接复用父进程的输入输出,适合需要交互、要看实时日志的场景
  • ignore:直接丢弃输入输出,适合完全不用管的后台任务

第三个坑:Windows 上直接找不到 npm

我在自己 Mac 上跑通了,发给同事 Windows 电脑上一试,直接报「npm 不是内部或外部命令」。我当时就懵了,他明明装了 node,终端里也能跑 npm 啊。查了半天才发现,Windows 下 npm 的可执行文件其实是 npm.cmd,spawn 默认不会自动补后缀,也不会走系统 shell 去解析命令。

解决办法也简单,加个 shell: true 就行。开启之后,命令会丢给系统的 shell(Windows 下是 cmd/powershell,Mac 下是 bash)去执行,环境变量、后缀名、特殊符号这些都会自动处理,跨平台兼容性直接拉满。

javascript 复制代码
const client = spawn(cmd, args, {
    cwd: process.cwd(),
    stdio: 'inherit',
    // 开启shell模式,跨平台兼容神器
    shell: true
});

当然也不是没有代价,开 shell 会多一层进程开销,而且有命令注入的风险。自己写内部 Agent 玩无所谓,生产环境对外服务的话,一定要做命令白名单校验。

一份能直接跑的完整 Demo

踩完这三个坑,我终于凑出了一份能稳定跑通的代码。直接存成 exec.jsnode exec.js 就能运行,会自动在当前目录下用 vite 创建一个 react-ts 的 todo 项目:

javascript 复制代码
import { spawn } from 'node:child_process';

// 要执行的命令,跟你在终端里敲的一模一样
const command = 'npm init vite react-todo-app --template react-ts';

// 拆分命令和参数,简单命令够用
const [cmd, ...args] = command.split(' ');

// 拿到当前工作目录,子进程就在这个目录下执行
const cwd = process.cwd();
console.log('当前工作目录:', cwd);

// 启动子进程
const client = spawn(cmd, args, {
    cwd,
    // 继承输入输出,实时看日志,支持交互
    // 别问我为什么知道要加这个,踩过的坑都在注释里
    stdio: 'inherit',
    // 开启shell,解决Windows兼容问题
    shell: true
});

let errorMsg = '';
// 监听错误事件:比如命令根本不存在、权限不够会触发
client.on('error', (err) => {
    errorMsg = err.message;
});

// 监听子进程退出
client.on('close', (code) => {
    if (code === 0) {
        // 退出码0代表执行成功
        console.log('命令执行完成');
        process.exit(0);
    } else {
        if (errorMsg) {
            console.error(`执行出错:${errorMsg}`);
        }
        process.exit(code || 1);
    }
});

这里多说一句退出码的事。正常执行完成的命令,退出码都是 0;非 0 就代表执行失败,不同的非 0 值对应不同的错误原因。我们可以通过退出码判断命令有没有跑成功,再决定 Agent 下一步的逻辑。

往深了扒一层:它到底是怎么工作的

跑通之后我好奇去翻了下底层原理,其实没那么玄乎。child_process 是 Node 的核心模块,底层靠 libuv 调用系统 API,创建的是真正的操作系统进程,不是线程。也就是说,每个子进程都有自己独立的内存空间、独立的事件循环,和主进程完全隔离,就算子进程崩了,也不会把主进程带挂。

主进程和子进程之间靠 IPC 通道通信,也就是进程间通信。传个状态码、短文本日志没问题,如果要传大量数据,就得用管道或者共享内存了。对应到 AI Agent 的场景里,这个通道就是大模型和命令工具之间的桥梁:大模型发指令,主进程收到后开子进程跑命令,子进程跑完把结果通过 IPC 传回来,主进程再整理成自然语言丢给大模型。整个流程串起来,就是我们看到的「AI 自动执行命令」。

还有几个小坑,你们别再踩了

长耗时命令别用 exec

很多人图省事用exec,因为它直接回调返回结果,不用监听事件。但 exec 有缓冲区大小限制,默认只有 200KB,输出多了直接溢出报错。而且 exec 是把所有输出存起来,跑完一次性返回,看不到实时进度。跑 npm install、构建这种耗时久、输出多的命令,老老实实⽤ spawn,流式输出边跑边打,体验好太多。

记得处理进程退出

子进程跑完了,主进程该退就退,别一直挂着占资源。反过来,如果主进程异常退出,最好也把子进程杀掉,不然会变成孤儿进程在后台偷偷跑,时间久了系统里全是僵尸进程。

别随便执行用户输入的命令

自己写内部 Demo 无所谓,如果是对外的服务,一定要做严格的命令白名单和参数校验。不然用户传个 rm -rf / 进来,直接就从删库到跑路了。

最后说几句

这趟踩坑跑下来,我最大的感受有三个。第一,很多看起来简单的 API,坑全在参数细节里,不亲手踩一遍根本记不住。child_process 我早就在文档里看过,真到自己写工具的时候,还是挨个踩了个遍。第二,AI Agent 没那么多黑科技,拆到底就是一个个小工具的组合。命令执行是最基础的能力,再往上还有文件读写、代码编辑、浏览器操作,把这些工具串起来,就是一个能干活的 Agent。第三,跨平台兼容永远是 Node 开发躲不开的坎,别光在自己电脑上跑通就完事了,Windows 环境的坑比你想象的多。

当然 child_process 也不是万能的,要是需要更复杂的进程管理、超时控制、重试机制,可以直接用 execa 这类封装好的第三方库,省心很多。但核心原理都是一样的,搞懂了原生 API,再用封装库心里也有底。

要是这篇帮你避开了某个坑,或者你也有过 child_process 的踩坑经历,欢迎留个言,我每条都会看。

相关推荐
逝水无殇3 小时前
C# 异常处理详解
开发语言·后端·c#
考虑考虑6 小时前
Sentinel安装
java·后端·微服务
IT_陈寒6 小时前
SpringBoot自动配置失灵?你可能忘了这个关键注解
前端·人工智能·后端
碎碎念_4926 小时前
SpringBoot + Vue 前后端分离从 0 到 1 完整环境配置流程
vue.js·spring boot·后端
逝水无殇6 小时前
C# 文件的输入与输出详解
开发语言·数据库·后端·c#
YIAN6 小时前
从手写 Agent 工具到 MCP 协议:一文搞懂大模型跨进程跨语言工具调用
后端·agent·mcp
小兔崽子去哪了7 小时前
Docker 删除镜像后磁盘空间没有释放?
后端·docker·容器
高明珠7 小时前
麒麟 V10 SP1 排查实录:ttyS5 每 10 秒莫名收到一批报文,元凶是 eGTouchD
后端
卷无止境7 小时前
Python FFI 技术深度解析:ctypes、cffi 与 pybind11 的性能差异与实践挑战
后端·python