TOOL,让大模型自动干活:手把手教你用 LangChain 打造编程助手 Agent 读完这篇你也能让 AI 自己读文件、写代码、跑命令再也不用复制粘贴了。一、从“只会聊天”到“自动干活”你有没有想过让大模型直接帮你创建一个 React Vite 的 TodoList 项目你只需要说一句话创建一个 reactvite 的 todolist如果模型只是“动嘴”它会给你一堆命令行和代码你还得自己复制、粘贴、执行。但如果它能动工具呢第一步调用write_file工具创建项目文件第二步调用cli工具执行npm create vitelatest第三步再调用cli工具执行npm install和npm run dev。整个过程全自动你只需要看着终端滚动。这就是 Agent 的魅力——让 LLM 拥有“手”和“脚”。说白了工具Tool就是大模型的“外挂器官”让它能读文件、写代码、调 API、操作数据库……而我们要做的就是把这些工具注册给模型并教会它什么时候用、怎么用。今天我就带你手写一个简化版的 Claude Code Agent用 LangChain 把读文件、解释代码这件事跑通。最终你会理解 ReAct 工作流的核心并能快速扩展到任何工具。二、先徒手搓一个 Agent 原型不依赖框架在引入 LangChain 之前我们先理解 Agent 的本质LLM Tools Loop。// 伪代码 const messages [用户问题]; let response await llm.chat(messages); while (response 有工具调用) { 执行工具得到结果; 把结果拼回 messages; response await llm.chat(messages); } // 最终回复 console.log(response.content);就这么简单。LLM 本身是 stateless 的但我们可以通过不断维护 messages 数组把每一次工具调用的结果都喂回去让它“记住”已经做了什么再决定下一步。这也是为什么很多人说Agent 就是“带记忆的对话 工具执行器”。三、LangChain给你一套顺手工具别重复造轮子上面那个 loop 我们自己写没问题但真实场景下你要兼容多家 LLMOpenAI、DeepSeek、Claude…要处理流式、重试、结构化输出、工具 schema 校验……这些如果自己撸工程量不小。LangChain 早在 OpenAI 大火之前就诞生了它的核心价值就是给 LLM 开发提供一套统一抽象的框架。统一接口ChatOpenAI、ChatAnthropic……换模型只改一行配置工具抽象用tool()或tool装饰器定义工具自动生成 JSON Schema消息类型SystemMessage、HumanMessage、AIMessage、ToolMessage语义清晰。我们先看一段完整的代码基于 DeepSeek API这个 Agent 可以读文件并解释代码。四、代码实战从零搭建一个文件读取 Agent1. 环境准备npm init -y npm install langchain/openai langchain zod dotenv在项目根目录创建.envDEEPSEEK_API_KEYsk-xxxxxx2. 引入依赖和模型实例import dotenv/config; import { ChatOpenAI } from langchain/openai; import { tool } from langchain/core/tools; import { HumanMessage, SystemMessage, ToolMessage, AIMessage } from langchain/core/messages; import fs from node:fs/promises; import { z } from zod; const model new ChatOpenAI({ modelName: deepseek-chat, // 或者 deepseek-v4-flash apiKey: process.env.DEEPSEEK_API_KEY, temperature: 0, configuration: { baseURL: https://api.deepseek.com/v1, } });注意DeepSeek 兼容 OpenAI 接口所以我们用langchain/openai即可。3. 定义第一个工具read_Fileconst 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];tool()帮我们干了三件事把异步函数包装成可调用的工具对象根据schema生成 JSON Schema供模型理解参数结构自动处理参数校验。4. 绑定工具到模型const modelWithTools model.bindTools(tools);bindTools是 LangChain 提供的方法它会把工具列表以 OpenAI 兼容的tools参数附在每次请求中。这样模型就知道它现在有这些工具可以用。5. 构造初始消息const messages [ new SystemMessage( 你是一个代码助手可以使用工具读取文件并解释代码。 工作流程: 1. 用户要求读取文件时立即调用 read_File 工具。 2. 等待工具返回文件内容。 3. 基于文件内容进行分析和解释。 可用工具: - read_File: 读取文件内容 ), new HumanMessage(请读取 src/tool.mjs 文件内容并解释代码), ];五、深度剖析 Agent 核心循环while 的精髓这是整个 Agent 的“发动机”部分也是你问得最深入的地方。我们先看完整代码再逐一拆解。// 注意此处必须用 let不能用 const let response await modelWithTools.invoke(messages); messages.push(response); while (response.tool_calls response.tool_calls.length 0) { console.log(\n[检测到 ${response.tool_calls.length} 个工具调用); // ---------------------------------------------- // 核心并发执行区map async Promise.all // ---------------------------------------------- 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 tool.invoke(toolCall.args); return result; } catch (error) { return 错误: ${error.message}; } }) ); // 将工具结果打包成 ToolMessage 追加到消息历史 response.tool_calls.forEach((toolCall, index) { messages.push( new ToolMessage({ content: toolResults[index], tool_call_id: toolCall.id // 关键ID 必须匹配模型靠这个关联请求和结果 }) ); }); // 再次调用模型这次带着工具执行结果 response await modelWithTools.invoke(messages); messages.push(response); } // 循环结束输出最终答案 console.log(response.content);下面我们把这段代码里藏着的“魔鬼细节”一条条拎出来。5.1 为什么response必须用let而不能是const这是 JavaScript 作用域和可变性的基本问题但在 Agent 开发中极易踩坑。const定义的是常量引用。一旦const response await modelWithTools.invoke(...)赋值成功response这个变量就永远指向第一次大模型返回的那个对象。但在while循环的最后一行我们写了response await modelWithTools.invoke(messages);这是重新赋值操作。如果用constJavaScript 引擎会直接抛出TypeError: Assignment to constant variable错误程序崩溃。深层原因Agent 的工作流天然要求“状态迭代”。第一次调用模型得到的是“带工具调用的回复”第二次调用模型得到的是“基于工具结果的最终回复”。变量必须能指向不同的内存地址所以这里let是唯一正确的选择。金句在 Agent 的世界里response就像接力棒每一棒都在变化。const适合拿死数据let才适合跑动态循环。5.2 深入解析while循环中的map和async很多同学在这里会卡住不理解async写在map里到底返回了什么。第一层async函数的返回值一定是Promise当你写下response.tool_calls.map(async (toolCall) { ... })时无论async函数内部return什么外部.map()拿到的都是一个由Promise对象组成的新数组。如果内部return contentmap得到的是[Promise, Promise, ...]如果内部throw new Error()map得到的还是[Promise (rejected), ...]。第二层Promise.all的作用await Promise.all([Promise, Promise, ...])会并发执行所有 Promise等待它们全部完成或任意一个失败然后返回一个包含所有实际结果content或错误字符串的新数组。第三层为什么要用这种组合—— 性能飞升如果模型一次性要求调用 3 个工具比如读三个不同文件而我们是串行执行for循环await总耗时 文件1耗时 文件2耗时 文件3耗时。但用mapPromise.all总耗时 ≈Math.max(文件1, 文件2, 文件3)因为它们是同时发起的。在 Agent 工程中一次请求带多个tool_calls极其常见比如“对比 A、B、C 三个配置文件”这里用并发性能直接拉满。如果你的工具是网络请求这种写法甚至能省几秒钟。5.3 数据流转详解从模型输出到工具结果这是最核心的数据流动环节我们一步步拆解。1tool_calls里到底存了什么当模型决定调用工具时LangChain 会在AIMessage对象上挂载一个tool_calls属性它是一个数组每个元素的结构大概如下json{ id: call_abc123, name: read_File, args: { filePath: src/tool.mjs } }id唯一标识这次工具调用请求后续ToolMessage必须引用它。name工具名称我们在定义工具时指定的。args模型根据我们的 schema 生成的参数对象。response.tool_calls就是这样一个数组它就是模型“想让你做的事情”的清单。2map之前和之后数据发生了什么变化map之前response.tool_calls是包含上述对象的数组每个对象描述一个待执行的任务。map过程中我们对每个toolCall执行async函数这个函数内部会根据toolCall.name找到对应的工具实例调用tool.invoke(toolCall.args)这是一个异步操作返回一个Promise如果成功return工具的执行结果例如文件内容字符串如果失败return一个错误描述字符串catch块中。map之后实际上还没完map返回的是一个由 Promise 组成的新数组而不是最终值。所以我们才需要用await Promise.all(...)来解析所有这些 Promise。重要map内部return的东西并不是最终给toolResults的而是作为 Promise 的 resolve 值。return的时机是在异步操作完成后所以return result中的result就是工具执行完的真实数据。3toolResults最终存储了什么经过await Promise.all(...)我们得到toolResults它是一个数组顺序与response.tool_calls完全对应。每个元素就是对应工具调用的最终结果如果工具执行成功toolResults[i]就是tool.invoke()返回的值比如文件内容字符串。如果工具执行失败toolResults[i]就是catch块中返回的错误字符串。例如假设我们请求读两个文件一个存在一个不存在toolResults可能是[ export const add (a, b) a b;, // 文件1内容 错误: ENOENT: no such file or directory // 文件2不存在 ]5.4 为什么要把toolResults打包成ToolMessage追加这是 LangChain 的契约也是 Agent 能否持续推理的关键。每个ToolMessage必须关联一个tool_call_id这个 ID 必须与对应的toolCall.id一致。这样当模型看到这条消息时它知道这个结果是为了响应之前哪个请求。我们把toolResults[i]作为content塞进去相当于告诉模型“你之前让我读文件现在我把读到的内容给你。”如果不打包模型就会丢失工具的执行结果下一轮推理就变成了“盲猜”永远无法给出基于实际内容的回答。更深层次messages数组承载的是整个对话的“因果链条”。AIMessage含tool_calls是“因”ToolMessage含结果是“果”。把因果都记录下来模型才能正确地“推理”。金句tool_calls是模型开出的“处方”toolResults是抓好的“药”而ToolMessage是把药递回给模型“服用”的勺子——缺一不可。5.5 循环中的健壮性设计try/catch 与工具查找代码中藏着两处防御性编程tools.find(t t.name toolCall.name)万一模型“胡说八道”调用了一个不存在的工具我们不会崩溃而是返回错误描述字符串。try/catch包裹tool.invoke文件可能不存在、权限可能不足这些运行时错误会被捕获同样以字符串形式返回。这样做的好处是我们把错误当作普通内容喂回给模型。模型看到“错误: 找不到文件 xxx”可能会在下一次推理中修正路径或者向用户说明情况。Agent 就有了“自我纠错”的能力。六、循环结束后发生了什么收尾逻辑当while的条件response.tool_calls response.tool_calls.length 0为false时循环退出。这意味着要么模型压根没想调用工具直接回答了你的问题要么模型在拿到工具结果后认为信息已经足够决定直接生成最终回复。这时的response已经是最后一次调用大模型返回的AIMessage对象了。它不再包含tool_calls而是包含纯文本的content。我们执行console.log(response.content)就能看到模型对代码的详细解释。至此一个完整的ReAct推理-行动-观察闭环完成。如果把 Agent 比作一个厨师while循环是他“尝菜-加调料-再尝”的过程退出循环说明“味道完美可以出锅”response.content就是那道最终端上桌的菜。七、工程化与性能优化小结通过上面的深度剖析我们可以提炼出 Agent 开发的三条黄金法则状态变量用let只要涉及循环迭代中的重新赋值必须用let别纠结。工具调用用Promise.all并发mapasync是黄金搭档能并行的绝不串行。消息历史要完整AIMessage意图和ToolMessage结果缺一不可它们是模型推理的基石。八、扩展思路下一步你可以加什么这个基础框架可以轻松扩展写文件工具(write_File)让模型能修改代码执行命令工具(exec_command)让模型能跑npm install、git add网络请求工具(fetch_url)让模型能查文档记忆工具(向量检索)让模型能访问知识库。你只需要在tools数组里追加定义模型就会自动学会使用它们。工具是插件Agent 是运行时。九、总结你学到了什么通过这篇文章你不仅掌握了完整的 Agent 代码更深入理解了Agent 的本质LLM 工具 循环让模型能自动调用外部能力LangChain 工具定义用tool()zod轻松创建带 schema 的工具工作流编排bindTools→invoke→ 检测tool_calls→ 执行 → 回填ToolMessage→ 再次invoke并发与错误处理用Promise.allmap提升性能用try/catch构建容错机制数据流转细节tool_calls的结构map与async的返回机制toolResults的真实内容以及打包ToolMessage的必要性JavaScript 高级特性为什么response必须用letasync在map中如何返回 Promise 数组。最后一句总结Agent 开发不是让模型更聪明而是给它一双能干活的手再给它一个不会失忆的循环。现在就动手试试吧把你日常的重复工作变成一行命令。