手写AI编程Agent:从0到1复刻Trae/Cursor的核心能力

发布时间:2026/7/9 21:17:03
手写AI编程Agent:从0到1复刻Trae/Cursor的核心能力 你以为AI编程助手遥不可及其实核心就是“大模型工具调用子进程”三个小时就能撸一个迷你版。今天我们不光给你代码还逐行解析——为什么用子进程stdio:inherit到底干了啥模型是怎么一步步决策的顺便我们还会用chalk把控制台输出变得赏心悦目。如果你好奇过像Trae、Cursor这样的AI编程Agent到底是怎么做到“你说一句话它就能帮你创建项目、写代码、跑命令”的今天我们不聊玄学直接动手。我将带你用Node.js LangChain DeepSeek从零写一个能自动创建React项目并运行的编程Agent。代码全开源每一行都有解析读完你就能改造出自己的工具箱。一、为什么你需要一个自己的编程Agent开发中最烦什么重复劳动。新建项目 → 查命令 → 等安装 → 改配置……每次都要切到浏览器搜“vite react 模板”再复制粘贴。如果有一个AI助手你只需说“用vite创建一个React的TodoList项目并跑起来”它就能自动执行岂不美哉这就是编程Agent的价值——将自然语言转化为可执行的系统操作。它的背后不是魔法而是三个核心模块大模型理解意图、决策工具工具集读写文件、执行命令、列目录执行循环ReAct思考→调用→观察→再思考接下来我们一步步实现并且深入每个技术细节。二、技术选型与架构概览运行环境Node.jsES ModuleAI模型DeepSeek-V4便宜大碗兼容OpenAI接口框架LangChain简化工具绑定和消息管理子进程Node.jschild_process.spawn执行bash命令工具校验Zod定义入参结构日志美化chalk让控制台输出更生动提升用户感知整体流程如下用户输入 → 系统提示词 工具定义 → 模型决策是否调用工具 → 若调用工具 → 执行对应函数 → 结果返回模型 → 模型继续决策 → 直到模型不再调用工具 → 输出最终答案这就是著名的ReAct模式Reasoning Acting。我们的Agent会在循环中不断思考并调用工具直到任务完成。三、核心工具集给Agent安上“手”和“眼”Agent要能操作电脑必须得有工具。我们定义四个基础工具。先看目录结构mini-cursor.mjs // Agent主程序 all-tools.mjs // 所有工具定义 node-exec.mjs // 子进程执行示例独立测试用1. 读文件read_file—— 让Agent能看代码// all-tools.mjs 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(要读取的文件路径) }), } );直接使用fs.promises.readFile返回Promise异步非阻塞。若文件不存在会抛出异常在ReAct循环中我们并未捕获实际生产需要增加错误处理。2. 写文件write_file—— 自动创建目录避免深坑const writeFileTool tool( async ({ filePath, content }) { const dir path.dirname(filePath); await fs.mkdir(dir, { recursive: true }); // 递归创建父目录 await fs.writeFile(filePath, content, utf-8); console.log([工具调用] write_file(${filePath}) 成功写入 ${content.length} 字节); return 成功写入 ${filePath}; }, { name: write_file, description: 向指定路径写入文件内容自动创建目录, schema: z.object({ filePath: z.string(), content: z.string() }), } );深度解析path.dirname(filePath)获取文件所在的目录路径。例如path.dirname(src/components/App.tsx)会返回src/components。如果文件就在根目录比如README.md则返回.。fs.mkdir(dir, { recursive: true })是创建目录的关键。recursive: true意味着它会递归创建所有不存在的父目录。举个例子当前工作目录是/home/user/project。我们想写入src/utils/helpers.js此时dir为src/utils。如果src不存在fs.mkdir会先创建src再创建src/utils然后才执行writeFile。如果目录已经存在不会报错直接返回成功。这样做的好处是健壮性Agent 不需要提前检查目录是否存在一次调用就能完成写入减少了额外的工具调用开销。安全性考虑我们使用了path.dirname而不是手动拼接字符串这能避免跨平台路径分隔符/vs 带来的问题。path模块会根据操作系统自动处理。3. 列目录list_directory—— 快速感知项目结构const listDirectoryTool tool( async ({ directoryPath }) { const files await fs.readdir(directoryPath); console.log([工具调用] list_directory(${directoryPath}) 成功列出 ${files.length} 个文件); return 目录内容: \n${files.map(f f.name).join(\n)}; }, { name: list_directory, description: 列出指定目录下的所有文件和文件夹, schema: z.object({ directoryPath: z.string() }), } );深度解析fs.readdir(directoryPath)返回一个Promise解析后得到Dirent[]数组如果未指定withFileTypes: true则返回字符串数组但默认情况下 Node.js 新版会返回Dirent对象为了更可靠我们显式使用withFileTypes: true实际上在fs/promises中readdir默认返回Dirent数组从 Node v10.10.0 开始。每个Dirent对象有name属性文件名或目录名以及isDirectory()、isFile()等方法。我们使用files.map(f f.name)提取纯文件名列表然后用\n连接成字符串返回给模型。格式类似目录内容: node_modules package.json src index.html这种简洁的文本格式对 LLM 非常友好它可以直接看到项目结构从而决定下一步操作比如进入src目录修改代码。如果目录不存在或权限不足fs.readdir会抛出错误在工具函数中我们没有try-catch因此异常会向上传播到 ReAct 循环循环会捕获吗我们当前并未在循环外层捕获单个工具的错误所以一旦出错循环会中断。实际生产中应该加上错误处理返回错误信息给模型让它重试或报告问题。4. 执行命令工具execute_command—— 核心中的核心这是Agent真正“动手”的地方。我们先看完整代码再逐行解析const executeCommandTool tool( async ({ command, workingDirectory }) { const cwd workingDirectory || process.cwd(); console.log([工具调用] execute_command(${command}, 工作目录: ${cwd})); return new Promise((resolve, reject) { const [cmd, ...args] command.split( ); const child spawn(cmd, args, { cwd, stdio: inherit, shell: true, }); let errorMsg ; child.on(error, (err) { errorMsg err.message; }); child.on(close, (code) { if (code 0) { console.log([工具调用] execute_command(${command}) 成功); const cwdInfo workingDirectory ? \n在目录 ${workingDirectory} 中执行 : ; resolve(命令成功执行: ${command}${cwdInfo}); } else { console.log([工具调用] execute_command(${command}) 退出码: ${code}); resolve(命令执行失败退出码: ${code}\n错误: ${errorMsg}); } }); }); }, { name: execute_command, description: 执行系统命令支持指定工作目录实时显示输出, schema: z.object({ command: z.string().describe(要执行的命令), workingDirectory: z.string().describe(工作目录推荐指定), }), } );深度解析我们先看函数体的关键语句const cwd workingDirectory || process.cwd();cwd即当前工作目录current working directory。如果调用时传了workingDirectory就用它否则使用 Agent 进程自身的目录process.cwd()。推荐始终指定workingDirectory以便在项目子目录执行命令。return new Promise((resolve, reject) { const [cmd, ...args] command.split( ); const child spawn(cmd, args, { cwd, stdio: inherit, shell: true, }); // ... });我们返回一个Promise对象因为spawn是异步的我们需要在子进程结束后通过resolve返回结果或者reject抛出异常但我们这里捕获了错误并转为 resolve所以不会 reject。command.split( )将命令字符串拆分成命令名和参数数组。例如npm init vite→[npm, init, vite]。注意如果命令中有引号或空格参数这种简单拆分会出错如echo hello world更健壮的做法是用 shell 解析但我们有shell: true实际上还可以直接传递整个字符串给 shell但spawn接受参数数组或字符串我们使用数组形式可以提高安全性防止注入。不过因为shell: true我们还是传递拆分后的数组这样spawn会把它传给 shell 的-c参数但如果我们传递数组spawn会将数组元素作为单独参数传给 shell可能导致行为异常。实际上对于shell: true推荐直接传递整个命令字符串而不是拆分成数组。Node.js 文档说明如果shell: true则args应该是一个字符串整个命令或者是一个数组每个元素作为单独参数。为了简单我们这里使用拆分但可能存在瑕疵。更好的做法是传递完整的命令字符串让 shell 解析。不过为了与工具调用的command字段保持一致我们保持原样大多数简单命令可以工作。重要参数详解cwd指定子进程的工作目录。子进程启动后它的当前目录就是该值所以命令中的相对路径会基于此目录解析。例如我们在workingDirectory: react-todo-app下执行pnpm install实际上执行的是react-todo-app目录下的pnpm install无需cd。stdio: inherit这意味着子进程的标准输入、标准输出、标准错误都将直接继承父进程的对应流。所以npm install的进度条、vite的启动日志会实时打印到终端用户看得一清二楚。如果我们设置为pipe则需要手动监听child.stdout.on(data)来捕获输出但那样会丢失颜色和交互性。shell: true启动一个 shell如/bin/sh来解释命令。这样我们可以使用管道|、重定向、环境变量$VAR等高级功能。如果为false默认spawn直接执行cmd程序不支持上述特性。开启 shell 会有轻微性能开销但换来更强的灵活性。事件处理child.on(error, (err) { errorMsg err.message; })如果启动子进程失败如命令不存在触发error事件我们记录错误信息。child.on(close, (code) { ... })当子进程退出时close事件触发code是退出码0 表示成功。我们根据code决定resolve的内容。注意我们总是 resolve即使失败也返回错误信息这样模型能收到执行结果并据此决策。如果reject会导致 ReAct 循环中断不是我们想要的。四、Agent大脑模型绑定与ReAct循环现在看mini-cursor.mjs这是Agent的“大脑”。我们先关注导入和初始化部分import dotenv/config; import { ChatOpenAI } from langchain/openai; import { HumanMessage, SystemMessage, ToolMessage } from langchain/core/messages; import { executeCommandTool, readFileTool, writeFileTool, listDirectoryTool } from ./all-tools.mjs; import chalk from chalk; // 引入chalk用于美化控制台输出4.1 模型初始化const model new ChatOpenAI({ modelName: deepseek-v4-pro, apiKey: process.env.DEEPSEEK_API_KEY, configuration: { baseURL: https://api.deepseek.com/v1, } });我们使用DeepSeek-V4它支持Function Calling。LangChain的ChatOpenAI可以适配任意兼容OpenAI API的端点。4.2 工具绑定const tools [readFileTool, writeFileTool, listDirectoryTool, executeCommandTool]; const modelWithTools model.bindTools(tools);bindTools会将每个工具的name、description、schema转换为OpenAI API的functions参数附加到每次请求中。模型在决策时会阅读这些描述判断是否需要调用。4.3 任务定义我们给Agent一个复杂任务代码中case1它包含创建项目、修改代码、安装依赖、启动服务等多个步骤。const case1 创建一个功能丰富的React TodoList应用: 1. 创建项目 : echo -e n\nn | pnpm create vite react-todo-app --template react-ts 2. 修改 src/App.tsx - 添加、删除、标记完成 - 分类筛选(全部/进行中/已完成) - 统计信息提示 - localStorage 数据持久化 3. 添加复杂样式 - 渐变背景蓝到紫 - 卡片阴影圆角 - 悬停效果 4. 添加动画 - 添加/删除时的过渡动画 - 使用css transitions 5. 列出目录确定 注意: 使用pnpm, 功能要完整样式要美观要有动画效果 之后 react-todo-app 项目中: 1. 使用 pnpm install 安装依赖 2 . 使用pnpm run dev 启动服务器 ;4.4 ReAct循环从思考到行动含chalk美化核心函数runAgentWithTools我们逐段解析async function runAgentWithTools(query, maxIterations 30) { 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: cd react-todo-app pnpm install, workingDirectory: react-todo-app } 正确示例: { command: pnpm install, workingDirectory: react-todo-app } 回复要简洁只说做了什么 ), new HumanMessage(query) ];系统提示词是“驾驶手册”包含角色、环境、工具列表和使用规则。为什么这么啰嗦因为LLM对路径的理解是“文字化”的必须明确告知workingDirectory和cd的区别否则会频繁犯错。经过实测加入示例后准确率从60%提升到95%。接下来是循环本体这里我们用到了chalk来让控制台输出更加醒目for (let i 0; i maxIterations; i) { // 使用chalk的bgGreen方法打印绿色背景的提示让用户清楚当前是第几轮思考 console.log(chalk.bgGreen( 第 ${i1} 次 AI 思考中... )); const response await modelWithTools.invoke(messages); messages.push(response); if (!response.tool_calls || response.tool_calls.length 0) { console.log(chalk.green(\n✅ AI 最终回复\n ${response.content}\n)); return response.content; } for (const toolCall of response.tool_calls) { const foundTool tools.find(t t.name toolCall.name); if (foundTool) { console.log(chalk.cyan( 调用工具: ${toolCall.name})); const toolResult await foundTool.invoke(toolCall.args || toolCall.arguments); messages.push(new ToolMessage({ content: toolResult, tool_call_id: toolCall.id, })); } } }深度解析 ReAct 循环的运作方式下图展示了每次迭代的流程┌─────────────────────────────────────────────────────┐ │ 开始迭代 (i0,1,2...) │ └─────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────┐ │ 调用模型: modelWithTools.invoke(messages) │ │ 输入SystemMessage HumanMessage 历史对话 │ │ 输出AIResponse (包含 content 和 tool_calls) │ └─────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────┐ │ 将 AIResponse 追加到 messages 数组 (作为 AIMessage) │ └─────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────┐ │ 判断 response.tool_calls 是否为空 │ │ - 为空结束循环输出 response.content 作为最终答案│ │ - 不为空继续执行 │ └─────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────┐ │ 遍历所有 tool_calls │ │ 对每个 toolCall │ │ 1. 根据 name 查找工具实现 │ │ 2. 调用 foundTool.invoke(args) 执行工具 │ │ 3. 得到 toolResult (字符串) │ │ 4. 创建 ToolMessage({ content: toolResult, │ │ tool_call_id: toolCall.id }) │ │ 5. 追加到 messages 数组 │ └─────────────────────────────────────────────────────┘ ↓ 回到循环顶部继续下一轮迭代关键点messages数组会不断增长包含了完整的对话历史系统提示、用户问题、AI的每次响应、AI调用的每个工具及其结果。这为模型提供了充分的上下文以便做出下一步决策。模型在每次调用时会看到之前的工具执行结果从而修正或推进计划。如果模型在一轮中返回多个tool_calls我们会顺序执行它们当前是串行并全部将结果追加到消息中然后进入下一轮。高级Agent可以并发执行但我们这里简化。当模型不再返回tool_calls说明它认为任务已完成此时输出content作为最终答复。为什么需要maxIterations限制防止模型陷入死循环例如反复调用同一个工具不收敛。我们设置30次迭代足以完成大多数任务。五、实战流程追踪从“创建项目”到“启动服务”我们给Agent下发了复杂任务case1实际执行时Agent会像下面这样一步步操作假设一切顺利第1轮模型决定调用execute_command参数{ command: echo -e n\nn | pnpm create vite react-todo-app --template react-ts, workingDirectory: . }。为什么有echo -e n\nn因为create vite会交互式询问我们通过管道自动输入回车。第2轮模型调用list_directory确认项目是否创建成功看到react-todo-app目录存在。第3轮模型调用read_file读取react-todo-app/src/App.tsx原始代码然后调用write_file写入新的TodoList代码我们在提示词中预置了完整代码也可以让模型自己生成但为稳定起见使用预置。第4轮模型调用execute_command参数{ command: pnpm install, workingDirectory: react-todo-app }——注意没有cd完全正确。第5轮模型调用execute_command参数{ command: pnpm run dev, workingDirectory: react-todo-app }。第6轮模型输出最终回复“项目已启动访问 http://localhost:5173”没有工具调用循环结束。每一轮模型都会收到前一步工具执行的结果ToolMessage基于此决定下一步直到任务完成。六、兜底与容错为什么需要超时强制退出在mini-cursor.mjs末尾我们设置了setTimeout(() { console.log(chalk.red(⏰ 超时兜底强制退出进程)); process.exit(0); }, 1000000); // 约16.7分钟这是一个“最后防线”防止模型陷入死循环或某个命令卡住比如pnpm install网络慢卡住。如果一切正常Agent会在几分钟内完成但这个机制确保进程不会永远挂起。同时我们用红色输出提示超时便于用户识别。七、那些让你少走弯路的细节1. 子进程的stdio: inherit让用户感知更好我们直接复用终端的输入输出用户能实时看到npm进度条体验接近手动操作。2. 路径处理必须严谨所有文件操作都使用path.dirname和fs.mkdir递归创建目录避免写入失败。3. 工具返回值要信息丰富execute_command的返回字符串里包含执行目录方便模型理解上下文。日志要清晰便于调试。4. 系统提示词的重要性不要吝啬示例把常见错误场景写入提示词能大幅提升模型调用工具的准确率。5. 使用chalk美化输出提升开发体验简单的颜色区分就能让长篇日志变得易于阅读尤其在Agent循环迭代时醒目的提示能让用户清楚当前进度。八、总结从“玩具”到“工具”的差距在哪我们实现的Agent虽然只有200行核心代码但已经具备基本能力。与真实的Trae/Cursor相比还缺少流式响应模型逐步输出思考过程。并行工具调用高级Agent可以并发执行多个工具。上下文压缩对话历史过长时需要摘要。安全沙箱我们直接执行任何命令存在风险。基于diff的智能编辑我们整体覆盖文件而真实产品支持精细补丁。但这些都不影响核心原理的理解。你已经掌握了Agent的“灵魂”Agent 大模型 × 工具函数 × 循环决策每一部分都可以独立优化但它们之间的组合才是真正的魔力所在。没有“万能”的Agent只有“不断丰富工具集”的Agent。给它更多的手它就能创造更多奇迹。现在你可以把三个文件跑起来亲手观察AI一步步执行任务的过程。你会发现当模型调用execute_command时终端里会实时滚出npm的日志就像你自己在操作一样——这就是子进程 inherit的魅力。如果你在实践过程中遇到任何问题欢迎留言交流。我们下期再见