🚲 手写一个"Claude Code":我用 LangChain + ReAct 循环,让 AI 自己读文件、解释代码

写在前面:今天继续学 Agent,而且我终于亲手写出了一个"迷你版 Claude Code"------一个能用 ReAct 循环自主调用工具的编程助手。老师说,Cursor 和 Claude Code 能做的事情,核心就是这个循环:LLM 收到任务 → 拆解 → 调用工具 → 拿到结果 → 再思考 → 再调工具 → 直到任务完成。代码量不到 100 行,但背后的思想涵盖了之前学的 Tool Use、Promise.all、四种 Message 类型、ReAct 循环......全串起来了。


一、Agent 的"肉":四种 Message 类型

1.1 LangChain 的 Message 体系

老师说:

"SystemMessage 设置 AI 是谁,可以干什么,有什么能力,以及一些回答、行为的规范等。"

在 LangChain 中,对话是通过四种 Message 类型来管理的:

类型 LangChain 类 对应原生 API 作用
SystemMessage new SystemMessage(...) role: 'system' 设置 AI 的角色和行为规范
HumanMessage new HumanMessage(...) role: 'user' 用户的问题
AIMessage new AIMessage(...) role: 'assistant' LLM 的回复(含工具调用)
ToolMessage new ToolMessage({...}) role: 'tool' 工具执行的结果

在 tool.mjs 中:

javascript 复制代码
import { 
    HumanMessage,
    SystemMessage,
    ToolMessage,
    AIMessage,
} from '@langchain/core/messages';

老师说:

"原生 OpenAI 返回工具调用 additional_kwargs -> tools -> 每个 tool。LangChain invoke 原样输出上面的,同时还会细心地准备好 tools 加到后面。LLM 工程开发的便捷性、可读性,帮助。"

LangChain 把赤裸裸的 API 输入输出,封装成了语义清晰、可读性好的 Message 派生类。


二、Agent 的"骨":ReAct 循环的代码实现

2.1 注册工具

看 tool.mjs 里的核心代码:

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('要读取的文件路径'),
        }),
    }
);

const tools = [readFileTool];
const modelWithTools = model.bindTools(tools);

2.2 第一次调用:LLM 决定是否需要工具

javascript 复制代码
const message = [
    new SystemMessage(`你是一个代码助手......`),
    new HumanMessage('请读取 src/tool.mjs 文件内容并解释代码')
];

let response = await modelWithTools.invoke(message);
message.push(response);

第一次 invoke 之后,LLM 可能直接回复,也可能返回 tool_calls 如果有 tool_calls,说明 LLM 说"我需要调用工具才能回答你"。

2.3 ReAct 循环:核心代码

javascript 复制代码
while (response.tool_calls && response.tool_calls.length > 0) {
    console.log(`\n[检测到] ${response.tool_calls.length} 个工具调用`);

    // 并行执行所有工具
    const toolResults = await Promise.all(
        response.tool_calls.map(async (toolCall) => {
            const tool = tools.find(t => t.name === toolCall.name);
            if (!tool) {
                return `错误:找不到工具${toolCall.name}`;
            }
            console.log(`[执行工具] ${toolCall.name} (${JSON.stringify(toolCall.args)})`);
            try {
                const result = await fs.readFile(toolCall.args.filePath, 'utf-8');
                return result;
            } catch (err) {
                return `错误:${err.message}`;
            }
        })
    );

    // 把工具结果推入 messages
    response.tool_calls.forEach((toolCall, index) => {
        message.push(
            new ToolMessage({
                content: toolResults[index],
                tool_call_id: toolCall.id
            })
        );
    });

    // 再次调用 LLM,让它基于工具结果继续
    response = await modelWithTools.invoke(message);
    message.push(response);
}

这段代码就是 ReAct 循环的"骨架":

代码行 对应 ReAct 步骤
while (response.tool_calls) 判断是否要继续循环
toolResults Act ------ 真正执行工具
ToolMessage Observe ------ 观察工具结果
modelWithTools.invoke(message) Reason ------ 再次调用 LLM 思考

循环的终止条件:response.tool_calls 为空。

当 LLM 认为不需要再调工具时,它就直接回复答案,tool_callsnull,循环结束。


三、并行执行:Promise.all 升级

3.1 为什么需要并行?

老师说:

"多个工具 await read await write 并发?response.tools,性能,有多个任务 Promise.all tool promises 数组。"

如果一个 Reasoning 决定同时调用多个工具(比如同时读两个文件),串行执行会浪费大量时间。

代码中的实现:

javascript 复制代码
const toolResults = await Promise.all(
    response.tool_calls.map(async (toolCall) => {
        // 每个 toolCall 并行执行
    })
);

老师们说:

"async 函数执行完成后是 Promise,return resolve 值。Promise.all、find、map。if (tool) try catch。"

每个工具调用都是异步的,Promise.all 让它们同时跑,互不等待。


四、完整工作流程演示

假设 Agent 收到的任务:"读取 src/tool.mjs 文件内容并解释代码"

