Skip to content

实现 mini cursor:大模型自动调用 Tool 执行命令

上节我们给大模型扩展了读文件的 Tool:你说一个文件路径让它解释,它就能自动调工具读文件内容给出解释了。

继续思考:如果我们给它扩展了执行命令、写文件、创建目录、读取目录、读文件等 Tool,是不是就能实现 Cursor 的功能呢?

比如创建项目、对文件做增删改;项目创建后自动执行命令安装依赖和跑服务。

简易版确实可以写了。这节我们就来实现:让大模型根据 prompt 生成项目代码,自动读写文件、通过命令安装依赖、自动把项目跑起来,全程自己调用 Tool。

不创建新项目了,直接在上节的 tool-test 项目继续写。

Node 里如何执行命令:child_process

首先,Node 里执行命令要用 child_process 这个内置模块。创建一个 src/node-exec.mjs

js
import { spawn } from 'node:child_process';

const command = 'ls -la';
const cwd = process.cwd();

// 解析命令和参数
const [cmd, ...args] = command.split(' ');

const child = spawn(cmd, args, {
  cwd,
  stdio: 'inherit', // 实时输出到控制台
  shell: true,
});

let errorMsg = '';

child.on('error', (error) => {
  errorMsg = error.message;
});

child.on('close', (code) => {
  if (code === 0) {
    process.exit(0);
  } else {
    if (errorMsg) {
      console.error(`错误: ${errorMsg}`);
    }
    process.exit(code || 1);
  }
});

几个关键点:

  • spawn 可以指定在 cwd 这个目录下执行命令,它会创建一个子进程来跑——这也是为什么这个模块叫 child_process
  • 用空格分割出命令和参数部分,分别作为 cmdargs
  • stdio: 'inherit' 表示子进程的 stdout 也输出到父进程的 stdout,也就是控制台,实现实时输出

跑一下:

bash
node ./src/node-exec.mjs

最终我们是要跑 npx create-vite 这个命令的,试一下:

js
const command = 'echo -e "n\nn" | pnpm create vite react-todo-app --template react-ts';

echo 两个 n,是因为有时候 vite 会让你选择两个选项(用不用 rolldown、安不安装依赖)。echo n 然后通过管道操作符输出给那个进程,就和我们键盘输入 n 是一样的效果。

小提示:echo -e 在 Windows 上可能不支持,可以去掉前面的 echo(不一定需要用户选择),或者换成 PowerShell 的写法。

封装工具:src/all-tools.mjs

单独一个文件来放所有的工具,创建 src/all-tools.mjs

js
import { tool } from '@langchain/core/tools';
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { z } from 'zod';

// 1. 读取文件工具
const readFileTool = tool(
  async ({ filePath }) => {
    try {
      const content = await fs.readFile(filePath, 'utf-8');
      console.log(`[工具调用] read_file("${filePath}") - 成功读取 ${content.length} 字节`);
      return `文件内容:\n${content}`;
    } catch (error) {
      console.log(`[工具调用] read_file("${filePath}") - 错误: ${error.message}`);
      return `读取文件失败: ${error.message}`;
    }
  },
  {
    name: 'read_file',
    description: '读取指定路径的文件内容',
    schema: z.object({
      filePath: z.string().describe('文件路径'),
    }),
  }
);

// 2. 写入文件工具
const writeFileTool = tool(
  async ({ filePath, content }) => {
    try {
      const dir = path.dirname(filePath);
      await fs.mkdir(dir, { recursive: true });
      await fs.writeFile(filePath, content, 'utf-8');
      console.log(`[工具调用] write_file("${filePath}") - 成功写入 ${content.length} 字节`);
      return `文件写入成功: ${filePath}`;
    } catch (error) {
      console.log(`[工具调用] write_file("${filePath}") - 错误: ${error.message}`);
      return `写入文件失败: ${error.message}`;
    }
  },
  {
    name: 'write_file',
    description: '向指定路径写入文件内容,自动创建目录',
    schema: z.object({
      filePath: z.string().describe('文件路径'),
      content: z.string().describe('要写入的文件内容'),
    }),
  }
);

