🖐️ 手写 Mini Cursor:用 Node.js Spawn 和 LangChain 打造全栈编程 Agent

0 阅读7分钟

各位掘金的同学们,大家好!👋 我是你们的 AI 领路人。

在上一篇文章中,我们揭开了 Agent 的神秘面纱,得出一个核心公式:Agent = LLM + Memory + Tools。如果说 LLM 是大脑,那么 Tools 就是它的手脚。

今天,我们要搞点硬核的!我们要模仿现在最火的 AI 编辑器 Cursor,手写一个能自己在终端跑命令、能自己写代码、能自己创建项目的 Mini Cursor

这就要求我们的 Agent 不仅要能“读写文件”,还得能“执行命令”(比如 npm install, git commit)。这可没那么简单,涉及到了 Node.js 的底层进程管理。

准备好了吗?我们要发车了!🚗💨


🚦 一、 前置知识:Node.js 的“分身术” —— spawn(产卵)

在给 Agent 装上“执行命令”的手臂之前,我们必须先补一课 Node.js 的基础。

1.1 为什么需要子进程?

众所周知,Node.js 是单线程的(基于 Event Loop)。这意味着主线程非常宝贵,主要用来处理 I/O 调度。

试想一下,如果 Agent 想要执行一个耗时 5 分钟的 npm install,如果你直接在主线程里跑(虽然 JS 也没法直接跑 Shell),或者用同步阻塞的方式,整个程序就卡死了!你没法打断它,也没法看进度。

这时候,我们需要多进程(Multi-process)

💡 通俗理解

  • 主进程(Main Process):就像是一个大厨,负责统筹全局,接单、指挥。
  • 子进程(Child Process):就像是切墩的小工。大厨喊一声:“去把土豆削了(执行 ls -la)!”小工就去旁边干活,干完了回来汇报结果,期间大厨可以继续接单。

1.2 spawn 实战:让代码跑起来

Node.js 内置模块 child_process 提供了 spawn 方法,它是生成子进程的神器。

让我们看看 cursor_indeed/tool-use/node-exec.mjs 中的这段代码:

// c:\Users\MR\Desktop\workspace\lesson_jp\ai\agent\mini-cursor\cursor_indeed\tool-use\node-exec.mjs

import { spawn } from 'node:child_process';

// 假设我们要执行的命令是列出当前目录详细信息
const command = 'ls -la';

// 1. 解析命令
// spawn 接收的第一个参数是命令(cmd),第二个参数是参数数组(args)
// 比如 'ls -la' 就要拆分成 'ls' 和 ['-la']
const [cmd, ...args] = command.split(' ');

const cwd = process.cwd(); // 获取当前工作目录
console.log(`当前工作目录:${cwd}`);

// 2. 🔥 核心:新建一个子进程
const child = spawn(cmd, args, {
    cwd, // 指定在这个目录下干活
    // stdio: 'inherit' 非常重要!
    // 它的意思是:子进程的输入输出直接“继承”父进程。
    // 也就是说,子进程打印的日志,会直接显示在你的控制台里,
    // 你也能看到 npm install 的进度条颜色!
    stdio: 'inherit',
    // shell: true 表示在 shell 环境中运行,
    // 这样你才能用管道符 | 或者 && 这些 shell 特性
    shell: true
});

1.3 进程间的“飞鸽传书”

小工(子进程)干活的时候,可能会出错,也可能会干完。大厨(主进程)怎么知道呢?

事件监听(Event Listener)

// c:\Users\MR\Desktop\workspace\lesson_jp\ai\agent\mini-cursor\cursor_indeed\tool-use\node-exec.mjs

let errorMsg = '';

// 👂 监听错误事件
// 如果命令根本没法运行(比如命令拼写错误),就会触发这个
child.on('error', (err) => {
    errorMsg = err.message;
    console.error('哎呀,出错了:', errorMsg);
});

