手写一个 mini-cursor:LangChain Agent 开发完全实战

0 阅读10分钟

手写一个 mini-cursor:LangChain Agent 开发完全实战

摘要:Cursor 能自动写代码、创建项目、执行命令,背后靠的是 Agent + Tool 的组合。本文从零搭建一个 mini-cursor,实现文件读写、目录列表、命令执行等核心 Tool,并用 LangChain 构建 ReAct 循环。读完你会理解 AI 编程助手的底层原理。

📑 目录

  • 项目背景:从一句话到可运行的项目
  • 工具层:Agent 的“手脚”
  • executeCommandTool:最复杂的工具
  • Agent 层:ReAct 循环的实现
  • LangChain 工作流解析
  • 一点总结
  • 互动讨论

项目背景:从一句话到可运行的项目

如果对 Cursor 说:“用 Vite 创建一个 React TodoList 项目,并把它运行起来”,它会怎么做?

它会自动完成:

  1. 创建项目目录
  2. 写入代码文件
  3. 安装依赖
  4. 启动开发服务器

这个过程看起来像魔法,但拆解开来就是 Agent + Tool 的组合。本文的目标就是手写一个简化版的 Cursor,让它能自动完成同样的任务。

上图展示了 mini-cursor 的整体架构:用户提出任务 → Mini Cursor(Agent)→ LLM 分析 → tool_calls → 四个工具(读文件、写文件、执行命令、创建目录)→ 返回结果。整个流程由 ReAct 循环驱动,LLM 在每次工具调用后重新评估任务状态,决定下一步做什么。

工具层:Agent 的“手脚”

Agent 的核心是 ReAct 循环——思考 → 行动 → 观察。而“行动”这一步,靠的就是 Tool。

项目中的 all-tools.mjs 定义了四个核心工具:读文件、写文件、列出目录、执行命令。这些工具就是 Agent 的“手脚”。

读文件工具

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() 接收两个参数:

  • 处理函数(异步):实际执行业务逻辑,返回结果
  • 描述对象namedescriptionschema(用 Zod 定义)

description 中的细节描述直接决定了 LLM 能否在正确的场景下调用这个工具。描述越具体、覆盖的场景越多,LLM 调用就越准确。

写文件工具

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

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

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

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

child.on('close', (code) => {
    if (code === 0) {
        resolve('命令执行成功');
    } else {
        resolve(`命令执行失败,退出码:${code}`);  // ✅ 不中断 Agent 循环
    }
});

使用 resolve 有几点好处:

  1. 将执行异常转化为上下文文本,让 LLM 像阅读报错日志一样读取错误信息
  2. 保留 LLM 的自主决策权:LLM 可以根据错误信息决定重试、换参数还是放弃
  3. 防止程序突然退出,避免给用户带来困扰

这里有一个潜在风险:LLM 可能误读“命令执行失败”为“命令执行成功但产生了 stderr 日志”。为了规避这个问题,我在返回内容中加入了强语义前缀,让 LLM 能清晰区分成功和失败状态。

显式 new Promise vs async 自动包装

executeCommandTool 与 readFileTool 在 Promise 处理上有一个关键区别:

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

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

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

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

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

console.log(chalk.bgRed.bold.underline(' 严重错误 '));
// 输出:红色背景 + 加粗 + 下划线 的文本

在当前项目中,chalk 主要做了两件事:

  1. 进度反馈chalk.bgGreen 让“AI 正在思考”的提示非常醒目,用户一眼就能看到程序在推进
  2. 错误区分:虽然代码中暂未使用,但用 chalk.red 或 chalk.bgRed 突出错误信息,能让用户快速定位问题

体验设计原则:Agent 是耗时操作,用户等待时需要明确的“进度感”。哪怕只是一个简单的 bgGreen 提示,也能有效降低用户的焦虑感,让 Agent 显得更“智能”和“可靠”。

工具结果与 ToolMessage

执行完工具后,结果通过 ToolMessage 加入上下文:

javascript

messages.push(new ToolMessage({
    content: toolResult,
    tool_call_id: toolCall.id
}));

tool_call_id 是必需的——因为多个工具调用可能并发执行,LLM 需要这个 ID 来关联“哪个结果对应哪个调用”。在顺序执行场景下其实不会混淆,但 LangChain 的设计规范要求带上它,这是一个好习惯。

LangChain 工作流解析

整个 mini-cursor 的工作流可以拆解为:

text

用户输入 → SystemMessage + HumanMessage
    ↓
modelWithTools.invoke(messages)  ← 第一次调用 LLM
    ↓
response.tool_calls 有值?
    ├── 无 → 返回最终答案
    └── 有 → 执行工具(find + invoke)
         ↓
    ToolMessage 加入 messages
         ↓
    回到 invoke(再次调用 LLM)
         ↓
    循环直到无 tool_calls

LangChain 在这个流程里帮我省掉了大量样板代码:

原生实现痛点LangChain 的解法
手写 JSON Schematool() + Zod 自动生成
手动解析 tool_calls返回结果中直接包含可用的 tool_calls 数组
手动管理对话历史四种 Message 类型标准化
手写循环for + invoke 即可

LangChain 把脏活累活都包了,让我专注于工具实现业务流程。在写这个项目之前,我试过用原生 OpenAI SDK 实现同样的功能,光处理 tool_calls 的解析和 additional_kwargs 的嵌套结构就花了不少时间。LangChain 把这些都标准化了。

一点总结

通过手写 mini-cursor,我理解了 AI 编程助手的底层原理:

  1. Agent = ReAct 循环:思考 → 行动 → 观察,循环往复,直到任务完成

  2. Tool 是 Agent 的手脚:读文件、写文件、执行命令——Agent 通过这些工具操作外部世界

  3. LangChain 的价值:标准化了 Tool 定义、消息管理、循环调度,让你专注于业务逻辑

  4. executeCommand 的设计决策

    • 使用 resolve 而非 reject,将错误转为上下文文本,保留 LLM 决策权
    • 显式 new Promise 封装基于事件的 spawn API
    • errorMsg 作为跨事件收集器,聚合 error 和 close 两个事件源的信息
    • stdio: 'inherit' 让命令输出实时显示在控制台
  5. 顺序执行的必要性:当任务存在前置依赖时,必须按顺序执行——先创建项目,再安装依赖,再启动服务器

  6. 后端思维:Agent 的工具运行在后端,异常处理是重中之重,任何未捕获的异常都可能导致进程崩溃

互动讨论

  1. executeCommandTool 中为什么用 resolve 而不是 reject 来处理命令执行失败?  如果改用 reject,Agent 的行为会有什么变化?
  2. error 和 close 两个事件的区别是什么?  为什么需要 errorMsg 作为外部变量来收集信息?
  3. 顺序执行和并发执行(Promise.all)在 Agent 中分别适合什么场景?  如何判断工具调用之间是否存在依赖关系?
  4. async 函数和显式 new Promise 的区别是什么?  什么情况下必须使用 new Promise
  5. 如果 Agent 陷入了无限循环,maxIterations 能完全防止吗?  还有什么其他保护机制?

📌 一点心得:Cursor 不是魔法,它是 ReAct 循环 + Tool 的组合。理解了这一点,你也能手写一个简化版的 Cursor。LangChain 把复杂的工作流标准化了,但核心的 ReAct 循环和工具设计思想,是所有 AI Agent 产品的共同基础。