200 行代码复刻 Cursor 核心:手把手写一个 Coding Agent 🔧

25 阅读10分钟

手写 Mini-Cursor:200 行代码打造你的专属编程 Agent 🚀

前言

Cursor、Claude Code、Trae……这些 AI 编程工具背后的核心技术都一样——Coding Agent

你有没有好奇过:一个 AI 是怎么做到"自动创建项目、写代码、运行起来"的?它背后到底是什么机制?

这篇文章带你用 200 行代码手写一个 Mini-Cursor——它会自动用 Vite 创建 React 项目、编写完整的 TodoList 代码、安装依赖、启动开发服务器。全程不需要你写一行 React 代码!

📌 读完你会收获:

  • 完整 Coding Agent 的架构设计
  • 4 大核心工具(读/写/列目录/执行命令)的实现细节
  • Node.js 子进程 spawn 在 Agent 中的实战应用
  • Promise.all 并发优化的真实案例
  • 一个真正能跑起来的编程助手

一、先看看它能做什么

假设你对 Mini-Cursor 说:

"用 Vite 创建一个 React TodoList 项目,要有完整功能、美观样式、动画效果,然后把它跑起来。"

它会自己做完下面这些事:

Agent 的 Planning(由 LLM 自动推理):
┌─────────────────────────────────────────┐
│ 步骤1:调用 execute_command             │
│   → pnpm create vite react-todo-app     │
│                                         │
│ 步骤2:调用 write_file                  │
│   → 写入完整 TodoList 组件代码           │
│                                         │
│ 步骤3:调用 execute_command             │
│   → pnpm install(安装依赖)            │
│                                         │
│ 步骤4:调用 execute_command             │
│   → pnpm run dev(启动开发服务器)       │
│                                         │
│ 步骤5:调用 list_directory              │
│   → 确认项目文件结构正确                │
└─────────────────────────────────────────┘

全程自动,你只需要看着它干活。


二、架构设计:一个 Coding Agent 需要哪些"器官"?

2.1 核心公式

Coding Agent = LLM(大脑)
             + fs 工具(读写文件的手)
             + cli 工具(执行命令的手)
             + ReAct 循环(决策→执行→观察→决策)

2.2 工具矩阵

一个能编程的 Agent 至少需要这 4 个工具:

┌──────────────────┬────────────────────────────────┐
│      工具         │           能力                  │
├──────────────────┼────────────────────────────────┤
│  read_file       │  读取文件,理解已有代码           │
│  write_file      │  创建/覆写文件,生成代码          │
│  list_directory  │  列出目录,确认项目结构           │
│  execute_command │  执行 cli 命令,真正"动手"       │
└──────────────────┴────────────────────────────────┘

🎯 这四个工具正好对应一个人类程序员的基本操作:看代码、写代码、看目录、跑命令。Agent 就是让 LLM 掌握了这四个"动作"。


三、工具实现:给 LLM 装上"手脚"

3.1 文件读写工具

这三个工具比较直观,我们用 Node.js 的 fs/promises 模块:

// all-tools.mjs(节选)
import { tool } from '@langchain/core/tools';
import fs from 'node:fs/promises';
import path from 'node:path';
import { z } from 'zod';

// 📖 读文件
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('要读取的文件路径')
        })
    }
);

// ✍️ 写文件
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().describe('文件路径'),
            content: z.string().describe('要写入的文件内容')
        })
    }
);

🧠 知识点:path.dirname + fs.mkdir({ recursive: true })

写文件时,如果目录不存在会报错。用 path.dirname(filePath) 提取目录路径,再用 fs.mkdir(dir, { recursive: true }) 递归创建,无论嵌套多深都能自动建好。这是 Agent 工具"鲁棒性"的关键——LLM 可能随便给一个路径,工具要能兜底。

3.2 列出目录工具

// 📂 列出目录
const listDirectoryTool = tool(
    async ({ directoryPath }) => {
        const files = await fs.readdir(directoryPath);
        console.log(`[工具调用] list_directory(${directoryPath}) 成功列出 ${files.length} 项`);
        return `目录内容:\n ${files.map(file => file).join('\n')}`;
    },
    {
        name: 'list_directory',
        description: '列出指定目录下的所有文件和文件夹',
        schema: z.object({
            directoryPath: z.string().describe('目录路径')
        })
    }
);

