做 Agent 会用到的 Node API(2):子进程
做 Agent 会用到的 Node API(2):子进程
本系列讲实现 Agent harness 时会反复碰到的 Node.js API。默认读者:会一点 JS,但还没系统用过 Node。
上一篇:(1)路径与文件
示例仓库:react-agent-mini
相关前作:Agent 终于有终端了 · 主循环
先弄清:什么是「进程」?什么是「子进程」?
你双击打开一个程序,操作系统就会为它创建一个进程(process):一块独立的内存空间 + 一串正在跑的代码。
- 你现在跑的 Node / Bun 程序,本身就是一个进程。
- 这个进程再去「拉起」另一个程序(比如
git、cmd.exe、一次测试),被拉起的那个叫子进程(child process)。
父子关系可以简化成:
你的 Agent 进程(父)
└── spawn 出来的 shell / git / 测试 等(子)
为什么 Agent 需要子进程?因为:
- 读文件可以用上一篇的
fs; - 跑一条终端命令(
git status、bun test、npm run build)操作系统只认「另起一个程序」,Node 里对应模块就是node:child_process。
本篇核心 API:
import { spawn } from "node:child_process";
对照示例仓库里的 Bash 工具:模型说「执行这条命令」,工具就 spawn 一个系统 shell,把命令交给它,再把输出收回来给模型。
1. 三个管子:stdin / stdout / stderr
每个进程通常带三条「标准流」,可以想成三根水管:
| 名字 | 方向(对子进程而言) | 日常含义 |
|---|---|---|
| stdin | 流进子进程 | 标准输入:你「喂」给程序的数据 |
| stdout | 从子进程流出 | 标准输出:程序正常打印的内容 |
| stderr | 从子进程流出 | 标准错误:警告、报错信息 |
在终端里你敲 git status,屏幕上看到的字,多半就是它的 stdout(有时错误在 stderr)。
Agent 的 Bash 工具要做的事,本质是:
- 起子进程去跑命令
- 监听 stdout / stderr,把文字攒起来
- 等进程结束,连同「退出码」一起塞进
tool_result - (一般)不往 stdin 写东西——命令参数已经通过 shell 传进去了
后面讲到「把 JSON 写进子进程」时,用的就是 stdin;本篇先把 Bash 这条主线讲透。
2. 退出码:命令成功还是失败
子进程结束时,操作系统会给一个整数退出码(exit code):
| 约定(常见) | 含义 |
|---|---|
0 |
成功 |
非 0 |
失败(具体含义因程序而异) |
另外还可能因为信号被杀掉(比如超时主动杀进程),这时不一定是普通的「跑完返回码」。
Agent 工具不要把「命令失败」当成「整个 Agent 崩溃」:应把非零退出整理成带 is_error 的 tool_result,让模型自己改策略。主循环继续转就行。
3. spawn 和 exec:为什么 Bash 选 spawn
child_process 里有好几种起子进程的方式,初学先分清这两个:
spawn |
exec |
|
|---|---|---|
| 输出怎么拿 | 一点点通过事件推过来(流式) | 默认先攒在内存,结束后一次给你 |
| 长输出 | 可以边收边截断、边决定是否杀掉 | 容易一把撑爆内存 |
| Agent 里更合适 | 是(测试日志、find 可能很大) |
短命令图省事时可以用 |
示意(先建立直觉,还不是仓库原码):
import { spawn } from "node:child_process";
const child = spawn("echo", ["hello"]); // 起名为 echo 的程序,参数 hello
child.stdout.on("data", (chunk) => {
// chunk 是 Buffer(字节块),常转成字符串
console.log(chunk.toString("utf-8"));
});
child.on("close", (code) => {
console.log("结束,退出码", code);
});
要点:
spawn(程序名, 参数数组, 选项)- 输出不是函数返回值,而是
data事件 一段段来 close表示进程结束(管道也关了)
4. Bash 工具为什么要「先起 shell,再执行整句」
模型给的常常是一整句用户习惯的写法,例如:
git status && bun test
这里有 &&(上一句成功才跑下一句)。如果直接:
spawn("git", ["status", "&&", "bun", "test"]); // 错:git 不认识 &&
&& 是 shell 的语法,不是 git 的参数。所以正确做法是:
- 先启动系统自带的 shell(Windows 上常见
cmd.exe,Unix 上常见bash) - 告诉 shell:「请执行后面这一整串命令」
- 由 shell 去解析
&&、管道、重定向等
示例仓库里的写法:
function runCommand(command: string, timeoutMs: number): Promise<BashRun> {
return new Promise(resolve => {
const isWin = process.platform === 'win32'
const shell = isWin
? process.env.ComSpec || 'cmd.exe'
: process.env.SHELL || '/bin/bash'
const shellArgs = isWin ? ['/d', '/s', '/c', command] : ['-c', command]
const child = spawn(shell, shellArgs, {
cwd: process.cwd(),
windowsHide: true,
})
逐项拆开:
| 代码 / 选项 | 含义 |
|---|---|
process.platform === 'win32' |
当前是不是 Windows |
ComSpec / SHELL |
系统环境变量里配置的默认 shell;没有就用常见默认值 |
Windows:/c + 命令 |
cmd 的意思:「执行后面这串,然后退出」 |
Unix:-c + 命令 |
bash 等同语义 |
cwd: process.cwd() |
子进程的「当前目录」锁在 Agent 工作区,避免命令跑到奇怪路径 |
windowsHide: true |
Windows 上尽量不弹出黑色控制台窗口 |
不用 shell: true 偷懒 |
自己显式选 shell 二进制,行为更可控、好调试 |
process.env 就是进程的环境变量表(PATH、SHELL 等);子进程默认会继承,除非你改 env 选项。
5. 收齐输出:监听 data,最后在 close 里收尾
骨架可以记成:
let stdout = "";
let stderr = "";
const cap = 200_000; // 最多攒多少字符,防止撑爆内存 / 上下文
child.stdout?.on("data", (chunk: Buffer) => {
if (stdout.length < cap) {
stdout += chunk.toString("utf-8");
}
});
child.stderr?.on("data", (chunk: Buffer) => {
if (stderr.length < cap) {
stderr += chunk.toString("utf-8");
}
});
child.on("close", (code, signal) => {
// code:退出码;signal:被信号杀掉时可能有值
// 在这里把 stdout/stderr 合并、截断,再 resolve Promise
});
child.on("error", (err) => {
// spawn 阶段就失败:比如找不到 shell 可执行文件
});
几个初学易混点:
Buffer:一块原始字节。toString('utf-8')才变成可读字符串。?.:有的配置下 stdout 可能是null(不配管时);可选链避免报错。- 边收边限长:输出可能无限刷,不能无盘接收。
- stderr 不要扔:编译失败、测试报错经常在 stderr;给模型排错很重要。示例里常把 stdout/stderr 合并成一段再截断,并注明截断。
结束时通常要汇报三件事给上层:
- 退出码
code - 是否因信号结束
signal - 是否超时
timedOut
对应到模型侧,就是「成功 / 失败 / 超时」三种文案的 tool_result。
6. 超时:必须杀,而且尽量杀掉整棵进程树
如果模型让 Agent 跑一条死循环或极慢的命令,不能干等。做法:
setTimeout到点 → 标记timedOut = true- 杀掉子进程
- 带着「超时」结果结束 Promise,让工具返回,主循环继续
为什么「只 kill 一下」往往不够?
你 spawn 的是 shell;shell 又可能再拉起 bun test、再拉起一堆 worker。只杀 shell,孙子进程可能还在跑,继续占 CPU、写文件。
示例仓库在 Windows 上用 taskkill /T /F(按进程树强杀);在 Unix 上对子进程发 SIGKILL:
function killTree(child: ReturnType<typeof spawn>): void {
if (process.platform === 'win32' && child.pid != null) {
try {
spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
windowsHide: true,
})
} catch {
child.kill('SIGKILL')
}
return
}
child.kill('SIGKILL')
}
配合定时器(逻辑示意):
const timer = setTimeout(() => {
timedOut = true;
killTree(child);
// 再 resolve 超时结果……
}, timeoutMs);
另外:超时上限要有硬顶。模型若传入天文数字的 timeout,不能照单全收。示例 Bash 默认约 120 秒、上限约 600 秒——具体数字以源码为准,原则是「可配置,但不能无限」。
SIGKILL 是 Unix 上「强制杀」的信号名;在 Node 里写成 child.kill('SIGKILL')。Windows 走 taskkill 分支。
7. 跨平台:同一套 Bash 工具,两套壳
| 点 | Windows | macOS / Linux |
|---|---|---|
| 常用 shell | cmd.exe(看 ComSpec) |
bash / sh(看 SHELL) |
| 「执行整句」 | /d /s /c <命令> |
-c <命令> |
| 杀进程树 | taskkill /pid … /T /F |
kill('SIGKILL')(仍可能有漏网情况) |
| 黑框窗口 | 用 windowsHide: true 尽量隐藏 |
一般没有这个问题 |
写 Agent 命令工具时:不要假设用户一定在 bash 里;用 process.platform 分支选壳,再谈命令内容。
命令内容本身也要注意:模型在 Windows 上生成的 bash 专属语法,有时会跑不通——那是产品层提示词 / 工具说明的问题;Node API 层至少要把「怎么起壳」做对。
8. 同一套子进程,另外两种常见用法(知道即可)
本篇主线是 Bash。同一套「起进程 + 管道」心智,还会出现在:
| 场景 | 和 Bash 的差别(直觉) |
|---|---|
| 命令型钩子脚本 | 除了看退出码,有时还要把一段 JSON 写进 stdin,脚本读完再决定放行/拒绝 |
| MCP 本地 stdio server | 子进程长时间活着,stdin/stdout 上跑协议报文;不是「跑完一句就退出」 |
细节分别在各自专题文里;这里只要记住:都是子进程 + 标准流,差别在生命周期和「往 stdin 写不写东西」。
常见坑
| 坑 | 建议 |
|---|---|
用 exec 跑可能巨量输出的命令 |
长任务用 spawn,自己限长、可中途杀 |
超时只 kill 父 shell |
Windows 用带 /T 的杀树;文档写清「尽力」 |
忘记设 cwd |
命令可能跑在意外目录;Agent 应锁工作区 |
| 丢掉 stderr | 模型排错缺信息;宜保留或与 stdout 合并 |
| 非零退出当成「工具代码崩了」 | 应变成失败向的 tool_result,别炸主循环 |
把 spawn 的返回值当「命令输出」 |
返回的是 ChildProcess;输出在 data 事件里 |
和主循环的关系
query
→ 模型返回 tool_use: Bash
→(可选)权限门卫决定是否允许跑
→ spawn(系统 shell, [执行整句命令])
→ 收 stdout/stderr、超时杀树、截断
→ tool_result 回模型
→ 主循环继续
主循环仍然不直接操作进程;Bash 这类工具才调用 spawn。学子进程 API,是在学 Agent 的「终端手脚」。
本系列下一篇
(3)异步与流:ReAct 怎么转起来——async / await、async function*、for await、流式 callModel。
你可以带走什么?
- 子进程 = 父进程再拉起的另一个程序;跑终端命令几乎总要它。
- stdout / stderr 用事件收,退出码表示成败;失败应回传给模型,而不是拖垮 Agent。
- 整句命令经系统 shell 执行(
/c或-c),&&等语法才生效。 - 优先
spawn:可流式收、可限长、可超时杀。 - 超时要尽量杀整棵进程树,输出要截断;并注意 Windows / Unix 差异。
仓库与延伸
- GitHub:react-agent-mini
- 相关前作:Bash 工具篇
- 源码:BashTool.ts
欢迎 Star、Issue 和 PR。
本文为「做 Agent 会用到的 Node API」系列第 2 篇;示例基于 react-agent-mini。
更多推荐



所有评论(0)