mini-cursor 揭秘:从 Tool 定义到 Agent 循环的完整实现
深入剖析一个迷你 Cursor 风格 AI Agent 的底层原理------4 个工具 + 1 个循环 = 一个能自动写项目的 AI。
一、mini-cursor 是什么?
这是一个极简版的 Cursor AI Agent。你给它一段需求描述,它就能自动完成以下所有事情:
- 用 Vite 脚手架创建 React 项目
- 编写完整的业务代码(增删改查、分类筛选、数据持久化)
- 添加样式和动画
- 安装依赖,启动开发服务器
整个过程不需要你碰一下键盘。而这背后支撑它的,只有两个核心文件:
| 文件 | 职责 | 行数 |
|---|---|---|
all_tools.mjs |
定义 AI 可以使用的"手和脚" | 132 行 |
main.mjs |
驱动 Agent 循环的"大脑" | 113 行 |
200 多行代码,实现了一个能自动干活的 AI Agent。下面我们逐一拆解。
二、all_tools.mjs:赋予 AI 四种"超能力"
这个文件定义了 4 个 Tool。每个 Tool 的结构在上篇文章中已经详细分析过(处理函数 + name + description + schema),这里我们聚焦于每个工具的设计巧思。
2.1 read_file ------ 最基础的眼
js
const readFileTool = tool(
async ({ filePath }) => {
const content = await fs.readFile(filePath, 'utf-8');
return `文件内容: \n${content}`;
},
{
name: 'read_file',
description: '读取指定路径的文件内容',
schema: z.object({
filePath: z.string().describe('文件路径')
})
}
)
最朴素的工具,但注意返回值被包了一层 文件内容: \n 前缀------这是给模型看的"格式化标签",帮助它区分"这是文件内容"和"这是系统消息"。
2.2 write_file ------ 能写,还能自动建目录
js
const writeFileTool = tool(
async ({ filePath, content }) => {
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true }); // 关键!
await fs.writeFile(filePath, content, 'utf-8');
return `文件写入成功: ${filePath}`;
},
{ ... }
)
这里的巧思是 fs.mkdir(dir, { recursive: true })。模型在写文件时,可能写出这样的路径:src/components/TodoList.tsx。如果 src/components/ 目录不存在,普通 writeFile 会直接报错。加上自动递归创建目录后,AI 可以放心地写任意深度的文件路径,不用先手动建目录。
这是一个工程化意识的体现------不要让 AI 做它容易出错的事(比如先 ls 再 mkdir 再 write),而是在工具层面就把容错做好。
2.3 execute_command ------ 最复杂、最危险、也最强大的工具
js
const executeCommandTool = tool(
async ({ command, workingDirectory }) => {
const cwd = workingDirectory || process.cwd();
return new Promise((resolve) => {
const [cmd, ...args] = command.split(' ');
const child = spawn(cmd, args, {
cwd,
stdio: 'inherit', // 实时输出到终端
shell: true
});
child.on('close', (code) => {
if (code === 0) {
resolve(`命令行执行成功: ${command}`);
} else {
process.exit(code || 1); // 失败直接退出整个进程
}
});
});
},
{
name: 'execute_command',
description: '执行系统命令,支持指定工作目录,实时显示输出',
schema: z.object({
command: z.string().describe('要执行的命令'),
workingDirectory: z.string().optional().describe('指定工作目录')
})
}
)
这个工具有几个设计要点值得展开:
a) spawn + stdio: 'inherit' ------ 实时输出
不用 exec(它会缓冲全部输出然后一次性返回),而是用 spawn + stdio: 'inherit'。这意味着 pnpm install 的进度条、pnpm run dev 的启动日志,都会实时显示在终端。用户体验拉满。
b) workingDirectory 参数 ------ 教模型不要用 cd
注意 SystemMessage 里反复强调的规则:
css
错误示例: { command: "cd react-todo-app && pnpm install", workingDirectory: "react-todo-app" }
正确示例: { command: "pnpm install", workingDirectory: "react-todo-app" }
这是实际使用中踩出来的坑------模型天然喜欢用 cd && command 的方式切换目录,但如果 workingDirectory 已经指定了目录,再 cd 就会路径叠加导致失败。解决方式不是改代码逻辑,而是在 System Prompt 里反复教育模型。
c) 失败时 process.exit(code || 1) ------ 直接退出
这是一个激进但合理的选择。命令行执行失败通常意味着不可恢复的错误(比如 pnpm create vite 失败了,后面所有操作都无意义),与其让 AI 在一个残破的状态下继续"修修补补",不如直接终止,让用户检查环境后重来。
2.4 list_directory ------ AI 的"ls 命令"
js
const listDirectoryTool = tool(
async ({ directoryPath }) => {
const files = await fs.readdir(directoryPath);
return `目录内容:\n ${files.map(f => `- ${f}`).join('\n')}`;
},
{
name: 'list_directory',
description: '列出指定目录下所有文件和文件夹',
schema: z.object({ directoryPath: z.string().describe('目录路径') })
}
)
最轻量的工具,但它的存在很重要------它是 AI 验证自己工作成果 的唯一手段。AI 写完文件后调用 list_directory,就能确认"我真的创建成功了"。
2.5 四个工具的协作全景
arduino
┌─────────────┐
│ list_dir │ ← "眼睛":看看有什么
└─────────────┘
┌─────────────┐
│ read_file │ ← "眼睛":看看里面是什么
└─────────────┘
┌─────────────┐
│ write_file │ ← "手":创造/修改文件
└─────────────┘
┌─────────────┐
│exec_command │ ← "手":执行命令(npm、git...)
└─────────────┘
眼睛(读)+ 手(写+执行)= 一个完整的开发者。 这就是 mini-cursor 的全部能力集合。
三、main.mjs:Agent 循环的深度解剖
如果说 all_tools.mjs 是给 AI 装上了"手脚",那 main.mjs 就是中枢神经系统------它决定了 AI 怎么思考、何时动手、如何调整。
3.1 初始化:装配阶段
js
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
temperature: 0, // 注意:设为 0,确保行为确定性
configuration: { baseURL: process.env.OPENAI_BASE_URL }
});
const tools = [readFileTool, writeFileTool, executeCommandTool, listDirectoryTool];
const modelWithTools = model.bindTools(tools);
temperature: 0 这个设置很关键。在 Agent 场景下,我们不希望模型"发挥创意"------工具调用的参数必须精准,执行顺序必须合理。temperature 为 0 最大程度保证了行为的一致性和可预测性。
3.2 核心函数 runAgentWithTools ------ 步步拆解
js
async function runAgentWithTools(query, maxIterations = 30) {
maxIterations = 30 是一个熔断机制。没有它,如果模型陷入"调用工具 → 不满意 → 再调用 → 再不满意"的死循环,程序永远不会停。30 次迭代是一个安全上限------既能完成复杂任务(创建项目+写代码+安装依赖+启动,大约 10-15 轮),又不会无限消耗 token。
第一层:构造初始对话
js
const message = [
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" }
- 正确示例: { command: "pnpm install", workingDirectory: "react-todo-app" }
回复要简洁,只说做了什么
`),
new HumanMessage(query),
];
这里的 System Prompt 设计值得细品:
当前工作目录: ${process.cwd()}------给模型提供上下文锚点,让它知道自己在哪- 工具清单 + 编号------简洁列出能力,不啰嗦
execute_command的专项规则------把踩过的坑写进 prompt,用正反示例教育模型- "回复要简洁"------控制输出长度,节省 token
第二层:主循环------Agent 的心脏
js
for (let i = 0; i < maxIterations; i++) {
console.log(chalk.bgGreen('⏳正在等待AI思考...'));
const response = await modelWithTools.invoke(message);
message.push(response);
// 分支1:没有 tool_calls → 任务完成
if (!response.tool_calls || response.tool_calls.length === 0) {
console.log(`\n AI 最终回复: \n ${response.content}\n`);
return response.content;
}
// 分支2:有 tool_calls → 执行工具
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);
message.push(new ToolMessage({
content: toolResult,
tool_call_id: toolCall.id
}));
}
}
}
这里和上一篇 tool-file-read.mjs 中的 while 循环有微妙但重要的区别:
| 特性 | tool-file-read.mjs | main.mjs |
|---|---|---|
| 循环方式 | while(response.tool_calls) |
for(let i=0; i<maxIterations; i++) |
| 安全上限 | 无(理论上可能死循环) | 30 次强制熔断 |
| 工具执行 | Promise.all 并行 |
for...of 串行 |
| 找不到工具 | 返回错误信息给模型 | 静默跳过 |
为什么用 for 而不是 while?
生产级 Agent 必须有熔断。一个复杂任务可能让模型反复调整(写了代码 → 不满意 → 改了再写 → 又发现新问题 → 继续改),如果没有上限,极端情况下可能跑上百轮,消耗大量 token 和时间。
为什么 for...of 串行而不是 Promise.all 并行?
对于文件操作和命令执行 ,顺序很重要------你不能在 mkdir 完成之前就开始 writeFile。串行执行保证了操作的因果顺序。这是一个务实的选择:牺牲一点性能,换取正确性保证。
为什么找不到工具时"静默跳过"?
注意这段代码:
js
const foundTool = tools.find(t => t.name === toolCall.name);
if (foundTool) {
// 执行...
}
// 没找到?什么都不做,跳过
对比 tool-file-read.mjs 中返回错误信息的做法,这里的设计哲学不同:mini-cursor 更偏向"自动驾驶",它不希望因为一个工具调用出错就打乱整个流程。如果模型幻觉了一个不存在的工具名,跳过它让模型在下一轮自己调整,比显式报错更稳健。
3.3 整个循环的完整心智模型
用一次真实的任务执行来还原这个过程:
makefile
📝 任务: "创建一个 React TodoList 应用"
━━━ 第 1 轮 ━━━
模型思考: 我需要先创建项目
tool_call → execute_command("pnpm create vite react-todo-app --template react-ts")
工具执行: ✅ Vite 项目创建成功
message 状态: [SystemMessage, HumanMessage, AIMessage(tool_call), ToolMessage(结果)]
━━━ 第 2 轮 ━━━
模型思考: 项目创建好了,现在修改 App.tsx
tool_call → read_file("react-todo-app/src/App.tsx")
工具执行: ✅ 读取到默认模板代码
message 状态: [...之前的消息, AIMessage(tool_call), ToolMessage(结果)]
━━━ 第 3 轮 ━━━
模型思考: 我看到了模板代码,现在重写它,实现增删改查+筛选+持久化
tool_call → write_file("react-todo-app/src/App.tsx", "完整TodoList代码...")
工具执行: ✅ 文件写入成功
message 状态: [...之前的消息, AIMessage(tool_call), ToolMessage(结果)]
━━━ 第 4 轮 ━━━
模型思考: 代码写完了,需要添加样式
tool_call → write_file("react-todo-app/src/App.css", "渐变背景+动画样式...")
工具执行: ✅ 样式写入成功
━━━ 第 5 轮 ━━━
模型思考: 确认文件都在,然后安装依赖
tool_call → list_directory("react-todo-app/src")
工具执行: ✅ App.tsx, App.css, main.tsx...
tool_call → execute_command("pnpm install", workingDirectory="react-todo-app")
工具执行: ✅ 依赖安装完成
━━━ 第 6 轮 ━━━
模型思考: 一切就绪,启动开发服务器
tool_call → execute_command("pnpm run dev", workingDirectory="react-todo-app")
工具执行: ✅ Vite 开发服务器启动在 localhost:5173
━━━ 第 7 轮 ━━━
模型思考: 任务完成,不需要再调用工具了
response.content → "已创建功能丰富的 TodoList 应用,包含增删改查、筛选..."
response.tool_calls → undefined ← 循环终止
每一轮,模型都在观察 → 规划 → 行动 → 观察结果 → 调整计划。这就是 Agent 的"思考-行动循环"(ReAct Loop)。
3.4 测试用例:一个能"自我实现"的任务
js
const casel = `
创建一个功能丰富的 React TodoList 应用:
1. 创建项目:pnpm create vite react-todo-app --template react-ts
2. 修改 src/App.tsx,实现完整功能的 TodoList:
- 添加、删除、编辑、标记完成
- 分类筛选(全部/进行中/已完成)
- 统计信息显示
- localStorage 数据持久化
3. 添加复杂样式:渐变背景、卡片阴影、圆角、悬停效果
4. 添加动画:添加/删除时的过渡动画
5. 确认并启动
注意:使用 pnpm,功能要完整,样式要美观,要有动画效果
`;
await runAgentWithTools(casel);
这个 casel 字符串就是整个任务的唯一输入 。它不是代码,不是配置文件,只是一段自然语言描述。然后 runAgentWithTools 会驱动 AI 一步步完成所有操作。
注意任务描述的写法也很有讲究:
- 步骤编号(1, 2, 3...)------帮助模型理解执行顺序
- 具体细节(渐变背景蓝到紫、CSS transitions)------减少模型自由发挥的空间,确保输出符合预期
- "注意"部分------强调关键约束(用 pnpm 而不是 npm,功能要完整)
四、两种架构的对观:Simple Agent vs Mini-Cursor
把上篇的 tool-file-read.mjs 和本篇的 main.mjs 放在一起对比,能看到 Agent 设计从"玩具"到"工具"的进化路径:
| 维度 | tool-file-read.mjs | main.mjs (mini-cursor) |
|---|---|---|
| 工具数量 | 1 个 | 4 个 |
| 循环控制 | while 无上限 |
for + maxIterations 熔断 |
| 并行策略 | Promise.all 并行 |
for...of 串行(保证因果顺序) |
| 错误处理 | 捕获并返回给模型 | 静默跳过 + 关键失败直接 exit |
| 用户体验 | 纯文本输出 | chalk 彩色输出 + 实时终端 |
| 提示词设计 | 简单一句话 | 带正反示例的规则教育 |
| 使用场景 | 读一个文件 | 创建一个完整项目 |
进化路线的核心趋势是:从"让模型能用工具"到"让模型用对工具、用好工具"。
五、mini-cursor 的架构精髓:三个关键词
5.1 "熔断"
maxIterations = 30 不是随便写的数字。太低了复杂任务完不成,太高了可能浪费资源。它体现了 Agent 设计的核心权衡:自主性 vs 安全性。
5.2 "教育"
System Prompt 里的正反示例(错误示例: cd + workingDirectory)说明了一个重要经验:工具设计的问题,很多时候要在 Prompt 层面解决,而不是代码层面。工具的参数 schema 只管类型,管不了"参数的语义正确性"------后者只能靠 Prompt 教育模型。
5.3 "闭环"
erlang
观察(读文件/列目录)→ 规划(模型推理)→ 执行(写文件/跑命令)→ 验证(再读/再列)→ ...
这四步构成了一个完整的反馈闭环。AI 不再是"一把梭写完不管对错",而是像真正的开发者一样------写完代码,跑一下,看看报错,修改,再跑,直到成功。
六、总结
200 多行代码,4 个工具,1 个循环,就能让 AI 从"聊天机器人"变成"能独立完成项目的开发者"。
核心公式依然不变:
定义 Tool(all_tools.mjs) → 绑定 Tool(bindTools) → 循环调用(主循环)
但 mini-cursor 在工程化上往前多走了几步:熔断机制、正反示例教育模型、串行保证因果顺序、实时终端反馈------这些细节让它从一个能跑的 Demo,变成了一个真正能用的工具。
如果你要写自己的 Agent,mini-cursor 的架构是一个很好的起点------足够简单能看懂,又足够完整能干活。