作者:互联网 时间: 2026-07-21 17:36:11
那天深夜,我盯着屏幕发呆。

"写个 TodoList"——这大概是每个前端开发者的噩梦。不是因为难,而是因为太无聊了。从 pnpm create vite 开始,到 npm run dev 结束,整个流程就像刷牙一样机械:创建项目、安装依赖、写组件、加样式、调接口...
我突然想:既然大模型能写代码,能不能让它替我干这活儿?
于是我花了一个周末,搭了个小工具。结果让我震惊——我只说了一句话:
然后泡了杯咖啡回来,发现 AI 已经:
整个过程,我一行代码都没写。
这不是科幻小说,这是真实发生的。今天我们就来拆解这个神奇的项目,看看它背后的原理——一个基于 ReAct 模式的 AI Agent,是如何学会使用工具、自主决策、完成复杂任务的。
在深入代码之前,先搞清楚我们在聊什么。这个项目叫 hello-langchain,但它可不是简单的 "hello world"——它是一个完整的 AI 编程助手。
整个项目的目录结构很清晰:
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 # 入口文件│ └── ...
项目依赖不多,但个个都是重量级选手:
| 依赖 | 作用 | 版本 |
|---|---|---|
@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 |
整个系统的工作流程可以用一张图概括:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐│ 用户提问│ ──→ │ AI 思考│ ──→ │ 调用工具││(自然语言) │ │(分析需求) │ │ (执行操作)│└──────────────┘ └──────────────┘ └──────────────┘ │ │ │ │ │ ↓ │ │┌──────────────┐ │ ││ 工具返回结果│ │ │└──────────────┘ │ │ │ │ ←───────────────────┘ │ │ ←───────────────────┘ │ ↓┌──────────────┐│ 最终回复 │└──────────────┘
这就是 ReAct 模式——Reasoning + Acting。AI 先推理(分析需求),再行动(调用工具),然后根据工具返回的结果继续推理,直到完成任务。
我们先从最简单的例子开始——index.mjs。这个文件演示了如何用 LangChain 调用大模型,就像发一条微信消息一样简单。
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);
这段代码虽然短,但包含了几个关键知识点:
ChatOpenAI 是 LangChain 提供的一个模型适配器。你可能会问:"我用的是 DeepSeek,为什么要导入 ChatOpenAI?"
答案很简单:DeepSeek 的 API 兼容 OpenAI 的格式。所以我们可以用 ChatOpenAI 来调用 DeepSeek,只需要指定 baseURL 即可。这是一个非常聪明的设计——LangChain 抽象了不同模型提供商的差异,让开发者可以用统一的接口调用各种模型。
这个设计背后的理念是适配器模式。LangChain 定义了一套统一的模型接口,然后为不同的模型提供商(OpenAI、Anthropic、Google、DeepSeek 等)提供适配器。这样一来,开发者只需要学习一套 API,就可以切换到不同的模型。
import 'dotenv/config' 这一行看起来不起眼,但它非常重要。它会自动加载项目根目录下的 .env 文件,并将其中的环境变量注入到 process.env 中。
为什么要这么做?因为 API Key 是敏感信息,不能硬编码在代码里。如果把 API Key 写在代码中,不小心提交到 GitHub,你的账号就可能被盗用。
.env 文件的内容通常是这样的:
DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
dotenv 会把这些键值对解析后注入到 process.env,所以我们可以通过 process.env.DEEPSEEK_API_KEY 来访问。
await model.invoke('...') 是 LangChain 提供的简化调用方法。它等价于原始的 OpenAI API 调用:
// 原始调用方式(伪代码)const response = await client.chat.completions.create({model: 'deepseek-v4-flash',messages: [{ role: 'user', content: '...' }],});
LangChain 帮我们做了封装,让调用变得更简洁。而且,invoke 方法不止支持字符串输入,还支持更复杂的消息格式,我们后面会看到。
当你运行这个文件时,会看到类似这样的输出:
棍王杯台球比赛的奖励设置可以结合趣味性和竞技性,以下是一些建议:1. 冠军奖励:奖杯 + 现金奖励 + 台球杆套装2. 亚军奖励:奖牌 + 现金奖励 + 台球用品礼包3. 季军奖励:奖牌 + 台球用品礼包...
这是 AI 的直接回复,没有调用任何工具。但如果用户的需求需要执行文件操作、运行命令呢?这就需要引入工具了。
小结:index.mjs 演示了 LangChain 最基础的使用方式——用 ChatOpenAI 适配器调用大模型,用 dotenv 加载环境变量,用 model.invoke() 获取回复。这就像给 AI 接通了电源,但它还没有手脚,只能动口不动手。下一章我们就来给 AI 装上"手脚"——工具系统。
如果说模型是 AI 的"大脑",那么工具就是 AI 的"手脚"。没有工具的 AI 只能坐而论道,有了工具的 AI 才能起而行之。
在 all-tools.mjs 中,每个工具都遵循相同的模式:
const toolName = tool(async ({ 参数 }) => {// 工具的实际逻辑return 结果;},{name: '工具名称',description: '工具描述(告诉 AI 什么时候用这个工具)',schema: z.object({参数名: z.string().describe('参数描述')})})
这个模式包含三个关键部分:
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 的主循环是异步的,如果用同步方法会阻塞整个循环。ToolMessage 添加到消息历史中,AI 可以根据文件内容做出下一步决策。边界条件思考:如果文件不存在怎么办?fs.readFile 会抛出错误。但这里没有显式的错误处理——这是一个潜在的问题。如果 AI 尝试读取一个不存在的文件,工具会抛出异常,可能导致整个 Agent 崩溃。在实际生产中,应该添加错误处理,友好地返回错误信息。
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/。性能权衡:每次写入文件都调用 fs.mkdir 是否有性能问题?实际上,fs.mkdir({ recursive: true }) 是一个幂等操作——如果目录已经存在,它不会做任何事情,只是返回成功。所以即使多次调用,也不会有性能问题。
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() 获取目录内容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 ? `nn重要提示:命令在目录"${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。
最后,所有工具通过 ES Module 的 export 导出,供 mini-cursor.mjs 使用:
export {readFileTool,writeFileTool,listDirectoryTool,executeCommandTool,}
小结:四大工具构成了 AI Agent 的核心能力——读写文件、浏览目录、执行命令。每个工具都遵循"功能函数 + 元数据"的模式,用 zod 定义参数类型,用描述文本告诉 AI 何时使用。特别是 execute_command 工具,通过子进程执行命令,实现了非阻塞、实时输出、隔离环境三大特性。有了这些工具,AI 终于可以动手做事了。但它还不会"思考"——不知道什么时候该用哪个工具,用了之后该干什么。下一章我们就来解决这个问题——ReAct 循环。
现在我们来到了项目的核心——mini-cursor.mjs。这个文件实现了 ReAct(Reasoning + Acting)模式,让 AI 能够像人类一样"思考-行动-观察-再思考"。
ReAct 模式的核心思想是:让 AI 在思考过程中产生行动,然后根据行动结果调整下一步计划。
传统的大模型调用是"一次性"的——用户问一个问题,AI 直接给出答案。但 ReAct 模式是"交互式"的——AI 可以:
这个过程就像人类解决问题一样:遇到问题 → 思考解决方案 → 采取行动 → 观察结果 → 调整方案。
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 参数中。模型会根据这个参数来决定是直接回答还是调用工具。
const case1 = `创建一个功能丰富的React TodoList 应用:1.创建项目:echo -e "nnn" | pnpm create-todo-app --template react-ts2.修改 src/App.tsx 实现完整功能的TodoList:- 添加、删除、标记、完成- 分类筛选(全部/进行中/已完成)- 统计信息显示- localStorage 数据持久化3.添加复杂样式- 渐变背景(如:linear-gradient(90deg, #007bff, #4b00b3ff))- 圆角边框 阴影效果- 鼠标悬停效果4.添加动画:- 添加/删除时的过渡动画- 使用css transitions5.列出目录确定注意:使用pnpm ,功能要完整,样式要美观,要有动画效果之后 react-todo-app 项目中:1.使用pnpm install 安装依赖2.使用pnpm run dev 启动项目`
这是一个典型的 多步骤复杂任务。AI 需要:
这正是 ReAct 模式的用武之地——单靠一次性的 prompt 很难完成这种需要多步交互的任务。
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;}
这段代码是整个项目的灵魂。让我们一步步拆解:
第一步:初始化消息历史
const messages = [new SystemMessage(`...`),new HumanMessage(query),];
messages 数组存储了整个对话的历史。它以 SystemMessage 开始,告诉 AI 它的角色、可用的工具和规则。然后是用户的问题 HumanMessage。
第二步:进入循环
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 陷入无限循环。每次循环都会:
第三步:判断是否调用工具
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 时会抛出错误。所以需要先检查是否存在。
第四步:执行工具调用
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 内部的协议,用于在多轮工具调用中正确关联请求和响应。
第五步:循环继续
下一次循环时,模型会看到工具返回的结果,然后决定下一步行动——是继续调用工具,还是直接回答用户。
性能思考:消息历史会不断增长,这会带来两个问题:
在实际生产中,可以考虑添加消息历史裁剪策略——只保留最近的 N 条消息,或者只保留关键的工具调用和结果。
整个流程可以用下面的 ASCII 图来表示:
┌─────────────────────────┐│ 初始化消息历史 ││ (SystemMessage + Query) │└─────────────────────────┘│▼┌─────────────────────────┐│ 调用 modelWithTools ││ 传入当前消息历史 │└─────────────────────────┘│▼┌─────────────────────────────────┐│检查 response.tool_calls │└─────────────────────────────────┘││▼▼┌──────────────┐ ┌─────────────────┐│ 无工具调用│ │ 有工具调用 │└──────────────┘ └─────────────────┘││▼▼┌──────────────┐ ┌─────────────────┐│ 返回最终回复│ │ 执行工具调用 ││ (结束)│ └─────────────────┘└──────────────┘ │ ▼┌─────────────────┐│ 添加 ToolMessage ││ 到消息历史 │└─────────────────┘ │ ▼┌─────────────────────────┐│ 检查是否达到最大次数 │└─────────────────────────┘││▼▼┌──────────┐┌──────────┐│ 达到上限││ 未达上限│└──────────┘└──────────┘││▼│┌──────────────┐││ 返回最后回复││└──────────────┘│▼┌─────────────────────────┐│ 回到循环开始│└─────────────────────────┘
try {await runAgentWithTools(case1);} catch(err) {console.error(`n 错误:${err.message}`);}
外层的 try-catch 确保即使 Agent 执行过程中出现未预期的错误,程序也不会崩溃,而是友好地显示错误信息。
小结:ReAct 循环是整个项目的灵魂。它通过"消息历史 + 循环调用 + 工具执行"的模式,让 AI 能够像人类一样思考和行动。每一轮循环,AI 都会根据历史对话决定下一步——是调用工具还是直接回答。这个设计虽然简单,但非常强大,是构建复杂 AI Agent 的基础。下一章我们就来看看这个 Agent 实际工作的成果——AI 自动生成的 React TodoList。
现在让我们看看 AI 生成的成果——react-todo-app。这是一个功能完整、样式精美的 TodoList 应用。更令人惊讶的是,整个项目的代码都是 AI 在没有人工干预的情况下完成的。
AI 生成的 App.tsx 包含了以下功能:
useState 管理待办列表、输入框内容、筛选状态localStorage 保存待办数据,刷新页面不丢失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 变化时才执行保存操作。localStorage 中的数据格式不正确(可能是手动修改或其他原因),JSON.parse 会抛出错误,此时返回空数组。设计亮点:数据持久化的双向绑定——读取时从 localStorage 初始化,写入时自动保存到 localStorage。用户体验非常流畅,刷新页面数据不会丢失。
const addTodo = useCallback(() => {const text = input.trim()if (!text) returnconst 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 过渡效果实现添加动画。const filteredTodos = todos.filter(todo => {if (filter === 'active') return !todo.completedif (filter === 'completed') return todo.completedreturn true})
简洁明了的筛选逻辑,根据当前筛选状态返回对应的待办列表。这种写法比 switch-case 更简洁,也比三元表达式更易读。
<div className="progress-fill"style={{width: `${totalCount > 0 ? (completedCount / totalCount) * 100 : 0}%`,}}></div>
动态计算完成百分比,用内联样式设置进度条宽度。注意处理 totalCount === 0 的边界情况,避免除以零。
AI 生成的 TodoList 不仅功能完整,样式也相当精美:
linear-gradient(90deg, #007bff, #4b00b3ff)小结:AI 生成的 TodoList 应用展示了 AI 编程的潜力——不仅功能完整,而且代码质量很高,遵循了 React 的最佳实践。从状态管理到数据持久化,从分类筛选到进度条,每个功能都考虑了边界条件和用户体验。这让我们思考:如果 AI 能写出这样质量的代码,程序员的价值在哪里?答案很简单——程序员负责定义问题、设计架构、把控质量,而 AI 负责实现细节。程序员从"写代码的人"变成了"指挥 AI 写代码的人"。
在 node-exec.mjs 中,演示了 Node.js 的子进程执行机制。这是理解 executeCommandTool 的关键。
为什么要使用子进程?
execSync,整个 Agent 会在命令执行期间卡住。pnpm install 失败),Agent 主程序仍然可以正常运行。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 表示成功,非零表示失败)。Node.js 提供了多种创建子进程的方式:
| 方法 | 特点 | 适用场景 |
|---|---|---|
spawn | 流式输出,适合长时间运行的命令 | pnpm install、npm run dev |
exec | 缓存输出,适合短时间命令 | ls -la、cat file.txt |
execFile | 不经过 shell,安全性更高 | 需要执行单个可执行文件 |
executeCommandTool 使用 spawn,因为它适合长时间运行的命令(如 pnpm install 可能需要几分钟),并且需要实时输出。
spawn 默认会创建一个 IPC(Inter-Process Communication)通道,允许父子进程之间通信。虽然在这个例子中没有显式使用,但这是 Node.js 多进程架构的基础。
通过 IPC,父进程可以向子进程发送消息,子进程也可以向父进程发送消息。这在需要双向通信的场景中非常有用。
小结:子进程是 Node.js 处理耗时操作的核心机制。通过 spawn 创建独立的子进程,可以实现非阻塞执行、隔离环境、实时输出。这就是 executeCommandTool 的底层原理——将命令行操作分离到独立的子进程中,确保 Agent 的主循环不会被阻塞。理解子进程机制,是理解整个 Agent 系统的关键。
这个项目虽然不大,但它展示了一个令人兴奋的未来——AI 不再只是回答问题,而是可以动手做事。
如果让我对这个项目进行重构,我会从以下几个方面入手:
第一,工具系统的抽象化。当前的工具定义虽然遵循相同的模式,但每个工具都是独立的函数。我会创建一个 Tool 类或工厂函数,统一处理工具的注册、调用、错误处理。这样可以减少重复代码,也方便添加新工具。
第二,消息历史的管理。当前的消息历史是一个简单的数组,会不断增长。我会添加消息历史的裁剪策略——保留最近的 N 条消息,或者根据消息类型进行筛选。同时,可以将消息历史持久化到文件中,支持任务的中断和恢复。
第三,工具调用的并行化。当前的工具调用是顺序执行的,如果 AI 在一次回复中调用了多个工具,它们会依次执行。我会使用 Promise.all 实现工具的并发调用,提高执行效率。
第四,安全性的增强。当前的 executeCommandTool 可以执行任意命令,存在安全风险。我会添加命令白名单机制,只允许执行预定义的安全命令。同时,可以添加路径白名单,限制文件操作的范围。
第五,任务状态的可视化。当前的执行过程只有文字日志,不够直观。我会添加一个简单的 UI,展示当前的任务状态、已执行的步骤、正在执行的操作等。
想象一下,这个 AI Agent 还能做什么:
AI 编程的时代已经到来。我们不再是单纯的代码编写者,而是变成了 AI 的指挥者——告诉 AI 做什么,而不是怎么做。
在写这篇文章的过程中,我一直在思考一个问题:AI 真的能取代程序员吗?
我的答案是:不会,至少短期内不会。
为什么?因为编程不仅仅是写代码。编程是:
这些能力,是当前的 AI 还无法完全具备的。AI 可以写出高质量的代码,但它无法理解业务需求的本质,无法做出正确的架构决策,无法在遇到问题时自主调试。
所以,程序员的价值不在于写代码,而在于理解问题、设计方案、把控质量。AI 是我们的工具,而不是我们的替代品。
最后的问题留给你:如果让你给这个 AI Agent 增加一个新功能,你会加什么?为什么?
本文基于 hello-langchain 项目源码分析,演示了如何用 LangChain 构建 ReAct 模式的 AI 编程助手。