本文将带你从零手写一个 AI 编程 Agent,理解 Trae、Cursor 等 AI 编程工具的底层实现原理。全程使用 Node.js + LangChain,最终实现一个能用自然语言驱动、自动创建并运行 React 项目的 Mini-Cursor。


一、前言:AI 编程 Agent 是什么

如果你用过 TraeCursor,一定对这样的体验不陌生:

“用 Vite 创建一个 React 的 TodoList 项目,并把它运行起来。”

Cursor 到底做了哪些事情?

当你在 Cursor 中输入:

"帮我创建一个 React TodoList 项目"

它其实不是一句话回答你。
而是在后台连续完成了下面这些事情:
① 分析你的需求
② 创建 React 项目
③ 修改 App.tsx
④ 写 CSS
⑤ 安装依赖
⑥ 启动开发服务器
⑦ 最后告诉你:"已经完成"

整个过程几乎和一位真正的程序员没有区别。

这背后是什么原理? 一句话概括:

大模型(LLM)作为"大脑"负责思考决策,工具(Tools)作为"手脚"负责执行操作,ReAct 循环将它们串联起来。

本文将手把手带你实现这个过程。读完你会理解:

  • AI 编程 Agent 的核心架构
  • Node.js 多进程模型在 Agent 中的应用
  • LangChain Tool 定义ReAct 循环
  • 实战踩坑与避坑指南

二、核心原理:Agent 如何"操作"你的电脑

2.1 ReAct 模式:思考 → 行动 → 观察

ReAct(Reasoning + Acting)是 AI Agent 的核心工作模式,它是一个循环:

用户指令 → [LLM 思考] → 调用工具 → [工具执行] 
→ 返回结果 → [LLM 再思考] → ... → 最终回复

用伪代码表示:

for (let i = 0; i < maxIterations; i++) {
    const response = await model.invoke(messages);  // ① LLM 思考:要不要调工具?
    if (!response.tool_calls) {
        return response.content;                     // ② 没有工具调用 = 任务完成
    }
    for (const toolCall of response.tool_calls) {
        const result = await executeTool(toolCall);  // ③ 执行工具
        messages.push(result);                       // ④ 结果反馈给 LLM
    }
}

面试重点:ReAct 的本质是让 LLM 在"思考"和"行动"之间交替,每一轮 LLM 都能看到上一轮工具的执行结果,从而调整下一步策略。

2.2 Tool Calling:大模型的"手"

LLM 本身只能输出文本,无法操作文件系统或执行命令。Tool Calling 机制给它装上了"手":

┌──────────────┐     tool_call 请求       ┌──────────────┐
│   LLM 大脑   │ ◄──────────────────────► │  Tool 工具层  │
│  (决策推理)   │     tool_result 返回     │  (读写执行)   │
└──────────────┘                          └──────┬───────┘
                                                  │
                                    ┌─────────────┼─────────────┐
                                    │             │             │
                                 读文件        写文件        执行命令

每个 Tool 包含三个要素:

要素 说明 示例
name 工具唯一标识 read_file
description 告诉 LLM 这个工具做什么 “读取文件内容”
schema 参数类型约束(Zod) { filePath: string }

三、实战:手写 Mini-Cursor

3.1 技术栈与环境

pnpm init
pnpm add @langchain/core @langchain/openai zod chalk dotenv
# .env
DEEPSEEK_API_KEY=sk-your-key-here

核心依赖说明

包名 作用
@langchain/core 提供 tool()、消息类型、Tool Calling 基础设施
@langchain/openai OpenAI 兼容的 LLM 客户端(DeepSeek 兼容此接口)
zod 定义工具参数的类型约束 Schema
chalk 终端彩色输出,方便调试观察 Agent 行为

3.2 编写 CLI 命令执行工具

这是整个 Agent 最核心的工具——让 LLM 拥有执行 Shell 命令的能力。

3.2.1 先理解:Node.js 多进程架构

在动手写代码前,先理解一个关键概念:

┌────────────────────────────────────────┐
│            Node 主进程 (Agent)          │
│                                        │
│  ┌──────────┐  ┌──────────┐            │
│  │ 子进程 #1 │  │ 子进程 #2 │  ...      │
│  │ ls -la   │  │ pnpm ... │            │
│  └────┬─────┘  └────┬─────┘            │
│       │              │                 │
│       └──────┬───────┘                 │
│              │                         │
│     IPC (进程间通信 Inter-Process       │
│          Communication)                │
│     子进程结束 → 通知主进程              │
└────────────────────────────────────────┘

为什么要用子进程? JavaScript 是单线程的,如果在主进程里执行 pnpm install,整个 Agent 会被阻塞住。把命令执行交给独立的子进程,主进程继续响应其他任务,子进程结束后通过 IPC 通知主进程。

