副标题:从 0 到 1 构建 ReAct 模式的代码生成助手,让大模型替你干活
1. 引言:我让 AI 帮我写了个 TodoList
那天深夜,我盯着屏幕发呆。
"写个 TodoList"------这大概是每个前端开发者的噩梦。不是因为难,而是因为太无聊了。从 pnpm create vite 开始,到 npm run dev 结束,整个流程就像刷牙一样机械:创建项目、安装依赖、写组件、加样式、调接口...
我突然想:既然大模型能写代码,能不能让它替我干这活儿?
于是我花了一个周末,搭了个小工具。结果让我震惊------我只说了一句话:
"帮我创建一个功能丰富的 React TodoList 应用,要有渐变背景、动画效果、localStorage 持久化..."
然后泡了杯咖啡回来,发现 AI 已经:
- 创建了 Vite + React + TypeScript 项目
- 写好了完整的 TodoList 组件(添加、删除、标记完成、分类筛选)
- 配置了渐变背景和动画效果
- 安装了依赖并成功启动了项目
整个过程,我一行代码都没写。
这不是科幻小说,这是真实发生的。今天我们就来拆解这个神奇的项目,看看它背后的原理------一个基于 ReAct 模式的 AI Agent,是如何学会使用工具、自主决策、完成复杂任务的。
2. 项目全景:这是个什么玩意儿
在深入代码之前,先搞清楚我们在聊什么。这个项目叫 hello-langchain,但它可不是简单的 "hello world"------它是一个完整的 AI 编程助手。
2.1 项目结构速览
整个项目的目录结构很清晰:
python
hello-langchain/
├── index.mjs # 基础调用:一句话让 AI 回答问题
├── package.json # 依赖配置
├── src/
│ ├── mini-cursor.mjs # 核心:ReAct Agent 主程序
│ ├── all-tools.mjs # 工具集:文件读写、目录列表、命令执行
│ ├── tool.mjs # 简化版工具演示
│ ├── node-exec.mjs # 子进程执行演示
│ └── react-todo-app/ # AI 自动生成的 TodoList 项目
│ ├── src/
│ │ ├── App.tsx # TodoList 核心组件
│ │ └── main.tsx # 入口文件
│ └── ...
2.2 核心依赖
项目依赖不多,但个个都是重量级选手:
| 依赖 | 作用 | 版本 |
|---|---|---|
@langchain/core |
LangChain 核心模块,提供工具绑定、消息类型 | ^1.2.1 |
@langchain/openai |
LangChain OpenAI 适配器(兼容 DeepSeek) | ^1.5.3 |
dotenv |
加载环境变量 | ^17.4.2 |
zod |
类型校验,定义工具参数 Schema | ^4.4.3 |
chalk |
控制台彩色输出,让日志更醒目 | ^5.6.2 |
2.3 整体工作流程
整个系统的工作流程可以用一张图概括:
scss
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 用户提问 │ ──→ │ AI 思考 │ ──→ │ 调用工具 │
│ (自然语言) │ │ (分析需求) │ │ (执行操作) │
└──────────────┘ └──────────────┘ └──────────────┘
│ │ │
│ │ ↓
│ │ ┌──────────────┐
│ │ │ 工具返回结果 │
│ │ └──────────────┘
│ │ │
│ ←───────────────────┘
│ │
←───────────────────┘
│
↓
┌──────────────┐
│ 最终回复 │
└──────────────┘
这就是 ReAct 模式------Reasoning + Acting。AI 先推理(分析需求),再行动(调用工具),然后根据工具返回的结果继续推理,直到完成任务。
3. 基础篇:从一句 prompt 到 AI 回复
我们先从最简单的例子开始------index.mjs。这个文件演示了如何用 LangChain 调用大模型,就像发一条微信消息一样简单。
3.1 核心代码解析
javascript
import { ChatOpenAI } from '@langchain/openai';
import 'dotenv/config';
const model = new ChatOpenAI({
modelName: 'deepseek-v4-flash',
apiKey: process.env.DEEPSEEK_API_KEY,
configuration: {
baseURL: 'https://api.deepseek.com/v1',
},
});
const response = await model.invoke('棍王杯台球比赛应该设什么奖励?');
console.log(response.content);
这段代码虽然短,但包含了几个关键知识点:
3.1.1 ChatOpenAI 的魔法
ChatOpenAI 是 LangChain 提供的一个模型适配器。你可能会问:"我用的是 DeepSeek,为什么要导入 ChatOpenAI?"
答案很简单:DeepSeek 的 API 兼容 OpenAI 的格式 。所以我们可以用 ChatOpenAI 来调用 DeepSeek,只需要指定 baseURL 即可。这是一个非常聪明的设计------LangChain 抽象了不同模型提供商的差异,让开发者可以用统一的接口调用各种模型。
这个设计背后的理念是适配器模式。LangChain 定义了一套统一的模型接口,然后为不同的模型提供商(OpenAI、Anthropic、Google、DeepSeek 等)提供适配器。这样一来,开发者只需要学习一套 API,就可以切换到不同的模型。
3.1.2 dotenv 的作用
import 'dotenv/config' 这一行看起来不起眼,但它非常重要。它会自动加载项目根目录下的 .env 文件,并将其中的环境变量注入到 process.env 中。
为什么要这么做?因为 API Key 是敏感信息,不能硬编码在代码里。如果把 API Key 写在代码中,不小心提交到 GitHub,你的账号就可能被盗用。
.env 文件的内容通常是这样的:
ini
DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
dotenv 会把这些键值对解析后注入到 process.env,所以我们可以通过 process.env.DEEPSEEK_API_KEY 来访问。
3.1.3 model.invoke() 的威力
await model.invoke('...') 是 LangChain 提供的简化调用方法。它等价于原始的 OpenAI API 调用:
javascript
// 原始调用方式(伪代码)
const response = await client.chat.completions.create({
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: '...' }],
});
LangChain 帮我们做了封装,让调用变得更简洁。而且,invoke 方法不止支持字符串输入,还支持更复杂的消息格式,我们后面会看到。
3.2 运行效果
当你运行这个文件时,会看到类似这样的输出:
markdown
棍王杯台球比赛的奖励设置可以结合趣味性和竞技性,以下是一些建议:
1. 冠军奖励:奖杯 + 现金奖励 + 台球杆套装
2. 亚军奖励:奖牌 + 现金奖励 + 台球用品礼包
3. 季军奖励:奖牌 + 台球用品礼包
...
这是 AI 的直接回复,没有调用任何工具。但如果用户的需求需要执行文件操作、运行命令呢?这就需要引入工具了。
小结 :index.mjs 演示了 LangChain 最基础的使用方式------用 ChatOpenAI 适配器调用大模型,用 dotenv 加载环境变量,用 model.invoke() 获取回复。这就像给 AI 接通了电源,但它还没有手脚,只能动口不动手。下一章我们就来给 AI 装上"手脚"------工具系统。
4. 工具篇:给 AI 装上手脚
如果说模型是 AI 的"大脑",那么工具就是 AI 的"手脚"。没有工具的 AI 只能坐而论道,有了工具的 AI 才能起而行之。
4.1 工具定义的基本模式
在 all-tools.mjs 中,每个工具都遵循相同的模式:
javascript
const toolName = tool(
async ({ 参数 }) => {
// 工具的实际逻辑
return 结果;
},
{
name: '工具名称',
description: '工具描述(告诉 AI 什么时候用这个工具)',
schema: z.object({
参数名: z.string().describe('参数描述')
})
}
)
这个模式包含三个关键部分:
- 功能函数:实际执行操作的异步函数
- 工具名称:AI 调用时使用的标识符
- 工具描述:告诉 AI "什么时候应该调用这个工具"------这非常重要,AI 全靠这个描述来判断是否使用工具
- 参数 Schema:用 zod 定义参数类型和描述,帮助 AI 正确传递参数
4.2 四大工具详解
4.2.1 read_file:读取文件
javascript
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('要读取的文件路径')
})
}
)
设计要点:
- 使用
fs/promises的readFile方法,返回 Promise。为什么不用同步的readFileSync?因为 Agent 的主循环是异步的,如果用同步方法会阻塞整个循环。 - 控制台打印工具调用日志,让用户知道 AI 在做什么。这很重要------Agent 执行任务可能很耗时,如果没有反馈,用户可能会以为程序卡住了。
- 返回文件内容,供 AI 后续分析使用。返回值会被包装成
ToolMessage添加到消息历史中,AI 可以根据文件内容做出下一步决策。
边界条件思考 :如果文件不存在怎么办?fs.readFile 会抛出错误。但这里没有显式的错误处理------这是一个潜在的问题。如果 AI 尝试读取一个不存在的文件,工具会抛出异常,可能导致整个 Agent 崩溃。在实际生产中,应该添加错误处理,友好地返回错误信息。
4.2.2 write_file:写入文件
javascript
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) {
console.log(`[工具调用] write_file(${filePath})
错误: ${err.message}`)
return `写入文件失败:${err.message}`
}
},
{
name: 'write_file',
description: '向指定路径写入文件内容,自动创建目录',
schema: z.object({
filePath: z.string().describe('文件路径'),
content: z.string().describe('要写入的文件内容')
})
}
)
设计要点:
- 使用
path.dirname()获取文件所在目录。这是一个关键操作------如果直接调用fs.writeFile写入一个不存在的目录中的文件,会抛出错误。 fs.mkdir({ recursive: true })递归创建目录。这个参数非常强大,它会创建路径上所有不存在的目录。例如,如果路径是/a/b/c/123.js,而/a/b/c/不存在,它会依次创建/a/、/a/b/、/a/b/c/。- 包含错误处理,使用 try-catch 包裹整个操作。即使写入失败,也会返回错误信息,而不是抛出异常导致 Agent 崩溃。
性能权衡 :每次写入文件都调用 fs.mkdir 是否有性能问题?实际上,fs.mkdir({ recursive: true }) 是一个幂等操作------如果目录已经存在,它不会做任何事情,只是返回成功。所以即使多次调用,也不会有性能问题。
4.2.3 list_directory:列出目录
javascript
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) {
console.log(`[工具调用] list_directory(${directoryPath})
错误: ${err.message}`)
return `列出目录内容失败:${err.message}`
}
},
{
name: 'list_directory',
description: '列出指定目录下的所有文件和文件夹',
schema: z.object({
directoryPath: z.string().describe('目录路径')
})
}
)
设计要点:
- 使用
fs.readdir()获取目录内容 - 将文件列表用换行符连接,便于 AI 读取
4.2.4 execute_command:执行命令(最强大的工具)
javascript
const executeCommandTool = tool(
async ({ command, directoryPath }) => {
const cwd = directoryPath || process.cwd();
console.log(`[工具调用] execute_command(${command})
工作目录:${cwd}`);
return new Promise((resolve, reject) => {
const [cmd, ...args] = command.split(' ');
const child = spawn(cmd, args, {
cwd,
stdio: 'inherit',
shell: true,
})
let errorMsg = '';
child.on('error', (err) => {
errorMsg = err.message
});
child.on('close', (code) => {
if (code === 0) {
console.log(`[工具调用] execute_command(${command})
成功执行`)
const cwdInfo = cwd ?
`\n\n重要提示:命令在目录"${cwd}" 执行` : '';
resolve(`命令行成功执行 ${command}${cwdInfo}`);
} else {
console.log(`[工具调用] execute_command(${command})
退出码:${code}`)
resolve(`命令执行失败,退出码:${code}\n 错误:${errorMsg}`)
}
})
})
},
{
name: 'execute_command',
description: '执行系统命令,支持指定工作目录,实时显示输出',
schema: z.object({
command: z.string().describe('要执行的命令'),
directoryPath: z.string().describe('工作目录(推荐指定)')
})
}
)
设计要点:
-
子进程执行 :使用
node:child_process的spawn方法,在独立的子进程中执行命令。这样做的好处是:- 不会阻塞主进程
- 可以实时获取命令输出
- 命令执行失败不会导致主程序崩溃
-
命令解析 :
const [cmd, ...args] = command.split(' ')将命令字符串拆分为命令和参数。例如"pnpm install"会被拆分为['pnpm', 'install']。 -
stdio: 'inherit':让子进程继承父进程的标准输入输出,这样命令的输出会直接显示在控制台中,用户可以实时看到执行过程。
-
shell: true:在 shell 环境中执行命令,支持管道、重定向等 shell 特性。
-
错误处理 :监听
error和close事件,捕获执行过程中的错误和退出码。注意这里的设计------即使命令执行失败(退出码非零),也调用resolve而不是reject。为什么?因为我们希望 Agent 能够知道命令执行失败,并根据失败信息做出下一步决策,而不是让 Promise 被拒绝导致整个 Agent 崩溃。
安全性思考 :execute_command 工具是一把双刃剑。它赋予了 AI 执行任意系统命令的能力,但这也带来了安全风险。如果 AI 被诱导执行危险命令(如 rm -rf /),可能会造成严重后果。在实际生产中,应该添加命令白名单机制,只允许执行预定义的安全命令。
命令解析的局限性 :const [cmd, ...args] = command.split(' ') 这种简单的分割方式有一个问题------如果命令参数中包含空格,就会被错误地分割。例如 "echo 'hello world'" 会被分割成 ['echo', "'hello", "world'"],而不是预期的 ['echo', 'hello world']。在实际生产中,可以考虑使用更复杂的命令解析库,如 shell-quote。
4.3 工具的导出
最后,所有工具通过 ES Module 的 export 导出,供 mini-cursor.mjs 使用:
javascript
export {
readFileTool,
writeFileTool,
listDirectoryTool,
executeCommandTool,
}
小结 :四大工具构成了 AI Agent 的核心能力------读写文件、浏览目录、执行命令。每个工具都遵循"功能函数 + 元数据"的模式,用 zod 定义参数类型,用描述文本告诉 AI 何时使用。特别是 execute_command 工具,通过子进程执行命令,实现了非阻塞、实时输出、隔离环境三大特性。有了这些工具,AI 终于可以动手做事了。但它还不会"思考"------不知道什么时候该用哪个工具,用了之后该干什么。下一章我们就来解决这个问题------ReAct 循环。
5. 核心篇:ReAct 循环让 AI 学会思考
现在我们来到了项目的核心------mini-cursor.mjs。这个文件实现了 ReAct(Reasoning + Acting)模式,让 AI 能够像人类一样"思考-行动-观察-再思考"。
5.1 ReAct 模式原理
ReAct 模式的核心思想是:让 AI 在思考过程中产生行动,然后根据行动结果调整下一步计划。
传统的大模型调用是"一次性"的------用户问一个问题,AI 直接给出答案。但 ReAct 模式是"交互式"的------AI 可以:
- 分析问题,决定需要调用哪个工具
- 调用工具,获取结果
- 根据结果,继续分析或回答
这个过程就像人类解决问题一样:遇到问题 → 思考解决方案 → 采取行动 → 观察结果 → 调整方案。
5.2 核心代码解析
5.2.1 初始化阶段
javascript
import dotenv from 'dotenv';
import path from 'node:path';
dotenv.config({ path: path.resolve(import.meta.url, '../.env') });
import {ChatOpenAI} from "@langchain/openai";
import {
HumanMessage,
SystemMessage,
ToolMessage,
} from "@langchain/core/messages";
import {
executeCommandTool,
readFileTool,
writeFileTool,
listDirectoryTool,
} from "./all-tools.mjs";
const tools = [
readFileTool,
writeFileTool,
listDirectoryTool,
executeCommandTool,
];
const model = new ChatOpenAI({
modelName: 'deepseek-v4-flash',
apiKey: process.env.DEEPSEEK_API_KEY,
configuration: {
baseURL: 'https://api.deepseek.com/v1',
},
});
const modelWithTools = model.bindTools(tools);
关键步骤:
-
加载环境变量 :这里有一个细节------
dotenv.config({ path: path.resolve(import.meta.url, '../.env') })。为什么要这么写?因为
mini-cursor.mjs在src/目录下,而.env文件在项目根目录下。如果直接调用dotenv.config(),它会在当前目录(src/)寻找.env文件,导致找不到。所以需要用path.resolve(import.meta.url, '../.env')指定正确的路径。 -
导入消息类型 :
HumanMessage(用户消息)、SystemMessage(系统提示)、ToolMessage(工具返回的消息)。这些消息类型是 LangChain 用来构建对话历史的核心。 -
绑定工具到模型 :
model.bindTools(tools)是关键操作。它告诉模型:"你可以使用这些工具来完成任务。" 绑定后,模型在生成回复时会考虑是否需要调用工具。
bindTools 的作用是什么?它会将工具的元数据(名称、描述、参数 Schema)转换为模型可以理解的格式,并在每次调用时自动添加到请求中。模型会根据这些信息决定是否调用工具,以及如何调用工具。
技术细节 :当调用 modelWithTools.invoke(messages) 时,LangChain 会自动将工具列表添加到 API 请求的 tools 参数中。模型会根据这个参数来决定是直接回答还是调用工具。
5.2.2 任务定义
javascript
const case1 = `
创建一个功能丰富的React TodoList 应用:
1.创建项目:echo -e "n\nn" | pnpm create-todo-app --template react-ts
2.修改 src/App.tsx 实现完整功能的TodoList:
- 添加、删除、标记、完成
- 分类筛选(全部/进行中/已完成)
- 统计信息显示
- localStorage 数据持久化
3.添加复杂样式
- 渐变背景(如:linear-gradient(90deg, #007bff, #4b00b3ff))
- 圆角边框 阴影效果
- 鼠标悬停效果
4.添加动画:
- 添加/删除时的过渡动画
- 使用css transitions
5.列出目录确定
注意:使用pnpm ,功能要完整,样式要美观,要有动画效果
之后 react-todo-app 项目中:
1.使用pnpm install 安装依赖
2.使用pnpm run dev 启动项目
`
这是一个典型的 多步骤复杂任务。AI 需要:
- 执行命令创建项目
- 读取生成的文件
- 修改文件内容
- 再次执行命令安装依赖和启动项目
这正是 ReAct 模式的用武之地------单靠一次性的 prompt 很难完成这种需要多步交互的任务。
5.2.3 ReAct 循环核心
javascript
async function runAgentWithTools(query, maxIterations = 30) {
const messages = [
new SystemMessage(`你是一个项目管理助手,使用工具完成任务。
当前工作目录:${process.cwd()}
工具:
1.read_file 读取文件内容
2.write_file 写入文件内容
3.list_directory 列出目录内容
4.execute_command 执行系统命令(支持 workingDirectory参数)
重要规则 - 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),
];
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(`\n:AI最终回复:\n${response.content}`);
return response.content;
}
for (const toolCall of response.tool_calls) {
const functionTool = tools.find(tool => tool.name === toolCall.name);
if (functionTool) {
const toolResult = await functionTool.invoke(toolCall.args);
messages.push(new ToolMessage({
content: toolResult,
tool_call_id: toolCall.id,
}));
}
}
}
return messages[messages.length - 1].content;
}
这段代码是整个项目的灵魂。让我们一步步拆解:
第一步:初始化消息历史
javascript
const messages = [
new SystemMessage(`...`),
new HumanMessage(query),
];
messages 数组存储了整个对话的历史。它以 SystemMessage 开始,告诉 AI 它的角色、可用的工具和规则。然后是用户的问题 HumanMessage。
第二步:进入循环
javascript
for (let i = 0; i < maxIterations; i++) {
console.log(chalk.bgGreen(`正在等待第${i+1}次ai思考...`));
const response = await modelWithTools.invoke(messages);
messages.push(response);
循环最多执行 maxIterations 次(默认 30 次),防止 AI 陷入无限循环。每次循环都会:
- 调用模型,传入当前的消息历史
- 将模型的回复添加到消息历史中
第三步:判断是否调用工具
javascript
if (!response.tool_calls || response.tool_calls.length === 0) {
console.log(`\n:AI最终回复:\n${response.content}`);
return response.content;
}
如果模型的回复中没有 tool_calls,说明 AI 认为任务已经完成,可以直接返回结果。
边界条件思考 :为什么要检查 response.tool_calls 是否存在?因为不是所有模型都支持工具调用,也不是所有回复都会包含工具调用。如果直接访问 response.tool_calls.length,当 tool_calls 为 undefined 时会抛出错误。所以需要先检查是否存在。
第四步:执行工具调用
javascript
for (const toolCall of response.tool_calls) {
const functionTool = tools.find(tool => tool.name === toolCall.name);
if (functionTool) {
const toolResult = await functionTool.invoke(toolCall.args);
messages.push(new ToolMessage({
content: toolResult,
tool_call_id: toolCall.id,
}));
}
}
如果模型决定调用工具,我们需要:
- 根据工具名称找到对应的工具函数。这里使用
tools.find(tool => tool.name === toolCall.name)来匹配工具。 - 调用工具函数,传入参数。
toolCall.args是模型生成的参数对象,包含工具所需的所有参数。 - 将工具返回的结果包装成
ToolMessage,添加到消息历史中。tool_call_id非常重要------它用于关联工具调用和工具结果,让模型知道哪个结果对应哪个调用。
关键设计 :ToolMessage 的 tool_call_id 参数必须与 toolCall.id 匹配。这是 LangChain 内部的协议,用于在多轮工具调用中正确关联请求和响应。
第五步:循环继续
下一次循环时,模型会看到工具返回的结果,然后决定下一步行动------是继续调用工具,还是直接回答用户。
性能思考:消息历史会不断增长,这会带来两个问题:
- Token 消耗:每次调用模型都需要发送完整的消息历史,消息越长,消耗的 Token 越多,成本越高。
- 上下文窗口:大模型有上下文窗口限制(如 128k、256k Token),如果消息历史太长,可能会超出限制。
在实际生产中,可以考虑添加消息历史裁剪策略------只保留最近的 N 条消息,或者只保留关键的工具调用和结果。
5.3 ReAct 循环流程图
整个流程可以用下面的 ASCII 图来表示:
scss
┌─────────────────────────┐
│ 初始化消息历史 │
│ (SystemMessage + Query) │
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ 调用 modelWithTools │
│ 传入当前消息历史 │
└─────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ 检查 response.tool_calls │
└─────────────────────────────────┘
│ │
▼ ▼
┌──────────────┐ ┌─────────────────┐
│ 无工具调用 │ │ 有工具调用 │
└──────────────┘ └─────────────────┘
│ │
▼ ▼
┌──────────────┐ ┌─────────────────┐
│ 返回最终回复 │ │ 执行工具调用 │
│ (结束) │ └─────────────────┘
└──────────────┘ │
▼
┌─────────────────┐
│ 添加 ToolMessage │
│ 到消息历史 │
└─────────────────┘
│
▼
┌─────────────────────────┐
│ 检查是否达到最大次数 │
└─────────────────────────┘
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ 达到上限 │ │ 未达上限 │
└──────────┘ └──────────┘
│ │
▼ │
┌──────────────┐ │
│ 返回最后回复 │ │
└──────────────┘ │
▼
┌─────────────────────────┐
│ 回到循环开始 │
└─────────────────────────┘
5.4 错误处理
javascript
try {
await runAgentWithTools(case1);
} catch(err) {
console.error(`\n 错误:${err.message}`);
}
外层的 try-catch 确保即使 Agent 执行过程中出现未预期的错误,程序也不会崩溃,而是友好地显示错误信息。
小结:ReAct 循环是整个项目的灵魂。它通过"消息历史 + 循环调用 + 工具执行"的模式,让 AI 能够像人类一样思考和行动。每一轮循环,AI 都会根据历史对话决定下一步------是调用工具还是直接回答。这个设计虽然简单,但非常强大,是构建复杂 AI Agent 的基础。下一章我们就来看看这个 Agent 实际工作的成果------AI 自动生成的 React TodoList。
6. 实战篇:AI 自动生成 React TodoList
现在让我们看看 AI 生成的成果------react-todo-app。这是一个功能完整、样式精美的 TodoList 应用。更令人惊讶的是,整个项目的代码都是 AI 在没有人工干预的情况下完成的。
6.1 App.tsx 核心功能
AI 生成的 App.tsx 包含了以下功能:
- 状态管理 :使用
useState管理待办列表、输入框内容、筛选状态 - 数据持久化 :使用
localStorage保存待办数据,刷新页面不丢失 - 核心操作:添加、删除、切换完成状态
- 分类筛选:全部/进行中/已完成
- 统计信息:显示总数、进行中、已完成数量
- 进度条:可视化完成进度
6.2 关键代码解析
6.2.1 状态初始化与持久化
javascript
const [todos, setTodos] = useState<Todo[]>(() => {
const saved = localStorage.getItem('react-todos')
if (saved) {
try {
return JSON.parse(saved)
} catch {
return []
}
}
return []
})
useEffect(() => {
localStorage.setItem('react-todos', JSON.stringify(todos))
}, [todos])
这段代码展示了 React 的最佳实践:
- 使用函数式初始化
useState(() => {...}),只在组件首次渲染时执行一次。如果用普通值初始化(如useState([])),每次渲染都会执行初始化逻辑。 - 在
useEffect中监听todos变化,自动保存到localStorage。依赖数组[todos]确保只有当todos变化时才执行保存操作。 - 包含 JSON 解析的错误处理,防止数据格式异常导致崩溃。如果
localStorage中的数据格式不正确(可能是手动修改或其他原因),JSON.parse会抛出错误,此时返回空数组。
设计亮点 :数据持久化的双向绑定------读取时从 localStorage 初始化,写入时自动保存到 localStorage。用户体验非常流畅,刷新页面数据不会丢失。
6.2.2 添加待办
javascript
const addTodo = useCallback(() => {
const text = input.trim()
if (!text) return
const newTodo: Todo = {
id: Date.now(),
text,
completed: false,
createdAt: Date.now(),
}
setTodos(prev => [newTodo, ...prev])
setInput('')
setIsAdding(true)
setTimeout(() => setIsAdding(false), 300)
}, [input])
设计要点:
- 使用
useCallback缓存函数引用,避免不必要的重渲染。如果addTodo作为 prop 传递给子组件,useCallback可以确保函数引用在依赖不变时保持稳定。 Date.now()作为唯一 ID,简单有效。对于 TodoList 这种简单应用,时间戳足够唯一。- 新待办添加到数组开头(
[newTodo, ...prev]),符合用户习惯------最新的待办应该显示在最前面。 setIsAdding状态用于触发添加动画。设置为true后 300ms 再设置为false,配合 CSS 过渡效果实现添加动画。
6.2.3 分类筛选
javascript
const filteredTodos = todos.filter(todo => {
if (filter === 'active') return !todo.completed
if (filter === 'completed') return todo.completed
return true
})
简洁明了的筛选逻辑,根据当前筛选状态返回对应的待办列表。这种写法比 switch-case 更简洁,也比三元表达式更易读。
6.2.4 进度条计算
javascript
<div className="progress-fill"
style={{
width: `${totalCount > 0 ? (completedCount / totalCount) * 100 : 0}%`,
}}
></div>
动态计算完成百分比,用内联样式设置进度条宽度。注意处理 totalCount === 0 的边界情况,避免除以零。
6.3 UI 设计亮点
AI 生成的 TodoList 不仅功能完整,样式也相当精美:
- 渐变背景 :
linear-gradient(90deg, #007bff, #4b00b3ff) - 圆角边框和阴影:现代感十足
- 悬停效果:按钮和待办项都有悬停动画
- 过渡动画:添加/删除时的平滑过渡
- 空状态提示:根据筛选状态显示不同的空状态图标和文案
小结:AI 生成的 TodoList 应用展示了 AI 编程的潜力------不仅功能完整,而且代码质量很高,遵循了 React 的最佳实践。从状态管理到数据持久化,从分类筛选到进度条,每个功能都考虑了边界条件和用户体验。这让我们思考:如果 AI 能写出这样质量的代码,程序员的价值在哪里?答案很简单------程序员负责定义问题、设计架构、把控质量,而 AI 负责实现细节。程序员从"写代码的人"变成了"指挥 AI 写代码的人"。
7. 进阶篇:子进程与并发执行
在 node-exec.mjs 中,演示了 Node.js 的子进程执行机制。这是理解 executeCommandTool 的关键。
7.1 子进程的必要性
为什么要使用子进程?
- 非阻塞执行 :主进程可以继续处理其他任务,不需要等待命令执行完成。如果用同步的
execSync,整个 Agent 会在命令执行期间卡住。 - 隔离环境 :子进程有独立的内存空间,命令执行失败不会影响主进程。即使命令崩溃(如
pnpm install失败),Agent 主程序仍然可以正常运行。 - 实时输出:可以实时获取命令的标准输出和错误输出。用户可以看到命令执行的进度,而不是等待很久后才看到结果。
- 资源管理:可以监控子进程的资源使用,及时清理。如果命令执行时间过长,可以强制终止子进程。
7.2 核心代码
javascript
import { spawn } from "node:child_process";
const command = 'npm init vite react-todo-app --template react-ts'
const [cmd, ...args] = command.split(" ");
const cwd = process.cwd();
const client = spawn(cmd, args, {
cwd,
stdio: "inherit",
shell: true,
});
let errorMsg = "";
client.on("error", (err) => {
errorMsg = err.message;
});
client.on("close", (code) => {
if (code === 0) {
process.exit(0);
} else {
if (errorMsg) {
console.error(`错误:${errorMsg}`);
}
process.exit(code || 1);
}
});
关键参数:
cwd:子进程的工作目录。这很重要------不同的命令需要在不同的目录下执行。stdio: "inherit":让子进程继承父进程的输入输出流。这样命令的输出会直接显示在控制台中,用户可以实时看到执行过程。shell: true:在 shell 环境中执行命令,支持管道、重定向等 shell 特性。
事件监听:
error:子进程启动失败时触发(如命令不存在)。close:子进程退出时触发,code为退出码(0 表示成功,非零表示失败)。
7.3 spawn vs exec vs execFile
Node.js 提供了多种创建子进程的方式:
| 方法 | 特点 | 适用场景 |
|---|---|---|
spawn |
流式输出,适合长时间运行的命令 | pnpm install、npm run dev |
exec |
缓存输出,适合短时间命令 | ls -la、cat file.txt |
execFile |
不经过 shell,安全性更高 | 需要执行单个可执行文件 |
executeCommandTool 使用 spawn,因为它适合长时间运行的命令(如 pnpm install 可能需要几分钟),并且需要实时输出。
7.4 IPC 机制
spawn 默认会创建一个 IPC(Inter-Process Communication)通道,允许父子进程之间通信。虽然在这个例子中没有显式使用,但这是 Node.js 多进程架构的基础。
通过 IPC,父进程可以向子进程发送消息,子进程也可以向父进程发送消息。这在需要双向通信的场景中非常有用。
小结 :子进程是 Node.js 处理耗时操作的核心机制。通过 spawn 创建独立的子进程,可以实现非阻塞执行、隔离环境、实时输出。这就是 executeCommandTool 的底层原理------将命令行操作分离到独立的子进程中,确保 Agent 的主循环不会被阻塞。理解子进程机制,是理解整个 Agent 系统的关键。
8. 总结与思考:AI 编程的现在与未来
这个项目虽然不大,但它展示了一个令人兴奋的未来------AI 不再只是回答问题,而是可以动手做事。
8.1 项目亮点
- ReAct 模式的优雅实现:通过简单的循环和消息历史管理,实现了复杂的多步推理。代码不到 50 行,却能让 AI 学会使用工具、自主决策、完成复杂任务。
- 工具的标准化定义:每个工具都遵循"功能函数 + 元数据"的模式,用 zod 定义参数类型,用描述文本告诉 AI 何时使用。这种标准化设计使得添加新工具变得非常简单。
- 错误处理的完整性:从工具调用到外层执行,都有完善的错误捕获。即使某个工具调用失败,也不会导致整个 Agent 崩溃。
- 用户体验的细节:彩色日志、实时反馈、清晰的错误信息。这些细节让用户能够清楚地看到 AI 在做什么,增强了信任感。
8.2 如果我来重构
如果让我对这个项目进行重构,我会从以下几个方面入手:
第一,工具系统的抽象化 。当前的工具定义虽然遵循相同的模式,但每个工具都是独立的函数。我会创建一个 Tool 类或工厂函数,统一处理工具的注册、调用、错误处理。这样可以减少重复代码,也方便添加新工具。
第二,消息历史的管理。当前的消息历史是一个简单的数组,会不断增长。我会添加消息历史的裁剪策略------保留最近的 N 条消息,或者根据消息类型进行筛选。同时,可以将消息历史持久化到文件中,支持任务的中断和恢复。
第三,工具调用的并行化 。当前的工具调用是顺序执行的,如果 AI 在一次回复中调用了多个工具,它们会依次执行。我会使用 Promise.all 实现工具的并发调用,提高执行效率。
第四,安全性的增强 。当前的 executeCommandTool 可以执行任意命令,存在安全风险。我会添加命令白名单机制,只允许执行预定义的安全命令。同时,可以添加路径白名单,限制文件操作的范围。
第五,任务状态的可视化。当前的执行过程只有文字日志,不够直观。我会添加一个简单的 UI,展示当前的任务状态、已执行的步骤、正在执行的操作等。
8.3 可以改进的地方
- 工具调用的并行化:当前是顺序执行多个工具调用,可以考虑并发执行
- 内存管理:消息历史会不断增长,可以添加清理机制
- 任务中断机制:用户无法在执行过程中中断任务
- 安全性:需要添加更多的安全检查,防止 AI 执行危险命令
8.4 未来展望
想象一下,这个 AI Agent 还能做什么:
- 自动化测试:写完代码后自动运行测试用例,发现并修复 bug
- 代码审查:读取代码并给出改进建议,包括性能优化、代码规范、安全漏洞
- 部署上线:自动构建、打包、部署到服务器,甚至配置 CI/CD 流程
- 文档生成:根据代码自动生成 API 文档、用户手册、README
AI 编程的时代已经到来。我们不再是单纯的代码编写者,而是变成了 AI 的指挥者------告诉 AI 做什么,而不是怎么做。
8.5 最后的思考
在写这篇文章的过程中,我一直在思考一个问题:AI 真的能取代程序员吗?
我的答案是:不会,至少短期内不会。
为什么?因为编程不仅仅是写代码。编程是:
- 理解需求:从模糊的业务描述中提取清晰的需求
- 设计架构:决定系统的整体结构、模块划分、技术选型
- 解决问题:遇到 bug 时,能够快速定位、分析、修复
- 权衡取舍:在性能、成本、复杂度之间做出权衡
- 团队协作:与产品经理、设计师、测试工程师沟通协作
这些能力,是当前的 AI 还无法完全具备的。AI 可以写出高质量的代码,但它无法理解业务需求的本质,无法做出正确的架构决策,无法在遇到问题时自主调试。
所以,程序员的价值不在于写代码,而在于理解问题、设计方案、把控质量。AI 是我们的工具,而不是我们的替代品。
最后的问题留给你:如果让你给这个 AI Agent 增加一个新功能,你会加什么?为什么?
本文基于 hello-langchain 项目源码分析,演示了如何用 LangChain 构建 ReAct 模式的 AI 编程助手。