大家好,我是你们的 AI 练习生!👋
最近 AI 圈子简直是“神仙打架”啊!🔥
🌟 一、 Agent 时代的寒武纪大爆发
咱们先来聊聊最近的行情。如果你还停留在“让 ChatGPT 帮我写个冒泡排序”的阶段,那可就有点 out 啦!现在的 AI 正在经历从 Chat(聊天) 到 Work(干活) 的巨大转变。
看看最近爆火的这些产品:
- 阿里千问、字节豆包、腾讯元宝:这些互联网大厂的计算能力正在向 AI Agent 推理倾斜,它们不再只是陪你聊天的吉祥物,而是能帮你点奶茶、定闹钟的贴心小秘。
- OpenClaw & 养虾:听说过“一人公司”吗?虚拟数字人 + 多 Agent 协作,写代码的 Agent(类似 Cursor)、做 PPT 的、算账的、做市场分析的,大家分工明确。你需要做的就是任务拆解,然后看着它们干活。
- Manus:最近风很大,号称全自动通用 Agent。当然,咱们程序员不仅要会用,还得会造!
- DeepSeek:从 Prompt Engineering 到 Agentic Engineering,全栈工程师的定义正在被改写。
所以,AI Agent 到底是个啥?🤔
🧠 Agent = LLM + Memory + Tool + RAG
这个公式请刻在烟吸肺里(开玩笑,记脑子里):
- LLM (Large Language Model):这是大脑,负责思考、推理、规划。
- Memory (记忆):你上周跟它聊的八卦它得记得住,不能像鱼一样只有7秒记忆。
- Tool (工具):这是今天的主角!大脑想上网?想读文件?想执行命令?得给它装上“手”和“眼”。
- RAG (检索增强生成):这是外挂知识库,基于公司私密文档回答问题,不瞎编乱造。
简单来说: 给大模型装上Tool(手)和Memory(脑子),它就能自动思考、规划、干活,这就是 Agent!
🛠️ 二、 今天的目标:给 LLM 装上“手” (Tool)
Cursor 为什么好用?因为它能读取你的代码库,能帮你改文件,能帮你运行终端。今天,我们就来迈出“手写 Cursor”的第一步:教会 AI 如何读取本地文件。
我们将使用 LangChain 框架(Node.js 版本),因为它生态最全,概念最清晰。
准备工作
确保你安装了 Node.js,并且准备好了 OpenAI 格式的 Key(DeepSeek、通义千问的 Key 都可以)。
💻 实战代码大解剖
咱们直接上代码,像做手术一样把逻辑拆解开来看!
1️⃣ 基础设施搭建:摇人(Import)
文件路径:hello-langchain/tool-file-read.mjs
// c:\Users\MR\Desktop\workspace\lesson_jp\ai\agent\mini-cursor\hello-langchain\tool-file-read.mjs
// 1. 引入环境变量配置
// 这一行非常关键!它会自动读取 .env 文件里的 API_KEY,
// 不然难道你要把 Key 硬编码在代码里吗?那可太刑了!🚓
import 'dotenv/config';
// 2. 引入 LangChain 全家桶
// ChatOpenAI: 用来连接大模型的客户端
import { ChatOpenAI } from "@langchain/openai";
// tool: 用来把普通函数包装成大模型能看懂的工具
import { tool } from "@langchain/core/tools";
// Messages: LangChain 中的消息类型,用来构建对话历史
import {
HumanMessage, // 人类说的话
SystemMessage, // 系统设定(人设)
ToolMessage // 工具返回的结果(这个很重要,后面细说)
} from "@langchain/core/messages";
// 3. Node.js 原生能力
// fs/promises: 异步文件读取,咱们 Agent 的物理外挂
import fs from 'node:fs/promises';
// 4. 数据校验神器 Zod
// 为什么需要 Zod?因为大模型有时候会“幻觉”,
// 我们需要 Zod 来严格约束模型传给工具的参数格式。
import { z } from 'zod';
2️⃣ 初始化大脑:Model
// 创建一个大模型实例
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME, // 比如 gpt-4o, deepseek-chat
apiKey: process.env.OPENAI_API_KEY,
configuration: {
baseURL: process.env.OPENAI_BASE_URL, // 如果用国内模型,记得换 BaseURL
},
// temperature: 0 表示“莫得感情”,
// 写代码、执行工具时,我们需要它严谨、稳定,不要发散。
temperature: 0
});
3️⃣ 打造神兵利器:定义 Tool 🔧
这是今天的重头戏!原生写 Tool 很麻烦,要写一大堆 JSON Schema。LangChain 提供的 tool 函数让这件事变得优雅无比。
const readFileTool = tool(
// --- 第一部分:工具的“肉体” (执行逻辑) ---
// 这是一个异步函数,接收一个对象 { path }
async ({path}) => {
// 真正的干活逻辑:调用 Node.js 的 fs 模块读取文件
const content = await fs.readFile(path, 'utf-8');
console.log(`[工具调用] read_file ("${path}") 成功读取文件内容...`);
return content; // 把读取到的内容返回出去
},
// --- 第二部分:工具的“灵魂” (描述与定义) ---
{
name: 'read_file', // 工具的名字,LLM 靠这个名字来以此称呼它
// description 非常重要!!!
// 这是写给 LLM 看的“说明书”。
// 你必须清晰地告诉 LLM:这个工具是干嘛的?什么时候用?
description: `用此工具读取文件内容,当用户需要读取文件,查看代码,分析文件内容时,调用此工具。输入文件路径(可以是相对路径或绝对路径)`,
// schema: 参数校验
// 告诉 LLM:你要调用我,必须传一个 JSON 对象,里面得有个 string 类型的 path。
schema: z.object({
path: z.string().describe('要读取的文件路径'),
}),
}
);
💡 重点笔记:description 写得好不好,直接决定了 Agent 聪不聪明。如果你写得含糊不清,LLM 可能根本不知道该不该用这个工具。
4️⃣ 装备工具:Binding
光有工具不行,得把它挂在 LLM 的腰带上。
// 创建工具列表,未来你可能有 write_file, run_shell 等等
const tools = [
readFileTool,
];
// bindTools: 这一步发生了什么?
// 其实是将 tools 转换成了 OpenAI API 要求的 format (JSON Schema),
// 并作为参数传给了模型。
// 现在,modelWithTools 就是一个“知道自己有读取文件能力”的超级模型了。
const modelWithTools = model.bindTools(tools);
5️⃣ 这里的“循环”才是 Agent 的精髓 🔄
很多人以为 Agent 就是调一次接口,其实不然。Agent 是一个 思考 -> 行动 -> 观察 -> 再思考 的循环过程。
我们先初始化对话历史:
const messages = [
new SystemMessage(`
你是一个代码助手,可以使用工具读取文件并解释代码。
工作流程:
1. 用户要求读取文件时,立即调用 read_file 工具
2. 等待工具返回文件内容
3. 基于文件内容进行分析和解释
`),
// 用户的需求:读文件 + 解释代码
new HumanMessage(`读取文件 ./tool-file-read.mjs 内容并解释代码`),
];
接下来,进入最烧脑的 Agent Loop(代理循环):
// 第一次召唤:LLM,请开始你的表演
// 此时 LLM 会分析用户的 intent,发现需要读取文件,
// 所以它不会直接返回文本,而是返回一个 "tool_calls" (工具调用请求)
let response = await modelWithTools.invoke(messages);
// 把 LLM 的回复(包含它想调用工具的意图)加入历史
messages.push(response);
// --- 进入循环 ---
// 只要 LLM 觉得还需要调用工具 (response.tool_calls 有内容),我们就陪它玩到底
while (response.tool_calls && response.tool_calls.length > 0) {
console.log(`\n[检测到 ${response.tool_calls.length} 个工具调用]`);
// 1. 执行工具
// LLM 可能一次性想调多个工具(比如并发读3个文件),所以用 Promise.all
const toolResults = await Promise.all(
response.tool_calls.map(async (tool_call) => {
// 根据名字找到对应的工具函数
const tool = tools.find(t => t.name === tool_call.name);
// 严谨的错误处理
if(!tool) return `错误:找不到工具 ${tool_call.name}`;
console.log(`[执行工具] ${tool_call.name}(${JSON.stringify(tool_call.args)})`);
try {
// tool_call.args 就是 LLM 从自然语言中提取出来的参数(比如 path: "./...")
// tool.invoke 会执行我们上面定义的那个 async 函数
const result = await tool.invoke(tool_call.args);
return result;
} catch (error) {
return `错误:${error.message}`;
}
})
);
// 2. 将工具执行结果反馈给 LLM
// 这步至关重要!LLM 只有看到了结果(ToolMessage),才知道下一步该说什么。
response.tool_calls.forEach((toolCall, index) => {
messages.push(
new ToolMessage({
content: toolResults[index], // 文件的真实内容
tool_call_id: toolCall.id, // 必须带上 ID,让 LLM 知道这是哪个调用的结果
})
);
});
// 3. 再次召唤 LLM
// 这次,messages 里包含了:
// [用户请求] -> [LLM想调工具] -> [工具返回了文件内容]
// LLM 看到文件内容后,就会开始进行代码解释,不再发起 tool_calls。
response = await modelWithTools.invoke(messages);
// 打印最终回复
console.log(response.content);
}
tool_calls内容截图:
🧐 深度解析:为什么这么繁琐?
你可能会问:“直接读文件不就行了吗?为什么要绕这么大圈子?”
兄弟,这就是 硬编码 (Hard Code) 和 智能 (Intelligence) 的区别!
- 意图识别:在这个流程里,我们没有写死“读取文件”。如果用户说“你好”,LLM 就不会触发 tool_calls,直接回复你好。如果用户说“帮我读代码”,它才会触发工具。这是动态的。
- 参数提取:用户可以说“读取当前目录下的那个 mjs 文件”,LLM 会自动理解并解析出
./tool-file-read.mjs,不需要正则匹配。 - 多轮交互:如果有报错(比如文件不存在),工具会返回错误信息,LLM 看到错误信息后,甚至可以自我修正(比如尝试换个路径),这就是 Agent 的雏形!
🎉 总结
今天我们完成了一个 最小版本的 Cursor 核心:
- 我们了解了 Agent = LLM + Memory + Tools。
- 我们学会了用 LangChain 定义一个 Tool(描述 + Schema + 函数体)。
- 我们手写了一个 While 循环 来处理 LLM 的工具调用请求,实现了“思考-执行-反馈”的闭环。
这只是第一步!想想看,如果我们再加一个 write_file 工具,加一个 run_command 工具,再配上一个能够检索整个项目结构的 Memory...
没错,那你离手搓一个真正的 Cursor 就不远了!🚀
本文代码基于 LangChain.js 0.3+ 版本,Node.js v20+ 环境测试通过。 原创不易,如果觉得有帮助,欢迎点赞收藏关注!👇