手写 Cursor 核心原理:从 Node.js 进程到智能 Agent

引言:为什么我们需要 Agent?

在 ChatGPT 刚出来时,我们只能和它聊天。它虽然聪明,但它被关在浏览器的"黑盒"里,无法操作你的电脑。

AI Agent(智能体) 的出现打破了这个限制。它的公式是:

Agent = 大模型 (大脑) + 工具 (双手) + 规划 (逻辑循环)

今天,我们将从零开始,用 Node.js 和 LangChain 实现一个能够自动创建 React 项目、写代码、甚至运行服务器的"Mini-Cursor"。


第一阶段:打造"机械手" ------ Node.js 进程控制

在让 AI 接管电脑前,我们首先得确保 Node.js 有能力执行系统命令(如 ls, npm install, mkdir)。这是 Agent 的"肌肉记忆"。

1. 设计思路

我们需要一个能够执行 Shell 命令的函数。

  • 不能只用简单的 exec,因为我们需要实时看到像 npm install 这种耗时命令的进度。
  • 必须支持 流(Stream) 的继承,让子进程的输出直接打印在主进程控制台。

2. 核心代码 (node-exec.mjs)

JavaScript 复制代码
import { spawn } from 'node:child_process'

// 模拟命令:列出当前目录详细信息
const command = 'ls -la'
const [cmd, ...args] = command.split(' ')
const cwd = process.cwd()

console.log(`当前工作目录: ${cwd}`)

// 使用 spawn 而不是 exec,因为 spawn 适合长耗时任务,支持流式输出
const child = spawn(cmd, args, {
  cwd,              // 指定命令执行的目录
  stdio: 'inherit', // 关键:继承父进程的输入输出,让你可以看到命令的实时打印
  shell: true       // 允许在 shell 环境中运行
})

let errorMsg = ''

// 监听错误事件
child.on('error', error => {
  errorMsg = error.message
})

// 监听关闭事件
child.on('close', code => {
  if (code === 0) {
    console.log('命令执行成功,子进程退出')
    process.exit(0)
  } else {
    console.error(`错误:${errorMsg}`)
    process.exit(code || 1)
  }
})

3. 深度解析

  • spawn vs exec : 这是一个关键点。exec 会把所有输出存到缓冲区,命令运行完一次性返回,如果输出太大(比如安装巨量依赖)会爆内存。而 spawn 是流式的,数据随产随出,这对于 Agent 的交互体验至关重要
  • stdio: 'inherit' : 这行代码让子进程的控制台输出直接"借用"主程序的控制台。如果你不加这行,AI 执行命令时,控制台将一片死寂,你不知道它到底卡住了还是在干活。

第二阶段:定义"技能树" ------ 封装 LangChain Tools

有了执行命令的能力还不够,大模型(LLM)只懂文本,不懂函数调用。我们需要用 LangChain 的 tool 方法,把代码逻辑包装成 AI 能看懂的"技能卡片"。

1. 设计思路

我们要给 AI 提供四样核心工具:

  1. read_file: 读取文件内容,让 AI 知道项目里有什么。
  2. write_file: 写入文件,这是 AI 写代码的核心。
  3. execute_command: 基于第一阶段的逻辑,让 AI 能运行 Shell 命令。
  4. list_directory: 让 AI 知道目录结构,防止它"迷路"。

我们需要使用 zod 库来定义参数格式,这就像给 AI 的输入加了强类型约束。

2. 核心代码 (all_tools.mjs)

JavaScript 复制代码
import { tool } from '@langchain/core/tools'
import fs from 'node:fs/promises'
import path from 'node:path'
import { spawn } from 'node:child_process'
import { z } from 'zod'

// --- 工具 1: 读取文件 ---
const readFileTool = tool(
  async ({ filePath }) => {
    try {
      console.log(
        `[工具调用] read_file("${filePath}") 成功读取 ${content.length} 字节`
      )
      const content = await fs.readFile(filePath, 'utf-8')
      return `文件内容:\n${content}`
    } catch (error) {
      console.log(`工具调用 read_file("${filePath}") 失败: ${error.message}`)
      return `错误:${error.message}`
    }
  },
  {
    name: 'read_file',
    description: '读取制定路径的文件内容',
    schema: z.object({
      filePath: z.string().describe('文件路径')
    })
  }
)

// --- 工具 2: 写入文件 ---
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 (error) {
      return `写入文件失败:${error.message}`
    }
  },
  {
    name: 'write_file',
    description: '向指定路径写入文件内容,自动创建目录',
    schema: z.object({
      filePath: z.string().describe('文件路径'),
      content: z.string().describe('要写入的文件内容')
    })
  }
)

