从零手写 AI 编程 Agent!带你实现一个 Mini Cursor(LangChain + Tool Calling + ReAct)
文章目录
本文将带你从零手写一个 AI 编程 Agent,理解 Trae、Cursor 等 AI 编程工具的底层实现原理。全程使用 Node.js + LangChain,最终实现一个能用自然语言驱动、自动创建并运行 React 项目的 Mini-Cursor。
一、前言:AI 编程 Agent 是什么
如果你用过 Trae 或 Cursor,一定对这样的体验不陌生:
“用 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 工作流程图
典型执行轨迹(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('要读取的文件路径'),
})
这段代码做了三件事:
- 类型校验:Zod 确保 LLM 传入的
filePath确实是字符串 - 语义提示:
.describe()的内容会编入发送给 LLM 的 Tool Definition,帮助 LLM 理解如何正确传参 - 防御性编程:即使 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 Schema:z.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 编程工具的钥匙。
更多推荐


所有评论(0)