做 Agent 会用到的 Node API(2):子进程

本系列讲实现 Agent harness 时会反复碰到的 Node.js API。默认读者:会一点 JS,但还没系统用过 Node
上一篇:(1)路径与文件
示例仓库:react-agent-mini
相关前作:Agent 终于有终端了 · 主循环


先弄清:什么是「进程」?什么是「子进程」?

你双击打开一个程序,操作系统就会为它创建一个进程(process):一块独立的内存空间 + 一串正在跑的代码。

  • 你现在跑的 Node / Bun 程序,本身就是一个进程。
  • 这个进程再去「拉起」另一个程序(比如 gitcmd.exe、一次测试),被拉起的那个叫子进程(child process)。

父子关系可以简化成:

你的 Agent 进程(父)
    └── spawn 出来的 shell / git / 测试 等(子)

为什么 Agent 需要子进程?因为:

  • 读文件可以用上一篇的 fs
  • 跑一条终端命令git statusbun testnpm 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 工具要做的事,本质是:

  1. 起子进程去跑命令
  2. 监听 stdout / stderr,把文字攒起来
  3. 等进程结束,连同「退出码」一起塞进 tool_result
  4. (一般)不往 stdin 写东西——命令参数已经通过 shell 传进去了

后面讲到「把 JSON 写进子进程」时,用的就是 stdin;本篇先把 Bash 这条主线讲透。


2. 退出码:命令成功还是失败

子进程结束时,操作系统会给一个整数退出码(exit code):

约定(常见) 含义
0 成功
0 失败(具体含义因程序而异)

另外还可能因为信号被杀掉(比如超时主动杀进程),这时不一定是普通的「跑完返回码」。

Agent 工具不要把「命令失败」当成「整个 Agent 崩溃」:应把非零退出整理成带 is_errortool_result,让模型自己改策略。主循环继续转就行。


3. spawnexec:为什么 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 的参数。所以正确做法是:

  1. 先启动系统自带的 shell(Windows 上常见 cmd.exe,Unix 上常见 bash
  2. 告诉 shell:「请执行后面这一整串命令」
  3. 由 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 就是进程的环境变量表(PATHSHELL 等);子进程默认会继承,除非你改 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 可执行文件
});

几个初学易混点:

  1. Buffer:一块原始字节。toString('utf-8') 才变成可读字符串。
  2. ?.:有的配置下 stdout 可能是 null(不配管时);可选链避免报错。
  3. 边收边限长:输出可能无限刷,不能无盘接收。
  4. stderr 不要扔:编译失败、测试报错经常在 stderr;给模型排错很重要。示例里常把 stdout/stderr 合并成一段再截断,并注明截断。

结束时通常要汇报三件事给上层:

  • 退出码 code
  • 是否因信号结束 signal
  • 是否超时 timedOut

对应到模型侧,就是「成功 / 失败 / 超时」三种文案的 tool_result


6. 超时:必须杀,而且尽量杀掉整棵进程树

如果模型让 Agent 跑一条死循环或极慢的命令,不能干等。做法:

  1. setTimeout 到点 → 标记 timedOut = true
  2. 杀掉子进程
  3. 带着「超时」结果结束 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 / awaitasync function*for await、流式 callModel


你可以带走什么?

  1. 子进程 = 父进程再拉起的另一个程序;跑终端命令几乎总要它。
  2. stdout / stderr 用事件收,退出码表示成败;失败应回传给模型,而不是拖垮 Agent。
  3. 整句命令经系统 shell 执行/c-c),&& 等语法才生效。
  4. 优先 spawn:可流式收、可限长、可超时杀。
  5. 超时要尽量杀整棵进程树,输出要截断;并注意 Windows / Unix 差异。

仓库与延伸

欢迎 Star、Issue 和 PR。


本文为「做 Agent 会用到的 Node API」系列第 2 篇;示例基于 react-agent-mini。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