给大模型装上“手和脚”,让它从“嘴炮”变成“实干家”。
你使用过大模型的 API 吗?发一个 prompt,等几秒,拿到一段文本——这已经是常规操作。但当你真的想让 AI 帮你读项目代码、自动创建文件、执行终端命令时,你会发现它只会给你“建议”,却无法真正动手。原因很简单:大模型(LLM)本质上是 stateless 的文本生成器,它没有记忆,更无法与外部世界交互。
Agent(智能体) 正是为了解决这个问题而生的。它在 LLM 之上构建了一套增强系统,赋予模型 记忆(Memory) 、工具调用(Tool Use) 、检索增强(RAG) 等能力,让它不仅能思考,更能行动。
本文会带你从零开始,用 LangChain 亲手打造一个具备文件读取能力的 Agent。我们会深入拆解:
- Agent 的工作流程
- LangChain 的核心抽象
- 工具调用 ID(tool_call_id)的作用与匹配机制
- Promise 异步机制与 Promise.all 并发优化
每一行代码都会附上详细解析,确保你“读完就能用”。
一、Agent 是什么?——补上大模型的“先天短板”
在动手之前,我们先弄清 Agent 要解决哪些痛点:
| 大模型的缺陷 | Agent 的解决方案 |
|---|---|
| 无状态,记不住历史对话 | Memory 模块:持久化存储对话记录(数据库、Redis 等) |
| 只会动嘴,不能操作文件、网页、系统 | Tool Use 模块:将外部函数封装成工具,供模型调用 |
| 知识截止于训练数据,不知道最新信息 | RAG(检索增强生成) :从外部知识库检索相关内容再生成 |
| 无法访问企业内部私有文档 | 结合 RAG + 私有向量数据库 |
| 复杂任务需要多步骤规划 | Planning/Reasoning:模型自主分解任务,按序调用工具 |
Agent = LLM + Memory + Tool Use + RAG + MCP + Skills
它知道自己能干什么、该用什么工具、如何记住重要信息——这就是你的 AI 得力助手。
二、Agent 的工作流程详解(思考→行动→观察循环)
一个典型的 Agent 执行任务时会经历 “思考 → 行动 → 观察” 的循环,直到任务完成。我们拆解每一步:
-
用户输入(User Prompt)
用户用自然语言提出一个复杂任务,例如:“读取tool.mjs文件并解释它的作用”。 -
规划与推理(Planning / Reasoning)
Agent 将任务交给 LLM。模型会分析意图,判断是否需要:- 加载历史记忆(Memory)
- 调用某个或多个工具(Tool)
- 从 RAG 检索辅助知识
-
工具调用(Tool Call)
如果模型决定调用工具,它会生成一个特殊的tool_calls结构,包含:id:一个唯一标识符(UUID),用于后续匹配工具执行结果。name:要调用的工具名称。args:符合工具 Schema 的参数对象。
注意: 此时模型不会直接输出最终答案,而是“暂停”并等待工具返回结果。
-
执行工具(Tool Execution)
开发者(或框架)负责解析tool_calls,调用对应的函数(如读取文件、查询数据库),得到结果。 -
关联结果与调用(Matching with ID)
执行完成后,我们必须将结果包装成ToolMessage,并原样带上原始调用中的id。模型依赖这个 ID 将结果对应到正确的调用,尤其当多个工具并发执行时,这个 ID 是唯一的路由凭证。 -
观察结果(Observation)
将ToolMessage追加到对话历史中,再次交给模型。 -
生成最终回复(Final Response)
模型基于工具返回的数据,结合原始问题,生成自然语言答案返回给用户。
如果任务尚未完成(例如需要连续调用多个工具),这个循环会重复执行。LangChain 和 LangGraph 提供了 AgentExecutor 等组件来自动管理这个循环。
三、LangChain 框架:为什么选它?
LangChain 是构建 Agent 最成熟的开源框架之一(支持 Python 和 JavaScript)。在 JS/TS 生态中,它提供了:
- 统一的模型接口:
ChatOpenAI、ChatAnthropic等,方便切换不同厂商的 LLM。 - 工具抽象:
tool()函数将任意异步函数包装为符合 OpenAI 工具调用规范的对象。 - 消息管理:
SystemMessage、HumanMessage、AIMessage、ToolMessage标准化对话轮次。 - 绑定机制:
model.bindTools(tools)将工具列表注册到模型,让模型自动决策。 - 内置 ID 管理:LangChain 自动为每次工具调用生成唯一 ID,并确保
ToolMessage正确引用该 ID。
LangChain 的出现比 OpenAI 函数调用(Function Calling)还早,它屏蔽了各家 API 的差异,让我们专注于业务逻辑。
四、实战:用 LangChain 构建一个“读文件” Agent
我们从零开始,逐步构建一个能读取文件内容并解释代码的 Agent。代码即文档,每一行我都会详细解析。
4.1 环境初始化
npm init -y
npm install @langchain/openai @langchain/core dotenv zod
# 或者用 pnpm / yarn
创建 .env 文件,存放你的 API 密钥(这里以 DeepSeek 为例,兼容 OpenAI 接口):
DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxx
解析:
@langchain/openai:封装了 OpenAI 兼容的模型调用(包括 DeepSeek、通义千问等)。@langchain/core:提供核心抽象(tool、messages、ChatOpenAI基类)。dotenv:加载环境变量。zod:用于定义工具参数的 Schema,确保类型安全。
4.2 初始化模型
新建 index.mjs(使用 ES Module):
import { ChatOpenAI } from '@langchain/openai';
import 'dotenv/config';
// 1. 实例化模型客户端
const model = new ChatOpenAI({
modelName: 'deepseek-v4-flash', // 模型名称
apiKey: process.env.DEEPSEEK_API_KEY, // 从环境变量读取
temperature: 0, // 0 表示确定性输出,适合工具调用
configuration: {
baseURL: 'https://api.deepseek.com/v1', // DeepSeek 的兼容端点
},
});
// 2. 简单调用测试
const response = await model.invoke('棍王杯台球比赛应该设什么奖励?');
console.log(response.content);
代码解析:
ChatOpenAI是 LangChain 提供的聊天模型类。它的invoke方法发送消息并返回AIMessage对象。modelName和apiKey必填。configuration.baseURL可以指向任何兼容 OpenAI 格式的代理地址。temperature = 0使输出更确定,适合工具调用场景(高温度会增加随机性,可能导致模型滥用工具)。invoke是 LangChain 统一的消息入口,底层会调用client.chat.completions.create()。
此时我们已经能正常调用大模型,但模型还不能调用工具。
4.3 定义一个工具:读取文件
新建 tool.mjs,我们将在这个文件中完成完整的工具定义和调用流程。
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { tool } from '@langchain/core/tools';
import {
SystemMessage,
HumanMessage,
ToolMessage,
AIMessage,
} from '@langchain/core/messages';
import fs from 'node:fs/promises';
import { z } from 'zod';
解析:
-
tool是一个高阶函数,接受两个参数:执行函数和配置对象,返回一个Tool实例。 -
消息类型:
SystemMessage:系统提示,设定角色和行为准则。HumanMessage:用户输入。AIMessage:模型的回复(可能包含tool_calls)。ToolMessage:工具执行后的结果,必须与tool_call_id对应。
-
fs/promises:Node.js 原生异步文件操作。 -
zod:定义参数结构。
定义 readFileTool
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('要读取的文件路径'),
}),
}
);
逐行解析:
-
执行函数:接收一个对象参数
{ filePath },这是由模型根据 schema 生成的。函数内部使用fs.readFile读取文件,返回内容字符串。工具可以返回任何可序列化的数据(字符串、对象、数组等)。 -
日志输出:在终端打印工具调用情况,让开发者感知进度(对于耗时任务尤其重要,避免用户以为程序卡死)。
-
元数据配置:
name:工具的唯一标识,模型在tool_calls中会使用这个名字。description:这是模型决策的关键! 模型会根据描述来判断何时调用此工具,所以要尽可能详细,覆盖典型使用场景。schema:使用 Zod 定义参数类型和描述。模型会根据 schema 生成符合格式的 JSON 参数。z.string().describe(...)既提供了类型校验,又给模型提供了参数说明。
绑定工具到模型
const tools = [readFileTool];
const modelWithTools = model.bindTools(tools);
解析:
model.bindTools(tools)会生成一个新的模型实例,它内部会将工具列表转换为 OpenAI Function Calling 格式,并在每次invoke时自动注入到请求中。- 绑定后,模型在生成回复时会根据用户消息和系统提示,自主决定是否调用工具以及调用哪个工具。
构造对话消息
const messages = [
new SystemMessage(`
你是一个代码助手,可以使用工具读取文件并解释代码。
工作流程:
1. 用户要求读取文件时,立即调用 read_File 工具。
2. 等待工具返回文件内容。
3. 基于文件内容进行分析和解释。
可用工具:
- read_File: 读取文件内容(使用此工具来获取文件内容)
`),
new HumanMessage('请读取 tool.mjs 文件内容并解释代码'),
];
解析:
SystemMessage用于设定 AI 的角色和规则。我们明确告诉模型:当需要读取文件时,必须调用工具,不要自己编造内容。- 这种提示工程(Prompt Engineering)对于 Agent 的稳定性至关重要。清晰的指令能让模型少犯错。
第一次调用:模型决策是否使用工具
let response = await modelWithTools.invoke(messages);
messages.push(response);
console.log(JSON.stringify(response, null, 2));
解析:
-
调用
modelWithTools.invoke(messages),LangChain 会将messages发送给 API。 -
由于模型绑定了工具,并且用户要求读取文件,模型会返回一个
AIMessage,其中包含tool_calls字段,而不是直接的文本内容。 -
tool_calls是一个数组,每个元素包含:id:唯一标识此次调用的 ID(例如"call_abc123"),由模型提供商生成。这个 ID 至关重要,它用于后续将工具执行结果与这次调用关联起来。name:要调用的工具名称(read_File)。args:工具参数对象({ filePath: 'tool.mjs' })。
-
我们将该
AIMessage追加到messages中,保留完整的对话上下文。
执行工具并返回结果(含 Tool ID 深度解析)
接下来我们需要手动(或借助 AgentExecutor)处理 tool_calls:
// 遍历所有 tool_calls
for (const toolCall of response.tool_calls) {
if (toolCall.name === 'read_File') {
// ① 调用工具的执行函数
const result = await readFileTool.invoke(toolCall.args);
// ② 构造 ToolMessage,务必带上 tool_call_id
const toolMessage = new ToolMessage({
content: result,
tool_call_id: toolCall.id, // 必须与原始调用 ID 一致
});
messages.push(toolMessage);
}
}
// 第二次调用模型,让模型根据工具结果生成最终回答
const finalResponse = await modelWithTools.invoke(messages);
console.log(finalResponse.content);
代码详解:
-
readFileTool.invoke(toolCall.args)会执行我们定义好的异步函数,返回文件内容。 -
ToolMessage的tool_call_id必须和原始tool_calls[].id完全一致。为什么?- 在大模型中,一次请求可以包含多个工具调用(例如同时读 3 个文件)。模型需要知道哪个结果对应哪个调用,以便正确理解上下文。
- 即使只有一个工具调用,ID 也是必需的,因为模型内部将工具调用视为“异步事件”,返回结果时需要明确归属。
- 如果 ID 不匹配,模型会忽略该结果或报错,导致 Agent 流程中断。
- 在并发场景下,ID 的作用更加凸显:当多个工具并行执行时,结果的返回顺序可能与调用顺序不同。只有通过 ID 才能将结果精准地路由到对应的调用位置,保证对话逻辑的正确性。
-
将
ToolMessage追加到messages后,再次调用modelWithTools.invoke(messages)。此时模型看到工具返回的内容,会基于此生成最终的自然语言回复。 -
最终
finalResponse.content就是用户想要的解释。
金句:tool_call_id 是连接“请求”与“结果”的唯一凭证,是多工具并发场景下的定海神针。
五、LangChain 的消息流转机制(含 ID 路由)
为了帮助理解,我们梳理一下 LangChain 中的消息类型和流转顺序,并特别标注 ID 的流动:
- 用户输入 →
HumanMessage - 系统指令 →
SystemMessage - 模型决策(含
tool_calls,每个带有唯一id)→AIMessage - 工具执行 → 返回结果,封装为
ToolMessage,并必须携带对应的tool_call_id - 模型根据 ID 将结果匹配到之前的调用,生成最终回答 →
AIMessage
所有消息按顺序存储在 messages 数组中,形成一个完整的对话历史。这个历史会被持续传递给模型,使其具备上下文感知能力。ID 的匹配过程由 LangChain 或开发者显式处理,确保无误。
六、并发优化:Promise 与 Promise.all 深入解析
在 Agent 场景中,模型可能会一次性返回多个工具调用(比如一次读取 3 个文件,或同时搜索网页和查询数据库)。如果我们串行执行这些工具,总耗时是每个工具耗时的累加,用户体验会很差。
JavaScript 的异步特性让我们可以并发执行这些 I/O 操作。下面我们深入讲解 Promise 和 Promise.all,并强调在并发时如何正确处理 ID。
6.1 Promise 的三个状态
- pending:初始状态,异步操作尚未完成。
- fulfilled:操作成功完成,
resolve(value)被调用。 - rejected:操作失败,
reject(error)被调用。
关键规则: 一旦状态从 pending 变为 fulfilled 或 rejected,就不能再改变。
6.2 Promise.all —— 并行执行多个异步任务
Promise.all(iterable) 接收一个 Promise 数组,返回一个新的 Promise。它会在所有输入 Promise 都变为 fulfilled 后,返回一个结果数组(顺序与输入一致);如果任何一个 Promise rejected,则立即 rejected。
在 Agent 中,我们可以这样使用:
// 假设 response.tool_calls 包含多个调用
const toolCalls = response.tool_calls; // 例如长度为 3
// 并发执行所有工具
const toolResults = await Promise.all(
toolCalls.map(async (call) => {
const result = await readFileTool.invoke(call.args);
return new ToolMessage({
content: result,
tool_call_id: call.id, // 每个结果带上自己对应的 ID
});
})
);
// 一次性追加所有 ToolMessage
messages.push(...toolResults);
性能对比:
- 串行:
t1 + t2 + t3(例如 2s + 1s + 0.5s = 3.5s) - 并发:
max(t1, t2, t3)(大约 2s)
ID 在并发中的关键作用:由于 Promise.all 会并发执行,各个工具完成的顺序可能不同于传入顺序。但我们在构造 ToolMessage 时,从 call.id 中读取了原始 ID,因此无论结果返回先后,每个消息都带上了正确的 ID,模型能正确归因。
6.3 容错:使用 Promise.allSettled
如果某个工具可能失败(例如文件不存在),Promise.all 会整体失败,导致其他工具的结果也丢失。这时可以用 Promise.allSettled:
const results = await Promise.allSettled(
toolCalls.map(call => readFileTool.invoke(call.args))
);
// 遍历 results,分别处理成功和失败的情况
results.forEach((result, index) => {
const callId = toolCalls[index].id; // 根据索引获取对应的 ID
if (result.status === 'fulfilled') {
messages.push(new ToolMessage({
content: result.value,
tool_call_id: callId,
}));
} else {
// 将错误信息作为工具结果返回,让模型知道出错
messages.push(new ToolMessage({
content: `Error: ${result.reason.message}`,
tool_call_id: callId,
}));
}
});
注意:
Promise.allSettled的结果数组中,每个元素都有status和value(或reason)。我们通过原数组索引获取对应的callId,确保即使失败也能正确关联。
6.4 并发示例:模拟天气和推文请求
下面是一个简单的演示(来自笔记中的 1.html),直观展示 Promise.all 的并发优势:
function getWeather() {
return new Promise(resolve => {
setTimeout(() => resolve({ temp: 38, conditions: 'Sunny' }), 2000);
});
}
function getTweets() {
return new Promise(resolve => {
setTimeout(() => resolve(['I like cake', 'BBQ is good']), 500);
});
}
async function main() {
console.time('concurrent');
const [weather, tweets] = await Promise.all([getWeather(), getTweets()]);
console.timeEnd('concurrent'); // 大约 2 秒,而不是 2.5 秒
console.log(weather, tweets);
}
main();
解析:
getWeather耗时 2s,getTweets耗时 0.5s。- 串行执行需要 2.5s,但用
Promise.all并发执行,总耗时 ≈ 2s(由最长的任务决定)。 - 返回结果数组的顺序与传入 Promise 的顺序一致,确保
weather对应getWeather,tweets对应getTweets。
6.5 在 Agent 中运用并发:最佳实践
- 识别独立的工具调用:只有那些不依赖彼此结果的工具才能并发。如果
tool2需要tool1的输出,则必须串行。 - 限制并发数量:如果工具数量非常多(例如几十个),可以使用
p-limit等库控制并发数,避免系统过载。 - 超时控制:为工具设置超时(
Promise.race),防止单个工具卡住拖慢整个流程。 - ID 管理:始终从原始
tool_call中提取id并正确传递给ToolMessage,这是并发安全的基石。
七、总结与下一步
我们走完了从理论到实践的完整旅程:
- ✅ 理解了 Agent 的核心组成(Memory、Tool、RAG、MCP、Skills)和工作流程(规划 → 调用 → 观察)。
- ✅ 掌握了 LangChain 的核心抽象:
ChatOpenAI、tool、bindTools、消息类型。 - ✅ 深入解析了 tool_call_id 的作用:它是连接调用请求与执行结果的唯一凭证,尤其在多工具并发场景下不可或缺。
- ✅ 亲手构建了一个可读取文件的 Agent,并深度解析了每段代码。
- ✅ 学会了用
Promise.all并发执行工具调用,大幅提升响应速度。 - ✅ 了解了
Promise的状态和容错策略(allSettled)。
Agent 的本质,就是让大模型拥有“手和脚”。而开发 Agent,本质上就是在设计一套“思考-行动”的闭环系统,其中 ID 是串联各环节的“回形针”。