3.3 执行命令工具——Agent 的"终极武器"

这是整个 Coding Agent 最有技术含量的工具。它需要解决一个问题:

Node.js 主进程(Agent)如何安全地执行外部命令并获取结果?

答案:子进程(child_process)

┌─────────────────┐     spawn()      ┌─────────────────┐
│   Node 主进程    │ ──────────────→  │    子进程        │
│   (Agent)       │                  │  (执行 cli 命令)  │
│                 │ ←── IPC 通信 ─── │                 │
│  单线程,不能    │                  │  隔离执行,       │
│  被阻塞          │                  │  不影响主进程      │
└─────────────────┘                  └─────────────────┘

为什么要用子进程?

Node.js 是单线程的。如果直接在主进程跑 npm create vite(一个可能耗时几十秒的命令),整个 Agent 就卡住了。 子进程把命令执行"分离"出去,主进程可以继续做其他事,等子进程完成后通过 IPC(进程间通信)通知主进程。

// 🔧 执行命令工具(带实时输出)
import { spawn } from 'node:child_process';

const executeCommandTool = tool(
    async ({ command, workingDirectory }) => {
        const cwd = workingDirectory || process.cwd();
        console.log(`[工具调用] execute_command(${command}) 工作目录:${cwd}`);

        return new Promise((resolve, reject) => {
            // 🔑 关键:拆分命令和参数
            // "pnpm create vite react-todo-app --template react-ts"
            // → cmd = "pnpm", args = ["create", "vite", "react-todo-app", "--template", "react-ts"]
            const [cmd, ...args] = command.split(' ');

            const child = spawn(cmd, args, {
                cwd,                    // 指定工作目录
                stdio: 'inherit',       // 继承父进程的输入输出(实时看到命令输出)
                shell: true,            // 支持 shell 语法
            });

            let errorMsg = '';

            child.on('error', (err) => {
                errorMsg = err.message;
            });

            child.on('close', (code) => {
                if (code === 0) {
                    // ✅ 退出码 0 = 成功
                    resolve(`命令执行成功:${command}`);
                } else {
                    // ❌ 非 0 退出码 = 失败
                    resolve(`命令执行失败,退出码:${code}\n错误:${errorMsg}`);
                }
            });
        });
    },
    {
        name: 'execute_command',
        description: '执行系统命令,支持指定工作目录,实时显示输出',
        schema: z.object({
            command: z.string().describe('要执行的命令'),
            workingDirectory: z.string().describe('工作目录(推荐指定)')
        })
    }
);

🧠 知识点:spawn 的四个关键参数

参数作用不设置的后果
cwd指定子进程的工作目录命令在当前目录执行,可能找不到项目文件夹
stdio: 'inherit'子进程的输出直接显示在控制台看不到 npm install 的进度,Agent 像"黑盒"
shell: true启用 shell 模式解析命令无法使用管道、重定向等 shell 语法
Promise 包装把事件驱动的子进程变成 async/await时序混乱,不知道命令什么时候执行完

🧠 知识点:退出码(Exit Code)

  • 0 = 命令成功执行
  • 0 = 出错了(如 1 一般错误,127 命令未找到)
  • Agent 不需要"猜"命令是否成功——看退出码就行

3.4 一个重要的细节:workingDirectory 的正确用法

在 System Prompt 里我们特别强调了一个容易踩坑的点:

// ❌ 错误用法:workingDirectory 已经切换到 react-todo-app 了,命令里又 cd 一次
{
    command: "cd react-todo-app && pnpm install",
    workingDirectory: "react-todo-app"
}
// 实际执行路径:react-todo-app/react-todo-app/ —— 不存在!

// ✅ 正确用法:workingDirectory 负责切换目录,命令只写要做的事
{
    command: "pnpm install",
    workingDirectory: "react-todo-app"
}
// 实际执行路径:react-todo-app/ —— 完美!