// 3. 执行命令工具(带实时输出)
const executeCommandTool = tool(
  async ({ command, workingDirectory }) => {
    const cwd = workingDirectory || process.cwd();
    console.log(`[工具调用] execute_command("${command}")${workingDirectory ? ` - 工作目录: ${workingDirectory}` : ''}`);
    return new Promise((resolve) => {
      // 解析命令和参数
      const [cmd, ...args] = command.split(' ');

      const child = spawn(cmd, args, {
        cwd,
        stdio: 'inherit', // 实时输出到控制台
        shell: true,
      });

      let errorMsg = '';

      child.on('error', (error) => {
        errorMsg = error.message;
      });

      child.on('close', (code) => {
        if (code === 0) {
          console.log(`[工具调用] execute_command("${command}") - 执行成功`);
          const cwdInfo = workingDirectory
            ? `\n\n重要提示:命令在目录 "${workingDirectory}" 中执行成功。如果需要在这个项目目录中继续执行命令,请使用 workingDirectory: "${workingDirectory}" 参数,不要使用 cd 命令。`
            : '';
          resolve(`命令执行成功: ${command}${cwdInfo}`);
        } else {
          console.log(`[工具调用] execute_command("${command}") - 执行失败,退出码: ${code}`);
          resolve(`命令执行失败,退出码: ${code}${errorMsg ? '\n错误: ' + errorMsg : ''}`);
        }
      });
    });
  },
  {
    name: 'execute_command',
    description: '执行系统命令,支持指定工作目录,实时显示输出',
    schema: z.object({
      command: z.string().describe('要执行的命令'),
      workingDirectory: z.string().optional().describe('工作目录(推荐指定)'),
    }),
  }
);

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

export { readFileTool, writeFileTool, executeCommandTool, listDirectoryTool };

这里创建了四个工具:

  • 读文件
  • 写文件(自动创建目录)
  • 执行命令
  • 读目录

注意 executeCommandTool 的工具调用返回结果里,我额外加了 cwd 的信息,避免之后命令胡乱 cd

重要提示:命令在目录 "${workingDirectory}" 中执行成功。
如果需要在这个项目目录中继续执行命令,
请使用 workingDirectory: "${workingDirectory}" 参数,
不要使用 cd 命令。

每个工具都是老套路:一个函数 + name + description + 基于 zod 声明的参数格式。

组装 Agent:src/mini-cursor.mjs

接下来就可以调用了。创建 src/mini-cursor.mjs

js
import 'dotenv/config';
import chalk from 'chalk';
import { ChatOpenAI } from '@langchain/openai';
import { HumanMessage, SystemMessage, ToolMessage } from '@langchain/core/messages';
import { executeCommandTool, listDirectoryTool, readFileTool, writeFileTool } from './all-tools.mjs';

const model = new ChatOpenAI({
  modelName: "qwen-plus",
  apiKey: process.env.OPENAI_API_KEY,
  temperature: 0,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
});

const tools = [
  readFileTool,
  writeFileTool,
  executeCommandTool,
  listDirectoryTool,
];

// 绑定工具到模型
const modelWithTools = model.bindTools(tools);

