引言:为什么我们需要 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)
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 提供四样核心工具:
- read_file: 读取文件内容,让 AI 知道项目里有什么。
- write_file: 写入文件,这是 AI 写代码的核心。
- execute_command: 基于第一阶段的逻辑,让 AI 能运行 Shell 命令。
- 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. 设计思路
- 绑定工具: 使用 model.bindTools 让大模型知道它有哪些能力。
- System Prompt (系统提示词) : 这是 Agent 的“人设”和“操作手册”。
- 循环迭代 (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 的短期记忆。- 用户说:“建个项目”。
- AI 回:“我要调用 execute_command”。(存入 messages)
- 工具跑完,返回:“执行成功”。(存入 messages)
- 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. 预期执行流程
当你运行这段代码时,你会看到控制台疯狂刷屏,流程如下:
- AI 思考: "我需要先创建项目。" -> 调用 execute_command (运行 pnpm create vite)。
- 工具反馈: "react-todo-app 创建成功。"
- AI 思考: "现在我要写 App.tsx。" -> 调用 write_file (写入 React 代码)。
- AI 思考: "我要写样式。" -> 调用 write_file (写入 index.css)。
- AI 思考: "依赖还没装。" -> 调用 execute_command (参数 workingDirectory: 'react-todo-app', 命令 pnpm install)。
- AI 思考: "启动服务。" -> 调用 execute_command (pnpm run dev)。
效果图:
总结与展望
通过这几百行代码,我们其实已经触碰到了当前最前沿 AI 产品的核心逻辑。
如果你想继续深入,可以尝试以下方向:
- Memory (长期记忆) : 目前的 Agent 如果重启,记忆就没了。可以引入向量数据库,让它记住你上周写的代码逻辑。
- RAG (检索增强) : 让 Agent 能先读取你的本地开发文档,再写代码,准确率会暴增。
- Human-in-the-loop (人类介入) : 在执行高风险命令(如删除文件)前,暂停程序,询问用户“是否允许执行”。
恭喜你!你现在已经是一名 AI Agent 开发者了。继续探索吧,这个领域充满了无限可能!