拒绝黑盒:手写一个 Mini Cursor,用 Node.js 驱动 AI 自动写代码

最近每天都在用 Trae 和 Cursor 这类 AI 编程工具,只要给一句"帮我用 Vite 创建一个 React 的 TodoList 项目,并且把它运行起来",它就能噼里啪啦地在终端里敲命令、建文件、写代码,最后直接把服务跑起来。

很多人觉得这很神奇,仿佛 AI 长了手。但作为开发者,我们不能只停留在"会用"的阶段。所谓的"编程 Agent 自动化",本质上到底是怎么运作的?

今天,我们就从零开始,用纯 Node.js 环境,配合大模型 API(以 DeepSeek 为例),手写一个迷你的 Cursor 核心引擎。我会把整个过程掰开揉碎,一步步带你探究 AI 是如何打破常规对话框,真正触碰到你电脑里的本地文件的。

核心思考:AI 为什么能写文件和跑命令?

我们要明白一个底层逻辑:大模型(LLM)本身只是一个文本生成器,它没有物理实体,也根本无法直接操作你的电脑系统。 那它是怎么做到执行命令的呢?答案是:我们(宿主程序)赋予了它调用工具的权限。

整个自动化编程 Agent 的运作流可以概括为一摊子事情:

  1. 我们给 AI 提供一套预先写好的"工具函数"(比如:读取文件、写入文件、执行终端命令)。
  2. AI 分析你的需求,决定此时应该调用哪个工具,并返回一段特定格式的指令(比如 JSON)。
  3. 我们的 Node.js 主进程解析这段指令,真刀真枪地在本地电脑上执行对应操作。
  4. 执行完毕后,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) 的结合。在这个模式下,大模型会开启一个无限循环的思考过程:

  1. 观察现状:看一眼目前的工程结构。
  2. 制定计划:决定接下来执行什么指令。
  3. 调用工具:输出工具参数。
  4. 得到反馈:接收工具在 Node.js 中执行的结果。
  5. 再次循环:直到任务完全结束。

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 机器人。

相关推荐
yeffky1 小时前
Vibe Coding一个能办事的 AI Agent:Spring Boot + DeepSeek + LangGraph4j 六节点 ReAct 项目实战
人工智能·后端·agent
东风破_1 小时前
RAG 是什么?从关键词搜索到语义搜索的完整过程
人工智能
AINative软件工程1 小时前
AI Agent 不是会重试就可靠:长任务的断点恢复与 Durable Execution 设计
人工智能·后端·ai编程
ZZZMMM.zip1 小时前
营养方程式-营养餐单规划的HarmonyOS开发实践
人工智能·华为·harmonyos·鸿蒙系统
广歌1 小时前
我花了半年驯养AI Agent,崩溃经历全记录
人工智能·开源
lauo1 小时前
从52起融资看教育科技新范式:PopLang引擎与Token节点经济如何重塑AI学习终端
人工智能·科技·学习
C137的本贾尼1 小时前
Agent 与 LLM 的交互协议:从 ReAct 到工具调用
人工智能·交互
BreezeJiang1 小时前
从零手写一个 AI 编程 Agent 之后,我终于理解了 Cursor 的核心原理
人工智能
进击切图仔1 小时前
基于千问的白盒蒸馏 SOP
运维·服务器·人工智能·python·效率