// 👂 监听关闭事件
// 当命令执行完毕(无论成功失败),进程都会退出,触发 close
child.on('close', (code) => {
    // code === 0 通常代表成功(Unix 惯例)
    if (code === 0) {
        console.log(`命令 ${command} 执行成功,子进程光荣下岗!`);
        process.exit(0);
    } else {
        // 非 0 代表执行过程中报错了
        if (errorMsg) {
            console.error(`错误详情:${errorMsg}`);
        }
        console.log(`子进程非正常退出,退出码:${code}`);
        process.exit(code || 1);
    }
});

搞懂了 spawn,我们就拥有了在代码里操作终端的能力。接下来,我们要把这个能力封装成 Agent 能用的 Tool!


🛠️ 二、 打造 Mini Cursor 的“四肢”:Tools 封装

我们来到 cursor-hand/all_tools.mjs。这里我们将封装四个核心工具,让 Agent 具备全栈开发的能力。

2.1 准备工作:导出工具库

// c:\Users\MR\Desktop\workspace\lesson_jp\ai\agent\mini-cursor\cursor-hand\all_tools.mjs

// 所有的工具最后都要在这里导出,供 main.mjs 调用
export {
    readFileTool,
    writeFileTool,
    executeCommanTool,
    listDirectoryTool
};

2.2 👁️ 眼睛:read_file (读取文件)

这是 Agent 理解项目的基础。

// 读取文件工具
const readFileTool = tool(
    async({ filePath }) => {
        try {
            console.log(`[工具调用] read_file ("${filePath}")`);
            // 使用 fs.readFile 读取内容,utf-8 编码
            const content = await fs.readFile(filePath, 'utf-8');
            return `文件内容:\n${content}`;
        } catch (error) {
            // ⚠️ 注意:Tool 内部报错不要直接 throw 崩掉程序,
            // 而是返回错误字符串,让 LLM 知道出错了,它会自己想办法修复!
            console.log(`[工具调用] read_file 错误:${error.message}`);
            return `错误:${error.message}`;
        }
    },
    {
        name: 'read_file',
        description: '读取指定路径的文件内容',
        schema: z.object({
            filePath: z.string().describe('文件路径')
        })
    }
);

2.3 ✍️ 手:write_file (写入文件)

Agent 写代码全靠它。这里有个细节:自动创建目录

// 写入文件工具
const writeFileTool = tool(
    async({ filePath, content }) => {
        try {
            // 比如 filePath 是 /a/b/c.txt
            // path.dirname 拿到 /a/b
            const dir = path.dirname(filePath);
            
            // recursive: true 意味着如果 /a 不存在,它会先创建 /a,再创建 /b
            // 这就是“递归创建”,非常贴心,防止报错
            await fs.mkdir(dir, { recursive: true });
            
            await fs.writeFile(filePath, content, 'utf-8');
            console.log(`[工具调用] write_file ("${filePath}") 写入成功`);
            return `文件写入成功: "${filePath}"`;
        } catch(error) {
            return `写入错误:${error.message}`;
        }
    },
    {
        name: 'write_file',
        description: '向指定路径写入文件内容,自动创建目录',
        schema: z.object({
            filePath: z.string().describe('文件路径'),
            content: z.string().describe('要写入的文件内容')
        })
    }
);

2.4 💪 肌肉:execute_command (执行命令)

这是最复杂的工具,结合了我们刚才学的 spawn

关键点:Tool 的执行函数必须是 async 的,但 spawn 是基于事件回调的。怎么把它们结合起来? 👉 答案:用 Promise 包装!