// --- 工具 3: 执行命令 (最复杂的核心工具) ---
const executeCommanTool = tool(
  async ({ command, workingDirectory }) => {
    // 默认在当前目录,或者切换到 AI 指定的目录
    const cwd = workingDirectory || process.cwd()
    
    // 必须包装成 Promise,因为 spawn 是基于事件的异步操作
    return new Promise((resolve, reject) => {
      const [cmd, ...args] = command.split(' ')
      const child = spawn(cmd, args, {
        cwd,
        stdio: 'inherit',
        shell: true
      })
      
      child.on('close', code => {
        if (code === 0) {
          // 这里的提示语非常重要,告诉 AI 下一步该怎么做
          const cwdInfo = workingDirectory
            ? `\n重要提示:命令在目录"${workingDirectory}"中执行成功。后续请继续使用 workingDirectory 参数,不要使用 cd 命令。`
            : ``
          resolve(`命令执行成功: ${command} ${cwdInfo}`)
        } else {
          resolve(`命令执行失败,退出码: ${code}`)
        }
      })
    })
  },
  {
    name: 'execute_command',
    description: '执行系统命令,支持指定工作目录',
    schema: z.object({
      command: z.string().describe('要执行的命令'),
      workingDirectory: z.string().optional().describe('指定工作目录')
    })
  }
)

// --- 工具 4: 列出文件目录工具 ---
const listDirectoryTool = tool(
  async ({ directoryPath }) => {
    try {
      // 读取目录内容
      const files = await fs.readdir(directoryPath)
      console.log(
        `[工具调用] list_directory("${directoryPath}") 成功列出 ${files.length} 个文件`
      )
      return `目录内容:\n ${files.map(f => `- ${f}`).join('\n')}`
    } catch (error) {
      console.log(
        `[工具调用] list_directory("${directoryPath}") 失败: ${error.message}`
      )
      return `列出目录失败:${error.message}`
    }
  },
  {
    name: 'list_directory',
    description: '列出指定目录下的所有文件和文件夹',
    schema: z.object({
      directoryPath: z.string().describe('目录路径')
    })
  }
)

export { readFileTool, writeFileTool, executeCommanTool, listDirectoryTool }

3. 深度解析

  • Promise 包装: 在 executeCommanTool 中,我们手动 new Promise。这是因为 LangChain 的工具调用需要 await 一个结果,而 spawn 是事件驱动的。我们需要在 close 事件触发时,手动 resolve 这个 Promise,告诉 AI 任务结束了。
  • 上下文提示 (Context Injection) : 注意我在 resolve 成功时返回了一段话:"如果需要在这个项目目录中继续执行命令..."。这是一种高级技巧,叫工具反馈增强。AI 有时候会忘记自己在哪个目录,通过工具的返回值不断提醒它,能极大提高成功率。

第三阶段:构建"大脑" ------ Agent 循环与逻辑规划

这是整个系统最精彩的部分。普通的脚本是线性的,而 Agent 是循环的。它需要不断地:思考 -> 调用工具 -> 观察结果 -> 再思考

1. 设计思路

  1. 绑定工具: 使用 model.bindTools 让大模型知道它有哪些能力。
  2. System Prompt (系统提示词) : 这是 Agent 的"人设"和"操作手册"。
  3. 循环迭代 (The Loop) : 使用 for 或 while 循环,直到 AI 觉得任务完成了(不再调用工具)或者达到最大重试次数。

2. 核心代码 (main.mjs)

JavaScript 复制代码
import { ChatOpenAI } from '@langchain/openai'
import { HumanMessage, SystemMessage, ToolMessage } from '@langchain/core/messages'
import { tools } from './all_tools.mjs' // 假设这里统一导出

// 1. 初始化模型
const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME || 'gpt-4',
  temperature: 0, // 设为0,让 AI 逻辑更严谨,不做发散创造
})

// 2. 绑定工具
const modelWithTools = model.bindTools(tools)

// 3. Agent 运行主函数
async function runAgerntWithTools(query) {
  const messages = [
    new SystemMessage(`
      你是一个项目管理助手,使用工具完成任务
      当前工作目录:${process.cwd()}
      
      工具:
      1. read_file: 读取制定路径的文件内容
      2. write_file: 向指定路径写入文件内容,自动创建目录
      3. execute_command: 在指定目录执行命令(支持workingDirectory参数)
      4. list_directory: 列出指定目录下的文件和子目录
      
      重要规则 - execute_command:
      - workingDirectory 参数会自动切换到指定目录
      - 当使用workingDirectory 参数时,不要在command中使用cd命令
      - 错误示例: { command: "cd react-todo-app && pnpm install", workingDirectory: "react-todo-app" }
        这是错误的!因为 workingDirectory 已经在 react-todo-app 目录了,再 cd react-todo-app 会找不到目录
        - 正确示例: { command: "pnpm install", workingDirectory: "react-todo-app" }
        这样就对了!workingDirectory 已经切换到 react-todo-app,直接执行命令即可

        回复要简洁,只说做了什么

      `),
    new HumanMessage(query)
  ]

  // 4. 思考-执行 循环
  for (let i = 0; i < 30; i++) {
    console.log(`\n⏳ 第 ${i + 1} 轮思考中...`)
    
    // AI 进行思考
    const response = await modelWithTools.invoke(messages)
    
    // 把 AI 的思考结果(包括它想调用的工具)存入历史记录
    messages.push(response)

    // 终止条件:如果 AI 没有调用任何工具,说明它认为任务完成了
    if (!response.tool_calls || response.tool_calls.length === 0) {
      console.log('✅ 任务完成')
      return response.content
    }

    // 执行 AI 想要调用的工具
    for (const toolCall of response.tool_calls) {
      const tool = tools.find(t => t.name === toolCall.name)
      if (tool) {
        console.log(`🔧 调用工具: ${tool.name}`)
        // 真正执行工具函数
        const result = await tool.invoke(toolCall.args)
        
        // 将工具的执行结果(Output)封装成 ToolMessage 返回给 AI
        messages.push(new ToolMessage({
          content: result,
          tool_call_id: toolCall.id
        }))
      }
    }
  }
}

