手写 Mini-Cursor:200 行代码打造你的专属编程 Agent 🚀
前言
Cursor、Claude Code、Trae......这些 AI 编程工具背后的核心技术都一样------Coding Agent。
你有没有好奇过:一个 AI 是怎么做到"自动创建项目、写代码、运行起来"的?它背后到底是什么机制?
这篇文章带你用 200 行代码手写一个 Mini-Cursor------它会自动用 Vite 创建 React 项目、编写完整的 TodoList 代码、安装依赖、启动开发服务器。全程不需要你写一行 React 代码!
📌 读完你会收获:
- 完整 Coding Agent 的架构设计
- 4 大核心工具(读/写/列目录/执行命令)的实现细节
- Node.js 子进程
spawn在 Agent 中的实战应用Promise.all并发优化的真实案例- 一个真正能跑起来的编程助手
一、先看看它能做什么
假设你对 Mini-Cursor 说:
"用 Vite 创建一个 React TodoList 项目,要有完整功能、美观样式、动画效果,然后把它跑起来。"
它会自己做完下面这些事:
arduino
Agent 的 Planning(由 LLM 自动推理):
┌─────────────────────────────────────────┐
│ 步骤1:调用 execute_command │
│ → pnpm create vite react-todo-app │
│ │
│ 步骤2:调用 write_file │
│ → 写入完整 TodoList 组件代码 │
│ │
│ 步骤3:调用 execute_command │
│ → pnpm install(安装依赖) │
│ │
│ 步骤4:调用 execute_command │
│ → pnpm run dev(启动开发服务器) │
│ │
│ 步骤5:调用 list_directory │
│ → 确认项目文件结构正确 │
└─────────────────────────────────────────┘
全程自动,你只需要看着它干活。
二、架构设计:一个 Coding Agent 需要哪些"器官"?
2.1 核心公式
markdown
Coding Agent = LLM(大脑)
+ fs 工具(读写文件的手)
+ cli 工具(执行命令的手)
+ ReAct 循环(决策→执行→观察→决策)
2.2 工具矩阵
一个能编程的 Agent 至少需要这 4 个工具:
arduino
┌──────────────────┬────────────────────────────────┐
│ 工具 │ 能力 │
├──────────────────┼────────────────────────────────┤
│ read_file │ 读取文件,理解已有代码 │
│ write_file │ 创建/覆写文件,生成代码 │
│ list_directory │ 列出目录,确认项目结构 │
│ execute_command │ 执行 cli 命令,真正"动手" │
└──────────────────┴────────────────────────────────┘
🎯 这四个工具正好对应一个人类程序员的基本操作:看代码、写代码、看目录、跑命令。Agent 就是让 LLM 掌握了这四个"动作"。
三、工具实现:给 LLM 装上"手脚"
3.1 文件读写工具
这三个工具比较直观,我们用 Node.js 的 fs/promises 模块:
js
// all-tools.mjs(节选)
import { tool } from '@langchain/core/tools';
import fs from 'node:fs/promises';
import path from 'node:path';
import { z } from 'zod';
// 📖 读文件
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('要读取的文件路径')
})
}
);
// ✍️ 写文件
const writeFileTool = tool(
async ({ filePath, content }) => {
// 🔑 自动创建目录(递归),不用担心路径不存在
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}`;
},
{
name: 'write_file',
description: '向指定路径写入文件内容,自动创建目录',
schema: z.object({
filePath: z.string().describe('文件路径'),
content: z.string().describe('要写入的文件内容')
})
}
);
🧠 知识点:
path.dirname+fs.mkdir({ recursive: true })写文件时,如果目录不存在会报错。用
path.dirname(filePath)提取目录路径,再用fs.mkdir(dir, { recursive: true })递归创建,无论嵌套多深都能自动建好。这是 Agent 工具"鲁棒性"的关键------LLM 可能随便给一个路径,工具要能兜底。
3.2 列出目录工具
js
// 📂 列出目录
const listDirectoryTool = tool(
async ({ directoryPath }) => {
const files = await fs.readdir(directoryPath);
console.log(`[工具调用] list_directory(${directoryPath}) 成功列出 ${files.length} 项`);
return `目录内容:\n ${files.map(file => file).join('\n')}`;
},
{
name: 'list_directory',
description: '列出指定目录下的所有文件和文件夹',
schema: z.object({
directoryPath: z.string().describe('目录路径')
})
}
);
3.3 执行命令工具------Agent 的"终极武器"
这是整个 Coding Agent 最有技术含量的工具。它需要解决一个问题:
Node.js 主进程(Agent)如何安全地执行外部命令并获取结果?
答案:子进程(child_process)。
scss
┌─────────────────┐ spawn() ┌─────────────────┐
│ Node 主进程 │ ──────────────→ │ 子进程 │
│ (Agent) │ │ (执行 cli 命令) │
│ │ ←── IPC 通信 ─── │ │
│ 单线程,不能 │ │ 隔离执行, │
│ 被阻塞 │ │ 不影响主进程 │
└─────────────────┘ └─────────────────┘
为什么要用子进程?
Node.js 是单线程的。如果直接在主进程跑 npm create vite(一个可能耗时几十秒的命令),整个 Agent 就卡住了。 子进程把命令执行"分离"出去,主进程可以继续做其他事,等子进程完成后通过 IPC(进程间通信)通知主进程。
js
// 🔧 执行命令工具(带实时输出)
import { spawn } from 'node:child_process';
const executeCommandTool = tool(
async ({ command, workingDirectory }) => {
const cwd = workingDirectory || process.cwd();
console.log(`[工具调用] execute_command(${command}) 工作目录:${cwd}`);
return new Promise((resolve, reject) => {
// 🔑 关键:拆分命令和参数
// "pnpm create vite react-todo-app --template react-ts"
// → cmd = "pnpm", args = ["create", "vite", "react-todo-app", "--template", "react-ts"]
const [cmd, ...args] = command.split(' ');
const child = spawn(cmd, args, {
cwd, // 指定工作目录
stdio: 'inherit', // 继承父进程的输入输出(实时看到命令输出)
shell: true, // 支持 shell 语法
});
let errorMsg = '';
child.on('error', (err) => {
errorMsg = err.message;
});
child.on('close', (code) => {
if (code === 0) {
// ✅ 退出码 0 = 成功
resolve(`命令执行成功:${command}`);
} else {
// ❌ 非 0 退出码 = 失败
resolve(`命令执行失败,退出码:${code}\n错误:${errorMsg}`);
}
});
});
},
{
name: 'execute_command',
description: '执行系统命令,支持指定工作目录,实时显示输出',
schema: z.object({
command: z.string().describe('要执行的命令'),
workingDirectory: z.string().describe('工作目录(推荐指定)')
})
}
);
🧠 知识点:spawn 的四个关键参数
参数 作用 不设置的后果 cwd指定子进程的工作目录 命令在当前目录执行,可能找不到项目文件夹 stdio: 'inherit'子进程的输出直接显示在控制台 看不到 npm install的进度,Agent 像"黑盒"shell: true启用 shell 模式解析命令 无法使用管道、重定向等 shell 语法 Promise包装把事件驱动的子进程变成 async/await 时序混乱,不知道命令什么时候执行完
🧠 知识点:退出码(Exit Code)
0= 命令成功执行- 非
0= 出错了(如1一般错误,127命令未找到)- Agent 不需要"猜"命令是否成功------看退出码就行
3.4 一个重要的细节:workingDirectory 的正确用法
在 System Prompt 里我们特别强调了一个容易踩坑的点:
js
// ❌ 错误用法:workingDirectory 已经切换到 react-todo-app 了,命令里又 cd 一次
{
command: "cd react-todo-app && pnpm install",
workingDirectory: "react-todo-app"
}
// 实际执行路径:react-todo-app/react-todo-app/ ------ 不存在!
// ✅ 正确用法:workingDirectory 负责切换目录,命令只写要做的事
{
command: "pnpm install",
workingDirectory: "react-todo-app"
}
// 实际执行路径:react-todo-app/ ------ 完美!
🎯 这个细节如果不写明在 System Prompt 里,LLM 很容易犯"双重 cd"的错误------因为人类也会这样写。这也是 System Prompt 工程的一部分。
四、ReAct 循环:Agent 的大脑运转机制
有了工具,下一步是让 LLM 知道什么时候用哪个工具、用完之后怎么继续。这就是 ReAct 循环。
scss
┌──────────────────────────────────────┐
│ │
▼ │
┌───────────┐ 有 tool_calls ┌─────────┐ │
│ LLM 推理 │ ───────────────→ │ 执行工具 │ │
│ (Reason) │ │ (Act) │ │
└───────────┘ └─────────┘ │
▲ │ │
│ ▼ │
│ 无 tool_calls ┌─────────┐ │
└── 直接输出结果 ←────────│ 观察结果 │──┘
│(Observe) │
└─────────┘
4.1 完整代码实现
js
// mini-cursor.mjs(核心部分)
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 参数会自动切换到指定目录
- 绝对不要在 command 中使用 cd
`),
new HumanMessage(query)
];
// 🔁 ReAct 循环
for (let i = 0; i < maxIterations; i++) {
console.log(`正在等待第 ${i} 次 AI 思考...`);
// Reason:LLM 推理,决定下一步
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;
}
// Act:执行 LLM 请求的所有工具
for (const toolCall of response.tool_calls) {
const foundTool = tools.find(t => t.name === toolCall.name);
if (foundTool) {
// Observe:工具执行结果
const toolResult = await foundTool.invoke(toolCall.args);
// 把结果以 ToolMessage 格式加入对话
messages.push(new ToolMessage({
content: toolResult,
tool_call_id: toolCall.id // ← id 关联!
}));
}
}
}
return messages[messages.length - 1].content;
}
4.2 为什么 ToolMessage 必须带 tool_call_id?
想象这样一个场景:LLM 一次请求调用了 3 个工具,返回了 3 个结果。如果没有 tool_call_id,LLM 就不知道"结果A 对应 工具1"还是"结果A 对应 工具3"。
ini
tool_call_id = "call_abc"
read_file ─────────────────────────────→ ToolMessage("文件内容...", id="call_abc")
tool_call_id = "call_def"
write_file ────────────────────────────→ ToolMessage("写入成功", id="call_def")
tool_call_id = "call_ghi"
exec_cmd ──────────────────────────────→ ToolMessage("命令成功", id="call_ghi")
🎯
tool_call_id就是工具调用和结果之间的"快递单号"------LLM 靠它来把结果和请求对上号。
五、并发执行:让 Agent 更快
5.1 来自实际项目的教训
看 tool.mjs 中的这段代码:
js
// ⚡ 并发执行所有工具调用
const toolResults = await Promise.all(
response.tool_calls.map(async (toolCall) => {
const tool = tools.find(t => t.name === toolCall.name);
if (!tool) {
return `错误:工具 ${toolCall.name} 不存在`;
}
try {
const result = await tool.invoke(toolCall.args);
return result;
} catch (error) {
return `错误:${error.message}`;
}
})
);
为什么用 Promise.all 而不是 for...of + await?
假设 LLM 一次返回了 3 个工具调用(读 A 文件、读 B 文件、列出 C 目录),它们之间互不依赖:
css
串行(for + await): 并行(Promise.all):
┌─────────┐ ┌─────────┐
│ 读 A 文件 │ 500ms │ 读 A 文件 │
└─────────┘ │ 读 B 文件 │ ← 同时起飞!
┌─────────┐ │ 列 C 目录 │
│ 读 B 文件 │ 500ms └─────────┘
└─────────┘ 总耗时 ≈ 500ms(取最慢的)
┌─────────┐ vs
│ 列 C 目录 │ 300ms
└─────────┘
总耗时 = 1300ms(累加)
5.2 Promise 的核心概念回顾
项目中的 1.html 用了一个极简 demo 来解释 Promise:
js
function getWeather() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ temp: 38, conditions: 'Sunny with Clouds' })
}, 2000) // 2秒后返回
})
}
function getTweets() {
return new Promise((resolve) => {
setTimeout(() => {
resolve(['I like cake', 'BBQ is good too!'])
}, 500) // 0.5秒后返回
})
}
// ❌ 串行:2s + 0.5s = 2.5s
const weather = await getWeather();
const tweets = await getTweets();
// ✅ 并行:max(2s, 0.5s) = 2s
const [weather, tweets] = await Promise.all([getWeather(), getTweets()]);
Promise 三态转换(不可逆):
scss
┌── resolve() → fulfilled(成功)
Pending ─┤
└── reject() → rejected(失败)
一旦从 Pending 变成 fulfilled/rejected,状态就锁定了,不能再变。
🎯 Agent 并发执行最佳实践 :当 LLM 返回的多个
tool_calls之间没有依赖关系时(读两个文件、查两个 API、列两个目录),用Promise.all并行执行,性能提升显著。当有依赖时(先创建文件再读它),串行执行。
六、完整任务拆解:看 Mini-Cursor 一次完整的执行
6.1 任务描述
我们给 Mini-Cursor 的任务是:
markdown
创建一个功能丰富的 React TodoList 应用:
1. 创建项目:pnpm create vite react-todo-app --template react-ts
2. 修改 src/App.tsx,实现完整功能:
- 添加、删除、标记完成
- 分类筛选(全部/进行中/已完成)
- 统计信息显示
- localStorage 数据持久化
3. 添加复杂样式:渐变背景、卡片阴影、圆角、悬停效果
4. 添加动画:CSS transitions
5. 列出目录确认
之后 react-todo-app 项目中:
1. pnpm install 安装依赖
2. pnpm run dev 启动服务器
6.2 LLM 的实际执行过程
css
🔄 第 0 次 AI 思考...
LLM:我需要先创建项目
→ tool_calls: [{ name: "execute_command", args: { command: "pnpm create vite react-todo-app --template react-ts" } }]
→ 执行:项目创建成功 ✅
🔄 第 1 次 AI 思考...
LLM:项目创建好了,现在写入 TodoList 代码
→ tool_calls: [{ name: "write_file", args: { filePath: "react-todo-app/src/App.tsx", content: "..." } }]
→ 执行:文件写入成功 ✅
🔄 第 2 次 AI 思考...
LLM:还需要写 CSS
→ tool_calls: [{ name: "write_file", args: { filePath: "react-todo-app/src/App.css", content: "..." } }]
→ 执行:CSS 写入成功 ✅
🔄 第 3 次 AI 思考...
LLM:列出目录确认文件都在
→ tool_calls: [{ name: "list_directory", args: { directoryPath: "react-todo-app/src" } }]
→ 执行:确认文件结构 ✅
🔄 第 4 次 AI 思考...
LLM:安装依赖
→ tool_calls: [{ name: "execute_command", args: { command: "pnpm install", workingDirectory: "react-todo-app" } }]
→ 执行:依赖安装完成 ✅
🔄 第 5 次 AI 思考...
LLM:启动开发服务器
→ tool_calls: [{ name: "execute_command", args: { command: "pnpm run dev", workingDirectory: "react-todo-app" } }]
→ 执行:开发服务器启动 ✅
🔄 第 6 次 AI 思考...
LLM:所有任务完成!
→ tool_calls: [] ← 没有工具调用了
→ 输出:"TodoList 项目已创建完成并启动..." 🎉
七、最终效果:Agent 生成的 TodoList
Mini-Cursor 生成的 App.tsx 包含了:
- ✅ 完整的状态管理 :
useState+useEffect+useCallback - ✅ localStorage 持久化:刷新不丢数据
- ✅ 三态筛选:全部 / 进行中 / 已完成
- ✅ 统计面板:总计、进行中、已完成数量
- ✅ 动画效果:添加/删除时的 CSS 过渡动画
- ✅ TypeScript:完整的类型定义
这说明 LLM 生成的代码质量已经相当高了------不仅能跑,而且代码结构清晰、类型完备。
八、体系化知识总结
8.1 四个层次的能力
rust
┌──────────────────────────────────────────────────────┐
│ 第一层:LLM 基础调用 │
│ model.invoke("问题") → 回答 │
│ 只能聊天,不能干活 │
├──────────────────────────────────────────────────────┤
│ 第二层:工具定义 + bindTools │
│ tool(async fn + zod schema + description) │
│ model.bindTools(tools) │
│ LLM 知道"我能用什么",但只会一轮 │
├──────────────────────────────────────────────────────┤
│ 第三层:ReAct 循环 │
│ invoke → tool_calls → execute → ToolMessage → │
│ invoke → ... → 无 tool_calls → 返回结果 │
│ LLM 能在多轮中持续决策直到任务完成 │
├──────────────────────────────────────────────────────┤
│ 第四层:完整 Coding Agent │
│ LLM + 4 个工具 + ReAct + 子进程 + 并发优化 │
│ 能独立完成:创建项目 → 写代码 → 安装依赖 → 启动服务 │
└──────────────────────────────────────────────────────┘
8.2 知识点速查表
| 概念 | 一句话解释 | 在代码中的位置 |
|---|---|---|
tool() |
LangChain 的工具定义函数,分处理函数 + 说明书两部分 | all-tools.mjs |
description |
写给 LLM 看的工具使用说明,决定 LLM 何时调用 | 每个 tool 的第二个参数 |
schema (Zod) |
参数校验规则,LLM 必须按要求提供参数 | z.object({...}) |
bindTools() |
把工具注册给 LLM,让 LLM "知道"自己能干什么 | model.bindTools(tools) |
tool_calls |
LLM 返回的工具调用请求列表 | response.tool_calls |
tool_call_id |
工具调用的唯一 ID,关联请求和结果 | ToolMessage({tool_call_id}) |
spawn |
创建子进程执行命令,不阻塞主进程 | execute_command |
stdio: 'inherit' |
子进程输出直接显示在控制台 | spawn 的第三个参数 |
| 退出码 | 0=成功,非 0=失败 |
child.on('close', code) |
Promise.all |
并行执行多个异步任务 | 工具并发执行 |
| ReAct 循环 | Reason → Act → Observe 的不断循环 | runAgentWithTools 的 for 循环 |
maxIterations |
防止 Agent 死循环的安全阀 | for (let i = 0; i < maxIterations; i++) |
temperature: 0 |
LLM 输出更稳定、更可预测 | new ChatOpenAI({ temperature: 0 }) |
8.3 Agent 开发的黄金法则
- 工具描述就是 LLM 的"使用说明书"------写得越具体,LLM 用工具越准
- System Prompt 是 Agent 的行为准则------规则、边界、坑点都要写清楚
- 始终有
maxIterations兜底------防止 Agent 陷入死循环 - 子进程 ≠ 主进程 ------命令行任务一定要用
spawn隔离执行 - 能并行就并行 ------多个不相关的工具调用用
Promise.all tool_call_id是"快递单号"------别漏了,否则 LLM 不知道结果对应哪个请求
九、延伸思考:从 Mini-Cursor 到真正的 Cursor
我们手写的 Mini-Cursor 只有 200 行代码,但它已经具备了 Cursor/Claude Code 的核心骨架。真正的 Coding Agent 在此基础上加了什么?
sql
Mini-Cursor(200行) 真正的 Coding Agent
───────────────────── ─────────────────────
4 个工具 几十个工具(git、LSP、浏览器...)
单 Agent 多 Agent 协作
无记忆 Memory 模块(长期记忆)
只有对话历史 向量检索(RAG)、上下文窗口管理
无权限控制 沙箱隔离、权限分级
ReAct 基础循环 复杂工作流编排(plan → execute → verify)
🎯 但核心思想完全一样:LLM + Tool + 循环 = Agent。理解了 Mini-Cursor,你就理解了所有 Coding Agent 的本质。
如果这篇文章帮你搞懂了 Coding Agent 的实现原理,欢迎点赞、收藏、关注!把
mini-cursor.mjs跑起来,你会更直观地感受到 Agent 的魔力 👏