// 执行命令工具
const executeCommanTool = tool(
    async ({ command, workingDirectory }) => {
        // 默认在当前目录,或者用户指定的目录执行
        const cwd = workingDirectory || process.cwd(); 
        console.log(`[工具调用] execute_command ("${command}") 工作目录:${cwd}`);

        // 返回一个 Promise,让 await 等待命令执行完
        return new Promise((resolve, reject) => {
            const [cmd, ...args] = command.split(' ');
            
            // 🚀 启动子进程
            const child = spawn(cmd, args, {
                cwd,
                stdio: 'inherit', // 让用户能看到 npm install 的花花绿绿
                shell: true
            });

            let errorMsg = '';
            child.on('error', (error) => {
                errorMsg = error.message;
                reject(errorMsg); // Promise 失败
            });

            child.on('close', (code) => {
                if(code === 0) {
                    // 🎉 成功!
                    console.log(`[工具调用] 命令执行成功`);
                    // 贴心地告诉 LLM:如果你要继续在这个目录操作,记得用 workingDirectory 参数
                    const cwdInfo = workingDirectory ? 
                    `\n重要提示:命令在目录 ${workingDirectory} 执行成功。后续请继续使用 workingDirectory 参数,不要使用 cd 命令。` : ``;
                    
                    resolve(`命令 ${command} 执行成功 ${cwdInfo}`);
                } else {
                    reject(errorMsg || `命令退出码:${code}`);
                }
            });
        });
    },
    {
        name: 'execute_command',
        description: '执行系统命令,支持指定工作目录,实时显示输出',
        schema: z.object({
            command: z.string().describe('要执行的命令'),
            workingDirectory: z.string().optional().describe('指定工作目录,默认当前工作目录')
        })
    }
);

2.5 🗺️ 地图:list_directory (列出目录)

Agent 经常需要看一眼现在目录下都有啥,防止文件重名或路径搞错。

// 列出目录工具
const listDirectoryTool = tool(
    async ({ directoryPath }) => {
        try {
            const files = await fs.readdir(directoryPath);
            return `目录内容:\n${files.map(f => `- ${f}`).join('\n')}`;
        } catch(error) {
            return `列出目录失败:${error.message}`;
        }
    },
    {
        name: 'list_directory',
        description: '列出指定目录下的所有文件和文件夹',
        schema: z.object({
            directoryPath: z.string().describe('目录路径')
        })
    }
);

🧠 三、 注入灵魂:Agent Loop (Mini Cursor 主逻辑)

工具齐了,现在我们要编写 main.mjs,让 LLM 动起来。

3.1 初始化模型与绑定

// c:\Users\MR\Desktop\workspace\lesson_jp\ai\agent\mini-cursor\cursor-hand\main.mjs

import { ChatOpenAI } from '@langchain/openai';
import { readFileTool, writeFileTool, executeCommanTool, listDirectoryTool } from './all_tools.mjs';

// 1. 召唤模型
const model = new ChatOpenAI({
    modelName: process.env.MODEL_NAME, 
    apiKey: process.env.OPENAI_API_KEY,
    temperature: 0, // 编程任务必须严谨,不要发散
    configuration: { baseURL: process.env.OPENAI_API_BASE_URL }
});

// 2. 装备工具包
const tools = [readFileTool, writeFileTool, executeCommanTool, listDirectoryTool];

// 3. 绑定!Model + Tools = Super Model
const modelWithTools = model.bindTools(tools);

3.2 核心逻辑:runAgentWithTools

这是一个能够自我迭代的函数。

