写在前面:今天继续学 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。LangChaininvoke原样输出上面的,同时还会细心地准备好 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_calls 为 null,循环结束。
三、并行执行:Promise.all 升级
3.1 为什么需要并行?
老师说:
"多个工具
await readawait write并发?response.tools,性能,有多个任务Promise.alltool 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 项目并运行"这样的任务,需要三个工具:
- 文件写入工具:vite 创建项目时写文件。
- LLM 编程能力:写代码。
- 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创建子进程,避免主进程卡死。"
然后看着面试官满意的表情,心里默念:这波,又稳了。
本文所有代码示例均来自课堂学习资料,真实可运行。