当 AI 学会写代码:一个自动生成 React 项目的 Agent 实战

副标题:从 0 到 1 构建 ReAct 模式的代码生成助手,让大模型替你干活


1. 引言:我让 AI 帮我写了个 TodoList

那天深夜,我盯着屏幕发呆。

"写个 TodoList"------这大概是每个前端开发者的噩梦。不是因为难,而是因为太无聊了。从 pnpm create vite 开始,到 npm run dev 结束,整个流程就像刷牙一样机械:创建项目、安装依赖、写组件、加样式、调接口...

我突然想:既然大模型能写代码,能不能让它替我干这活儿?

于是我花了一个周末,搭了个小工具。结果让我震惊------我只说了一句话:

"帮我创建一个功能丰富的 React TodoList 应用,要有渐变背景、动画效果、localStorage 持久化..."

然后泡了杯咖啡回来,发现 AI 已经:

  1. 创建了 Vite + React + TypeScript 项目
  2. 写好了完整的 TodoList 组件(添加、删除、标记完成、分类筛选)
  3. 配置了渐变背景和动画效果
  4. 安装了依赖并成功启动了项目

整个过程,我一行代码都没写。

这不是科幻小说,这是真实发生的。今天我们就来拆解这个神奇的项目,看看它背后的原理------一个基于 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('参数描述')
    })
  }
)

这个模式包含三个关键部分:

  1. 功能函数:实际执行操作的异步函数
  2. 工具名称:AI 调用时使用的标识符
  3. 工具描述:告诉 AI "什么时候应该调用这个工具"------这非常重要,AI 全靠这个描述来判断是否使用工具
  4. 参数 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/promisesreadFile 方法,返回 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('工作目录(推荐指定)')
    })
  }
)

设计要点

  1. 子进程执行 :使用 node:child_processspawn 方法,在独立的子进程中执行命令。这样做的好处是:

    • 不会阻塞主进程
    • 可以实时获取命令输出
    • 命令执行失败不会导致主程序崩溃
  2. 命令解析const [cmd, ...args] = command.split(' ') 将命令字符串拆分为命令和参数。例如 "pnpm install" 会被拆分为 ['pnpm', 'install']

  3. stdio: 'inherit':让子进程继承父进程的标准输入输出,这样命令的输出会直接显示在控制台中,用户可以实时看到执行过程。

  4. shell: true:在 shell 环境中执行命令,支持管道、重定向等 shell 特性。

  5. 错误处理 :监听 errorclose 事件,捕获执行过程中的错误和退出码。注意这里的设计------即使命令执行失败(退出码非零),也调用 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 可以:

  1. 分析问题,决定需要调用哪个工具
  2. 调用工具,获取结果
  3. 根据结果,继续分析或回答

这个过程就像人类解决问题一样:遇到问题 → 思考解决方案 → 采取行动 → 观察结果 → 调整方案。

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);

关键步骤

  1. 加载环境变量 :这里有一个细节------dotenv.config({ path: path.resolve(import.meta.url, '../.env') })。为什么要这么写?

    因为 mini-cursor.mjssrc/ 目录下,而 .env 文件在项目根目录下。如果直接调用 dotenv.config(),它会在当前目录(src/)寻找 .env 文件,导致找不到。所以需要用 path.resolve(import.meta.url, '../.env') 指定正确的路径。

  2. 导入消息类型HumanMessage(用户消息)、SystemMessage(系统提示)、ToolMessage(工具返回的消息)。这些消息类型是 LangChain 用来构建对话历史的核心。

  3. 绑定工具到模型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 需要:

  1. 执行命令创建项目
  2. 读取生成的文件
  3. 修改文件内容
  4. 再次执行命令安装依赖和启动项目

这正是 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 陷入无限循环。每次循环都会:

  1. 调用模型,传入当前的消息历史
  2. 将模型的回复添加到消息历史中

第三步:判断是否调用工具

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_callsundefined 时会抛出错误。所以需要先检查是否存在。

第四步:执行工具调用

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,
        }));
    }
}

如果模型决定调用工具,我们需要:

  1. 根据工具名称找到对应的工具函数。这里使用 tools.find(tool => tool.name === toolCall.name) 来匹配工具。
  2. 调用工具函数,传入参数。toolCall.args 是模型生成的参数对象,包含工具所需的所有参数。
  3. 将工具返回的结果包装成 ToolMessage,添加到消息历史中。tool_call_id 非常重要------它用于关联工具调用和工具结果,让模型知道哪个结果对应哪个调用。

关键设计ToolMessagetool_call_id 参数必须与 toolCall.id 匹配。这是 LangChain 内部的协议,用于在多轮工具调用中正确关联请求和响应。

第五步:循环继续

下一次循环时,模型会看到工具返回的结果,然后决定下一步行动------是继续调用工具,还是直接回答用户。