3.2.2 spawn vs exec 怎么选

child_process 模块提供两种方式:

方法 特点 适用场景
spawn 流式输出,数据分块返回 长时间运行、需要实时输出的命令(pnpm dev
exec 缓冲输出,执行完一次性返回 短命令、只关心最终结果(ls -la

编程 Agent 需要实时看到命令输出(用户可能等不及),所以选 spawn


完整代码

import { spawn } from 'node:child_process';

// 执行一个简单的 shell 命令
const command = 'ls -la';
const [cmd, ...args] = command.split(' ');
const cwd = process.cwd();

// 开启子进程
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) {
        console.log('命令执行成功');
    } else {
        console.error(`命令失败,退出码: ${code}, 错误: ${errorMsg}`);
    }
});

⚠️ 踩坑点:如果 shell: true,必须把整个命令作为一个完整字符串传给 spawn 的第一个参数,而不是拆分成 cmd + args。否则管道 |、重定向等 shell 特性会失效,Node.js 还会抛出 DEP0190 弃用警告。正确写法见下一节。

3.3 编写文件 I/O 工具(LangChain Tool 规范)

编程 Agent 除了执行命令,还需要读写文件。使用 LangChain 的 tool() 函数来定义标准化工具。

3.3.1 读文件工具

import { tool } from '@langchain/core/tools';
import fs from 'node:fs/promises';
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('要读取的文件路径'),
        }),
    }
);

代码解析

  • 第一个参数:功能函数,接收 LLM 传来的参数,执行实际逻辑
  • 第二个参数:元信息对象name + description 给 LLM 看,schema 用 Zod 做类型约束
  • Schema 的 .describe() 对 LLM 理解参数含义非常关键,不要省略

3.3.2 写文件工具

import path from 'node:path';

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');
            console.log(`[工具调用] write_file(${filePath}) 成功写入 ${content.length} 字节`);
            return `成功写入 ${filePath}`;
        } catch (err) {
            return `写入文件失败: ${err.message}`;
        }
    },
    {
        name: 'write_file',
        description: '向指定路径写入文件内容,自动创建不存在的目录。',
        schema: z.object({
            filePath: z.string().describe('文件路径'),
            content: z.string().describe('要写入的文件内容'),
        }),
    }
);

重点细节fs.mkdir(dir, { recursive: true }) 等价于 mkdir -p,自动递归创建 a/b/c/ 这样的多级目录。不写 recursive: true 的话,父目录不存在会直接报错。

3.3.3 列出目录工具

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

3.3.4 命令执行工具(LangChain 标准封装)

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

        return new Promise((resolve) => {
            // ⚠️ shell: true 时,整个命令作为第一个参数传入
            const child = spawn(command, [], {
                cwd,
                stdio: 'inherit',
                shell: true,
            });

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

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

⚠️ 容易犯的错:Schema 里的参数名(如 directoryPath)必须和功能函数的解构参数名完全一致,否则 LangChain 传参时会对不上,导致 undefined

3.4 组装 Agent:ReAct 循环

有了工具,接下来把 LLM 和工具串联起来:

import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { HumanMessage, SystemMessage, ToolMessage } from '@langchain/core/messages';
import chalk from 'chalk';
import { executeCommandTool, readFileTool, writeFileTool, listDirectoryTool } from './all-tools.mjs';

// ① 初始化 LLM
const model = new ChatOpenAI({
    modelName: 'deepseek-v4-flash',
    apiKey: process.env.DEEPSEEK_API_KEY,
    configuration: {
        baseURL: 'https://api.deepseek.com/v1',
    },
});

// ② 注册工具 + 绑定到模型
const tools = [executeCommandTool, readFileTool, writeFileTool, listDirectoryTool];
const modelWithTools = model.bindTools(tools);

// ③ 定义任务
const task = `
创建一个功能丰富的 React TodoList 应用:
1. 使用 pnpm create vite react-todo-app --template react-ts 创建项目
2. 修改 src/App.tsx,实现完整 TodoList(添加/删除/标记完成/筛选/持久化)
3. 添加样式和动画
4. pnpm install 安装依赖
5. pnpm run dev 启动服务
`;

// ④ ReAct 循环
async function runAgent(query, maxIterations = 30) {
    const messages = [
        new SystemMessage(`你是一个编程助手。当前工作目录:${process.cwd()}
工具: read_file / write_file / list_directory / execute_command
规则: directoryPath 参数直接切换目录,不要用 cd 命令。回复简洁。`),
        new HumanMessage(query),
    ];

    for (let i = 0; i < maxIterations; i++) {
        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(`[Agent] 任务完成: ${response.content}`));
            return response.content;
        }

        // 逐个执行工具调用
        for (const toolCall of response.tool_calls) {
            const foundTool = tools.find(t => t.name === toolCall.name);
            if (foundTool) {
                const result = await foundTool.invoke(toolCall.args);
                messages.push(new ToolMessage({
                    content: result,
                    tool_call_id: toolCall.id,
                }));
            }
        }
    }
    return messages[messages.length - 1].content;
}

