LangChain JS 工具调用实战:基于 DeepSeek-V4-Flash 实现本地文件读取 AI 代码助手(完整可运行源码)

**## 前言

现在很多 AI 代码工具(Cursor、CodeGeeX 等)都具备读取本地文件、自动分析代码的能力,底层核心就是 LLM Function Calling(工具调用) 。本文使用 LangChain JS + DeepSeek 大模型,手写一套可读取本地文件、自动解析代码的轻量 Agent,不使用高阶 Agent 执行器,手动走完「模型判断调用工具→执行本地 IO→工具结果回传模型→输出代码解读」完整链路,彻底搞懂 Tool 调用的底层原理。

技术栈说明

  1. @langchain/openai:统一 LLM 封装,兼容所有遵循 OpenAI 协议的大模型
  2. @langchain/core:提供 tool 工具装饰器、四类标准消息基类
  3. zod:工具参数强类型校验,规范 LLM 传参格式
  4. dotenv:管理 API 密钥,避免硬编码泄露
  5. 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 类对接,只需要修改两个配置:

  1. baseURL 换成 DeepSeek 的接口地址;
  2. 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 做了两件事:

  1. 把所有工具的名称、描述、参数格式,按照 OpenAI 工具调用协议格式化;
  2. 每次调用模型时,自动把工具列表塞进请求体里发给模型。

绑定后的 modelWithTools 有两种输出可能:

  • 不需要工具:返回普通文本回答;
  • 需要工具:返回 tool_calls 结构化指令,不输出文本。

3.5 对话上下文:为什么要用消息数组

LLM 是无状态的,每一次请求都必须把完整对话历史发过去,模型才能知道之前说了什么、工具返回了什么。

四类消息的协作流程:

  1. SystemMessage 先定规则;
  2. HumanMessage 用户提需求;
  3. AIMessage 模型回复「我要调用工具」;
  4. ToolMessage 把工具执行结果还回去;
  5. 模型再输出最终回答。

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 installnpm install

5.2 工具调用后模型仍然编造代码

  • 检查 temperature 是否设为 0;
  • SystemMessage 里明确约束「禁止编造文件内容,必须调用工具」;
  • 工具 description 写清楚适用场景。

5.3 Timer 'xxx' does not exist

console.timeconsole.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 并行:总耗时 = 执行最慢的单个工具耗时。

七、总结

  1. Tool 调用核心逻辑:LLM 识别场景下发指令 → 本地执行 IO / 接口 → 结果回传模型生成最终回答;
  2. tool() 二分结构:业务执行函数 + Zod 参数约束 + 描述元数据,缺一不可;
  3. 四类消息维持完整上下文,tool_call_id 是工具与模型上下文关联的关键;
  4. ChatOpenAI 兼容全部 OpenAI 协议大模型,切换模型仅需修改 baseURLmodelName
  5. 手动实现工具链路,能彻底理解 Cursor、CodeGeeX 等代码 AI 的底层运行原理,后续可轻松扩展文件写入、命令执行、知识库检索等更多 Agent 能力。**
相关推荐
尼斯湖皮皮怪1 小时前
iceCoder 记忆系统深度分析
前端·javascript
爸爸6191 小时前
07 ArkTS 基础类型与接口定义:从 TypeScript 到鸿蒙的类型升级
javascript·华为·typescript·harmonyos·鸿蒙系统
默_笙2 小时前
🌏 MCP 是 AI 界的 USB-C:一个协议让所有模型都能用上所有工具
前端·javascript
齐 飞3 小时前
LangGraph快速入门-02状态
人工智能·langchain
椰椰椰耶4 小时前
【LangChain系列十二】RAG全流程下:从零搭建知识问答系统
langchain
Darling噜啦啦4 小时前
手写 mini-cursor 完整版:从 Promise 子进程到 ReAct 循环,30 轮迭代完成 TodoList 全流程
langchain·agent
月光刺眼4 小时前
用LangChain 打造第一个 Agent:LLM 大脑与 Tool 手脚的完整闭环
javascript·人工智能·全栈
星栈4 小时前
LiveView 的认证系统:从登录到权限,我一开始以为有 `current_user` 就算完事了
前端·前端框架·elixir
西西学代码5 小时前
Flutter---底部导航栏(2)
开发语言·javascript·flutter