async function runAgentWithTools(query, maxIterations = 30) {
    // 💡 System Prompt (系统提示词) 是 Agent 的灵魂
    // 我们在这里立规矩,特别是关于 cd 命令的坑,必须教给 LLM
    const messages = [
        new SystemMessage(`
        你是一个项目管理助手,使用工具完成任务。
        当前工作目录:${process.cwd()}

        工具列表...(省略)

        🛑 重要规则 - execute_command:
        - 不要使用 cd 命令!因为子进程的 cd 不会影响主进程或其他子进程。
        - 必须使用 workingDirectory 参数来指定目录。
        - 错误示例: "cd app && npm install"
        - 正确示例: command: "npm install", workingDirectory: "app"
        `),
        new HumanMessage(query) // 用户的需求
    ];

    // 🔄 Agent Loop:思考 -> 行动 -> 观察 -> 再思考
    // 设置 maxIterations 防止死循环耗尽 Token
    for(let i = 0; i < maxIterations; i++) {
        console.log(chalk.bgGreen('⏳ 正在等待 AI 思考...'));
        
        // 1. AI 进行决策
        const response = await modelWithTools.invoke(messages);
        
        // 必须把 AI 的回复(包括它想调用的工具)加入历史
        messages.push(response);

        // 🛑 终止条件:AI 觉得没工具可调了,说明任务完成了,或者它想跟你聊天了
        if(!response.tool_calls || response.tool_calls.length === 0) {
            console.log(`\n🤖 AI 最终回复:\n ${response.content}\n`);
            return response.content;
        }

        // 2. 执行工具
        // 遍历所有 AI 想要调用的工具
        for(const toolCall of response.tool_calls) {
            const foundTool = tools.find(t => t.name === toolCall.name);
            if (foundTool) {
                // 执行工具逻辑,注意工具的调用时要await,并且工具不止包含方法还要对应的描述,langchain使用invoke执行工具中的方法
                const toolResult = await foundTool.invoke(toolCall.args);
                
                // 3. 反馈结果
                // 重点:一定要带上 tool_call_id!
                // 这样 LLM 才知道这个结果是对应哪次调用的,特别是并发调用时。
                messages.push(new ToolMessage({
                    content: toolResult,
                    tool_call_id: toolCall.id
                }));
            }
        }
        // 循环继续,带着工具的结果,进入下一轮 invoke
    }

    return messages[messages.length - 1].content;
}

🎮 四、 实战演练:一句话生成 React 应用

现在,见证奇迹的时刻到了。我们定义一个复杂的任务 case1

const case1 = `
创建一个功能丰富的 React TodoList 应用:
1. 创建项目:使用 pnpm create vite ...
2. 修改 src/App.tsx 实现增删改查、分类筛选、持久化...
3. 添加美观的 CSS 样式...
4. 这里的关键是:AI 需要自己先创建项目,然后切换到目录下安装依赖,再写代码。
`

try {
    // 启动 Mini Cursor!
    await runAgentWithTools(case1);
} catch (error) {
    console.log(chalk.red(`[错误] ${error.message}`));
}

当你运行这段代码时,你会看到控制台疯狂输出:

  1. AI 调用 execute_command 执行 pnpm create vite
  2. AI 调用 read_file 查看生成的文件结构。
  3. AI 调用 write_file 重写 App.tsxindex.css
  4. AI 调用 execute_command 执行 pnpm install

你会亲眼看着一个项目从无到有,就像有一个隐形的程序员在帮你敲键盘一样!🤩


来看看运行效果:

屏幕截图 2026-02-25 204239.png 当你看到自己写的agent像cursor或者trea一样不断思考完成任务的时候将会很爽。

屏幕截图 2026-02-25 205230.png 虽然最后的效果好像挺一般哈哈。

📝 五、 总结与避坑指南

今天我们手写了一个 Mini Cursor,核心知识点回顾:

  1. 多进程:利用 Node.js 的 spawn 执行 Shell 命令,不阻塞主线程。
  2. Promise 封装:将事件驱动的 spawn 转换为 async/await 友好的 Promise。
  3. Agent Loop:通过 while/for 循环和 messages 数组,维护多轮对话状态。
  4. System Prompt 调优:明确告诉 LLM 不要用 cd,而是用 workingDirectory,这是 Agent 开发中典型的 Prompt Engineering。

⚠️ Windows 用户特别提示

如果你在 Windows 上运行,spawnshell: true 选项通常会调用 cmd.exe。如果你想用 ls, rm -rf 等 Linux 命令,建议:

  1. 在代码中显式指定 shell 为 Git Bash 的路径。
  2. 或者直接在 Git Bash 终端里运行你的 node main.mjs
  3. 或者让 AI 意识到它是 Windows 环境,生成 dir 代替 ls(这可以通过 System Prompt 注入环境信息来实现)。

动手试试吧! 当你第一次看到 AI 真的在你的电脑上自动把代码跑起来的时候,那种感觉绝对是——泰酷辣!🌶️


希望这篇文章能帮你打开 Agent 开发的大门!如果觉得有用,记得点赞收藏哦!