从零手写一个 AI 编程 Agent 之后,我终于理解了 Cursor 的核心原理

发布时间:2026/7/9 1:17:44

从零手写一个 AI 编程 Agent 之后,我终于理解了 Cursor 的核心原理 从零手写一个 AI 编程 Agent 之后我终于理解了 Cursor 的核心原理本文适合对 AI Agent 感兴趣的 Node.js 开发者。我们不谈虚的——用不到 200 行代码从零实现一个能自动创建项目、写代码、装依赖、启动服务的编程 Agent并在这个过程中真正理解为什么一个 for 循环就能驱动复杂任务。文章目录从零手写一个 AI 编程 Agent 之后我终于理解了 Cursor 的核心原理一、一个被忽视的问题二、先立一个公式Agent LLM 工具 循环三、工具设计的三个关键权衡权衡 1健壮性 vs 精确性——写文件时要不要自动创建目录权衡 2实时反馈 vs 结果收集——执行命令时用 spawn 还是 exec权衡 3描述详细度 vs 上下文消耗——怎么给工具写 description四、ReAct 循环的优雅——一个 for 循环为什么能驱动复杂任务4.1 模型绑定——为什么 bindTools 是桥梁而非黑魔法4.2 循环体——整个 Agent 的核心不到 30 行4.3 System Prompt——Agent 的护栏五、实战验证让它建一个完整的 React 项目六、从 200 行到 Cursor中间差了什么七、总结三个核心观点如果你也想自己试试一、一个被忽视的问题过去一年Cursor、Copilot、Devin 这些 AI 编程工具已经渗透了我们的日常工作。敲一行需求AI 自动创建项目、写代码、装依赖、跑起来——这已经不是什么新鲜事了。但有一个问题大多数人用了很久也没有认真想过这个自动完成任务的循环内部到底是怎么工作的我第一次认真思考这个问题是在用 Cursor 搭建一个全栈项目的时候。AI 先创建了前端项目又创建了后端项目然后自动装了依赖甚至帮我修正了一个配置错误——整个流程大概 20 个步骤它自己走完了。我当时想它怎么知道下一步该做什么它的决策机制是什么后来我读了不少 Agent 相关的论文和源码发现这件事的核心其实是一个诞生于 2022 年的老模式ReActReasoning Acting。而且它的实现骨架简单到让人意外——一个 while 循环不到 50 行代码。这篇文章我想跟你分享一个从零手写 mini-Cursor的过程。它只有四个工具、一个 ReAct 循环但它完整地复现了 AI 编程 Agent 的核心骨架。更重要的是——理解了这个骨架之后你对市面上所有 AI 编程工具的理解会从魔法变成工程。二、先立一个公式Agent LLM 工具 循环任何 AI Agent不管多复杂都可以用这个公式概括Agent LLM大脑 工具手 循环调用心跳LLM负责思考——分析当前状态决定下一步做什么。每次调用就像一次快照式的判断它不记得上一刻发生了什么——除非你把历史告诉它。工具负责行动——读文件、写代码、执行命令、查数据库。LLM 本身只是一个语言模型它不能操作文件系统不能联网不能执行命令。工具是它伸向外部世界的触手。循环负责持续——LLM 思考一次就停了但任务通常需要多步才能完成。循环让这个过程持续运转思考 → 行动 → 观察结果 → 再思考。这听起来像是废话——“有大脑、有手、有心跳当然能干活”。但我想说的是这个公式里最不直观、也最关键的一环是循环。因为市面上大部分关于 Agent 的讨论焦点都放在 LLM 的能力和工具的丰富度上很少有人去思考“LLM 为什么能在一个循环里持续做出正确的决策”我认为答案是LLM 并没有规划整个任务它只是在每一步基于当前上下文 上一步的工具结果做一次推理。全局的智能感是从局部的、一次次的微观决策中涌现出来的。所以要理解 Agent重点不是用了什么模型或有多少工具而是这个循环怎么设计以及每一步的上下文怎么传递。下面我们通过代码来验证这件事。三、工具设计的三个关键权衡在写 ReAct 循环之前我们需要给 Agent 装上手。我们定义了四个工具read_file、write_file、list_directory、execute_command分别对应读取、写入、浏览、执行。每个工具用 LangChain 的tool()封装包含三个要素功能函数、name description告诉 LLM 它能干什么、Zod schema告诉 LLM 参数类型。具体代码我不在这里全部展开完整版本见文末我想重点讨论的是工具设计中的三个关键权衡——这些权衡反映了 Agent 开发中普遍存在的取舍。权衡 1健壮性 vs 精确性——写文件时要不要自动创建目录constwriteFileTooltool(async({filePath,content}){constdirpath.dirname(filePath);awaitfs.mkdir(dir,{recursive:true});// 自动建awaitfs.writeFile(filePath,content,utf-8);return成功写入${filePath};},{/* name, description, schema */});fs.mkdir(dir, { recursive: true })这行代码我犹豫过要不要加。理由是加了Agent 可能在不该建目录的地方建了目录不加Agent 在写嵌套路径时会反复失败。最终我选择了加。因为这个 Agent 的场景是自动化编程LLM 大概率会写出src/components/Header.tsx这样的路径——如果src/components不存在就报错Agent 会在创建目录和写文件之间反复试错浪费多轮调用。这个取舍背后的原则是工具应该容忍 LLM 的粗放——LLM 擅长生成意图不擅长精准操作。你在设计 Agent 工具时需要主动判断哪些操作可以容错哪些必须严格。这和传统 API 设计的思路完全不同——传统 API 强调契约Agent 工具强调鲁棒性。权衡 2实时反馈 vs 结果收集——执行命令时用 spawn 还是 execconstchildspawn(cmd,args,{cwd,stdio:inherit,// 子进程的输出直接透传到终端shell:true,});spawnstdio: inherit是一个刻意为之的选择。如果用exec子进程的输出会被缓冲等命令执行完才返回。对于pnpm install这种带进度条的命令用户会在终端前干等好几分钟啥也看不到——这体验太差了。但stdio: inherit也有代价子进程的输出不经过 Node.js所以你拿不到它的 stdout无法在事后分析输出内容来辅助决策。如果 Agent 需要根据命令输出来判断下一步该做什么spawn就不是好选择。取舍的原则是如果命令的输出是给用户看的安装进度、构建日志优先保证体验用 spawn如果命令的输出需要被 Agent 解析和推理用 exec 收集完整结果。在这个 mini-Cursor 中四个工具里有三个都需要 Agent 拿到返回结果做下一步决策唯独execute_command是个例外——因为它的主要场景是安装和启动用户更关心看到它跑起来了而非Agent 分析输出。权衡 3描述详细度 vs 上下文消耗——怎么给工具写 description每个工具都需要一个description字段告诉 LLM 它是干什么的。我一开始写得很简略description:读取文件,后来发现这样写LLM 会在看代码和查目录之间选错工具——它分不清什么时候该用read_file什么时候该用list_directory。于是我把 description 改成了更详细的版本description:用此工具来读取文件内容当用户要求读取文件、查看代码、分析文件内容时调用此工具。输入文件路径可以是相对路径或绝对路径,但问题又来了——description 太长会消耗上下文窗口。四个工具每个 100 字的 description加上 System Prompt已经占了几百个 token。对于简单任务这完全是浪费。原则是description 的长度应该和调用歧义度成正比。两个容易混淆的工具如read_file和list_directorydescription 要写得足够有区分度不太可能混淆的工具如execute_commanddescription 可以简略。这三个权衡做下来你会发现一个更底层的规律工具设计不是在写 API而是在和 LLM “对话”。你要预判 LLM 可能在什么场景下犹豫主动给它铺好路。这种产品思维比纯工程思维重要得多——这也是我觉得 Agent 开发中最有意思的部分。四、ReAct 循环的优雅——一个 for 循环为什么能驱动复杂任务工具定义好了现在到最有意思的部分把这个 for 循环跑起来看 LLM 怎么在每一步做决策。4.1 模型绑定——为什么bindTools是桥梁而非黑魔法constmodelnewChatOpenAI({modelName:deepseek-v4-pro,temperature:0,// 代码生成不需要创意要确定性});consttools[readFileTool,writeFileTool,listDirectoryTool,executeCommandTool];constmodelWithToolsmodel.bindTools(tools);bindTools是 LangChain 提供的一个方法它做的事情很朴素把每个工具的 name、description 和 zod schema 转成 OpenAI 兼容的 function-calling 格式塞到 API 请求里。模型收到后在连续推理的过程中可以在任意时刻选择我要调工具了——它会输出一个tool_calls数组里面包含工具名和参数。这里有一个细节值得注意temperature: 0。Agent 任务不是写诗——每一步都应该稳定、可预测。如果 temperature 设高了模型可能突发奇想调用不存在的工具或者在参数里填错路径。对于代码生成和工具调用确定性比创意重要。4.2 循环体——整个 Agent 的核心不到 30 行asyncfunctionrunAgent(query,maxIterations30){constmessages[newSystemMessage(你是一个项目管理助手... 当前工作目录:${process.cwd()}可用工具: read_file, write_file, list_directory, execute_command 重要规则: workingDirectory 和 cd 不要同时使用...),newHumanMessage(query),];for(leti0;imaxIterations;i){constresponseawaitmodelWithTools.invoke(messages);messages.push(response);// 没有 tool_calls → 任务完成if(!response.tool_calls||response.tool_calls.length0){returnresponse.content;}// 有 tool_calls → 执行工具结果塞回对话历史for(consttoolCallofresponse.tool_calls){consttooltools.find(tt.nametoolCall.name);if(tool){constresultawaittool.invoke(toolCall.args);messages.push(newToolMessage({content:result,tool_call_id:toolCall.id,}));}}}}这个循环的心跳可以画成这样┌──────────┐ response.tool_calls 存在 ┌──────────┐ │ LLM 推理 │ ────────────────────────────► │ 执行工具 │ │ (invoke) │ │ (invoke) │ └──────────┘ └──────────┘ ▲ │ │ ToolMessage 塞回 messages │ └───────────────────────────────────────────┘ │ response.tool_calls 不存在 │ ▼ ┌──────────┐ │ 任务完成 │ └──────────┘我真正理解 ReAct 的本质就是在仔细看了这个循环中的消息传递机制之后。有两点是最关键的第一ToolMessage的回传是整个系统的记忆机制。LLM 自己是无状态的——每一次invoke它唯一的信息来源就是messages数组。所以你必须把工具执行结果原封不动地塞回去。如果你忘了这一步LLM 在下一轮调用中会失忆——它不知道上一轮做了什么更不知道结果是成功还是失败。然后它会怎么做它大概率会重复调用同一个工具陷入loop death。第二LLM 没有全局规划只有局部推理。这是很多人对 Agent 最大的误解。我们回顾一下 Agent 创建 React 项目的真实执行轨迹第 1 次推理: 我的任务是创建项目应该先执行 vite 命令 → 调 tool: execute_command(pnpm create vite ...) → 结果: 成功执行 第 2 次推理: 项目建好了但我还不知道里面有什么文件先看看 → 调 tool: list_directory(react-todo-app/src) → 结果: App.tsx, main.tsx, App.css, ... 第 3 次推理: 有 App.tsx我应该重写它实现 TodoList 逻辑 → 调 tool: write_file(react-todo-app/src/App.tsx, 完整代码...) → 结果: 成功写入 第 4 次推理: 逻辑写完了还需要样式文件来加渐变背景和动画 → 调 tool: write_file(react-todo-app/src/App.css, 样式代码...) → 结果: 成功写入 第 5 次推理: 代码和样式都写好了该装依赖了 → 调 tool: execute_command(pnpm install, { workingDirectory: react-todo-app }) → 结果: 成功执行 第 6 次推理: 依赖装好了启动 → 调 tool: execute_command(pnpm run dev, { workingDirectory: react-todo-app }) → 结果: 成功执行 第 7 次推理: 所有步骤都完成了项目已经在运行 → 无 tool_calls直接返回最终文本注意看LLM 并没有在第一步就想好所有步骤。它每一步只做一件事做完后根据新的上下文判断下一步。全局的智能感是从一次次局部推理中自然涌现的——就像蚂蚁没有巢穴设计图但成千上万只蚂蚁协作出来的蚁巢结构极其精妙。这就是 ReAct 模式最优雅的地方复杂度由环境messages 历史 工具结果承担而非由模型承担。模型不需要规划它只需要在每一步做最好的判断。而环境提供了它做判断所需的全部信息。4.3 System Prompt——Agent 的护栏这 30 行循环代码是心脏但 System Prompt 是基因。我在 System Prompt 里做了几件关键的事告诉它当前工作目录${process.cwd()}——不写这句它可能把文件写到意想不到的地方复述一遍工具列表虽然bindTools已经传了工具定义但 System Prompt 里再列一遍能显著降低模型调用错误工具的几率。这是实践中的经验——双重信息tool definition prompt description比单一信息更可靠重点标注易错规则workingDirectory和cd的冲突。第三点值得展开说说。execute_command支持一个workingDirectory参数用来指定命令在哪个目录执行。但 LLM 有一个思维惯性——当它想进入某个目录执行命令时会下意识地在 command 里写cd react-todo-app pnpm install。问题是如果同时传了workingDirectory: react-todo-app实际路径就变成了react-todo-app/react-todo-app——直接报错。我在 System Prompt 里特意写上了正确示例和错误示例重要规则 - execute_command - workingDirectory 参数会自动切换到指定目录 - 当使用 workingDirectory 时绝对不要在 command 中使用 cd - 错误示例: { command: cd react-todo-app pnpm install, workingDirectory: react-todo-app } 这是错误的因为 workingDirectory 已经在 react-todo-app 了 - 正确示例: { command: pnpm install, workingDirectory: react-todo-app }这看起来像是在啰嗦但在 Agent 开发中对待已知的、高概率的错误模式最好的方式就是直接写在 prompt 里。不要指望 LLM 自己悟出来——它没有常识你越把它当聪明的但不太靠谱的同事去对待效果越好。五、实战验证让它建一个完整的 React 项目说了这么多理论Agent 到底能不能干活我给它的任务是创建一个功能丰富的 React TodoList 应用 1. 使用 pnpm create vite react-todo-app --template react-ts 创建项目 2. 修改 App.tsx实现完整功能增删改查、分类筛选、统计、localStorage 持久化 3. 添加美观样式蓝紫渐变背景、卡片阴影和圆角、悬停效果、CSS transitions 动画 4. pnpm install 安装依赖 5. pnpm run dev 启动开发服务器运行node mini-cursor.mjs终端开始输出正在执行第 1 次 AI 思考 [工具调用] execute_command(pnpm create vite react-todo-app --template react-ts) 工作目录/Users/xxx/hello-langchain/src ...Vite 脚手架输出... [工具调用] execute_command(pnpm create vite ...) 成功执行 正在执行第 2 次 AI 思考 [工具调用] list_directory(react-todo-app/src) 成功列出 5 个文件和文件夹 正在执行第 3 次 AI 思考 [工具调用] write_file(react-todo-app/src/App.tsx) 成功写入 4521 字节 ...大概 7 轮之后Agent 完成了所有步骤。打开浏览器访问http://localhost:5173一个完整的 TodoList 就摆在眼前——渐变背景、卡片圆角、hover 效果、添加/删除动画还有 localStorage 持久化。刷新页面数据不丢失。这个 demo 不是为了炫技——它的目的只有一个验证LLM 工具 循环这条公式在实践中是成立的。一个不到 200 行的程序配合一个语言模型确实可以自动完成一个端到端的开发任务。这证明了 Agent 的核心不是模型有多强而是循环 工具传递的设计是否合理。六、从 200 行到 Cursor中间差了什么理解了 mini-Cursor 之后再回去看真正的 Cursor它的差异不在于用了什么魔法而在于以下这些工程化的增量维度mini-Cursor真正的 Cursor工具数量4 个可能上百个文件操作、Git、终端、LSP、浏览器…上下文管理简单的 messages 数组精巧的上下文压缩、文件相关性排序、RAG 检索代码编辑整文件覆写diff 级别的精确编辑applyEdit人机交互纯命令行GUI 中 diff 预览、一键 accept/reject、inline chat错误恢复简单的 maxIterations 兜底自动重试、回退、LSP 诊断反馈记忆无项目级和用户级记忆跨会话持久化但骨骼是一样的——ReAct 循环。所有的增量都是在循环之上做的优化让工具更丰富、让上下文更精准、让交互更友好。这不是降维打击而是同一个架构在不同成熟度阶段的表现。这个认知帮我澄清了一个常见的误解“Cursor 之所以强是因为用了最强的模型。”不是的。Cursor 强在它的工具链设计和上下文管理——这两件事跟模型能力一样重要甚至更重要。模型只是思考的质量而工具设计和上下文管理决定了思考的方向对不对。七、总结三个核心观点AI Agent 的本质就是一个带工具调用的对话循环。LLM 推理 → 调工具 → 观察结果 → 再推理。一个 30 行的 for 循环就是整个系统的心跳。理解了这一点任何 Agent 框架LangChain、AutoGPT、CrewAI在你眼里都会变得透明。工具设计不是在写 API而是在和 LLM “对话”。你需要预判 LLM 会在什么场景下犹豫主动设计容错和引导。描述写多详细、参数怎么定义、是否自动建目录——这些人机交互层面的决策比算法层面的决策更影响最终效果。Agent 的智能感是涌现的不是规划的。LLM 每一步只做一次局部的判断但消息历史把所有判断串起来之后表现出来的就是全局的智能。这意味着你的工作不是教 LLM 做规划而是确保每一步的上下文工具结果 历史消息足够清晰、完整。如果你也想自己试试完整的代码已经在上文中贴出all-tools.mjs 和 mini-cursor.mjs配好 DeepSeek API Key 就能跑。如果你想继续深入三个值得探索的方向记忆系统给 Agent 加上向量数据库让它记住之前的对话和项目上下文跨会话保持状态多 Agent 协作一个 Agent 写前端一个写后端一个跑测试通过消息传递协作Human-in-the-loop关键操作删文件、推送代码在执行前让用户确认安全性和可控性大幅提升你把 mini-Cursor 跑起来之后可能会遇到一些意外行为——比如 LLM 写了一个语法错误的文件或者死循环调用同一个工具。这些都不是 bug而是 Agent 开发中正常的沟通过程。每一次调试都是你在教 LLM 怎么更好地理解你的意图。在这个过程中你最大的收获可能不是一个能跑的项目而是对AI 编程工具到底在干什么这件事建立了一套属于自己的、工程化的理解。

相关新闻