TOOL,让大模型自动干活:手把手教你用 LangChain 打造编程助手 Agent
读完这篇,你也能让 AI 自己读文件、写代码、跑命令,再也不用复制粘贴了。
一、从“只会聊天”到“自动干活”
你有没有想过,让大模型直接帮你创建一个 React + Vite 的 TodoList 项目?你只需要说一句话:
创建一个 react+vite 的 todolist
如果模型只是“动嘴”,它会给你一堆命令行和代码,你还得自己复制、粘贴、执行。但如果它能动工具呢?
- 第一步:调用
write_file工具,创建项目文件; - 第二步:调用
cli工具,执行npm create vite@latest; - 第三步:再调用
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 我们自己写没问题,但真实场景下,你要兼容多家 LLM(OpenAI、DeepSeek、Claude…),要处理流式、重试、结构化输出、工具 schema 校验……这些如果自己撸,工程量不小。
LangChain 早在 OpenAI 大火之前就诞生了,它的核心价值就是给 LLM 开发提供一套统一抽象的框架。
- 统一接口:
ChatOpenAI、ChatAnthropic……换模型只改一行配置; - 工具抽象:用
tool()或@tool装饰器定义工具,自动生成 JSON Schema; - 消息类型:
SystemMessage、HumanMessage、AIMessage、ToolMessage,语义清晰。
我们先看一段完整的代码(基于 DeepSeek API),这个 Agent 可以读文件并解释代码。
四、代码实战:从零搭建一个文件读取 Agent
1. 环境准备
npm init -y
npm install @langchain/openai langchain zod dotenv
在项目根目录创建 .env:
DEEPSEEK_API_KEY=sk-xxxxxx
2. 引入依赖和模型实例
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_File
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];
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);,这是重新赋值操作。 - 如果用
const,JavaScript 引擎会直接抛出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 content,map得到的是[Promise, Promise, ...]; - 如果内部
throw new Error(),map得到的还是[Promise (rejected), ...]。
第二层:Promise.all 的作用
await Promise.all([Promise, Promise, ...]) 会并发执行所有 Promise,等待它们全部完成(或任意一个失败),然后返回一个包含所有实际结果(content 或错误字符串)的新数组。
第三层:为什么要用这种组合?—— 性能飞升
如果模型一次性要求调用 3 个工具(比如读三个不同文件),而我们是串行执行(for 循环 await),总耗时 = 文件1耗时 + 文件2耗时 + 文件3耗时。
但用 map + Promise.all,总耗时 ≈ Math.max(文件1, 文件2, 文件3),因为它们是同时发起的。
在 Agent 工程中,一次请求带多个 tool_calls 极其常见(比如“对比 A、B、C 三个配置文件”),这里用并发,性能直接拉满。如果你的工具是网络请求,这种写法甚至能省几秒钟。
5.3 数据流转详解:从模型输出到工具结果
这是最核心的数据流动环节,我们一步步拆解。
(1)tool_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 就是这样一个数组,它就是模型“想让你做的事情”的清单。
(2)map 之前和之后,数据发生了什么变化?
-
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 就是工具执行完的真实数据。
(3)toolResults 最终存储了什么?
经过 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并发:map+async是黄金搭档,能并行的绝不串行。 - 消息历史要完整:
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.all+map提升性能,用try/catch构建容错机制; - 数据流转细节:
tool_calls的结构,map与async的返回机制,toolResults的真实内容,以及打包ToolMessage的必要性; - JavaScript 高级特性:为什么
response必须用let,async在map中如何返回 Promise 数组。
最后一句总结:Agent 开发不是让模型更聪明,而是给它一双能干活的手,再给它一个不会失忆的循环。 现在就动手试试吧,把你日常的重复工作变成一行命令。
更多推荐
所有评论(0)