上节我们给大模型扩展了读文件的 Tool:你说一个文件路径让它解释,它就能自动调工具读文件内容给出解释了。
继续思考:如果我们给它扩展了执行命令、写文件、创建目录、读取目录、读文件等 Tool,是不是就能实现 Cursor 的功能呢?
比如创建项目、对文件做增删改;项目创建后自动执行命令安装依赖和跑服务。
简易版确实可以写了。这节我们就来实现:让大模型根据 prompt 生成项目代码,自动读写文件、通过命令安装依赖、自动把项目跑起来,全程自己调用 Tool。
不创建新项目了,直接在上节的 tool-test 项目继续写。
Node 里如何执行命令:child_process
首先,Node 里执行命令要用 child_process 这个内置模块。创建一个 src/node-exec.mjs:
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- 用空格分割出命令和参数部分,分别作为
cmd、args stdio: 'inherit'表示子进程的 stdout 也输出到父进程的 stdout,也就是控制台,实现实时输出
跑一下:
node ./src/node-exec.mjs最终我们是要跑 npx create-vite 这个命令的,试一下:
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:
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:
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 用来加点颜色,不然输出全是白的不好看:
pnpm install chalk实战:让 AI 自动创建一个 TodoList 项目
接下来写个 case:
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 里经常做这种事情?今天用自己写的工具来做:
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 学起来还是很有趣的!
