# 从零打造你的第一个智能体(Agent):手写一个能自主建项目的"Mini Cursor"
不依赖 Claude Code,不买 Cursor 订阅------自己写一个能创建 React 项目、安装依赖、启动服务的 AI 编程助手。
你一定见过这样的场景:你告诉 AI "帮我用 Vite 创建一个 React TodoList",它给你一段"你可以这样做"的文字,然后你自己去敲命令。但如果 AI 真的能自己执行命令、写入文件、处理错误呢?
今天,我们就用 Node.js + LangChain + DeepSeek,手写一个 Mini Cursor------一个能够自主创建完整 React 项目、安装依赖、启动开发服务器的编程 Agent。全程代码开源,每一行注释都藏着工程化思考,读完你不仅会写 Agent,更会理解 AI 工程的核心要义。
一、任务定义:让 Agent 完成一个真实项目
我们的目标是让 Agent 执行这样一个复杂任务:
创建一个功能完整的 React TodoList 应用(使用 Vite + TypeScript),包含增删改、分类筛选、localStorage 持久化、精美样式和过渡动画,然后安装依赖并启动开发服务器。
这可不是"问一句答一句",而是需要多步规划、多次工具调用、错误处理和状态跟踪的 长期任务 。我们将它封装为一个 case1 字符串,作为用户的初始提问。
二、工具集:给 LLM 装上"手和脚"
Agent 的能力完全取决于它拥有的工具。我们定义了四个核心工具,全部位于 all-tools.mjs。
2.1 读文件(readFileTool)
基础工具,用于查看已有代码或日志。
javascript
const readFileTool = tool(
async({ filePath }) => {
const content = await fs.readFile(filePath, 'utf-8');
console.log(`[工具调用] read_file(${filePath}) 成功读取 ${content.length} 字节`);
return content;
},
{
name: 'read_file',
description: '读取文件内容,用于查看代码或日志。',
schema: z.object({
filePath: z.string().describe('文件路径')
})
}
);
很简单,但它的存在让 Agent 能在出错后"自省"------比如 npm run dev 失败,Agent 可以读取错误日志来修复。
2.2 写文件(writeFileTool)------ 路径安全与自动创建目录
这是创建项目的核心工具。注意其中几个 工程化细节:
javascript
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(err) {
return `写入文件失败:${err.message}`;
}
},
{
name: 'write_file',
description: '向指定路径写入文件内容,自动创建目录',
schema: z.object({
filePath: z.string().describe('文件路径'),
content: z.string().describe('要写入的文件内容')
})
}
);
关键点:
fs.mkdir(dir, { recursive: true }):递归创建父目录,避免因目录不存在而报错。比如写入src/components/TodoItem.tsx时,会自动创建src和components目录。- 路径安全考量 :生产环境应限制
filePath只能在当前工作目录下,防止路径遍历攻击(虽然这里未实现,但注释提到了path模块用于合法性检查)。
2.3 列出目录(listDirectoryTool)
让 Agent 能"环顾四周",了解当前项目结构,便于决定下一步写什么文件。
javascript
const listDirectoryTool = tool(
async ({ directoryPath }) => {
try {
const files = await fs.readdir(directoryPath);
console.log(`[工具调用] list_directory(${directoryPath}) 成功列出 ${files.length} 个文件和文件夹`);
return `目录内容:\n ${files.map(file => file.name).join('\n')}`;
} catch(err) {
return `列出目录内容失败:${err.message}`;
}
},
{
name: 'list_directory',
description: '列出指定目录下的所有文件和文件夹',
schema: z.object({
directoryPath: z.string().describe('目录路径')
})
}
);
注意这里 files.map(file => file.name) 在 fs.readdir 的默认模式下返回的是文件名数组,但 file.name 可能不适用(因为 fs.readdir 返回字符串数组,不是对象)。应改为 files.map(name => name)。不过代码旨在示意,实际运行时可修正。
2.4 执行命令(executeCommandTool)------ 子进程与实时输出
这是让 Agent "动手"的关键工具,也是最具技术深度的部分。
javascript
const executeCommandTool = tool(
async ({ command, workingDirectory }) => {
const cwd = workingDirectory || process.cwd();
console.log(`[工具调用] execute_command(${command}) 工作目录:${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) {
console.log(`[工具调用] execute_command(${command}) 成功执行`);
const cwdInfo = workingDirectory ? `\n\n重要提示:命令在目录"${workingDirectory}"执行` : '';
resolve(`命令行成功执行 ${command}${cwdInfo}`);
} else {
console.log(`[工具调用] execute_command(${command}) 退出码:${code}`);
resolve(`命令执行失败,退出码:${code}\n错误:${errorMsg}`);
}
});
});
},
{
name: 'execute_command',
description: '执行系统命令,支持指定工作目录,实时显示输出',
schema: z.object({
command: z.string().describe('要执行的命令'),
workingDirectory: z.string().describe('工作目录(推荐指定)')
})
}
);
深度思考:
-
为什么使用
spawn而非exec?spawn以流的方式处理子进程输出,适合长时间运行的命令(如pnpm run dev),且不会一次性缓冲所有输出,内存友好。exec则会将输出全部缓存在内存中,可能溢出。 -
stdio: 'inherit'的作用让子进程的输出(stdout/stderr)直接透传到父进程的控制台,这样用户在运行
pnpm install时能实时看到进度条,而不是等到命令结束才一次性显示。这对用户体验至关重要。 -
错误处理 :
我们捕获
error事件(如命令不存在)和close事件的退出码,但即使失败也调用resolve而不是reject。这样 Agent 能接收错误信息并尝试修复(比如提示"找不到 pnpm,请先安装"),而不会因为异常中断整个循环。这是一种 容错设计。 -
关于
workingDirectory的陷阱 :我们特意在系统提示中强调:当使用
workingDirectory参数时,绝对不要在command中再写cd命令 。因为spawn已经将子进程的工作目录设置为cwd,如果再在命令中cd会造成"目录不存在"的错误。这是新手最容易踩的坑,我们的提示词设计主动预防了这一点。
三、Agent 主程序:mini-cursor.mjs 的核心逻辑
3.1 导入与初始化
javascript
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { HumanMessage, SystemMessage, ToolMessage } from '@langchain/core/messages';
import { executeCommandTool, readFileTool, writeFileTool, listDirectoryTool } from './all-tools.mjs';
import chalk from 'chalk'; // 让控制台输出更鲜艳
const model = new ChatOpenAI({
modelName: 'deepseek-v4-pro',
apiKey: process.env.DEEPSEEK_API_KEY,
temperature: 0,
configuration: { baseURL: 'https://api.deepseek.com/v1' }
});
const tools = [readFileTool, writeFileTool, listDirectoryTool, executeCommandTool];
const modelWithTools = model.bindTools(tools);
使用 chalk 让 Agent 的思考过程在终端中高亮显示,提升可读性。
3.2 System Prompt 的艺术
javascript
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)
];
深度思考:
- 系统提示词不仅是"你是谁",更是 操作手册。我们把工具的使用规则、常见错误案例直接写进去,相当于给 LLM 做了一次"岗前培训"。
- 我们要求"回复要简洁",避免 LLM 在工具调用之外输出过多废话,节省 token 并提高执行效率。
3.3 ReAct 循环(核心)
javascript
async function runAgentWithTools(query, maxIterations=30) {
const messages = [ /* 如上 */ ];
for(let i = 0; i < maxIterations; i++){
console.log(chalk.bgGreen(`正在等待第 ${i} 次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;
}
为什么是循环?
因为任务复杂,Agent 可能需要多轮"思考-行动-观察"。每轮 LLM 可能产生多个 tool_calls,我们依次执行并将结果以 ToolMessage 加入对话历史,然后再次调用 LLM。直到 LLM 不再产生 tool_calls,说明任务完成。
并发优化思考 :目前我们顺序执行每个 tool_calls,但多个工具之间若没有依赖,完全可以用 Promise.all 并行。在上一篇文章中我们演示过并发,本文为了突出流程清晰,采用了顺序模式,但你可以轻松改造。
3.4 超时兜底机制
javascript
setTimeout(() => {
console.log("⏰ 超时兜底强制退出进程");
process.exit(0);
}, 1000000);
这是一个 安全阀:防止 Agent 陷入死循环(比如不断重试失败命令)导致进程永不退出。给一个超时(如 1000 秒)强制终止,适合演示环境。
3.5 入口执行
javascript
try {
await runAgentWithTools(case1);
} catch (err) {
console.error(`\n 错误:${err.message}`);
}
整个程序在 try-catch 中运行,确保异常不会导致进程崩溃。
四、子进程的独立使用:node-exec.mjs 的示例
node-exec.mjs 是一个独立演示,展示了如何用 spawn 执行 npm create vite 并继承 stdio:
javascript
const command = 'npm create vite@latest vite-react-todo-app -- --template react-ts';
const [cmd, ...args] = command.split(' ');
const cwd = process.cwd();
const client = spawn(cmd, args, { cwd, stdio: 'inherit', shell: true });
client.on('close', (code) => {
if (code === 0) process.exit(0);
else process.exit(code || 1);
});
这段代码体现了 进程间通信(IPC) 的思想:父进程启动子进程,等待它完成,获取退出码并据此决定自身退出状态。而我们的 Agent 工具进一步封装了这一逻辑,使其能被 LLM 驱动。
五、流程可视化:Agent 执行全景图
我们可以用 Mermaid 绘制整个 Agent 的工作流:
这个循环模拟了 ReAct(Reasoning + Acting) 模式,LLM 每轮都会"思考"下一步行动,执行后"观察"结果,然后继续。
六、工程化思考:让 Agent 稳定可靠
6.1 容错设计
- 工具执行失败时,我们返回错误信息(而不是抛出异常),让 LLM 能读取并尝试补救。
- 命令执行即使返回非零码,我们也
resolve,给 Agent 机会重试。
6.2 提示词作为"护栏"
System Prompt 中的"禁止使用 cd"规则,大幅减少了工具调用错误。这提示我们:与 LLM 沟通,就像与一位聪明但缺乏经验的实习生沟通,清晰的规则和示例比什么都重要。
6.3 性能考量
- 虽然我们顺序执行工具,但工具本身(如
writeFile)是异步非阻塞的,不会阻塞主事件循环。 - 对于长时间命令(
pnpm run dev),stdio: 'inherit'保证了实时反馈,用户体验好。
6.4 安全边界
- 实际生产中,应对
workingDirectory和filePath进行合法性校验,禁止访问项目外目录。 - 命令执行应禁用危险命令(如
rm -rf),或使用白名单机制。
七、运行效果与展望
当你运行 mini-cursor.mjs 时,控制台会依次显示:
- AI 思考轮次(chalk 高亮)
- 每个工具调用的日志
- 命令执行的实时输出(比如
pnpm install的进度条) - 最终 Agent 返回"项目已成功启动,访问 http://localhost:5173"
整个过程全自动,无需人工干预。你可以在此基础上继续扩展:
- 添加 Git 工具,让 Agent 自动初始化仓库并提交。
- 集成 RAG,让 Agent 查阅公司内部组件库。
- 使用 LangGraph 构建多 Agent 协作,分别负责编码、测试、部署。
八、总结
我们实现了一个"Mini Cursor"------一个能自主创建项目、安装依赖、启动服务的编程 Agent。核心思想并不复杂:
- 工具扩展了 LLM 的能力边界;
- 消息历史维持了对话状态;
- 循环实现了自主规划与执行;
- 子进程让命令执行安全且可观测。
这其中最宝贵的不是代码本身,而是 工程化思维:路径安全、提示词设计、错误容错、用户体验......每一个细节都决定了 Agent 能否真正落地。现在,轮到你动手了------用你自己的 API Key,跑起这个 Agent,让它为你写第一个项目吧!
本文代码已开源,可在 Node.js 18+ 环境运行。如有任何问题或想法,欢迎评论区交流。