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

手写一个 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

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

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 作为"跨事件信息收集器"是必要的------errorclose 是独立的事件,触发时机不同。命令路径错误时,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 有几点好处:

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

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

显式 new Promise vs async 自动包装

executeCommandToolreadFileTool 在 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,需要通过 errorclose 事件来获取结果。所以只能手动创建一个 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 主要做了两件事:

  1. 进度反馈chalk.bgGreen 让"AI 正在思考"的提示非常醒目,用户一眼就能看到程序在推进
  2. 错误区分 :虽然代码中暂未使用,但用 chalk.redchalk.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 编程助手的底层原理:

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

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

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

  4. executeCommand 的设计决策

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

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

互动讨论

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

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

相关推荐
chanalbert1 小时前
Agent Skill 工程化指南(三):运行与生效 — 运行时的全链路机制
llm·agent
风吹夏回1 小时前
LangChain Agent 入门实战:从报错到跑通带记忆的交互式智能体(初学者保姆级教程)
langchain
武子康2 小时前
调查研究-222 PagedAttention 详解:vLLM 如何像操作系统一样管理 KV Cache
人工智能·llm·agent
武子康2 小时前
调查研究-221 KV Cache:LLM 推理服务真正的显存黑洞
人工智能·llm·agent
带刺的坐椅2 小时前
多 Agent 协作实战:任务编排与子代理系统
java·ai·llm·agent·solon·harness
Token炼金师2 小时前
提词的艺术:Few-shot、CoT、Function Calling、ReAct 与自洽性 —— Prompt 工程六技
人工智能·llm·prompt·react·function call·cot·few-show
明明如月学长2 小时前
别再用AI帮你“读”文章了,你浪费了它90%的能力
aigc·agent
Token炼金师2 小时前
知识的外挂:分块、Embedding、Rerank、GraphRAG 与多路融合 —— RAG 检索增强六脉
人工智能·深度学习·llm·embedding·chunk·graphrag·rerank
HIT_Weston3 小时前
144、【Agent】【OpenCode】启动分析(ANSI 颜色能力)
人工智能·agent·opencode