// ⑤ 启动
runAgent(task).catch(err => console.error(`错误: ${err.message}`));

四、完整的 Agent 工作流程图

用户输入任务

构建初始消息
SystemMessage + HumanMessage

ReAct 循环

Step1: LLM 推理
modelWithTools.invoke

response.tool_calls?

Step2: 逐个执行工具
按 name 匹配 → invoke → 构造 ToolMessage

Step3: 结果反馈
ToolMessage 带 tool_call_id 推入 messages

🏁 任务完成
返回最终文本

典型执行轨迹(6 轮)

轮次 LLM 决策 工具 结果
1 创建项目脚手架 execute_command
2 写入 App.tsx 组件 write_file
3 写入 App.css 样式 write_file
4 安装依赖 execute_command
5 启动开发服务器 execute_command
6 确认任务完成 (无) 🏁

五、重点知识点深度解析

5.1 child_process 多进程架构

┌─────────────────────────────────────┐
│          Node 主进程 (Agent)        │
│          JS 单线程,事件驱动         │
│                                    │
│   ┌──────┐  ┌──────┐  ┌──────┐     │
│   │ 子进程│ │ 子进程│  │ 子进程│     │
│   │ pnpm │  │  ls  │  │ vite │     │
│   └──┬───┘  └──┬───┘  └──┬───┘     │
│      │         │         │         │
│      └─────────┼─────────┘         │
│                │                    │
│      子进程结束 → close 事件         │
│      主进程收到通知,继续 ReAct       │
└─────────────────────────────────────┘

三个关键参数

参数 含义
cwd 目录路径 子进程的工作目录,等价于先 cd 再执行
stdio: 'inherit' 子进程的输出直接打印到当前终端,用户能看到实时输出
shell: true 启用 shell 环境,管道 | 和重定向 > 才能正常工作

重点child_process 的四种创建方式——spawn(流式)、exec(缓冲)、fork(Node 专用,自带 IPC 通道)、execFile(直接执行文件,不启动 shell)。

5.2 spawn 配合 shell: true 的正确写法

❌ 错误写法(触发 DEP0190 警告、管道失效):

const [cmd, ...args] = command.split(' ');
spawn(cmd, args, { shell: true });

✅ 正确写法

spawn(command, [], { shell: true });

原因shell: true 时,Node.js 将命令交给系统 shell(Windows 上为 cmd.exe,Linux/Mac 上为 /bin/sh)解析执行。Shell 自己负责解析管道、引号、变量等。把命令拆成数组反而干扰了 Shell 的解析逻辑

5.3 LangChain tool() 的 Schema 约束机制

schema: z.object({
    filePath: z.string().describe('要读取的文件路径'),
})

这段代码做了三件事:

  1. 类型校验:Zod 确保 LLM 传入的 filePath 确实是字符串
  2. 语义提示.describe() 的内容会编入发送给 LLM 的 Tool Definition,帮助 LLM 理解如何正确传参
  3. 防御性编程:即使 LLM 传了错误类型,Zod 会抛出明确的校验错误,不会让错误数据进入业务逻辑

5.4 bindTools 底层做了什么

                 用户
                  │
                  ▼
          ChatOpenAI(大脑)
                  │
      bindTools() 给它装工具
                  │
     ┌────────────┼────────────┐
     ▼            ▼            ▼
read_file   write_file   execute_command
     │            │            │
     ▼            ▼            ▼
 fs.readFile  fs.writeFile   spawn()

model.bindTools(tools) 做了三件事,把 LangChain Tool 转成 LLM 能理解的 Function Calling 格式:

① Zod Schema → JSON Schemaz.string().describe('文件路径') 编译为 { "type": "string", "description": "文件路径" },放入 parameters 字段。

② 拼接 Function Definition:将 name + description + parameters 包装为标准结构 { type: "function", function: {...} }

③ 注入请求体:每次 invoke() 时自动附带 tools: [...] 数组。这个转换只执行一次(预编译),后续请求直接复用缓存结果。

三个关键细节:

  • bindTools 返回新对象,原 Model 不变,两者可同时使用(纯对话 / 带工具)。
  • LLM 看不到你的实现代码,它只看 name + description + schema 这份"API 说明书"来做决策。
  • 换模型时只需改 Model 类,Tools 定义完全复用——LangChain 内部适配了不同模型的格式差异。

一句话bindTools = Zod 编译 + Function Calling 格式化 + 请求注入。