性能思考:消息历史会不断增长,这会带来两个问题:

  1. Token 消耗:每次调用模型都需要发送完整的消息历史,消息越长,消耗的 Token 越多,成本越高。
  2. 上下文窗口:大模型有上下文窗口限制(如 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 包含了以下功能:

  1. 状态管理 :使用 useState 管理待办列表、输入框内容、筛选状态
  2. 数据持久化 :使用 localStorage 保存待办数据,刷新页面不丢失
  3. 核心操作:添加、删除、切换完成状态
  4. 分类筛选:全部/进行中/已完成
  5. 统计信息:显示总数、进行中、已完成数量
  6. 进度条:可视化完成进度

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 子进程的必要性

为什么要使用子进程?

  1. 非阻塞执行 :主进程可以继续处理其他任务,不需要等待命令执行完成。如果用同步的 execSync,整个 Agent 会在命令执行期间卡住。
  2. 隔离环境 :子进程有独立的内存空间,命令执行失败不会影响主进程。即使命令崩溃(如 pnpm install 失败),Agent 主程序仍然可以正常运行。
  3. 实时输出:可以实时获取命令的标准输出和错误输出。用户可以看到命令执行的进度,而不是等待很久后才看到结果。
  4. 资源管理:可以监控子进程的资源使用,及时清理。如果命令执行时间过长,可以强制终止子进程。

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 installnpm run dev
exec 缓存输出,适合短时间命令 ls -lacat 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 项目亮点

  1. ReAct 模式的优雅实现:通过简单的循环和消息历史管理,实现了复杂的多步推理。代码不到 50 行,却能让 AI 学会使用工具、自主决策、完成复杂任务。
  2. 工具的标准化定义:每个工具都遵循"功能函数 + 元数据"的模式,用 zod 定义参数类型,用描述文本告诉 AI 何时使用。这种标准化设计使得添加新工具变得非常简单。
  3. 错误处理的完整性:从工具调用到外层执行,都有完善的错误捕获。即使某个工具调用失败,也不会导致整个 Agent 崩溃。
  4. 用户体验的细节:彩色日志、实时反馈、清晰的错误信息。这些细节让用户能够清楚地看到 AI 在做什么,增强了信任感。

8.2 如果我来重构

如果让我对这个项目进行重构,我会从以下几个方面入手:

第一,工具系统的抽象化 。当前的工具定义虽然遵循相同的模式,但每个工具都是独立的函数。我会创建一个 Tool 类或工厂函数,统一处理工具的注册、调用、错误处理。这样可以减少重复代码,也方便添加新工具。

第二,消息历史的管理。当前的消息历史是一个简单的数组,会不断增长。我会添加消息历史的裁剪策略------保留最近的 N 条消息,或者根据消息类型进行筛选。同时,可以将消息历史持久化到文件中,支持任务的中断和恢复。

第三,工具调用的并行化 。当前的工具调用是顺序执行的,如果 AI 在一次回复中调用了多个工具,它们会依次执行。我会使用 Promise.all 实现工具的并发调用,提高执行效率。

第四,安全性的增强 。当前的 executeCommandTool 可以执行任意命令,存在安全风险。我会添加命令白名单机制,只允许执行预定义的安全命令。同时,可以添加路径白名单,限制文件操作的范围。

第五,任务状态的可视化。当前的执行过程只有文字日志,不够直观。我会添加一个简单的 UI,展示当前的任务状态、已执行的步骤、正在执行的操作等。

8.3 可以改进的地方

  1. 工具调用的并行化:当前是顺序执行多个工具调用,可以考虑并发执行
  2. 内存管理:消息历史会不断增长,可以添加清理机制
  3. 任务中断机制:用户无法在执行过程中中断任务
  4. 安全性:需要添加更多的安全检查,防止 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 编程助手。

相关推荐
m0_466525291 小时前
东软医疗光子 CT 实现国产高端影像从跟跑到领跑
人工智能
码栈研说1 小时前
Go 语言大白话入门 7 - 函数:把重复代码收起来
后端·程序员
智能体与具身智能2 小时前
TVA 本质内涵与核心特征(系列)
人工智能·python·智能体视觉
澹锦汐2 小时前
AI 创意画板:生成按钮之前,先设计可撤销的创作过程
人工智能
邵宇然2 小时前
计算图优化的编译器视角:AI 推理引擎的算子融合与内存布局重构
人工智能
中微极客2 小时前
KoMA:基于知识驱动的多智能体LLM自动驾驶框架深度解析
人工智能·机器学习·自动驾驶
Imagination官方博客2 小时前
边缘AI处理器的架构创新
人工智能·架构·gpu算力
我是谁??2 小时前
AI Movie Studio —— 一个面向电影、电视剧、动漫、短剧的 Agent 化影视理解与自动解说平台
人工智能