3. 深度解析

  • Prompt Engineering (提示词工程) :

    你在代码中看到的 SystemMessage 非常关键。

    • "严禁使用 cd 命令":这是一个痛点。因为 spawn 产生的子进程是隔离的,在子进程里 cd 不会影响主进程,也不会影响下一次命令。必须通过 cwd 参数来控制。如果不写这条规则,AI 很容易在这个坑里打转。
  • State Management (状态管理) :

    messages 数组就是 Agent 的短期记忆。

    1. 用户说:"建个项目"。
    2. AI 回:"我要调用 execute_command"。(存入 messages)
    3. 工具跑完,返回:"执行成功"。(存入 messages)
    4. AI 看到"执行成功",接着回:"那我现在开始写代码..."。
      这个链条断了任何一环,Agent 就傻了。

第四阶段:实战演练 ------ 全自动生成 React 应用

最后,我们给 Agent 下达一个复杂的指令,检验它的成色。

1. 任务指令

JavaScript 复制代码
const case1 = `创建一个功能丰富的 React TodoList 应用:

1. 创建项目:echo -e "n\nn" | pnpm create vite react-todo-app --template react-ts
2. 修改 src/App.tsx,实现完整功能的 TodoList:
 - 添加、删除、编辑、标记完成
 - 分类筛选(全部/进行中/已完成)
 - 统计信息显示
 - localStorage 数据持久化
3. 添加复杂样式:
 - 渐变背景(蓝到紫)
 - 卡片阴影、圆角
 - 悬停效果
4. 添加动画:
 - 添加/删除时的过渡动画
 - 使用 CSS transitions
5. 列出目录确认

注意:使用 pnpm,功能要完整,样式要美观,要有动画效果

之后在 react-todo-app 项目中:
1. 使用 pnpm install 安装依赖
2. 使用 pnpm run dev 启动服务器`

try {
  await runAgerntWithTools(case1)
} catch (error) {
  console.log(chalk.red(`\n错误: ${error.message}`))
}

2. 预期执行流程

当你运行这段代码时,你会看到控制台疯狂刷屏,流程如下:

  1. AI 思考 : "我需要先创建项目。" -> 调用 execute_command (运行 pnpm create vite)。
  2. 工具反馈: "react-todo-app 创建成功。"
  3. AI 思考 : "现在我要写 App.tsx。" -> 调用 write_file (写入 React 代码)。
  4. AI 思考 : "我要写样式。" -> 调用 write_file (写入 index.css)。
  5. AI 思考 : "依赖还没装。" -> 调用 execute_command (参数 workingDirectory: 'react-todo-app', 命令 pnpm install)。
  6. AI 思考 : "启动服务。" -> 调用 execute_command (pnpm run dev)。

效果图


总结与展望

通过这几百行代码,我们其实已经触碰到了当前最前沿 AI 产品的核心逻辑。

如果你想继续深入,可以尝试以下方向:

  1. Memory (长期记忆) : 目前的 Agent 如果重启,记忆就没了。可以引入向量数据库,让它记住你上周写的代码逻辑。
  2. RAG (检索增强) : 让 Agent 能先读取你的本地开发文档,再写代码,准确率会暴增。
  3. Human-in-the-loop (人类介入) : 在执行高风险命令(如删除文件)前,暂停程序,询问用户"是否允许执行"。

恭喜你!你现在已经是一名 AI Agent 开发者了。继续探索吧,这个领域充满了无限可能!

相关推荐
掘金安东尼2 小时前
如何为 AI 编码代理配置 Next.js 项目
人工智能
aircrushin2 小时前
轻量化大模型架构演进
人工智能·架构
AlienZHOU2 小时前
为 AI Agent 编写高质量 Skill:Claude 官方指南
agent·ai编程·claude
文心快码BaiduComate3 小时前
百度云与光本位签署战略合作:用AI Agent 重构芯片研发流程
前端·人工智能·架构
风象南4 小时前
Claude Code这个隐藏技能,让我告别PPT焦虑
人工智能·后端
KaneLogger4 小时前
【翻译】打造 Agent Skills 的最佳实践
agent·ai编程·claude
QCY4 小时前
「完全理解」1 分钟实现自己的 Coding Agent
前端·agent·claude
Mintopia4 小时前
OpenClaw 对软件行业产生的影响
人工智能
mCell5 小时前
从零构建一个 Mini Claude Code:面向初学者的 Agent 开发实战指南
typescript·agent·claude