// Agent 执行函数
async function runAgentWithTools(query, maxIterations = 30) {
  const messages = [
    new SystemMessage(`你是一个项目管理助手,使用工具完成任务。
当前工作目录: ${process.cwd()}
工具:
1. read_file: 读取文件
2. write_file: 写入文件
3. execute_command: 执行命令(支持 workingDirectory 参数)
4. list_directory: 列出目录
重要规则 - execute_command:
- workingDirectory 参数会自动切换到指定目录
- 当使用 workingDirectory 时,绝对不要在 command 中使用 cd
- 错误示例: { command: "cd react-todo-app && pnpm install", workingDirectory: "react-todo-app" }
这是错误的!因为 workingDirectory 已经在 react-todo-app 目录了,再 cd react-todo-app 会找不到目录
- 正确示例: { command: "pnpm install", workingDirectory: "react-todo-app" }
这样就对了!workingDirectory 已经切换到 react-todo-app,直接执行命令即可
回复要简洁,只说做了什么`),
    new HumanMessage(query)
  ];

  for (let i = 0; i < maxIterations; i++) {
    console.log(chalk.bgGreen(`⏳ 正在等待 AI 思考...`));
    const response = await modelWithTools.invoke(messages);
    messages.push(response);

    // 检查是否有工具调用
    if (!response.tool_calls || response.tool_calls.length === 0) {
      console.log(`\n✨ AI 最终回复:\n${response.content}\n`);
      return response.content;
    }

    // 执行工具调用
    for (const toolCall of response.tool_calls) {
      const foundTool = tools.find(t => t.name === toolCall.name);
      if (foundTool) {
        const toolResult = await foundTool.invoke(toolCall.args);
        messages.push(new ToolMessage({
          content: toolResult,
          tool_call_id: toolCall.id,
        }));
      }
    }
  }

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

大部分代码我们都写过,简单过一遍:

  • 创建大模型对象,temperature 指定为 0,不让 AI 随意发挥;模型用 qwen-plus,比 qwen-coder-turbo 好一点(那个老模型对 tool calls 支持不稳定,返回的 tool_calls 大概率是空数组)
  • 把 tools 绑定到模型
  • 后面就是反复对话的过程:AI 可能返回调用工具很多次,所以加了个最大循环次数限制 maxIterations = 30。因为大模型上下文是有限的,限制一下沟通次数;等后面学了 Memory,就不用这么写了
  • 用 SystemMessage 指定 AI 可以做什么、回答的规范,明确告诉它有哪些工具;我还特意说明了 cd 的问题——有了 workingDirectory 参数之后,就不让模型用 cd
  • 把调用工具返回的内容封装成 ToolMessage 放回消息数组

这里和上节有个小差异:上节是先把所有 tool_calls 执行完、再统一 push ToolMessage;这节是边执行边 push。两种写法都行,只要 tool_call_id 和工具调用关联上就可以。

chalk 用来加点颜色,不然输出全是白的不好看:

bash
pnpm install chalk

实战:让 AI 自动创建一个 TodoList 项目

接下来写个 case:

js
const case1 = `创建一个功能丰富的 React TodoList 应用:
1. 创建项目: echo -e "n\nn" | pnpm create vite react-todo-app --template react-ts
2. 修改 src/App.tsx,实现完整功能的 TodoList:
 - 添加、删除、编辑、标记完成
 - 分类筛选(全部 / 进行中 / 已完成)
 - 统计信息显示
 - localStorage 数据持久化
3. 添加复杂样式:
 - 渐变背景(蓝到紫)
 - 卡片阴影、圆角
 - 悬停效果
4. 添加动画:
 - 添加 / 删除时的过渡动画
 - 使用 CSS transitions
5. 列出目录确认
注意:使用 pnpm,功能要完整,样式要美观,要有动画效果
之后在 react-todo-app 项目中:
1. 使用 pnpm install 安装依赖
2. 使用 pnpm run dev 启动服务器
`;

try {
  await runAgentWithTools(case1);
} catch (error) {
  console.error(`\n❌ 错误: ${error.message}\n`);
}

告诉它创建一个 todo app,然后安装依赖、跑起来——你是不是在 Cursor 里经常做这种事情?今天用自己写的工具来做:

bash
node ./src/mini-cursor.mjs

运行过程中,它就会调用各种工具:读取目录、写入文件、读取文件、执行命令——我们写的四个工具都会用上。

当然,这个过程慢是正常的,生成过程本来就慢;我们没用流式展示过程,其实你等待的时间里它一直在输出内容。流式相关的内容后面再做。

最终,这个项目的代码是用我们写的 mini cursor 自动创建、自动跑起来的。它和 Cursor 肯定有差距,但是已经实现了部分功能。我们不是想真的实现 Cursor,只是要知道它的实现原理。

完整代码在课程仓库:https://github.com/QuarkGluonPlasma/ai-agent-course-code/tool-test

常见问题

为什么生成的代码样式很乱? 多半是 index.css 的样式影响了,或者 App.tsx 里没引入 App.css——这个模型不是专门写代码用的,偶尔会犯这个毛病。在 prompt 里明确加上"引入 App.css"这类提示就能解决,或者换 qwen-max 或专门的编程模型。

执行 pnpm run dev 后程序停住了? 因为 spawn 会阻塞当前进程,项目跑起来后就不需要继续调用大模型了,直接 Ctrl+C 打断即可。如果想让它不阻塞,可以在 spawn 调用时加 detach 参数,让命令在独立进程跑,不耽误后面继续调用 AI。

模型用哪个好? 实测 qwen-coder-turbo 太老,tool_calls 大概率返回空数组导致不执行任何工具;qwen-plus 是通用模型,编程能力一般但 tool 调用稳定;想代码质量更高可以换 qwen-max 或专门的编程模型。

总结

这节我们创建了更多的 Tool:目录和文件的读写,还有用 spawn 执行命令。基于这些 Tool 实现了部分 Cursor 功能——最终效果是,它可以帮你创建项目、写入文件、执行安装依赖和跑项目的命令。

相信学到这,你就知道 Cursor 的大概实现原理了:大模型 + 一组 Tool + 一个循环

你也可以基于 Tool + LLM 来做一些自己想做的功能,边学边练,AI 学起来还是很有趣的!

基于 VitePress 构建 · 专注前端与 AI 实战