当前位置: 首页 > news >正文

手写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的价值——将自然语言转化为可执行的系统操作。它的背后,不是魔法,而是三个核心模块:

  1. 大模型(理解意图、决策工具)
  2. 工具集(读写文件、执行命令、列目录)
  3. 执行循环(ReAct:思考→调用→观察→再思考)

接下来,我们一步步实现,并且深入每个技术细节。


二、技术选型与架构概览

  • 运行环境:Node.js(ES Module)
  • AI模型: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,此时dirsrc/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会将每个工具的namedescriptionschema转换为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对路径的理解是“文字化”的,必须明确告知workingDirectorycd的区别,否则会频繁犯错。经过实测,加入示例后准确率从60%提升到95%。

接下来是循环本体,这里我们用到了chalk来让控制台输出更加醒目

for (let i = 0; i < maxIterations; i++) { // 使用chalk的bgGreen方法打印绿色背景的提示,让用户清楚当前是第几轮思考 console.log(chalk.bgGreen(` 第 ${i+1} 次 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 循环的运作方式

下图展示了每次迭代的流程:

┌─────────────────────────────────────────────────────┐ │ 开始迭代 (i=0,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. 第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. 第2轮:模型调用list_directory确认项目是否创建成功,看到react-todo-app目录存在。
  3. 第3轮:模型调用read_file读取react-todo-app/src/App.tsx原始代码,然后调用write_file写入新的TodoList代码(我们在提示词中预置了完整代码,也可以让模型自己生成,但为稳定起见使用预置)。
  4. 第4轮:模型调用execute_command,参数{ command: 'pnpm install', workingDirectory: 'react-todo-app' }——注意没有cd,完全正确。
  5. 第5轮:模型调用execute_command,参数{ command: 'pnpm run dev', workingDirectory: 'react-todo-app' }
  6. 第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.dirnamefs.mkdir递归创建目录,避免写入失败。

3. 工具返回值要信息丰富

execute_command的返回字符串里包含执行目录,方便模型理解上下文。日志要清晰,便于调试。

4. 系统提示词的重要性

不要吝啬示例,把常见错误场景写入提示词,能大幅提升模型调用工具的准确率。

5. 使用chalk美化输出,提升开发体验

简单的颜色区分,就能让长篇日志变得易于阅读,尤其在Agent循环迭代时,醒目的提示能让用户清楚当前进度。


八、总结:从“玩具”到“工具”的差距在哪?

我们实现的Agent虽然只有200行核心代码,但已经具备基本能力。与真实的Trae/Cursor相比,还缺少:

  • 流式响应:模型逐步输出思考过程。
  • 并行工具调用:高级Agent可以并发执行多个工具。
  • 上下文压缩:对话历史过长时需要摘要。
  • 安全沙箱:我们直接执行任何命令,存在风险。
  • 基于diff的智能编辑:我们整体覆盖文件,而真实产品支持精细补丁。

但这些都不影响核心原理的理解。你已经掌握了Agent的“灵魂”:

Agent = 大模型 × 工具(函数) × 循环决策

每一部分都可以独立优化,但它们之间的组合才是真正的魔力所在。

没有“万能”的Agent,只有“不断丰富工具集”的Agent。给它更多的手,它就能创造更多奇迹。

现在,你可以把三个文件跑起来,亲手观察AI一步步执行任务的过程。你会发现,当模型调用execute_command时,终端里会实时滚出npm的日志,就像你自己在操作一样——这,就是子进程 +inherit的魅力。

如果你在实践过程中遇到任何问题,欢迎留言交流。我们下期再见!

http://www.jsqmd.com/news/1156738/

相关文章:

  • Unity移动端性能优化:LoopScrollRect对象池与数据驱动原理实战
  • 如何快速配置黑苹果SMBIOS:面向新手的完整一键生成解决方案
  • 新手小白开抖店完整教程,市面使用率最高抖掌柜一站式搞定 - 抖掌柜
  • Unity开发避坑指南:从环境搭建到性能优化的全流程问题解决
  • 南京防水补漏正规公司评测 服务能力与收费标准全对比 - 徽顺虹
  • STM32 CAN 过滤器配置实战:32位掩码模式实现 2 类 ID 同时接收
  • 无训练局部导航:基于精确符号距离函数的实时避障框架
  • Unity集成AI视频生成:EasyAnimateV5实时特效方案与工程实践
  • 影刀RPA MQTT物联网自动化:传感器数据采集与设备监控
  • Unity与Android原生通信实战:第三方登录集成与安全数据传递
  • Unity与测绘数据集成:从UDB到平面图导出的完整技术方案
  • 锂电池组主动平衡方案设计与BQ25887应用
  • 2026年7月最新亨得利官方钟表服务中心|完整官方电话和网点地址权威信息公告 - 亨得利官方
  • Unity DOTS多人游戏47ms延迟突增:Netcode指令调度与Ghost同步优化实战
  • Unity城市交通系统插件:从架构到实战的完整指南
  • STM32L162ZE与MCP3551高精度ADC硬件设计与软件实现
  • Cursor官方食谱:AI原生开发的Vibe Coding与Agent工作流实战指南
  • Unity游戏国际化:基于TextMeshPro构建多语言Emoji动态切换系统
  • Unity描边效果优化:从屏幕后处理到几何体膨胀方案详解
  • 抖音无水印视频下载工具完全指南:从零开始批量保存高清内容
  • 基于OSGEarth的海洋流场三维动态演示工程(含可运行C++源码与详细构建指南)
  • 基于STM32单片机智能厨房安全控制天然气甲烷检测火焰火灾报警321(设计源文件+万字报告+讲解)(支持资料、图片参考_相关定制)_
  • YOWO 实时时空动作检测:3D-ResNext-101 + Darknet-19 双分支架构与 CFAM 模块解析
  • 软考中级系统集成 2026版:信息化5层次与6要素核心考点精析(附3年真题分布)
  • Windows Update Blocker v1.8 与 WUShowHide 对比:3大场景下的禁用策略选择
  • C++算法实战:组合计数核心方法与工程应用详解
  • TMC7300与PIC18F86J11实现高效有刷直流电机控制
  • GESP C++ 三级 2023年12月T2单位转换:3种代码实现对比与10倍效率优化
  • 孤能子视角:从全局强耦合到动态稀疏协同——大模型注意力机制演化的关系场动力学
  • Windows 11 + UE5.6 编译运行 Colosseum (AirSim 分支) 全流程与兼容性修复指南