步骤 类型 发生了什么
1 Reason LLM:需要读取文件,调用 read_file 工具
2 Act 执行 fs.readFile('src/tool.mjs')
3 Observe 拿到文件内容,包装成 ToolMessage
4 Reason LLM:内容拿到了,开始分析和解释
5 --- 循环结束,LLM 直接输出解释
6 用户 看到:"这个文件定义了一个代码助手 Agent......"

全程不到 10 行核心循环代码,就让 AI 从"只能聊天"变成了"能读文件、能解释代码"。

老师说:

"这个就是最简单的 Agent------有工具调用就执行,没有就直接生成结果。"


五、node-exec.mjs:子进程执行 CLI 命令

5.1 为什么需要子进程?

老师说:

"Node 主进程 Agent 执行 JS 单线程。调用工具去执行命令任务(分离出去,独立的子进程)。Node 多进程架构。child_process 做完后,IPC(进程间通信 Inner Process Communication)告诉主进程。"

Node.js 是单线程的,如果你在主进程里执行一个耗时的 CLI 命令,整个程序都会卡住。

解决方法:创建子进程(child_process),把命令交给子进程执行,主进程继续干别的事。子进程结束后通过 IPC(进程间通信)告诉主进程结果。

javascript 复制代码
import { spawn } from 'node:child_process';

老师说,Agent 要完成"vite 创建 React 项目并运行"这样的任务,需要三个工具:

  1. 文件写入工具:vite 创建项目时写文件。
  2. LLM 编程能力:写代码。
  3. CLI 命令工具npm run dev 启动项目。

spawn 就是启动子进程的工具。 它像一个"外挂处理器"------把耗时任务甩给子进程,主进程安心做 Agent 的循环调度。


六、总结:100 行代码的 Agent 骨架

知识点 说明
四种 Message System、Human、AI、Tool ------ 对话的结构化表达
ReAct 循环 while(tool_calls) 判断 → 执行工具 → 观察结果 → 再次调用
Promise.all 并行执行多个工具,提升性能
model.bindTools LangChain 把工具注册到 LLM
tool LangChain 的 tool 函数,包装 async fn + schema
child_process 子进程执行 CLI 命令,防止主进程卡死
IPC 子进程和主进程之间的通信
循环终止 tool_calls 为空时直接输出最终结果

Agent 不神秘。它就是一个 while 循环 + 工具注册 + Promise.all + 四种 Message。 你给它注册什么工具,它就能干什么活。工具是 AI 的"手和脚",ReAct 循环是"灵魂",LangChain 是"骨架"。三者合一,就是一个能自主工作的 AI Agent。

老师说后面还要继续讲,估计要加上 Memory 层和更复杂的任务编排。期待!


写在最后

今天最大的收获,是亲手写出了 ReAct 循环的代码。以前觉得 Cursor 和 Claude Code 很神秘,现在知道了------核心就是一个 while 循环,判断 LLM 返回的 tool_calls,有就执行工具,没有就直接生成结果。代码不超过 100 行,背后的思想却涵盖了之前学的大部分内容。

下次面试官问你:"怎么用 LangChain 实现一个简单的 Agent?"

你可以淡定地说:

"用 LangChain 实现 Agent 的核心是三步:第一步,用 tool() 函数注册工具 ,绑定异步执行函数和 zod 参数约束;第二步,用 model.bindTools(tools) 把工具列表绑定到模型上;第三步,实现 ReAct 循环 ------while(response.tool_calls) 判断是否要继续,有工具调用就用 Promise.all 并行执行,把结果包装成 ToolMessage 推入对话上下文,然后再次 invoke 调用 LLM。循环的条件是 tool_calls 为空。对于需要执行 CLI 命令的场景,还可以用 child_process.spawn 创建子进程,避免主进程卡死。"

然后看着面试官满意的表情,心里默念:这波,又稳了。


本文所有代码示例均来自课堂学习资料,真实可运行。

相关推荐
星河耀银海1 小时前
框架结合:Vue+HTML5+AI实现智能前端应用开发
前端·vue.js·html5
等咸鱼的狸猫2 小时前
# JS 核心六大主题:闭包内存模型、原型链查找、事件流机制与 Promise / Event Loop 实现视角复盘
前端·javascript
袋鱼不重2 小时前
乐观锁与悲观锁:原理、选型与前后端实践
前端·后端
Charonmomo3 小时前
react/antd - 不能正确打开标签页问题记录
前端·react.js·前端框架
gis开发之家3 小时前
vue3直击架构痛点 依赖倒置与接口隔离——打造坚如磐石的插件与高阶组件(HOC)模式
javascript·架构·typescript·前端框架·vue3
柒和远方3 小时前
LeetCode 139. 单词拆分 —— 从暴力回溯到 DP 完全背包
javascript·python·算法
先吃饱再说3 小时前
流式输出完全指南:让 AI 对话像打字机一样流畅
javascript·vue.js
冬枳3 小时前
基于OpenSeagragon封装的前端病理切片展示组件
前端·typescript
爱勇宝4 小时前
《道德经》第6章:别把团队的创造力用到枯竭
前端·后端