🎯 这个细节如果不写明在 System Prompt 里,LLM 很容易犯"双重 cd"的错误——因为人类也会这样写。这也是 System Prompt 工程的一部分。


四、ReAct 循环:Agent 的大脑运转机制

有了工具,下一步是让 LLM 知道什么时候用哪个工具、用完之后怎么继续。这就是 ReAct 循环。

         ┌──────────────────────────────────────┐
         │                                      │
         ▼                                      │
   ┌───────────┐   有 tool_calls    ┌─────────┐  │
   │  LLM 推理  │ ───────────────→  │ 执行工具  │  │
   │ (Reason)  │                   │  (Act)   │  │
   └───────────┘                   └─────────┘  │
         ▲                              │       │
         │                              ▼       │
         │   无 tool_calls         ┌─────────┐  │
         └── 直接输出结果 ←────────│ 观察结果  │──┘
                                   │(Observe) │
                                   └─────────┘

4.1 完整代码实现

// mini-cursor.mjs(核心部分)
async function runAgentWithTools(query, maxIterations = 30) {
    const messages = [
        new SystemMessage(`你是一个项目管理助手,使用工具完成任务。
        当前工作目录: ${process.cwd()}
        工具:
        1. read_file: 读取文件
        2. write_file: 写入文件
        3. execute_command: 执行命令(支持 workingDirectory 参数)
        4. list_directory: 列出目录
        重要规则 - execute_command:
            - workingDirectory 参数会自动切换到指定目录
            - 绝对不要在 command 中使用 cd
        `),
        new HumanMessage(query)
    ];

    // 🔁 ReAct 循环
    for (let i = 0; i < maxIterations; i++) {
        console.log(`正在等待第 ${i} 次 AI 思考...`);

        // Reason:LLM 推理,决定下一步
        const response = await modelWithTools.invoke(messages);
        messages.push(response);

        // 没有工具调用 → 任务完成,返回结果
        if (!response.tool_calls || response.tool_calls.length === 0) {
            console.log(`\n AI 最终回复:\n ${response.content}\n`);
            return response.content;
        }

        // Act:执行 LLM 请求的所有工具
        for (const toolCall of response.tool_calls) {
            const foundTool = tools.find(t => t.name === toolCall.name);
            if (foundTool) {
                // Observe:工具执行结果
                const toolResult = await foundTool.invoke(toolCall.args);

                // 把结果以 ToolMessage 格式加入对话
                messages.push(new ToolMessage({
                    content: toolResult,
                    tool_call_id: toolCall.id  // ← id 关联!
                }));
            }
        }
    }

    return messages[messages.length - 1].content;
}

4.2 为什么 ToolMessage 必须带 tool_call_id

想象这样一个场景:LLM 一次请求调用了 3 个工具,返回了 3 个结果。如果没有 tool_call_id,LLM 就不知道"结果A 对应 工具1"还是"结果A 对应 工具3"。

              tool_call_id = "call_abc"
read_file ─────────────────────────────→ ToolMessage("文件内容...", id="call_abc")
              tool_call_id = "call_def"
write_file ────────────────────────────→ ToolMessage("写入成功", id="call_def")
              tool_call_id = "call_ghi"
exec_cmd ──────────────────────────────→ ToolMessage("命令成功", id="call_ghi")

🎯 tool_call_id 就是工具调用和结果之间的"快递单号"——LLM 靠它来把结果和请求对上号。


五、并发执行:让 Agent 更快

5.1 来自实际项目的教训

tool.mjs 中的这段代码:

// ⚡ 并发执行所有工具调用
const toolResults = await Promise.all(
    response.tool_calls.map(async (toolCall) => {
        const tool = tools.find(t => t.name === toolCall.name);
        if (!tool) {
            return `错误:工具 ${toolCall.name} 不存在`;
        }
        try {
            const result = await tool.invoke(toolCall.args);
            return result;
        } catch (error) {
            return `错误:${error.message}`;
        }
    })
);

为什么用 Promise.all 而不是 for...of + await

