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

3 阅读7分钟

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

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

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

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

生成关系图片.png

今天,我们将从零开始,用 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)

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 执行命令时,控制台将一片死寂,你不知道它到底卡住了还是在干活。

image.png


第二阶段:定义“技能树” —— 封装 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)

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)

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. 任务指令

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)。

image.png

效果图

image.png


总结与展望

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

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

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

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