最近每天都在用 Trae 和 Cursor 这类 AI 编程工具,只要给一句"帮我用 Vite 创建一个 React 的 TodoList 项目,并且把它运行起来",它就能噼里啪啦地在终端里敲命令、建文件、写代码,最后直接把服务跑起来。
很多人觉得这很神奇,仿佛 AI 长了手。但作为开发者,我们不能只停留在"会用"的阶段。所谓的"编程 Agent 自动化",本质上到底是怎么运作的?
今天,我们就从零开始,用纯 Node.js 环境,配合大模型 API(以 DeepSeek 为例),手写一个迷你的 Cursor 核心引擎。我会把整个过程掰开揉碎,一步步带你探究 AI 是如何打破常规对话框,真正触碰到你电脑里的本地文件的。
核心思考:AI 为什么能写文件和跑命令?
我们要明白一个底层逻辑:大模型(LLM)本身只是一个文本生成器,它没有物理实体,也根本无法直接操作你的电脑系统。 那它是怎么做到执行命令的呢?答案是:我们(宿主程序)赋予了它调用工具的权限。
整个自动化编程 Agent 的运作流可以概括为一摊子事情:
- 我们给 AI 提供一套预先写好的"工具函数"(比如:读取文件、写入文件、执行终端命令)。
- AI 分析你的需求,决定此时应该调用哪个工具,并返回一段特定格式的指令(比如 JSON)。
- 我们的 Node.js 主进程解析这段指令,真刀真枪地在本地电脑上执行对应操作。
- 执行完毕后,Node.js 把成功或报错的反馈重新扔给 AI,AI 再决定下一步该干嘛。
既然一切的基础是 Node.js 在本地执行操作,那么我们要解决的第一个难点就是:如何在 Node 进程中,去跑类似 npm init vite 这样的命令行指令?
第一步:跳出 Node 单线程,探索 Child Process (子进程)
Node.js 是著名的单线程运行环境。但当我们要在终端里敲击类似于 ls -al (查看目录详情) 或者 npm run dev 时,这些都是实打实的操作系统级别的独立命令。如果 Node.js 亲自去跑这些重度阻塞的任务,主线程就会直接卡死。
这时候,我们就需要引入 Node.js 的核心模块:child_process。
child_process 允许我们从当前的 Node 主进程中,"孵化(Spawn)"出一个完全独立的子进程去干脏活累活。当子进程干完后,再通过 IPC(进程间通信,Inter-Process Communication)机制把结果汇报给主进程。
我们先写一个单纯的脚本 node-exec.mjs,来模拟这一个过程:
JavaScript
javascript
// node-exec.mjs
import { spawn } from 'node:child_process';
// 假设这是 AI 让我们执行的命令
const command = 'npm init vite react-todo-app --template react-ts';
// 第一步:拆分命令。spawn 要求命令本身和参数数组分离
// 用空格切分,'npm' 就是 cmd,后面的都是 args 参数数组
const [cmd, ...args] = command.split(' ');
// 获取当前 Node 进程的工作目录 (Current Working Directory)
const cwd = process.cwd();
console.log(`准备在目录执行命令:${cwd}`);
// 第二步:开启子进程
const client = spawn(cmd, args, {
cwd, // 告诉子进程在哪个目录执行
// 【核心配置】:stdio 的 inherit 属性
stdio: 'inherit',
// 开启 Shell 支持,否则无法解析像 npm 这样的环境变量命令
shell: true
});
深入解析:stdio: 'inherit' 到底是什么?
这里的 stdio: 'inherit' 是点睛之笔。 Node 运行时会向操作系统申请标准输入输出资源,而底层的 Bash(命令行环境)运行命令时也需要这些资源。如果我们将 stdio 设置为 inherit(继承),子进程就会直接继承父进程(也就是我们当前的终端窗口)的输入输出流。
直白点说: 配置了这一行,Vite 创建项目时打印的彩色的进度条、报错信息,就会实时、原封不动地直接显示在你当前的控制台上,就像是你亲手在终端敲下这条命令一样。
接下来我们要监听子进程的生命周期,告诉主进程它什么时候结束:
JavaScript
javascript
let errorMsg = '';
// 监听子进程的错误流
client.on('error', (err) => {
errorMsg = err.message;
});
// 监听子进程的关闭事件
client.on('close', (code) => {
// 退出码 code 为 0 代表运行顺利,完美成功退出
if (code === 0) {
console.log("子进程完美执行!");
process.exit(0); // 安全退出主进程
} else {
// code 非 0 说明命令执行遭遇了系统层面的失败
if (errorMsg) {
console.error(`系统级错误:${errorMsg}`);
}
process.exit(code || 1); // 带着错误码退出
}
});
补充知识:为什么我们在 Windows 上也能跑原本属于 Linux 的 bash 脚本?因为比如我们常用的 Git Bash,其内部自带了一个非常小巧的 Linux 系统级运行环境,能够解析这些跨平台的 Shell 指令。
第二步:为 AI 打造左膀右臂(封装工具链)
单单能跑命令还不够,Cursor 还能帮你检查代码(读文件)、修改代码(写文件)。我们需要利用大模型框架 LangChain,把这些能力封装成标准化的"工具(Tools)",以便 AI 能够识别和调用。
我们要创建一份 all-tools.mjs 文件。为了保证工具的严谨性,我们需要用到 zod 这个库。zod 的作用是进行数据模式(Schema)验证------大模型经常会胡说八道,我们用 zod 强行规定大模型每次调用工具时,必须按照严格的数据类型传入参数。
1. 读文件工具 (Read File Tool)
利用 Node 内置的 fs/promises 模块。因为磁盘 I/O(输入/输出)非常耗时,所以这里所有的操作全部基于 async/await 异步处理。
JavaScript
javascript
// all-tools.mjs
import { tool } from '@langchain/core/tools';
import fs from 'node:fs/promises';
import { z } from 'zod';
const readFileTool = tool(
// 功能函数主体
async ({ filePath }) => {
// 以 utf-8 编码读取文件内容
const content = await fs.readFile(filePath, 'utf-8');
// 【重要细节】:时刻反馈 Agent 的执行状态
// Agent 的思考和任务执行通常很耗时,如果不在终端随时 console.log,
// 开发者会以为程序卡死了而强行退出。
console.log(`[工具调用] read_file(${filePath}) 成功读取 ${content.length} 字节`);
return content; // 返回给 AI 分析
},
{
name: 'read_file',
description: '用此工具来读取文件内容。当需要查看代码、分析文件内容时调用此工具。',
// 使用 Zod 规定 AI 必须传入名为 filePath 的字符串
schema: z.object({
filePath: z.string().describe('要读取的文件路径(相对或绝对路径)')
})
}
);
2. 写文件工具 (Write File Tool)
写文件比读文件复杂一点。当 AI 试图在一个根本不存在的文件夹(比如 /src/components/)里新建文件时,如果直接写入是会报错的。这就要求我们在写入前,判断并自动创建所需的目录层级。
这里需要引入 Node.js 专门用来处理路径拼接和合法性判断的内置模块:path。
JavaScript
javascript
import path from 'node:path';
const writeFileTool = tool(
async ({ filePath, content }) => {
try {
// 提取文件所属的目录。比如输入 /a/b/c/123.js,会返回 /a/b/c
const dir = path.dirname(filePath);
// { recursive: true } 是核心参数。
// 它代表递归创建。哪怕 /a/b/c 整个都不存在,它会一口气把 a、b、c 按层级全部建好。
// 如果目录已存在,它也不会报错中断。
await fs.mkdir(dir, { recursive: true });
// 写入文件
await fs.writeFile(filePath, content, 'utf-8');
console.log(`[工具调用] write_file(${filePath}) 成功写入 ${content.length} 字节`);
return `成功写入 ${filePath}`;
} catch (err) {
return `写入文件失败:${err.message}`; // 错误需要返回给 AI,让它知道写错了
}
},
{
name: 'write_file',
description: '向指定路径写入文件内容,具备自动创建缺失目录的能力',
schema: z.object({
filePath: z.string().describe('要写入的文件路径'),
content: z.string().describe('要写入的完整文件内容')
})
}
);
3. 把命令行封装成 Agent 工具
现在,我们要把第一步手写的 node-exec 逻辑套入到 Tool 格式中。因为这是要在 Agent 内部运行,我们需要把异步回调封装成 Promise,让代码执行流能够被 await 捕获。
JavaScript
javascript
import { spawn } from 'node:child_process';
const executeCommandTool = tool(
async ({ command, workingDirectory }) => {
// 允许 AI 指定命令执行的目录,不指定则默认为宿主进程目录
const cwd = workingDirectory || process.cwd();
return new Promise((resolve, reject) => {
const [cmd, ...args] = command.split(' ');
const child = spawn(cmd, args, {
cwd,
stdio: 'inherit',
shell: true,
});
let errorMsg = '';
child.on('error', (err) => { errorMsg = err.message });
child.on('close', (code) => {
if (code === 0) {
// 运行顺利解决 Promise
resolve(`命令行成功执行 ${command}`);
} else {
// 哪怕失败了,也要 resolve 返回给 AI,决不能 reject。
// 因为 reject 会导致 Node 进程崩溃,而 resolve 错误信息可以让 AI "看到"报错并尝试自我修复!
resolve(`命令执行失败,退出码:${code}\n 错误:${errorMsg}`);
}
});
});
},
{
name: 'execute_command',
description: '执行系统终端命令,支持指定工作目录',
schema: z.object({
command: z.string().describe('要执行的命令'),
workingDirectory: z.string().describe('工作目录(推荐显式指定)')
})
}
);
(备注:笔记中还有一个 listDirectoryTool 用于查看目录列表,为了控制篇幅,其原理与读文件类似,调用 fs.readdir 即可,在此略过展示代码。)
第三步:注入灵魂!构建 ReAct Agent 运行循环
万事俱备,我们现在有了可以操作本地系统的工具链。接下来,我们需要打造 Agent 的大脑。这里我们创建一个核心启动文件 mini-cursor.mjs。
我们将使用目前性价比和逻辑推理能力都极佳的 DeepSeek API,通过 LangChain 构建整个自动编程系统。
编程 Agent 能够自动运作的本质,是采用了 ReAct (Reasoning and Acting) 模式。 这不是前端那个框架 React,而是 思考(Reasoning) + 行动(Acting) 的结合。在这个模式下,大模型会开启一个无限循环的思考过程:
- 观察现状:看一眼目前的工程结构。
- 制定计划:决定接下来执行什么指令。
- 调用工具:输出工具参数。
- 得到反馈:接收工具在 Node.js 中执行的结果。
- 再次循环:直到任务完全结束。
1. 挂载模型与工具
JavaScript
javascript
// mini-cursor.mjs
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { HumanMessage, SystemMessage, ToolMessage } from '@langchain/core/messages';
import chalk from 'chalk'; // 颜色输出库,用于突出终端里的重点日志
import { executeCommandTool, readFileTool, writeFileTool, listDirectoryTool } from './all-tools.mjs';
// 初始化模型配置 (使用兼容 OpenAI 格式的 DeepSeek V4)
const model = new ChatOpenAI({
modelName: 'deepseek-v4-pro',
apiKey: process.env.DEEPSEEK_API_KEY,
temperature: 0, // 编程需要绝对的严谨,温度降到 0 消除随机性
configuration: {
baseURL: 'https://api.deepseek.com/v1',
},
});
// 将我们刚才写的工具收集成数组
const tools = [ readFileTool, writeFileTool, listDirectoryTool, executeCommandTool ];
// LangChain 提供的核心 API:将工具能力与模型绑定
const modelWithTools = model.bindTools(tools);
2. 编写硬核系统提示词 (System Prompt)
要让 AI 不犯傻,你必须在系统提示词里定下铁律。特别是针对路径管理,大模型非常容易犯低级错误(比如反复使用 cd 命令)。
JavaScript
bash
const systemPrompt = new SystemMessage(`你是一个项目管理助手,使用工具完成任务。
当前工作目录: ${process.cwd()}
可用工具: read_file, write_file, execute_command, list_directory
【重要安全规则 - execute_command】:
1. workingDirectory 参数会自动为你切换到指定目录。
2. 当使用 workingDirectory 时,绝对不要在 command 中画蛇添足使用 cd 命令!
3. 错误示例: { command: "cd react-todo-app && pnpm install", workingDirectory: "react-todo-app" }
(这是灾难性的!目录重叠会导致找不到路径)
4. 正确示例: { command: "pnpm install", workingDirectory: "react-todo-app" }
你的回复要简洁专业,只告诉我你做了什么。`);
3. 实现 ReAct 核心引擎算法
真正的核心是一段看似简单的 for 循环代码。它维护着一个对话历史数组 messages,不断地让 AI 和你的本地环境进行"抛接球":
JavaScript
javascript
async function runAgentWithTools(query, maxIterations = 30) {
// 初始化消息列队,压入系统规则和用户的初始需求
const messages = [ systemPrompt, new HumanMessage(query) ];
// ReAct 循环机制:设定最大思考次数(防止陷入死循环把 API 钱扣光)
for (let i = 0; i < maxIterations; i++) {
console.log(chalk.bgGreen(`\n>>> 正在等待第 ${i} 次 AI 思考分析...`));
// 把上下文发给 AI
const response = await modelWithTools.invoke(messages);
messages.push(response); // 把 AI 的回答立刻存入记忆
// 【核心判断】:如果 AI 觉得任务完成了,没有返回工具调用意图,直接退出循环!
if (!response.tool_calls || response.tool_calls.length === 0) {
console.log(`\n🎉 AI 最终回复:\n${response.content}\n`);
return response.content;
}
// 如果 AI 决定调用工具,遍历解析其生成的工具调用栈
for (const toolCall of response.tool_calls) {
// 在本地注册的工具中寻找对应的函数
const foundTool = tools.find(t => t.name === toolCall.name);
if (foundTool) {
// 真刀真枪地去执行 Node.js 操作(比如写文件、跑 spawn 子进程)
const toolResult = await foundTool.invoke(toolCall.args);
// 关键步骤:把真实终端跑出来的结果,以 ToolMessage 的身份塞回记忆数组
messages.push(new ToolMessage({
content: toolResult,
tool_call_id: toolCall.id // ID 一定要对应,否则 AI 不知道这是哪个指令的反馈
}));
}
}
}
return messages[messages.length - 1].content;
}
最后,我们只需要给它下达终极测试任务,并设置一个保险的全局超时退出机制:
JavaScript
javascript
const task = `
创建一个功能丰富的React TodoList 应用:
1. 建立项目:运行命令:echo -e "n\nn" | pnpm create vite react-todo-app --template react-ts
2. 进去之后使用 pnpm install 安装依赖。
3. 修改 src/App.tsx,实现带有分类筛选、localStorage 数据持久化的功能。
4. 加点漂亮的渐变背景和卡片阴影动画。
5. 最后 pnpm run dev 跑起来。
`;
try {
await runAgentWithTools(task);
} catch (error) {
console.error(`\n 系统崩溃:${error.message}`);
}
// 兜底机制:一旦执行过久(比如超过十多分钟),强行杀掉进程
setTimeout(() => {
console.log("执行严重超时,强行退出 Node 进程保护系统安全");
process.exit(0);
}, 10000000);
总结
当我们跑起上面这段代码时,你会看到 Node.js 控制台开始不断地闪烁出信息:AI 在自动建文件夹、在疯狂写文件、并且子进程中的彩色 Vite 进度条就在你眼前滚动。
看,AI 并不可怕。它没有什么魔法,无非是强大的语义解析能力,配合着最基础的 spawn 子进程和 fs 文件读写。当我们了解了底层运行原理,不再把这些智能体当作"黑盒"对待时,我们才能更好地掌控未来的开发工具链,甚至为你自己的业务定制出专属的编程 Agent 机器人。