假设 LLM 一次返回了 3 个工具调用(读 A 文件、读 B 文件、列出 C 目录),它们之间互不依赖:

串行(for + await):                并行(Promise.all):
┌─────────┐                          ┌─────────┐
│ 读 A 文件 │  500ms                  │ 读 A 文件 │
└─────────┘                          │ 读 B 文件 │  ← 同时起飞!
┌─────────┐                          │ 列 C 目录 │
│ 读 B 文件 │  500ms                  └─────────┘
└─────────┘                          总耗时 ≈ 500ms(取最慢的)
┌─────────┐                               vs
│ 列 C 目录 │  300ms
└─────────┘
总耗时 = 1300ms(累加)

5.2 Promise 的核心概念回顾

项目中的 1.html 用了一个极简 demo 来解释 Promise:

function getWeather() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve({ temp: 38, conditions: 'Sunny with Clouds' })
        }, 2000)  // 2秒后返回
    })
}

function getTweets() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(['I like cake', 'BBQ is good too!'])
        }, 500)   // 0.5秒后返回
    })
}

// ❌ 串行:2s + 0.5s = 2.5s
const weather = await getWeather();
const tweets = await getTweets();

// ✅ 并行:max(2s, 0.5s) = 2s
const [weather, tweets] = await Promise.all([getWeather(), getTweets()]);

Promise 三态转换(不可逆):

         ┌── resolve() → fulfilled(成功)
Pending ─┤
         └── reject()  → rejected(失败)

一旦从 Pending 变成 fulfilled/rejected,状态就锁定了,不能再变。

🎯 Agent 并发执行最佳实践:当 LLM 返回的多个 tool_calls 之间没有依赖关系时(读两个文件、查两个 API、列两个目录),用 Promise.all 并行执行,性能提升显著。当有依赖时(先创建文件再读它),串行执行。


六、完整任务拆解:看 Mini-Cursor 一次完整的执行

6.1 任务描述

我们给 Mini-Cursor 的任务是:

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

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

6.2 LLM 的实际执行过程

🔄 第 0 次 AI 思考...
   LLM:我需要先创建项目
   → tool_calls: [{ name: "execute_command", args: { command: "pnpm create vite react-todo-app --template react-ts" } }]
   → 执行:项目创建成功 ✅

🔄 第 1 次 AI 思考...
   LLM:项目创建好了,现在写入 TodoList 代码
   → tool_calls: [{ name: "write_file", args: { filePath: "react-todo-app/src/App.tsx", content: "..." } }]
   → 执行:文件写入成功 ✅

🔄 第 2 次 AI 思考...
   LLM:还需要写 CSS
   → tool_calls: [{ name: "write_file", args: { filePath: "react-todo-app/src/App.css", content: "..." } }]
   → 执行:CSS 写入成功 ✅

🔄 第 3 次 AI 思考...
   LLM:列出目录确认文件都在
   → tool_calls: [{ name: "list_directory", args: { directoryPath: "react-todo-app/src" } }]
   → 执行:确认文件结构 ✅

🔄 第 4 次 AI 思考...
   LLM:安装依赖
   → tool_calls: [{ name: "execute_command", args: { command: "pnpm install", workingDirectory: "react-todo-app" } }]
   → 执行:依赖安装完成 ✅

🔄 第 5 次 AI 思考...
   LLM:启动开发服务器
   → tool_calls: [{ name: "execute_command", args: { command: "pnpm run dev", workingDirectory: "react-todo-app" } }]
   → 执行:开发服务器启动 ✅

🔄 第 6 次 AI 思考...
   LLM:所有任务完成!
   → tool_calls: []  ← 没有工具调用了
   → 输出:"TodoList 项目已创建完成并启动..." 🎉

七、最终效果:Agent 生成的 TodoList

Mini-Cursor 生成的 App.tsx 包含了:

  • 完整的状态管理useState + useEffect + useCallback
  • localStorage 持久化:刷新不丢数据
  • 三态筛选:全部 / 进行中 / 已完成
  • 统计面板:总计、进行中、已完成数量
  • 动画效果:添加/删除时的 CSS 过渡动画
  • TypeScript:完整的类型定义

