**## 前言
现在很多 AI 代码工具(Cursor、CodeGeeX 等)都具备读取本地文件、自动分析代码的能力,底层核心就是 LLM Function Calling(工具调用) 。本文使用 LangChain JS + DeepSeek 大模型,手写一套可读取本地文件、自动解析代码的轻量 Agent,不使用高阶 Agent 执行器,手动走完「模型判断调用工具→执行本地 IO→工具结果回传模型→输出代码解读」完整链路,彻底搞懂 Tool 调用的底层原理。
技术栈说明
@langchain/openai:统一 LLM 封装,兼容所有遵循 OpenAI 协议的大模型@langchain/core:提供tool工具装饰器、四类标准消息基类zod:工具参数强类型校验,规范 LLM 传参格式dotenv:管理 API 密钥,避免硬编码泄露node:fs/promises:Node.js 原生异步文件读写能力
一、前置环境与依赖安装
1.1 项目初始化
bash
运行
bash
mkdir hello-langchain && cd hello-langchain
npm init -y
1.2 安装全部依赖
bash
运行
bash
npm install dotenv @langchain/openai @langchain/core zod
1.3 配置环境变量
项目根目录新建 .env 文件,填入你的 DeepSeek 密钥:
env
ini
DEEPSEEK_API_KEY=sk-xxx你的密钥xxx
二、完整可运行源码(逐行注释版)
文件名:tool.mjs
javascript
运行
javascript
// 加载 .env 环境变量到 process.env,重复导入不生效,仅执行一次
import 'dotenv/config';
// 导入 LangChain 封装的 OpenAI 兼容模型类
// 注意:名字虽带 OpenAI,但所有支持 OpenAI 接口格式的模型都能用(DeepSeek、通义千问等)
import { ChatOpenAI } from '@langchain/openai';
// 导入工具定义函数,用来把普通 JS 函数包装成 LLM 可识别的标准工具
import { tool } from '@langchain/core/tools';
// 导入 LangChain 四类标准消息对象,用来维护完整对话上下文
import {
HumanMessage, // 用户发送的消息,对应 role: 'user'
SystemMessage, // 系统提示词消息,对应 role: 'system',设定 AI 角色与规则
ToolMessage, // 工具执行结果消息,对应 role: 'tool',把工具返回值传给模型
AIMessage // AI 模型返回的消息,对应 role: 'assistant'
} from '@langchain/core/messages';
// 导入 Node.js 原生异步文件模块,返回 Promise,可配合 await 使用
import fs from 'node:fs/promises';
// 导入 Zod 类型校验库,给工具入参做格式约束
import { z } from 'zod';
// ===================== 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 的 OpenAI 兼容接口地址
}
});
// ===================== 2. 定义文件读取工具 =====================
// tool() 接收两个参数:① 工具执行函数 ② 工具元数据(给模型看的说明书)
const readFileTool = tool(
// 第一参数:工具真实执行的异步逻辑
async ({ filePath }) => {
// 以 UTF-8 格式读取指定路径的文件
const content = await fs.readFile(filePath, 'utf-8');
// 控制台打印日志,方便调试,观察模型什么时候调用了工具
console.log(`[工具调用]read_file(${filePath}) 成功读取${content.length}字节内容`);
// 返回值会被封装成 ToolMessage,最终传回给大模型
return content;
},
// 第二参数:工具元数据,大模型靠这些信息判断「什么时候调用、传什么参数」
{
name: 'read_file', // 工具唯一名称,模型调用时通过 name 匹配工具
description: `读取本地文件内容,当用户要求查看代码、分析文件、读取脚本时必须调用此工具;支持相对路径/绝对路径`,
schema: z.string().describe('待读取的文件路径') // 参数约束:必须是字符串,附带描述
}
);
// 工具列表数组,后续新增工具直接往数组里追加即可
const tools = [readFileTool];
// ===================== 3. 把工具绑定到模型 =====================
// bindTools 会把工具描述注入到模型请求中,让模型知道自己有哪些工具可用
const modelWithTools = model.bindTools(tools);
// ===================== 4. 初始化对话上下文消息队列 =====================
// messages 数组保存完整对话历史,每一轮交互都往里面追加消息
const messages = [
// 系统提示词:给 AI 设定角色、工作流程、行为约束
new SystemMessage(`
你是专业代码助手,仅能通过read_file工具获取本地文件内容,严格遵循流程:
1. 用户要求读取/解析文件时,必须调用read_file工具,禁止凭空编造代码;
2. 等待工具返回完整文件文本;
3. 基于真实文件内容做逻辑拆解、逐行注释、问题分析。
可用工具:read_file 读取本地文件
`),
// 用户的提问:触发工具调用的入口
new HumanMessage('请读取tool.mjs文件内容,并完整解释这份LangChain工具调用代码')
];
// ===================== 5. 主执行流程(完整工具调用链路) =====================
async function runAgent() {
// 第一轮 invoke:把对话历史发给模型,让模型推理是否需要调用工具
// invoke = 发起一次完整的模型推理请求,等待全部结果返回
let response = await modelWithTools.invoke(messages);
// 把模型返回的结果加入对话历史,维持上下文
messages.push(response);
// 判断模型是否返回了工具调用指令
if (response.tool_calls?.length > 0) {
// 遍历所有工具调用(支持同时调用多个工具)
for (const toolCall of response.tool_calls) {
const { name, args, id } = toolCall;
let toolResult;
// 根据工具名称匹配,执行对应工具逻辑
if (name === 'read_file') {
// 调用工具的 invoke 方法,传入参数,拿到执行结果
toolResult = await readFileTool.invoke(args);
}
// 把工具执行结果包装成 ToolMessage,必须携带 tool_call_id
// 模型通过这个 id 匹配:这次结果对应哪一次工具调用
messages.push(new ToolMessage({
tool_call_id: id,
content: toolResult
}));
}
// 第二轮 invoke:把工具结果一起发给模型,让模型基于真实文件内容生成最终回答
const finalAns = await modelWithTools.invoke(messages);
console.log("\n===== AI 代码解析结果 =====");
console.log(finalAns.content);
} else {
// 如果不需要调用工具,直接打印模型的文字回答
console.log(response.content);
}
}
// 启动 Agent
runAgent();
三、核心代码逐模块深度拆解
3.1 依赖导入模块:每个包的作用
表格
| 导入项 | 核心作用 | 易错点 |
|---|---|---|
dotenv/config |
自动读取根目录 .env 文件,把变量注入 process.env |
重复导入无效,代码里写两次属于冗余,不报错但不推荐 |
ChatOpenAI |
LLM 统一调用入口,兼容所有 OpenAI 协议模型 | 对接非 OpenAI 模型必须改 baseURL,否则会请求到 OpenAI 官网 |
tool |
标准化工具封装函数,自动生成符合模型要求的工具描述 | 入参结构固定:先执行函数,再元数据对象 |
| 四类 Message | 统一对话消息格式,维护上下文链路 | ToolMessage 必须带 tool_call_id,否则模型无法匹配上下文 |
z (Zod) |
运行时参数校验,强制 LLM 传参符合格式 | schema 描述越清晰,模型传参准确率越高 |
3.2 大模型初始化:为什么能这么对接 DeepSeek
DeepSeek 官方提供了 OpenAI 兼容接口,因此可以直接用 ChatOpenAI 类对接,只需要修改两个配置:
baseURL换成 DeepSeek 的接口地址;modelName换成 DeepSeek 的模型名。
关键配置 temperature: 0
- 取值范围 0~2,值越大输出越随机、越有创造性;
- 工具调用、代码分析场景必须设为 0,避免模型编造文件内容、胡乱调用工具。
3.3 Tool 工具定义:二分结构是核心
tool(执行函数, 元数据) 是 LangChain 工具的标准定义方式,两部分各司其职:
第一部分:异步执行函数
- 是工具真正的业务逻辑,可以是读文件、调接口、查数据库;
- 入参由大模型生成传递,返回值会传回给大模型;
- 必须是异步函数(async),因为工具操作大多是 IO 耗时操作。
第二部分:元数据配置
name:工具唯一标识,模型调用时靠它匹配;description:给模型看的「使用说明书」,写得越详细,模型调用准确率越高;schema:Zod 参数约束,相当于给模型定死「你必须传什么类型的参数」,同时附带自然语言描述。
3.4 bindTools:给模型装上「工具箱」
js
运行
ini
const modelWithTools = model.bindTools(tools);
bindTools 做了两件事:
- 把所有工具的名称、描述、参数格式,按照 OpenAI 工具调用协议格式化;
- 每次调用模型时,自动把工具列表塞进请求体里发给模型。
绑定后的 modelWithTools 有两种输出可能:
- 不需要工具:返回普通文本回答;
- 需要工具:返回
tool_calls结构化指令,不输出文本。
3.5 对话上下文:为什么要用消息数组
LLM 是无状态的,每一次请求都必须把完整对话历史发过去,模型才能知道之前说了什么、工具返回了什么。
四类消息的协作流程:
SystemMessage先定规则;HumanMessage用户提需求;AIMessage模型回复「我要调用工具」;ToolMessage把工具执行结果还回去;- 模型再输出最终回答。
3.6 主流程:两次 invoke 分别在干嘛
整个流程一共调用了两次 invoke,这是工具调用的标准链路:
第一次 invoke:判断要不要用工具
- 输入:系统提示 + 用户问题
- 模型推理:这个问题能不能直接答?要不要读文件?
- 输出:工具调用指令(包含工具名、参数、调用 ID)
第二次 invoke:拿到结果后生成答案
- 输入:之前的全部对话 + 工具执行结果
- 模型推理:根据真实文件内容,组织语言输出代码解读
- 输出:自然语言的最终回答
invoke是 LangChain 的统一执行入口,不管是模型、工具还是链,都用.invoke(入参)来执行,含义就是「传入输入、执行逻辑、返回完整结果」。
四、关键知识点:tool_calls 数据结构解析
模型返回的 AIMessage 核心结构简化示例:
json
css
{
"content": "",
"tool_calls": [
{
"name": "read_file",
"args": { "input": "tool.mjs" },
"id": "call_00_TjGbBoBzcvEKkL56qzbF9059"
}
],
"finish_reason": "tool_calls"
}
content为空:模型不输出文字,只下发工具调用指令;finish_reason: "tool_calls":本轮对话终止原因是触发工具调用,程序必须处理工具后二次请求;id:工具调用唯一标识,ToolMessage必须携带相同 ID,否则模型无法关联上下文。
五、常见踩坑点解决方案
5.1 ERR_MODULE_NOT_FOUND 找不到 dotenv
- 原因:没有安装依赖,或项目移动目录后 pnpm 软链接失效;
- 解决:删除
node_modules和锁文件,重新执行pnpm install或npm install。
5.2 工具调用后模型仍然编造代码
- 检查
temperature是否设为 0; - SystemMessage 里明确约束「禁止编造文件内容,必须调用工具」;
- 工具 description 写清楚适用场景。
5.3 Timer 'xxx' does not exist
console.time 和 console.timeEnd 的标签名必须完全一致,大小写都不能差,否则会报计时器不存在。
5.4 Promise 串行 vs 并行耗时问题
- 变量形式
const p = new Promise():定义时就立刻执行,多个 Promise 默认并行计时; - 函数形式
function getP(){return new Promise()}:调用时才执行,分开 await 就是串行,时间相加; Promise.all([p1, p2]):强制并行,总耗时 = 最慢那个任务的耗时。
六、拓展延伸:多工具并行与 Promise.all
复杂 Agent 可能同时调用多个工具,串行执行会很慢,用 Promise.all 并发执行可以大幅提速:
js
运行
javascript
// 把所有工具调用包装成 Promise 数组
const toolTasks = response.tool_calls.map(async (call) => {
let result;
if (call.name === 'read_file') result = await readFileTool.invoke(call.args);
// 后续新增工具在这里扩展匹配
return new ToolMessage({
tool_call_id: call.id,
content: result
});
});
// 并行等待所有工具执行完成
const toolMessages = await Promise.all(toolTasks);
// 一次性全部加入上下文
messages.push(...toolMessages);
- 串行执行:总耗时 = 所有工具耗时相加;
Promise.all并行:总耗时 = 执行最慢的单个工具耗时。
七、总结
- Tool 调用核心逻辑:LLM 识别场景下发指令 → 本地执行 IO / 接口 → 结果回传模型生成最终回答;
tool()二分结构:业务执行函数 + Zod 参数约束 + 描述元数据,缺一不可;- 四类消息维持完整上下文,
tool_call_id是工具与模型上下文关联的关键; ChatOpenAI兼容全部 OpenAI 协议大模型,切换模型仅需修改baseURL与modelName;- 手动实现工具链路,能彻底理解 Cursor、CodeGeeX 等代码 AI 的底层运行原理,后续可轻松扩展文件写入、命令执行、知识库检索等更多 Agent 能力。**