手写一个 mini-cursor:LangChain Agent 开发完全实战
摘要:Cursor 能自动写代码、创建项目、执行命令,背后靠的是 Agent + Tool 的组合。本文从零搭建一个 mini-cursor,实现文件读写、目录列表、命令执行等核心 Tool,并用 LangChain 构建 ReAct 循环。读完你会理解 AI 编程助手的底层原理。
📑 目录
- 项目背景:从一句话到可运行的项目
- 工具层:Agent 的"手脚"
- executeCommandTool:最复杂的工具
- Agent 层:ReAct 循环的实现
- LangChain 工作流解析
- 一点总结
- 互动讨论
项目背景:从一句话到可运行的项目
如果对 Cursor 说:"用 Vite 创建一个 React TodoList 项目,并把它运行起来",它会怎么做?
它会自动完成:
- 创建项目目录
- 写入代码文件
- 安装依赖
- 启动开发服务器
这个过程看起来像魔法,但拆解开来就是 Agent + Tool 的组合。本文的目标就是手写一个简化版的 Cursor,让它能自动完成同样的任务。
上图展示了 mini-cursor 的整体架构:用户提出任务 → Mini Cursor(Agent)→ LLM 分析 → tool_calls → 四个工具(读文件、写文件、执行命令、创建目录)→ 返回结果。整个流程由 ReAct 循环驱动,LLM 在每次工具调用后重新评估任务状态,决定下一步做什么。
工具层:Agent 的"手脚"
Agent 的核心是 ReAct 循环------思考 → 行动 → 观察。而"行动"这一步,靠的就是 Tool。
项目中的 all-tools.mjs 定义了四个核心工具:读文件、写文件、列出目录、执行命令。这些工具就是 Agent 的"手脚"。
读文件工具
javascript
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('需要读取的文件的文件地址')
})
}
)
tool() 接收两个参数:
- 处理函数(异步):实际执行业务逻辑,返回结果
- 描述对象 :
name、description、schema(用 Zod 定义)
description 中的细节描述直接决定了 LLM 能否在正确的场景下调用这个工具。描述越具体、覆盖的场景越多,LLM 调用就越准确。
写文件工具
javascript
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');
return `成功写入 ${FilePath}`;
} catch(err) {
return `写入文件失败:${err.message}`;
}
},
{
name: 'write_file',
description: '此工具用来写入文件,如果文件不存在则自动创建目录',
schema: z.object({
FilePath: z.string().describe('需要写入文件的路径'),
content: z.string().describe('需要写入文件的内容')
})
}
)
这里用到了 path.dirname() 提取目录路径,然后用 fs.mkdir(dir, { recursive: true }) 自动创建目录------如果目录已存在,这个操作会直接变成 fulfilled,不会报错。recursive: true 让代码更健壮,不需要手动判断目录是否存在。
列出目录工具
javascript
javascript
const listDirectoryTool = tool(
async ({ directoryPath }) => {
try {
const files = await fs.readdir(directoryPath);
return `目录内容: ${files.join('\n')}`;
} catch(err) {
return `列出目录内容失败:${err.message}`;
}
},
{
name: 'list_directory',
description: '此工具用来罗列文件,列出指定目录下的所有文件和文件夹',
schema: z.object({
directoryPath: z.string().describe('目录路径')
})
}
)
在这个工具里我特别注意了异常处理。写后端代码和写前端代码的思维不一样------前端追求用户体验,后端追求稳定。Agent 的工具运行在后端,任何一个未捕获的异常都可能导致整个 Agent 进程崩溃。所以每个工具都用 try-catch 包裹,把错误信息返回给 LLM 处理,而不是让程序直接挂掉。
executeCommandTool:最复杂的工具
在所有工具中,executeCommandTool 是最复杂的。它涉及到 Node.js 的子进程(child_process) 和事件监听机制。
为什么需要子进程?
Node.js 是单线程的,但如果 Agent 需要执行一个耗时的命令行任务(如 npm install),主进程不能被阻塞。同时,命令的执行结果需要被 Agent 捕获并返回给 LLM。
node:child_process 模块提供了 spawn 函数,可以启动一个独立的子进程来执行命令:
javascript
javascript
import { spawn } from 'node:child_process';
const [cmd, ...args] = command.split(' ');
const child = spawn(cmd, args, {
cwd, // 工作目录
stdio: 'inherit', // 继承父进程的输入输出,让命令输出直接显示在控制台
shell: true, // 使用 shell 执行,支持管道、通配符等
});
stdio: 'inherit' 让子进程的输出直接打印到当前终端,用户能实时看到命令执行进度,体验更好。如果用户在用这个 Agent 创建项目,他能看到 npm install 的进度条在滚动,而不是等一个黑盒执行完才看到结果。
事件监听:error 和 close
spawn 返回的是一个 ChildProcess 对象,它继承自 EventEmitter,通过事件来通知状态变化:
javascript
javascript
let errorMsg = '';
child.on('error', (err) => {
errorMsg = err.message; // 暂存启动错误
});
child.on('close', (code) => {
if (code === 0) {
resolve('命令执行成功');
} else {
resolve(`命令执行失败,退出码: ${code}\n错误: ${errorMsg}`);
}
});
两个事件各司其职:
| 事件 | 触发时机 | 适用场景 |
|---|---|---|
error |
子进程启动瞬间发生错误(命令不存在、路径错误) | 捕获 Node.js 层面的启动失败 |
close |
子进程的 stdio 流完全关闭后触发,返回退出码 | 判断命令执行成功(code===0)还是失败 |
errorMsg 作为"跨事件信息收集器"是必要的------error 和 close 是独立的事件,触发时机不同。命令路径错误时,error 触发但 close 不会触发;命令启动但执行失败时,error 不触发但 close 触发。这个变量把两个事件源的信息暂存起来,最后在 close 里聚合返回,确保无论哪种错误路径,返回给 LLM 的信息都是完整的。
为什么用 resolve 而不是 reject?
这是一个重要的设计决策。在 ReAct 循环中,LLM 是决策中心。
如果工具层直接 reject(抛出错误),会被上层的 try-catch 捕获,通常会导致 Agent 循环中断,直接输出错误日志。这剥夺了 LLM 的决策权。
javascript
javascript
child.on('close', (code) => {
if (code === 0) {
resolve('命令执行成功');
} else {
resolve(`命令执行失败,退出码:${code}`); // ✅ 不中断 Agent 循环
}
});
使用 resolve 有几点好处:
- 将执行异常转化为上下文文本,让 LLM 像阅读报错日志一样读取错误信息
- 保留 LLM 的自主决策权:LLM 可以根据错误信息决定重试、换参数还是放弃
- 防止程序突然退出,避免给用户带来困扰
这里有一个潜在风险:LLM 可能误读"命令执行失败"为"命令执行成功但产生了 stderr 日志"。为了规避这个问题,我在返回内容中加入了强语义前缀,让 LLM 能清晰区分成功和失败状态。
显式 new Promise vs async 自动包装
executeCommandTool 与 readFileTool 在 Promise 处理上有一个关键区别:
javascript
javascript
// readFileTool:async 函数自动包装返回值
const readFileTool = tool(
async ({ FilePath }) => {
const content = await fs.readFile(FilePath, 'utf-8');
return content; // ← async 函数自动把返回值包装成 Promise
}
);
// executeCommandTool:必须显式 new Promise
const executeCommandTool = tool(
async ({ command, workingDirectory }) => {
return new Promise((resolve, reject) => {
// 基于事件的 API(spawn)必须手动封装
});
}
);
为什么 executeCommand 必须显式 new Promise?因为 child_process.spawn 不是基于 Promise 的 API,而是基于事件监听的。fs.readFile 返回的是 Promise,可以直接 await;但 spawn 不返回 Promise,需要通过 error 和 close 事件来获取结果。所以只能手动创建一个 Promise,在事件回调里决定什么时候 resolve。
Agent 层:ReAct 循环的实现
mini-cursor.mjs 是 Agent 的主入口,它实现了完整的 ReAct 循环。
初始化:SystemMessage + HumanMessage
javascript
arduino
const messages = [
new SystemMessage(`你是一个项目管理助手,使用工具完成任务。
当前工作目录: ${process.cwd()}
工具:
1.read_file: 读取文件
2.write_file: 写入文件
3.list_directory: 列出目录
4.execute_command: 执行命令(支持 workingDirectory 参数)
重要规则 - execute_command:
- workingDirectory 参数会自动切换到指定目录
- 当使用 workingDirectory 时,绝对不要在 command 中使用 cd
- 正确示例: { command: "pnpm install", workingDirectory: "react-todo-app" }
`),
new HumanMessage(query)
];
SystemMessage 设置了 Agent 的身份、可用工具、以及重要的使用规则 。我在提示词里特意加了一段关于 workingDirectory 的说明,因为在测试中发现 LLM 经常会在指定了 workingDirectory 的情况下还在命令里写 cd react-todo-app,导致路径错误。把规则写进 SystemMessage,LLM 就会遵守。
for 循环:迭代控制
javascript
ini
for (let i = 0; i < maxIterations; i++) {
const response = await modelWithTools.invoke(messages);
messages.push(response);
if (!response.tool_calls || response.tool_calls.length === 0) {
console.log(`AI 最终回复: ${response.content}`);
return response.content;
}
// 执行工具调用...
}
用 for 循环而不是 while,是因为 maxIterations 是强制终止条件------防止 Agent 陷入死循环。如果 Agent 在 30 轮后还没完成任务,就返回最后一次的结果,让用户来判断哪里出了问题。
顺序执行工具调用
javascript
ini
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
}));
}
}
这里使用顺序执行 (for 循环 + await),因为工具调用之间存在依赖关系------先创建项目,再安装依赖,再启动服务器。顺序执行保证了前置条件满足后再执行后续步骤。
如果工具之间没有依赖关系,可以用 Promise.all 并发执行来提升效率,但这个场景不行------pnpm install 必须在项目目录存在之后才能执行。
用 chalk 优化终端输出,让用户感知 Agent 在"思考"
在 Agent 执行复杂任务时,LLM 的推理和工具调用可能需要几秒甚至更长时间。如果终端没有任何输出,用户可能会以为程序卡死了------这是很糟糕的体验。
在 mini-cursor.mjs 中,我引入了 chalk 模块来优化这个问题:
javascript
javascript
import chalk from 'chalk';
// 在循环的每一次迭代中,用醒目的绿色背景提示用户
console.log(chalk.bgGreen(`正在等待第${i}次 AI 思考...`));
chalk 是一个 Node.js 终端样式库,可以给 console.log 的输出添加颜色、背景色、加粗、下划线等样式。它让原本单调的终端文本变得直观、易于阅读,用户能清晰感知程序正在运行。
常用的 chalk 方法:
| 方法 | 作用 | 示例输出效果 |
|---|---|---|
chalk.green(text) |
绿色文字 | 成功信息 |
chalk.red(text) |
红色文字 | 错误信息 |
chalk.yellow(text) |
黄色文字 | 警告信息 |
chalk.blue(text) |
蓝色文字 | 普通提示 |
chalk.bgGreen(text) |
绿色背景 | 醒目的状态提示 |
chalk.bgRed(text) |
红色背景 | 严重错误 |
chalk.bold(text) |
加粗 | 强调关键词 |
chalk.underline(text) |
下划线 | 链接或重要内容 |
chalk.italic(text) |
斜体 | 次要信息 |
这些方法还可以链式组合使用:
javascript
arduino
console.log(chalk.bgRed.bold.underline(' 严重错误 '));
// 输出:红色背景 + 加粗 + 下划线 的文本
在当前项目中,chalk 主要做了两件事:
- 进度反馈 :
chalk.bgGreen让"AI 正在思考"的提示非常醒目,用户一眼就能看到程序在推进 - 错误区分 :虽然代码中暂未使用,但用
chalk.red或chalk.bgRed突出错误信息,能让用户快速定位问题
体验设计原则 :Agent 是耗时操作,用户等待时需要明确的"进度感"。哪怕只是一个简单的
bgGreen提示,也能有效降低用户的焦虑感,让 Agent 显得更"智能"和"可靠"。
工具结果与 ToolMessage
执行完工具后,结果通过 ToolMessage 加入上下文:
javascript
less
messages.push(new ToolMessage({
content: toolResult,
tool_call_id: toolCall.id
}));
tool_call_id 是必需的------因为多个工具调用可能并发执行,LLM 需要这个 ID 来关联"哪个结果对应哪个调用"。在顺序执行场景下其实不会混淆,但 LangChain 的设计规范要求带上它,这是一个好习惯。
LangChain 工作流解析
整个 mini-cursor 的工作流可以拆解为:
text
arduino
用户输入 → SystemMessage + HumanMessage
↓
modelWithTools.invoke(messages) ← 第一次调用 LLM
↓
response.tool_calls 有值?
├── 无 → 返回最终答案
└── 有 → 执行工具(find + invoke)
↓
ToolMessage 加入 messages
↓
回到 invoke(再次调用 LLM)
↓
循环直到无 tool_calls
LangChain 在这个流程里帮我省掉了大量样板代码:
| 原生实现痛点 | LangChain 的解法 |
|---|---|
| 手写 JSON Schema | tool() + Zod 自动生成 |
| 手动解析 tool_calls | 返回结果中直接包含可用的 tool_calls 数组 |
| 手动管理对话历史 | 四种 Message 类型标准化 |
| 手写循环 | for + invoke 即可 |
LangChain 把脏活累活都包了,让我专注于工具实现 和业务流程 。在写这个项目之前,我试过用原生 OpenAI SDK 实现同样的功能,光处理 tool_calls 的解析和 additional_kwargs 的嵌套结构就花了不少时间。LangChain 把这些都标准化了。
一点总结
通过手写 mini-cursor,我理解了 AI 编程助手的底层原理:
-
Agent = ReAct 循环:思考 → 行动 → 观察,循环往复,直到任务完成
-
Tool 是 Agent 的手脚:读文件、写文件、执行命令------Agent 通过这些工具操作外部世界
-
LangChain 的价值:标准化了 Tool 定义、消息管理、循环调度,让你专注于业务逻辑
-
executeCommand的设计决策:- 使用
resolve而非reject,将错误转为上下文文本,保留 LLM 决策权 - 显式
new Promise封装基于事件的spawnAPI errorMsg作为跨事件收集器,聚合error和close两个事件源的信息stdio: 'inherit'让命令输出实时显示在控制台
- 使用
-
顺序执行的必要性:当任务存在前置依赖时,必须按顺序执行------先创建项目,再安装依赖,再启动服务器
-
后端思维:Agent 的工具运行在后端,异常处理是重中之重,任何未捕获的异常都可能导致进程崩溃
互动讨论
executeCommandTool中为什么用resolve而不是reject来处理命令执行失败? 如果改用reject,Agent 的行为会有什么变化?error和close两个事件的区别是什么? 为什么需要errorMsg作为外部变量来收集信息?- 顺序执行和并发执行(
Promise.all)在 Agent 中分别适合什么场景? 如何判断工具调用之间是否存在依赖关系? async函数和显式new Promise的区别是什么? 什么情况下必须使用new Promise?- 如果 Agent 陷入了无限循环,
maxIterations能完全防止吗? 还有什么其他保护机制?
📌 一点心得:Cursor 不是魔法,它是 ReAct 循环 + Tool 的组合。理解了这一点,你也能手写一个简化版的 Cursor。LangChain 把复杂的工作流标准化了,但核心的 ReAct 循环和工具设计思想,是所有 AI Agent 产品的共同基础。