1. 项目概述:为什么我们要手写一个“最小版本”的Cursor?
最近在AI编程工具圈里,Cursor这个名字可以说是如雷贯耳。它凭借深度集成大语言模型(LLM)的能力,将代码补全、解释、重构甚至整个功能的生成都提升到了一个新的自动化水平。很多开发者第一次用上Cursor,都会有那种“哇,原来编程可以这样”的震撼感。但震撼过后,作为一个喜欢刨根问底的技术人,我脑子里冒出的第一个念头是:这玩意儿到底是怎么工作的?它的核心魔法是什么?能不能我们自己动手,搞出一个最精简的版本,来理解其背后的机制?
这就是“手写Cursor最小版本”这个项目的由来。它不是一个要替代Cursor的商业产品,而是一个纯粹的技术探索和教学项目。我们的目标,是像拆解一台精密的钟表一样,把Cursor这类AI编程助手中最核心的“Agent(智能体)”与“Tool(工具)”交互机制剥离出来,用最少的代码、最清晰的逻辑,实现一个可以理解自然语言指令、调用代码工具、并完成简单编程任务的微型系统。
这个项目特别适合以下几类朋友:一是对AI应用开发,特别是Agent架构感兴趣,但被LangChain等框架的复杂性劝退的入门者;二是已经用过Cursor、GitHub Copilot,想深入理解其原理的中高级开发者;三是任何希望将大语言模型能力以结构化、可控制的方式集成到自己产品中的工程师。通过这个项目,你将不再把Cursor看作一个黑盒魔法,而是能清晰地看到其内部齿轮如何咬合转动。我们会使用Node.js环境,因为它对异步操作和快速原型开发非常友好,并且会涉及LangChain的核心思想,但我们会刻意避免直接使用其重型框架,而是从零构建,确保每一步你都了然于胸。
2. 核心架构设计:Agent与Tool的共生关系
要理解我们的小型Cursor,首先得吃透两个核心概念:Agent(智能体)和Tool(工具)。你可以把Agent想象成一位经验丰富但“手无寸铁”的软件架构师。他拥有强大的思维能力(由大语言模型提供),能理解你的需求(“帮我创建一个Express服务器”),也能规划步骤(“先初始化项目,再安装依赖,然后创建入口文件”),但他自己不会敲键盘写代码。这时候,Tool就是他的双手。每一位“架构师”身边都围绕着一群专业的“工具人”(Tools),比如“文件系统工具人”负责读写文件,“NPM工具人”负责执行包管理命令,“代码执行工具人”能运行一段脚本。
2.1 Agent的核心职责:思考、规划与调度
在我们的最小系统中,Agent是整个大脑。它的工作流是一个经典的“感知-思考-行动”循环:
- 感知:接收用户的自然语言指令,例如:“在
./src目录下创建一个名为app.js的文件,内容是一个简单的HTTP服务器。” - 思考:大语言模型(LLM)分析这条指令。它会将模糊的需求分解成具体的、可执行的操作序列。这个思考过程的关键输出是一个结构化决策:下一步该调用哪个Tool?调用时应该传入什么参数?
- 行动:根据思考结果,调用对应的Tool,并传入精确的参数。
- 观察:获取Tool执行后的结果(成功或失败,包括输出信息)。
- 循环:基于观察到的结果,再次进行“思考”,决定下一步行动,直到任务被判定为完成或无法继续。
这个循环的难点在于,如何让LLM的“思考”结果,能稳定地、结构化地驱动我们的程序。我们不能指望LLM每次都会输出“请调用createFileTool,参数为{path: ‘./src/app.js’, content: ‘...’}”这样完美的JSON。因此,我们需要设计一个“交互协议”。
2.2 Tool的设计哲学:单一职责与标准化接口
Tool的设计必须遵循“单一职责原则”。一个Tool只做一件事,并且把它做好。在我们的最小版本里,我们可能只需要三个核心Tool:
- 文件操作Tool:创建、读取、写入、删除文件。
- Shell命令执行Tool:执行如
npm init -y,node -v这样的系统命令。 - 代码片段解释Tool:对某段代码进行总结、解释或提出修改建议(这是Cursor的亮点功能之一)。
每个Tool都必须提供标准化的描述和接口。描述是给Agent(LLM)看的,需要清晰说明这个Tool是干什么的、接受什么参数。例如,文件写入Tool的描述可能是:“writeFile:将内容写入指定路径的文件。参数:path(字符串,文件路径),content(字符串,文件内容)。” 接口是给我们的程序调用的,就是一个普通的JavaScript函数。
2.3 为什么不用现成的LangChain?
你可能会问,LangChain不就是专门干这个的吗?为什么还要手写?没错,LangChain提供了一整套强大的Agent和Tool抽象。但正因其强大和全面,它也带来了较高的学习成本和抽象层次。对于学习原理而言,它就像直接给了你一辆组装好的汽车,而你却想看看发动机和变速箱是怎么连接的。我们的“手写”过程,就是从制造螺丝和齿轮开始,理解整个传动系统。这能让你在未来即使使用LangChain、LangGraph或AutoGen这类框架时,也能清楚地知道底层在发生什么,从而能更灵活地调试和定制。
3. 手把手实现:从零搭建最小化Agent系统
理论说得再多,不如一行代码。让我们开始动手搭建。请确保你已安装Node.js(建议版本18以上)和npm。
3.1 项目初始化与核心依赖安装
首先,创建一个新目录并初始化项目:
mkdir mini-cursor-agent && cd mini-cursor-agent npm init -y接着,安装我们最核心的依赖:用于与大语言模型API通信的库。这里我们选择OpenAI的官方Node.js SDK,因为它最通用。你当然也可以替换成其他兼容OpenAI API格式的模型服务(如DeepSeek、Ollama本地模型等)。
npm install openai同时,我们还需要dotenv来管理API密钥等敏感信息:
npm install dotenv在项目根目录创建.env文件,填入你的OpenAI API密钥:
OPENAI_API_KEY=sk-your-actual-api-key-here3.2 构建基础Tool类与具体工具实现
我们先定义一个基础的Tool类,所有具体工具都继承自它。这个类主要定义了工具的描述和统一的调用方法。
src/core/Tool.js
class Tool { constructor(name, description, parameters) { this.name = name; this.description = description; // 给LLM看的描述 this.parameters = parameters; // 参数定义,例如 [{name: ‘path’, type: ‘string’}] } // 实际执行工具功能的函数,由子类实现 async _call(argumentsObject) { throw new Error(‘_call() must be implemented by subclass’); } // 提供给Agent调用的安全接口 async call(argumentsObject) { try { const result = await this._call(argumentsObject); return { success: true, output: result }; } catch (error) { return { success: false, output: `Error: ${error.message}` }; } } // 生成给LLM的工具描述片段 toLLMDescriptor() { return { name: this.name, description: this.description, parameters: this.parameters, }; } } module.exports = Tool;现在,让我们实现第一个具体的工具:文件写入工具。
src/tools/WriteFileTool.js
const Tool = require(‘../core/Tool’); const fs = require(‘fs’).promises; const path = require(‘path’); class WriteFileTool extends Tool { constructor() { super( ‘write_file’, ‘Write content to a file at the specified path. Creates directories if needed.’, [ { name: ‘path’, description: ‘The file path’, type: ‘string’, required: true }, { name: ‘content’, description: ‘The content to write’, type: ‘string’, required: true }, ] ); } async _call({ path: filePath, content }) { // 确保目录存在 const dir = path.dirname(filePath); await fs.mkdir(dir, { recursive: true }); // 写入文件 await fs.writeFile(filePath, content, ‘utf-8’); return `File written successfully to ${filePath}`; } } module.exports = WriteFileTool;按照同样的模式,我们可以快速实现一个执行Shell命令的工具。这里我们需要特别注意安全性,避免执行任意危险命令。在我们的最小版本中,我们可以做一个简单的允许命令列表,或者仅用于项目相关的安全命令(如npm, git等)。
src/tools/ShellCommandTool.js(简化安全版)
const Tool = require(‘../core/Tool’); const { exec } = require(‘child_process’); const { promisify } = require(‘util’); const execAsync = promisify(exec); class ShellCommandTool extends Tool { constructor(allowedCommands = [‘npm’, ‘node’, ‘ls’, ‘pwd’, ‘mkdir’, ‘echo’]) { super( ‘shell_command’, ‘Execute a safe shell command. Currently allowed prefixes: ‘ + allowedCommands.join(‘, ‘), [ { name: ‘command’, description: ‘The shell command to execute’, type: ‘string’, required: true }, ] ); this.allowedCommands = allowedCommands; } async _call({ command }) { // 基础的安全检查:命令是否以允许的前缀开头 const isAllowed = this.allowedCommands.some(cmd => command.trim().startsWith(cmd)); if (!isAllowed) { throw new Error(`Command not allowed. Allowed prefixes: ${this.allowedCommands.join(‘, ‘)}`); } const { stdout, stderr } = await execAsync(command, { cwd: process.cwd() }); if (stderr) { // 有些命令(如npm install)会输出信息到stderr,但不一定是错误 console.warn(‘Shell command stderr:’, stderr); } return stdout || ‘Command executed (no stdout)’; } } module.exports = ShellCommandTool;3.3 实现Agent大脑:与LLM的思维链交互
这是最核心的部分。我们需要设计一个Agent类,它持有可用的工具列表,并能与LLM对话,将LLM的文本输出解析成工具调用指令。
src/core/Agent.js(核心骨架)
const OpenAI = require(‘openai’); require(‘dotenv’).config(); class Agent { constructor(tools = []) { this.openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); this.tools = tools; this.conversationHistory = []; // 保存对话历史,用于保持上下文 } // 关键方法:让Agent根据当前对话历史和用户新消息,决定下一步行动 async think(userMessage) { this.conversationHistory.push({ role: ‘user’, content: userMessage }); // 1. 准备给LLM的系统提示词(System Prompt),这是指导LLM行为的关键 const systemPrompt = `You are a helpful programming assistant. You have access to the following tools: ${this.tools.map(t => `- ${t.name}: ${t.description}`).join(‘\n’)} To use a tool, you MUST respond in the following JSON format: { “thought”: “Your reasoning about what to do next”, “action”: { “name”: “tool_name”, “arguments”: { “arg1”: “value1”, “arg2”: “value2” } } } If the task is complete or no tool is needed, respond with: { “thought”: “Final summary or answer”, “action”: null } Current working directory: ${process.cwd()} `; // 2. 构建消息列表 const messages = [ { role: ‘system’, content: systemPrompt }, …this.conversationHistory, ]; // 3. 调用LLM const completion = await this.openai.chat.completions.create({ model: ‘gpt-4o-mini’, // 或 ‘gpt-3.5-turbo’, 根据成本和性能选择 messages, temperature: 0.1, // 低温度,让输出更确定、更遵循格式 response_format: { type: “json_object” }, // 强制要求返回JSON,这是关键! }); const llmResponse = completion.choices[0].message.content; this.conversationHistory.push({ role: ‘assistant’, content: llmResponse }); // 4. 解析LLM的响应 let actionDecision; try { actionDecision = JSON.parse(llmResponse); } catch (error) { console.error(‘Failed to parse LLM response as JSON:’, llmResponse); // 如果解析失败,可以尝试让LLM重试,或返回一个错误结果 return { thought: ‘I received an invalid response format.’, action: null, result: null, }; } return actionDecision; // 返回 { thought, action } } // 执行Agent决策出的行动 async act(actionDecision) { if (!actionDecision.action) { // 没有行动,直接返回思考结果作为最终回复 return { result: actionDecision.thought }; } const { name: toolName, arguments: toolArgs } = actionDecision.action; const tool = this.tools.find(t => t.name === toolName); if (!tool) { return { result: `Error: Tool ‘${toolName}’ not found.` }; } // 调用工具 const toolResult = await tool.call(toolArgs); // 将工具执行结果也加入历史,供下一轮思考参考 this.conversationHistory.push({ role: ‘tool’, content: JSON.stringify({ tool: toolName, result: toolResult }), }); return { result: toolResult }; } // 主循环:接收用户输入,思考,行动,直到任务完成 async run(userInput) { console.log(`User: ${userInput}`); let shouldContinue = true; let finalAnswer = null; while (shouldContinue) { const decision = await this.think(userInput); console.log(`Agent Thought: ${decision.thought}`); if (decision.action) { console.log(`Agent Action: ${decision.action.name}`, decision.action.arguments); const actionResult = await this.act(decision); console.log(`Tool Result:`, actionResult.result); // 如果工具执行成功,将本轮的用户输入置空(或一个固定提示),让Agent基于工具结果进行下一轮思考。 // 如果失败或任务完成,则跳出循环。 if (actionResult.result.success === false) { finalAnswer = `Task failed: ${actionResult.result.output}`; shouldContinue = false; } else { // 继续循环,下一轮“思考”的输入是上一步的工具结果摘要 userInput = `The previous action ‘${decision.action.name}’ completed with result: ${actionResult.result.output}. What should I do next based on the original goal?`; } } else { // 没有下一步行动,任务完成 finalAnswer = decision.thought; shouldContinue = false; } } return finalAnswer; } } module.exports = Agent;这段代码是核心中的核心。有几个关键点:
- 系统提示词(System Prompt):它定义了Agent的角色、可用的工具以及强制性的JSON输出格式。这个格式是我们与LLM约定的“协议”,是让非结构化的文本对话变成结构化程序指令的桥梁。
response_format: { type: “json_object” }:这是OpenAI API的一个强大功能,它极大地提高了LLM返回规整JSON的概率,是我们项目能跑通的关键。- 对话历史管理:我们将用户消息、Assistant的思考、Tool的执行结果都存入
conversationHistory。这样,LLM在每一轮思考时都能拥有完整的上下文,知道之前做了什么、结果如何,从而做出连贯的决策。 - 主循环(
run方法):它实现了经典的Agent循环:用户输入 -> 思考(LLM) -> 执行(Tool) -> 观察结果 -> 再次思考…,直到LLM认为任务完成(返回action: null)。
3.4 组装并运行:创建你的第一个AI编程助手
现在,让我们把零件组装起来,看看它能否真正工作。
src/index.js
const Agent = require(‘./core/Agent’); const WriteFileTool = require(‘./tools/WriteFileTool’); const ShellCommandTool = require(‘./tools/ShellCommandTool’); async function main() { // 1. 初始化工具 const tools = [ new WriteFileTool(), new ShellCommandTool(), // 未来可以轻松添加更多工具,如 ReadFileTool, GitTool等 ]; // 2. 创建Agent,注入工具 const myAssistant = new Agent(tools); // 3. 发布第一个任务! const task = “Initialize a new Node.js project in the current directory and create a simple ‘hello.js’ file that prints ‘Hello from Mini-Cursor!’.”; console.log(‘Starting task:’, task); const finalResult = await myAssistant.run(task); console.log(‘\n=== Task Finished ===’); console.log(‘Final Result:’, finalResult); } main().catch(console.error);运行这个程序:
node src/index.js如果一切配置正确,你将看到类似以下的输出流:
Starting task: Initialize a new Node.js project... User: Initialize a new Node.js project... Agent Thought: I need to first run ‘npm init -y’ to create a package.json, then create the hello.js file. Agent Action: shell_command { command: ‘npm init -y’ } Tool Result: { success: true, output: ‘… package.json created …’ } User: The previous action ‘shell_command’ completed with result: … What should I do next based on the original goal? Agent Thought: Now I need to create the hello.js file with the specified content. Agent Action: write_file { path: ‘./hello.js’, content: “console.log(‘Hello from Mini-Cursor!’);” } Tool Result: { success: true, output: ‘File written successfully to ./hello.js’ } User: The previous action ‘write_file’ completed with result: … What should I do next? Agent Thought: Both steps are complete. The task is finished. === Task Finished === Final Result: Both steps are complete. The task is finished.检查你的目录,会发现新生成了package.json和hello.js文件。运行node hello.js,你会看到打印出的问候语。恭喜!你已经成功创建了一个具备基础“思考-行动”能力的AI编程助手雏形。
4. 深入优化与实战技巧
上面的代码跑通了核心流程,但距离一个健壮的、可用的系统还有距离。下面分享几个关键的优化点和实战中踩过的坑。
4.1 提升LLM决策的稳定性与准确性
LLM的输出具有随机性,即使我们要求返回JSON,它有时也会“胡言乱语”或格式错误。除了使用response_format参数,我们还可以在代码层面增加“重试”和“后处理”逻辑。
- 结构化参数验证:在
Agent.act()方法中调用工具前,严格验证参数是否存在、类型是否正确。可以集成像zod这样的验证库。 - 思维链(Chain-of-Thought)鼓励:在系统提示词中明确要求LLM先进行推理(
“thought”字段),再决定行动。这能显著提高决策质量。我们的提示词已经包含了这一点。 - 错误处理与重试:当LLM返回的JSON无法解析,或指定的工具不存在时,不要直接崩溃。可以将错误信息反馈给LLM,让它重新思考。这需要在
run循环中增加一个错误处理分支,将错误信息作为新一轮的用户输入。
4.2 工具设计的进阶考量
- 工具结果的处理:工具返回的结果可能很长(如
npm install的输出)。直接塞回给LLM可能会浪费tokens并干扰其思考。一个好的做法是让工具返回一个摘要。例如,ShellCommandTool可以在成功时返回“Command ‘npm init’ executed successfully.”,失败时返回简洁的错误信息。 - 工具的组合与规划:复杂的任务(如“搭建一个Express服务器”)需要多个工具按顺序调用。目前我们的Agent能通过循环自动处理。但对于更复杂的、有分支条件的任务,可能需要引入更高级的规划能力,这就可以借鉴
LangGraph中“状态机”的概念。 - 安全性加固:我们的
ShellCommandTool的白名单机制非常初级。在生产环境中,需要更严格的沙箱机制,比如在Docker容器内执行命令,或使用更精细的权限控制。
4.3 扩展你的工具库
Cursor的强大在于丰富的工具集。你可以轻松地为你的迷你Agent添加新工具:
- 代码解释工具:接受一个文件路径,读取文件内容,然后调用LLM的API(使用另一个专门的提示词)来总结或解释代码。
- 代码重构工具:接受代码片段和重构指令(如“提取函数”),调用LLM生成新代码,然后通过
WriteFileTool写回。 - Git操作工具:封装
git add,git commit,git status等命令。 - 网络搜索工具:集成Serper API或类似服务,让Agent能获取最新信息来解决问题。
添加新工具的过程完全标准化:继承Tool基类,实现_call方法,定义好描述和参数即可。然后将其加入到传递给Agent的工具列表中。这就是模块化的魅力。
4.4 性能与成本控制
- 上下文长度管理:
conversationHistory会不断增长,导致每次调用API的tokens消耗增加,成本上升,并且可能超过模型的最大上下文长度。需要实现一个“滑动窗口”或“摘要”机制,只保留最近N轮对话或对历史进行总结压缩。 - 模型选择:对于简单的代码生成和工具调用,
gpt-4o-mini或gpt-3.5-turbo通常足够且成本更低。对于复杂的逻辑规划,可以考虑使用gpt-4o。 - 异步与流式处理:如果工具调用比较耗时(如下载依赖),可以考虑让多个工具并行执行(如果它们之间没有依赖关系)。对于长时间运行的任务,可以向用户提供流式进度反馈。
5. 常见问题与排查实录
在开发和测试这个最小系统的过程中,我遇到了不少典型问题,这里记录下排查思路和解决方案。
问题1:LLM不返回JSON,或者返回的JSON格式错误。
- 现象:
JSON.parse抛出异常,程序中断。 - 排查:首先检查系统提示词是否清晰强调了JSON格式。然后,打印出LLM返回的原始内容
llmResponse,看看它到底说了什么。有时候LLM会在JSON前后加上“```json”这样的markdown标记。 - 解决:
- 在代码中增加预处理,尝试去除
llmResponse中的markdown代码块标记。 - 使用更严格的提示词,例如:“你必须且只能返回一个有效的JSON对象,不要有任何其他文字。”
- 启用API的
response_format: { type: “json_object” }参数(我们已采用),这是最有效的解决方案。 - 实现重试逻辑:捕获解析异常后,将错误信息连同原始对话历史再次发送给LLM,要求它纠正。
- 在代码中增加预处理,尝试去除
问题2:Agent陷入死循环,或者重复执行同一个操作。
- 现象:控制台不断打印相似的思考和行动,任务无法完成。
- 排查:观察
conversationHistory。问题通常出在工具执行结果的反馈上。如果工具结果过于冗长或模糊,LLM可能无法正确判断任务状态。也可能是系统提示词中关于“任务完成”的条件描述不清。 - 解决:
- 优化工具反馈:确保工具返回清晰、简短、确定性的结果。例如,“File created successfully”比一长串文件内容更好。
- 增强提示词:在系统提示词中明确给出任务完成的例子。例如:“当你认为用户请求的所有步骤都已正确执行完毕时,将
action设置为null,并在thought中总结完成情况。” - 设置循环上限:在
Agent.run方法中设置一个最大循环次数(比如10次),超过后强制终止,避免无限消耗API费用。
问题3:Shell命令执行失败,但错误信息不清晰。
- 现象:
ShellCommandTool返回success: false,但output只是简单的“Error: Command failed”。 - 排查:
child_process.exec的错误对象通常包含stderr信息。 - 解决:修改
ShellCommandTool._call中的错误处理,将stderr信息包含在返回的错误结果中,方便Agent(和开发者)诊断。try { const { stdout, stderr } = await execAsync(command, { cwd: process.cwd() }); // … 处理成功情况 } catch (error) { // 将stderr和error.message都返回 return { success: false, output: `Command failed: ${error.message}. Stderr: ${error.stderr || ‘None’}`, }; }
问题4:如何处理用户模糊或复杂的指令?
- 现象:用户说“优化这个文件”,Agent可能不知所措,因为它不知道“优化”具体指什么,也不知道是哪个文件。
- 解决:这是当前AI助手的通用挑战。在我们的架构下,有两种应对策略:
- 让Agent学会追问:在系统提示词中赋予Agent在信息不足时主动询问用户的能力。例如,可以设计一个特殊的
ask_user工具,当LLM认为需要澄清时,就调用这个工具,将问题输出给用户,并等待用户下一轮输入。 - 设计更精准的工具:将“优化”拆解成多个具体工具,如
format_code_tool(格式化)、refactor_code_tool(重构)、add_comments_tool(加注释)。然后由LLM根据上下文决定调用哪一个或哪几个。
- 让Agent学会追问:在系统提示词中赋予Agent在信息不足时主动询问用户的能力。例如,可以设计一个特殊的
通过这个从零手写最小版本Cursor Agent的项目,我们不仅实现了一个能跑通的自动化编程助手原型,更重要的是,我们彻底拆解了AI Agent的核心运作机制:如何让大语言模型从“聊天者”转变为“执行者”。这个过程中对工具抽象、提示词工程、交互协议和安全性的思考,是任何深入AI应用开发的开发者都必须掌握的基石。你可以以此为基础,添加更强大的工具(如数据库操作、API调用)、集成更复杂的规划逻辑(如LangGraph)、甚至为其开发一个前端聊天界面,逐步构建出属于你自己的、高度定制化的AI生产力工具。