5.5 ToolMessage 中 tool_call_id 为什么必须存在

一句话答案:LLM 可能一轮同时调用多个工具,tool_call_id 是它把"答案"和"问题"对应起来的唯一依据。

举例:LLM 同时读取 App.tsx(id: call_001)和 main.tsx(id: call_002),返回两个结果。没有 tool_call_id,LLM 不知道哪个内容属于哪个文件,后续修改必然出错。有 tool_call_id,它能精确推理:“call_001 的结果是 App.tsx,应该改它;call_002 的结果是 main.tsx,暂时不动。”

协议约束:这不是 LangChain 的规定,而是 OpenAI Function Calling 协议的硬性要求——每个 ToolMessage 的 tool_call_id 必须匹配此前 AIMessage 中的 tool_calls[].id,否则 API 返回 400 错误。

一句话tool_call_id 是 LLM 推理链中的因果锚点,没有它并行工具调用的结果就是一团乱麻。

5.6 LangChain 帮我们做了什么

Agent 核心逻辑其实就是一个 for 循环 + LLM 调用 + 工具分发,那 LangChain 到底省了什么?

环节 纯手写 LangChain
API 调用 手写 fetch + 拼接请求体 + 解析 SSE model.invoke()
消息管理 手写 {role, content} 易拼错 SystemMessage 等语义化类
Tool 定义 手写 JSON Schema tool(fn, {name, desc, schema})
Schema 转换 手动 Zod→JSON Schema bindTools 自动完成
Tool 分发 手动 switch-case + try-catch tools.find().invoke()
模型切换 适配不同 API 格式 换 Model 类即可

具体来说就是 5 件事:消息类型封装、Schema 自动转换、工具调用分发(含 Zod 校验 + 异常捕获)、多模型格式适配、对话历史自动管理。

一句话:LangChain 的价值不在算法创新,而在省胶水代码——让你专注 Agent 业务逻辑,而不是跟 JSON 格式和 HTTP 请求较劲。


六、全文总结

本文从零实现了一个 AI 编程 Agent(Mini-Cursor),核心架构可以归纳为三层:

层级 组件 职责
决策层 LLM(DeepSeek) 理解用户意图,规划步骤,决定调用哪个工具
工具层 4 个 LangChain Tool 读写文件、列出目录、执行命令
调度层 ReAct 循环 串联 LLM 和工具,循环直到任务完成

整体数据流

用户任务 → System Prompt → LLM 决策 → Tool Call → 子进程/文件系统
                                                    │
              ┌─────────────────────────────────────┘
              ▼
         执行结果 → LLM 再决策 → ... → 任务完成

核心知识点复盘

序号 知识点 一句话总结
1 ReAct 模式 LLM 思考 → 调用工具 → 观察结果 → 再思考,循环迭代
2 Tool Calling 通过 name + description + schema 让 LLM 学会"使用工具"
3 bindTools LangChain 将 Tool 定义转为 OpenAI 兼容的 Function Calling 格式
4 child_process Node 多进程方案,子进程执行耗时命令,主进程保持响应
5 spawn vs exec spawn 流式输出(适合长命令),exec 缓冲输出(适合短命令)
6 shell: true 传给 shell 解析,命令整体传入,不要拆分
7 stdio: ‘inherit’ 子进程输出直接显示在父进程终端,用户可看到实时进度
8 Zod Schema 定义参数类型 + 给 LLM 提供语义提示,双重保障

常见问题 / 避坑指南

❌ 坑 1:Schema 参数名与函数参数名不一致

现象:LLM 调用了工具但函数收到的参数为 undefined

// ❌ Schema 叫 directory,函数用的 directoryPath —— 对不上!
schema: z.object({ directory: z.string() }),
async ({ directoryPath }) => { ... }

// ✅ 保持一致
schema: z.object({ directoryPath: z.string() }),
async ({ directoryPath }) => { ... }

解决:Prompt 中的示例参数名必须和 Schema 定义完全一致

❌ 坑 2:process.exit() 放在 close 事件外层

现象:执行完第一个命令后,整个 Node 进程直接退出,Agent 没来得及进行下一轮 ReAct。

解决:不要在子进程的 close 回调中调用 process.exit(),让主进程自然控制流程。

❌ 坑 3:忘记 recursive: true

现象fs.mkdir('a/b/c') 报错,因为父目录 a/b/ 不存在。

解决:始终使用 fs.mkdir(dir, { recursive: true }),等价于 mkdir -p


最后:本文实现的 Mini-Cursor 是 AI 编程 Agent 的最小可用原型。理解了本文的核心架构,你就掌握了理解所有 AI 编程工具的钥匙。

Logo

AtomGit AI 社区提供模型库、数据集、Agent、Token等资源

更多推荐