写在前面
上回书说到,咱们把 Agent 的 while 循环转起来了------但那个 Agent 只有一个 read_file 工具,相当于一个只能看、不能动手的实习生。你让它建项目,它能给你讲一节课,就是一行文件都不落地。
今天补上最后一块拼图:给它装上 write_file、list_directory、execute_command,凑齐工具四件套,手写一个 mini 版 Cursor / Claude Code。
任务就一个,也是 Trae、Cursor 用户天天干的事:
用 vite 创建一个 react 的 todolist 项目,并且把它运行起来。
工具四件套:Cursor 的手和脚
拆开任何一个编程 Agent,工具盘大差不差就这四样:
| 工具 | 干啥 | 对应人类动作 |
|---|---|---|
read_file |
读文件内容 | 眯眼看代码 |
write_file |
写入文件 | 敲键盘 |
list_directory |
列目录 | ls -la 扫一眼项目结构 |
execute_command |
执行命令行 | 上终端敲命令 |
前三个都是 node:fs/promises 的活,一个 await fs.readFile 就完事。真正的硬骨头是最后一个------让 Agent 会跑命令。而它,牵出了 Node.js 的一个底层架构问题。
Node 单线程的坎:child_process 登场
Node 主进程是单线程 的,跑着咱们的 Agent 循环。但你让它去执行 pnpm create vite------一个要跑几十秒、疯狂输出日志的 bash 命令------单线程直接被堵死,Agent 整个卡住。
而且别忘了:Windows 上的 git bash,内部本身就藏着一个 mini Linux 系统。ls -la 这些命令,得在 Node 进程之外另起炉灶。
解法:node:child_process 模块的 spawn,把命令甩给一个独立的子进程去跑:
js
arduino
import { spawn } from 'node:child_process';
const command = 'ls -la';
const [cmd, ...args] = command.split(' '); // 第一项是命令,rest 全是参数
const child = spawn(cmd, args, {
cwd: process.cwd(), // 工作目录
stdio: 'inherit', // 子进程继承父进程的输入输出
shell: true, // 走 shell 解释
});
主进程和子进程怎么通信?靠 IPC(Inner Process Communication,进程间通信)。子进程干完活,通过事件把结果告诉主进程:
| 配置/事件 | 作用 | 不写会怎样 |
|---|---|---|
stdio: 'inherit' |
子进程日志直接打到你控制台 | 输出黑洞,什么都看不到 |
shell: true |
走 shell 解释命令 | pnpm 这种命令找不到 |
child.on('close', code) |
监听退出码,0 是成功 | 不知道命令成没成 |
child.on('error', err) |
捕获启动失败 | 报错信息直接丢 |
spawn 返回的是个 Promise------code === 0 时 resolve,否则把错误信息包在结果里回传。这套结构,就是 execute_command 工具的全部骨架。
最阴的坑:cd 和 workingDirectory 打架
execute_command 有个 workingDirectory 参数,用来指定命令在哪个目录跑。听起来很美好,坑就坑在这。
LLM 拿到这个参数后,经常自作聪明地写出这种组合:
| 写法 | 结果 |
|---|---|
command: "cd react-todo-app && pnpm install",workingDirectory: "react-todo-app" |
❌ 目录不存在------已经在 react-todo-app 里了,再 cd 一次直接报错 |
command: "pnpm install",workingDirectory: "react-todo-app" |
✅ 正确姿势,直接执行 |
这坑怎么治?把错误示例和正确示例直接写进 SystemMessage,用自然语言告诉 LLM"别这么干":
js
bash
new SystemMessage(`
重要规则 - execute_command:
- workingDirectory 参数会自动切换到指定目录
- 当使用 workingDirectory 时,绝对不要在 command 中使用 cd
- 错误示例: { command: "cd react-todo-app && pnpm install", workingDirectory: "react-todo-app" }
- 正确示例: { command: "pnpm install", workingDirectory: "react-todo-app" }
`)
这是 Agent 开发的通用套路:LLM 犯过的错,用 system prompt 圈起来警示后人。相当于给实习生贴了张"此处有坑"的便利贴。
write_file 的细节:递归建目录
写文件工具有个容易忽略的点------写 /a/b/c/App.tsx 这种深层路径时,a、b、c 目录可能压根不存在。
js
csharp
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true }); // 递归创建,存在就跳过
await fs.writeFile(filePath, content, 'utf-8');
path.dirname 抠出路径里的目录部分,mkdir 加 recursive: true 一路递归建下去。已存在的目录不会报错,稳。
ReAct 循环 + 两道刹车
工具齐了,主循环跟上。这回不用 while(true) 了,换成 for 循环加上限:
js
ini
async function runAgentWithTools(query, maxIterations = 30) {
for (let i = 0; i < maxIterations; i++) {
console.log(chalk.bgGreen(`正在等待第${i}次 AI 思考...`));
const response = await modelWithTools.invoke(messages);
messages.push(response);
// 没有工具调用 → LLM 觉得干完了,直接给最终回复
if (!response.tool_calls?.length) {
return response.content;
}
// 有工具调用 → 逐个执行,结果带 id 回喂
for (const toolCall of response.tool_calls) {
const foundTool = tools.find(t => t.name === toolCall.name);
if (foundTool) {
const result = await foundTool.invoke(toolCall.args);
messages.push(new ToolMessage({
content: result,
tool_call_id: toolCall.id,
}));
}
}
}
}
为什么这里不用 Promise.all 并行?因为建项目这活有严格顺序依赖------先 create 项目,再写文件,再 install,最后 run dev。并行跑反而乱套。
循环外面还垫了一层保险:
js
arduino
setTimeout(() => {
console.log("⏰ 超时兜底强制退出进程");
process.exit(0);
}, 1000000);
两道刹车,各管一头:
| 刹车 | 防什么 |
|---|---|
maxIterations = 30 |
LLM 无限调工具,循环永远不退 |
setTimeout 兜底 |
万一有命令挂住不返回(比如 dev server 常驻进程),整个 Node 进程赖着不死 |
pnpm run dev 起的 dev server 是个常驻进程,它永远不会"执行完毕"------没有 setTimeout 兜底,你的命令行窗口会一直挂着回不来。
跑起来的效果:chalk 绿底日志一行行刷,"检测到工具调用"→"执行 write_file"→"execute_command 成功"→ 最后 AI 输出总结。一个项目就这么被它自己建好、跑起来了。
5 个踩坑提醒
1. stdio 不设 'inherit',日志全丢。 子进程的输出默认不走你的控制台,Agent 装了半天气,你一行日志都看不到,还以为它死了。
2. command 和 workingDirectory 混用 cd。 上面说的那个坑,百分百踩。错误/正确示例必须写进 system prompt。
3. 工具执行结果当错误抛。 别 throw------工具挂了要把错误信息当结果回喂给 LLM,让它自己看到报错、自己决定下一步。throw 出去整个 Agent 就崩了。
4. spawn 不加 shell: true。 Windows 下命令直接找不到,报 ENOENT,你还以为是路径问题。
5. 循环没有退出上限。 LLM 偶尔会陷入"调工具→看结果→再调工具"的鬼打墙,30 次上限是成本底线。
写在最后
到这儿,一个能干活的编程 Agent 齐活了:LLM 出脑子,四件套工具出手脚,ReAct 循环转起来,双刹车保平安。Trae 和 Cursor 剥掉外壳,内核就是这么一套东西,只是工具更多、prompt 调得更狠。