这说明 LLM 生成的代码质量已经相当高了——不仅能跑,而且代码结构清晰、类型完备。


八、体系化知识总结

8.1 四个层次的能力

┌──────────────────────────────────────────────────────┐
│ 第一层:LLM 基础调用                                  │
│   model.invoke("问题") → 回答                         │
│   只能聊天,不能干活                                   │
├──────────────────────────────────────────────────────┤
│ 第二层:工具定义 + bindTools                          │
│   tool(async fn + zod schema + description)          │
│   model.bindTools(tools)                             │
│   LLM 知道"我能用什么",但只会一轮                     │
├──────────────────────────────────────────────────────┤
│ 第三层:ReAct 循环                                    │
│   invoke → tool_calls → execute → ToolMessage →      │
│   invoke → ... → 无 tool_calls → 返回结果             │
│   LLM 能在多轮中持续决策直到任务完成                    │
├──────────────────────────────────────────────────────┤
│ 第四层:完整 Coding Agent                             │
│   LLM + 4 个工具 + ReAct + 子进程 + 并发优化           │
│   能独立完成:创建项目 → 写代码 → 安装依赖 → 启动服务   │
└──────────────────────────────────────────────────────┘

8.2 知识点速查表

概念一句话解释在代码中的位置
tool()LangChain 的工具定义函数,分处理函数 + 说明书两部分all-tools.mjs
description写给 LLM 看的工具使用说明,决定 LLM 何时调用每个 tool 的第二个参数
schema (Zod)参数校验规则,LLM 必须按要求提供参数z.object({...})
bindTools()把工具注册给 LLM,让 LLM "知道"自己能干什么model.bindTools(tools)
tool_callsLLM 返回的工具调用请求列表response.tool_calls
tool_call_id工具调用的唯一 ID,关联请求和结果ToolMessage({tool_call_id})
spawn创建子进程执行命令,不阻塞主进程execute_command
stdio: 'inherit'子进程输出直接显示在控制台spawn 的第三个参数
退出码0=成功,非 0=失败child.on('close', code)
Promise.all并行执行多个异步任务工具并发执行
ReAct 循环Reason → Act → Observe 的不断循环runAgentWithTools 的 for 循环
maxIterations防止 Agent 死循环的安全阀for (let i = 0; i < maxIterations; i++)
temperature: 0LLM 输出更稳定、更可预测new ChatOpenAI({ temperature: 0 })

8.3 Agent 开发的黄金法则

  1. 工具描述就是 LLM 的"使用说明书"——写得越具体,LLM 用工具越准
  2. System Prompt 是 Agent 的行为准则——规则、边界、坑点都要写清楚
  3. 始终有 maxIterations 兜底——防止 Agent 陷入死循环
  4. 子进程 ≠ 主进程——命令行任务一定要用 spawn 隔离执行
  5. 能并行就并行——多个不相关的工具调用用 Promise.all
  6. tool_call_id 是"快递单号"——别漏了,否则 LLM 不知道结果对应哪个请求

九、延伸思考:从 Mini-Cursor 到真正的 Cursor

我们手写的 Mini-Cursor 只有 200 行代码,但它已经具备了 Cursor/Claude Code 的核心骨架。真正的 Coding Agent 在此基础上加了什么?

Mini-Cursor200行)        真正的 Coding Agent
─────────────────────      ─────────────────────
4 个工具                     几十个工具(git、LSP、浏览器...)
单 Agent                     多 Agent 协作
无记忆                       Memory 模块(长期记忆)
只有对话历史                  向量检索(RAG)、上下文窗口管理
无权限控制                   沙箱隔离、权限分级
ReAct 基础循环               复杂工作流编排(plan → execute → verify)

🎯 但核心思想完全一样:LLM + Tool + 循环 = Agent。理解了 Mini-Cursor,你就理解了所有 Coding Agent 的本质。


如果这篇文章帮你搞懂了 Coding Agent 的实现原理,欢迎点赞、收藏、关注!把 mini-cursor.mjs 跑起来,你会更直观地感受到 Agent 的魔力 👏