深入浅出 LangChain —— 第五章:工具系统(Tools与MCP)

240 阅读11分钟

📖 本章学习目标

  • ✅ 理解工具系统的核心机制(Tool Calling)
  • ✅ 使用 tool() API 定义同步和异步工具
  • ✅ 掌握工具的错误处理策略
  • ✅ 实现动态工具选择(基于权限或上下文)
  • ✅ 了解MCP并实现自定义MCP服务器
  • ✅ 在LangChain中集成 MCP 协议连接外部工具生态
  • ✅ 设计高质量的工具描述和参数 Schema
  • ✅ 避免常见的工具设计陷阱

一、工具的本质:赋予 Agent 行动能力

大语言模型(LLM)本质上是一个基于概率预测的文本生成器,它知识来源于预训练的数据。这导致其存在两大核心缺陷:一是知识具有时效滞后性,无法感知训练数据之外的实时信息;二是缺乏精准的计算与执行能力,在处理复杂数学运算或确定性任务时容易产生“幻觉”。 这就就像是一个被关在图书馆里的知识渊博的学者,尽管他知道很多知识(训练数据),但他无法上网查最新信息,也无法帮你发邮件,执行任务。

工具调用(Tool Calling) 正是为了突破这些局限而生。它相当于为模型装上了“眼睛”和“双手”:一方面,通过调用搜索引擎或外部API,模型能够获取实时信息,打破知识的时空限制;另一方面,通过将计算、代码执行或业务操作交由专业工具处理,模型能够确保结果的绝对精准,并真正具备与外部系统交互、解决实际问题的能力。

1、工具调用机制

工具调用的核心流程:

sequenceDiagram
    participant U as 用户
    participant A as Agent
    participant L as LLM
    participant T as Tool

    U->>A: "帮我查北京今天的天气"
    A->>L: 传入用户消息 + 工具定义
    Note over L: LLM 分析任务<br/>决定需要查天气
    L-->>A: tool_call(get_weather, {city: "北京"})
    Note over A: LLM 给出工具名和参数<br/>但不实际执行
    A->>T: 调用 get_weather("北京")
    T-->>A: "北京今日晴,22°C"
    A->>L: 传入工具结果
    Note over L: LLM 基于真实数据回答
    L-->>A: "北京今天天气晴朗..."
    A-->>U: 返回最终回答

从机制上来看,LLM 只决定调用哪个工具、传什么参数,由程序负责实际执行工具调用,工具调用的结果回传给 LLM,让它基于真实信息回答。

2、常见工具类型

类型具体示例应用场景
信息检索网络搜索、知识库查询、数据库读取获取实时信息、查询私有数据
计算执行Python 代码解释器、计算器、SQL 执行数学计算、数据分析
系统操作文件读写、进程管理、终端命令自动化工作流
外部集成邮件发送、日历操作、第三方 API与外部系统交互
数据处理图像识别、文档解析、格式转换多媒体内容处理

二、定义工具:tool() API

LangChain.js 的 tool() 函数是定义工具的标准方式。

1、基础使用

每个工具由三部分组成:

  • 要执行的函数
  • 函数的元数据,包括名称、描述等
  • 使用tool工具函数进行包装
import { tool } from "@langchain/core/tools";
import { z } from "zod";

const myTool = tool(
  // 第一部分:执行函数(做什么)
  (params) => {
    // 实际的业务逻辑
    return "结果字符串";
  },
  
  // 第二部分:元数据(告诉 LLM 这是什么)
  {
    name: "my_tool",                    // 工具名称
    description: "工具的功能描述",       // LLM 根据描述决定是否调用
    schema: z.object({...}),            // 参数校验规则
  }
);

2、简单示例:计算器工具

让我们从零开始构建一个计算器工具。

第一步:定义执行函数

// 简单的数学计算函数
function calculateExpression(expression: string): string {
  try {
    // 注意:eval 在生产环境有安全风险
    // 实际应用要用安全的数学解析库(如 mathjs)
    const result = eval(expression);
    return `计算结果:${result}`;
  } catch {
    return `计算错误:无效的表达式 "${expression}"`;
  }
}

第二步:定义参数 Schema

import { z } from "zod";

// 使用 Zod 定义参数结构
const calculatorSchema = z.object({
  expression: z
    .string()
    .describe("要计算的数学表达式,如 '2 + 3 * 4' 或 'Math.sqrt(16)'"),
});

Schema参数的作用非常重要,主要有:

  • 确保 LLM 传入正确类型的参数,在运行时自动校验参数
  • 提供参数说明,帮助 LLM 理解如何使用,LLM调用工具时传递的参数依据就是定义的Schema

第三步:组合成完整工具

使用tool包装函数,将定义好的函数、Schema以及工具的名称、描述等信息组成成一个完整的工具。

import { tool } from "@langchain/core/tools";

const calculator = tool(
  // 执行函数
  ({ expression }) => {
    try {
      const result = eval(expression);
      return `计算结果:${result}`;
    } catch {
      return `计算错误:无效的表达式`;
    }
  },
  
  // 元数据
  {
    name: "calculator",
    description: "计算数学表达式,支持加减乘除和基本函数(sin, cos, sqrt 等)",
    schema: calculatorSchema,
  }
);

完整的工具定义要素:

要素作用重要性
name工具的唯一标识符⭐⭐⭐ LLM 用它来引用工具
description功能描述和使用场景⭐⭐⭐⭐⭐ 最关键,直接影响调用准确性
schema参数结构和校验规则⭐⭐⭐⭐ 保证类型安全

一个好的工具可以让LLM更精准的调用和输出,在定义工具时,我们至少应该在元数据的schema.description中做好以下三个方面的描述:

  1. 这个工具做什么?(功能)
  2. 什么时候应该用它?(使用场景)
  3. 输出格式是什么?(返回内容)

❌ 模糊的描述:

// ❌ 模糊的描述
description: "搜索信息"

//✅ 清晰的描述
description: "使用 Google 搜索互联网上的最新信息,返回前 5 条搜索结果的标题、摘要和链接。适合查询最新事件、获取外部知识、验证事实信息。不适合查询本地文件或数据库内容。"

3、异步工具:处理 I/O 操作

大多数真实工具需要做 I/O 操作(网络请求、数据库查询等),需要用异步函数。让我们以构建一个真实的天气查询工具为例,可以使用免费的 Open-Meteo API查询某个城市的天气。

第一步:了解 API

Open-Meteo 提供两个接口:

  1. 地理编码 API:城市名 → 经纬度
  2. 天气 API:经纬度 → 天气数据

第二步:编写执行函数

// src/tools/weather.ts

import { tool } from "@langchain/core/tools";
import { z } from "zod";

const getWeather = tool(
  async ({ city, country = "CN" }) => {
    try {
      // 第一步:地理编码(城市名 → 经纬度)
      const geoRes = await fetch(
        `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`
      );
      const geoData = await geoRes.json();

      if (!geoData.results?.length) {
        return `找不到城市:${city}`;
      }

      const { latitude, longitude } = geoData.results[0];

      // 第二步:获取天气数据
      const weatherRes = await fetch(
        `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&current=temperature_2m,precipitation,wind_speed_10m`
      );
      const weatherData = await weatherRes.json();
      const current = weatherData.current;

      // 第三步:格式化返回结果
      return JSON.stringify({
        city,
        temperature: `${current.temperature_2m}°C`,
        precipitation: `${current.precipitation}mm`,
        windSpeed: `${current.wind_speed_10m}km/h`,
      });
    } catch (error) {
      return `天气查询失败:${error instanceof Error ? error.message : "未知错误"}`;
    }
  },
  
  // 元数据
  {
    name: "get_weather",
    description: "查询指定城市的当前实时天气,返回温度、降水量和风速。适合回答关于天气的问题。",
    schema: z.object({
      city: z.string().describe("城市名称,支持中英文,如 '北京' 或 'Beijing'"),
      country: z.string().optional().describe("国家代码(ISO 3166),默认 CN"),
    }),
  }
);

基本的思路就是先将城市转换为经纬度,再使用经纬度查询对应的天气,并对结果、错误等做处理并输出。

可以看到,即便是真实的应用场景,定义LLM调用工具其实与我们平时写业务API没啥本质的区别。在工具函数里你可以做任何你想做的事情并输出结果供LLM使用,唯一需要特别处理的就是传递给tool函数的元数据描述。

第三步:测试工具

创建一个agent并将定义好的工具传递给agent即可使用。

// src/5-weather.ts

import { createAgent} from './agents';
import { getWeather } from './tools/weather';

const agent = createAgent({
  tools: [getWeather],
}); 

const result = await agent.invoke({
  messages: [{ role: "user", content: "北京今天天气怎么样?" }]
});

console.log(result.messages.at(-1)?.content);
// 输出:北京今天天气晴朗,气温 22°C,微风...

4、返回复杂内容

工具不仅可以返回字符串,还可以返回任何你想要的对象、命令(Command)。LangChain会根据不同的返回类型做相应的处理并传递给LLM使用。

Command类型是LangGraph提供的一个类,工具返回Command实例时,可以改变LangGraph执行图的状态。更多详细内容可关注后续推出的LangGraph教程。

(1)返回对象数组的例子

import { tool } from "@langchain/core/tools";
import { z } from "zod";

// 返回多媒体内容的工具
const generateChart = tool(
  async ({ data, title }) => {
    // 假设有一个生成图表的函数
    const chartUrl = await createChartImage(data, title);

    // 返回多个内容块
    return [
      { type: "text", text: `图表已生成:${title}` },
      { type: "image_url", image_url: { url: chartUrl } },
    ];
  },
  {
    name: "generate_chart",
    description: "根据数据生成柱状图或折线图,返回图片 URL",
    schema: z.object({
      data: z.array(z.number()).describe("图表数据数组,如 [10, 20, 30]"),
      title: z.string().describe("图表标题"),
    }),
  }
);

(2)返回Command的例子

import { tool, ToolMessage, type ToolRuntime } from "langchain";
import { Command } from "@langchain/langgraph";
import * as z from "zod";

const setLanguage = tool(
  async ({ language }, config: ToolRuntime) => {
    return new Command({
      update: {
        preferredLanguage: language,
        messages: [
          new ToolMessage({
            content: `Language set to ${language}.`,
            tool_call_id: config.toolCallId,
          }),
        ],
      },
    });
  },
  {
    name: "set_language",
    description: "Set the preferred response language.",
    schema: z.object({ language: z.string() }),
  },
);

三、工具的错误处理

工具调用出错是常见情况(API 超时、参数错误、权限不足等)。如何优雅地处理错误,直接影响 Agent 的健壮性。

1、常见的处理策略

策略优点缺点适用场景
工具内部处理简单直接,灵活控制每个工具都要写错误处理少量工具
中间件统一处理DRY 原则,统一管理可能丢失特定工具的上下文大量工具
混合策略兼顾灵活性和一致性复杂度稍高生产环境推荐

2、工具内部处理(简单场景)

在工具函数里捕获错误,返回错误描述字符串:

const searchDatabase = tool(
  async ({ query, table }) => {
    try {
      // 执行数据库查询
      const results = await db.query(`SELECT * FROM ${table} WHERE ...`);
      
      if (results.length === 0) {
        return "查询没有找到结果,请尝试不同的搜索条件。";
      }
      
      // 只返回前 10 条,避免 Token 浪费
      return JSON.stringify(results.slice(0, 10));
      
    } catch (error) {
      // 根据错误类型返回不同的消息
      if (error instanceof DatabaseError) {
        return `数据库查询失败:表 "${table}" 不存在或无权访问。`;
      }
      
      return `查询出错:${error instanceof Error ? error.message : "未知错误"}`;
    }
  },
  {
    name: "search_database",
    description: "在数据库中搜索数据,返回匹配的记录",
    schema: z.object({
      query: z.string().describe("搜索关键词"),
      table: z.string().describe("要搜索的表名,如 'users' 或 'orders'"),
    }),
  }
);

这种处理方式可以针对特定错误类型返回单个工具的定制化消息,LLM 可以根据错误信息调整策略(如换个表名再试)。

3、中间件统一处理(推荐用于大量工具)

对于大量工具,在每个工具里写错误处理会有重复代码。更好的做法是在 Agent 级别统一处理。

import { createAgent, createMiddleware } from "langchain";
import { ToolMessage } from "@langchain/core/messages";

// 创建统一的工具错误处理中间件
const toolErrorHandler = createMiddleware({
  name: "ToolErrorHandler",
  
  // 拦截工具调用
  wrapToolCall: async (request, handler) => {
    try {
      // 正常执行工具
      return await handler(request);
    } catch (error) {
      // 统一返回友好的错误消息
      console.error(`工具 ${request.toolCall.name} 调用失败:`, error);
      
      return new ToolMessage({
        content: `工具调用失败:${error instanceof Error ? error.message : "未知错误"}。请尝试其他方式或告知用户无法完成该操作。`,
        tool_call_id: request.toolCall.id!,
      });
    }
  },
});

// 注册中间件
const agent = createAgent({
  model: "openai:gpt-4o",
  tools: [searchDatabase, getWeather, calculator],
  middleware: [toolErrorHandler],  // 添加错误处理中间件
});

工作原理:

  1. 中间件拦截所有工具调用
  2. 如果工具抛出异常,捕获它
  3. 返回标准化的错误消息给 LLM
  4. LLM 根据错误消息决定下一步行动

这种统一管理的方式除了可以不必到处编写错误处理逻辑外,还可以保证所有工具的错误格式一致,这样如果需要做日志记录也可以在中间件里统一记录错误日志。当然,如果想针对每一个特定的工具都定制不同的错误信息,可能会相对麻烦。

4、混合策略(生产环境推荐)

结合两种方式:

  • 工具内部处理业务逻辑错误(如"找不到城市")
  • 中间件处理系统级错误(如网络超时、权限不足)
// 工具内部:处理预期的业务错误
const getWeather = tool(
  async ({ city }) => {
    const geoData = await fetchGeoData(city);
    
    if (!geoData.results?.length) {
      // 这是预期的业务错误,返回友好提示
      return `找不到城市:${city},请检查城市名称是否正确。`;
    }
    
    // ...继续处理
  },
  { /* 元数据 */ }
);

// 中间件:处理未预期的系统错误
const toolErrorHandler = createMiddleware({
  name: "ToolErrorHandler",
  wrapToolCall: async (request, handler) => {
    try {
      return await handler(request);
    } catch (error) {
      // 这些是未预期的错误(网络故障、权限问题等)
      logErrorToMonitoring(error);  // 记录到监控系统
      return new ToolMessage({
        content: "系统暂时无法完成此操作,请稍后重试。",
        tool_call_id: request.toolCall.id!,
      });
    }
  },
});

四、工具注册与管理

在LangChain中,工具定义后可以静态注册,也可以动态注册,这为针对不同的场景选择不同的方式提供了极大的便利和可扩展性。

1、静态工具列表(最常见)

创建 Agent 时直接传入工具数组:

import { createAgent } from "langchain";

const agent = createAgent({
  model: "openai:gpt-4o",
  tools: [
    searchTool,
    calculatorTool,
    weatherTool,
    emailTool,
  ],
});

适用场景: 工具列表固定,所有用户都能使用相同的工具。

2、动态工具选择(基于权限)

有时候你不希望把所有工具都暴露给 Agent(比如某些工具只有管理员才能用)。

实现思路

import { createAgent } from "langchain";

// 定义工具池
const allTools = {
  basic: [searchTool, calculatorTool],
  advanced: [weatherTool, codeExecutorTool],
  admin: [deleteDataTool, systemConfigTool],
};

// 根据用户角色获取可用工具
function getToolsForUser(userRole: "admin" | "user" | "guest") {
  switch (userRole) {
    case "admin":
      return [...allTools.basic, ...allTools.advanced, ...allTools.admin];
    case "user":
      return [...allTools.basic, ...allTools.advanced];
    case "guest":
      return allTools.basic;
    default:
      return allTools.basic;
  }
}

// 创建 Agent(初始不传工具)
const agent = createAgent({ 
  model: "openai:gpt-4o", 
  tools: [] 
});

// 调用时动态传入工具
const userRole = "user";  // 从会话中获取
const result = await agent.invoke(
  { messages: [{ role: "user", content: "帮我删除测试数据" }] },
  { configurable: { tools: getToolsForUser(userRole) } }
);

执行流程:

  1. 用户发起请求
  2. 根据用户角色筛选可用工具
  3. 将筛选后的工具传给 Agent
  4. Agent 只能看到和使用授权的工具

安全优势:

  • ✅ 最小权限原则:用户只能访问必要的工具
  • ✅ 防止越权操作:普通用户无法调用管理员工具
  • ✅ 灵活控制:可以随时调整用户的工具权限

3、动态工具选择(基于上下文)

根据任务类型动态加载相关工具,减少无关工具的干扰。

// 按类别组织工具
const toolCategories = {
  research: [searchTool, webpageFetcherTool, summarizerTool],
  coding: [codeExecutorTool, fileReaderTool, linterTool],
  communication: [emailTool, slackNotifierTool],
};

// 根据任务类型选择工具
function selectToolsByTask(taskType: keyof typeof toolCategories) {
  return toolCategories[taskType] || toolCategories.research;
}

// 使用示例
const agent = createAgent({ model: "openai:gpt-4o", tools: [] });

// 编程任务:只加载编程相关工具
const result = await agent.invoke(
  { messages: [{ role: "user", content: "帮我写一个排序算法" }] },
  { configurable: { tools: selectToolsByTask("coding") } }
);

优势:

  • ✅ 提高准确性:减少无关工具的干扰
  • ✅ 节省 Token:工具定义会占用 Prompt 空间
  • ✅ 加快响应:LLM 不需要在不相关的工具中做选择

五、MCP:连接外部工具生态

1、什么是 MCP?

MCP(Model Context Protocol,模型上下文协议) 是由 Anthropic 于 2024 年推出的开放标准协议,被誉为 “AI 领域的 USB-C 接口”。它是一套标准化的通信规则,旨在让大语言模型(LLM)能够安全、统一地连接外部世界,包括本地文件、数据库、各类 API 以及第三方服务。

上文我们已经了解了工具调用可以拓展LLM的能力边界,但是在没有 MCP 之前,AI 应用开发面临着严重的“接口碎片化”问题。开发者想要让模型连接 m 个工具和 n 个模型,往往需要进行 m×n 次复杂的定制化开发,导致效率极低且难以维护。MCP 的出现彻底解决了这一痛点,它通过统一的协议将模型与外部资源解耦。开发者只需开发一次 MCP 服务端(MCP Server),就能让所有支持该协议的 AI 应用(如 Claude Desktop、Cursor、VS Code 等)直接“即插即用”。

简单来说,MCP不仅极大降低了开发者的集成成本,也为 AI 智能体打通了与现实世界交互的最后一公里。

flowchart LR
    subgraph LangChain["LangChain Agent"]
        Agent["Agent"]
        Client["MultiServerMCPClient"]
    end

    subgraph MCP_Servers["MCP 服务器(可来自任何地方)"]
        S1["文件系统 MCP<br/>读写本地文件"]
        S2["GitHub MCP<br/>操作代码仓库"]
        S3["数据库 MCP<br/>查询数据库"]
        S4["自定义 MCP<br/>你自己的服务"]
    end

    Client <-->|MCP 协议| S1
    Client <-->|MCP 协议| S2
    Client <-->|MCP 协议| S3
    Client <-->|MCP 协议| S4
    Agent --> Client
    
    style Client fill:#f6ffed,stroke:#52c41a,stroke-width:3px

2、MCP vs 直接定义工具

维度直接定义 tool()使用 MCP
适用场景项目内部的工具逻辑独立的工具服务,需要在多个应用间复用
接入成本低,直接写函数较高,需要搭建 MCP 服务器
可复用性仅限当前项目任何 MCP 兼容的客户端都可以使用
工具生态自己写可接入社区现有的 MCP 服务器
维护成本中(需要维护独立的服务)

💡 选择建议

  • 项目内部的工具优先用 tool() 直接定义
  • 需要跨应用复用、或接入社区工具生态时,使用 MCP

3、安装 MCP 适配器

在LangChain中可以使用MCP适配器来使用MCP。使用之前需要先安装适配器:

pnpm add @langchain/mcp-adapters

4、连接 MCP 服务器

MCP 支持两种传输方式:

方式 1:stdio(标准输入输出,使用本地子进程)

这是最轻量、最基础的本地通信方式。它的工作原理是MCP 客户端直接将 MCP 服务器作为本地的一个子进程启动,通过操作系统的标准输入(stdin)和标准输出(stdout)管道来交换 JSON-RPC 消息。适用于本地环境,例如你在本地电脑上使用 Claude Desktop、Cursor 或 VS Code 等 AI 客户端以及你使用LangChain创建的Agent,去调用本地的文件系统、本地数据库或命令行工具。

它的核心特点是无需网络端口,数据完全在本地流转,隐私安全性极高,且不需要复杂的网络配置。

import { MultiServerMCPClient } from "@langchain/mcp-adapters";
import { createAgent } from "langchain";

// 创建 MCP 客户端
const client = new MultiServerMCPClient({
  // 连接文件系统 MCP 服务器
  filesystem: {
    transport: "stdio",
    command: "npx",
    args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/directory"],
  },
  
  // 连接数学计算 MCP 服务器
  math: {
    transport: "stdio",
    command: "node",
    args: ["./mcp-servers/math-server.js"],
  },
});

// 获取所有 MCP 工具(自动转换为 LangChain 工具格式)
const mcpTools = await client.getTools();

// 创建 Agent
const agent = createAgent({
  model: "openai:gpt-4o",
  tools: mcpTools,
});

// 使用
const result = await agent.invoke({
  messages: [{ role: "user", content: "读取 README.md 文件的内容并总结" }],
});

代码解读:

  1. 使用MultiServerMCPClient配置文件系统 MCP 服务器和自定义数学服务器
  2. 使用MultiServerMCPClient实例上的getTools方法获取所有工具列表(注意是异步的)
  3. 创建Agent时将获取到的工具列表传递给agent

方式 2:Streamable HTTP(可流式 HTTP,使用远程服务器)

这是目前官方推荐的、用于远程网络通信的标准方式。它的工作原理是MCP 服务器作为一个独立的网络服务运行,通过单一的 HTTP 端点(如 /message)与客户端通信。它非常灵活,既支持返回标准的 HTTP 响应,也支持升级为 SSE(Server-Sent Events)流式传输,非常适合 AI 逐字生成等实时交互场景。主要用于远程和云端环境,例如多个用户共享一个部署在云端的 MCP 服务,或者需要对接企业内部的 SaaS 系统、远程数据库等。

它的核心特点是支持多客户端并发访问,易于部署和集中更新,且能很好地兼容现代网络基础设施(如 API 网关、负载均衡等)。

💡 补充说明

你可能还会看到 HTTP+SSE 或 WebSocket 的说法。 HTTP+SSE 是 MCP 早期版本(2024年底)采用的一种远程传输方案,但由于它需要维护两个独立的连接(一个用于发送,一个用于接收),资源消耗大且兼容性较差,目前已被官方弃用(Deprecated),并由更先进的 Streamable HTTP 全面取代

WebSocket 虽然在部分技术讨论或早期实现中被提及(用于低延迟全双工通信),但在当前官方最新的标准规范中,核心的主流传输方式就是上述的 Stdio 和 Streamable HTTP

在LangChain中,目前还是传入transport: 'sse'的方式来定义,但其背后仍旧使用的是Streamable HTTP。

const client = new MultiServerMCPClient({
  // 连接远程天气服务 MCP 服务器
  weather: {
    transport: "sse",  // Server-Sent Events
    url: "https://your-mcp-server.com/sse",
    headers: {
      Authorization: `Bearer ${process.env.MCP_API_KEY}`,
    },
  },
});

const mcpTools = await client.getTools();

5、自己构建 MCP 服务器

如果你想把现有的服务能力暴露为 MCP 工具,可以用 MCP SDK 快速搭建。以一个数据库查询 MCP 服务器为例:

第一步:初始化项目

mkdir database-mcp-server && cd database-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk

第二步:编写服务器代码

// mcp-servers/database-server.ts

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

// 创建 MCP 服务器实例
const server = new McpServer({
  name: "database-server",
  version: "1.0.0"
});

// 注册工具
server.registerTool(
  "query_users",
  {
    title: "查询用户列表",
    description: "根据条件查询用户列表",
    inputSchema: z.object({
      name: z.string().optional().describe("用户姓名(模糊匹配)"),
      limit: z.number().default(10).describe("返回条数,默认 10"),
    }),
  },
  async ({ name, limit = 10 }) => {
    // 实际场景应该执行数据库查询
    // const users = await db.query(
    //   "SELECT id, name, email FROM users WHERE name LIKE ? LIMIT ?",
    //   [`%${name || ""}%`, limit]
    // );
    // mock数据
    const users = mockDatabase(name, limit);

    // 返回结果
    return {
      content: [{ type: "text", text: JSON.stringify(users) }],
    };
  }
);

// 启动服务器(stdio 模式)
const transport = new StdioServerTransport();
await server.connect(transport);

console.log("Database MCP Server started");


function mockDatabase(name?: string, limit: number = 10) {
    const users = [];
    for(let i = 0; i < limit; i++) {
        users.push({
            id: i,
            name: `${name}-${i}-${Math.random().toString(36).substring(2, 7)}`,
            email: `user${i}@example.com`,
        });
    }
    return users;
}

第三步:在 LangChain 中使用

// src/5-mcp.ts

import { MultiServerMCPClient } from "@langchain/mcp-adapters";
import { createAgent } from "./agents";

const client = new MultiServerMCPClient({
  database: {
    transport: "stdio",
    command: "npx",
    args: ["tsx", "./src/mcp-servers/database-server.ts"],
  },
});

const mcpTools = await client.getTools();
const agent = createAgent({ tools: mcpTools });


const result = await agent.invoke({
  messages: [{ role: "user", content: "查找15个包含贾维斯的用户,使用表格输出" }]
});

console.log(result.messages.at(-1)?.content);

结果如下:

QQ20260518-221624.png

六、实用工具示例集

以下是几个在实际 Agent 开发中常用的工具模板。

1、网页内容抓取

import { tool } from "@langchain/core/tools";
import { z } from "zod";

const fetchWebpage = tool(
  async ({ url }) => {
    try {
      // 设置超时和用户代理
      const response = await fetch(url, {
        headers: { 
          "User-Agent": "Mozilla/5.0 (compatible; LangChain Agent)" 
        },
        signal: AbortSignal.timeout(10000), // 10 秒超时
      });

      if (!response.ok) {
        return `请求失败:HTTP ${response.status}`;
      }

      const html = await response.text();
      
      // 简单提取纯文本
      // 生产环境建议用 cheerio 或 unfluff
      const text = html
        .replace(/<script[\s\S]*?<\/script>/gi, "")
        .replace(/<style[\s\S]*?<\/style>/gi, "")
        .replace(/<[^>]+>/g, " ")
        .replace(/\s+/g, " ")
        .trim()
        .slice(0, 5000); // 限制返回长度,避免 Token 浪费

      return text;
    } catch (error) {
      return `抓取失败:${error instanceof Error ? error.message : "网络错误"}`;
    }
  },
  {
    name: "fetch_webpage",
    description: "获取指定 URL 的网页内容(纯文本),适合阅读文章、博客、新闻等内容。不支持 JavaScript 渲染的页面。",
    schema: z.object({
      url: z.string().url().describe("要访问的完整 URL,必须以 http:// 或 https:// 开头"),
    }),
  }
);

关键点:

  • 设置超时防止长时间挂起
  • 限制返回长度(5000 字符)避免 Token 浪费
  • 简单的 HTML 清理(生产环境用专业库)

2、文件读写(带安全限制)

import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { readFile, writeFile } from "fs/promises";
import { join, resolve } from "path";

// 限制工作目录,防止 Agent 访问不该访问的文件
const WORKSPACE_DIR = resolve("./workspace");

const readFileTool = tool(
  async ({ filename }) => {
    try {
      // 安全检查:防止路径遍历攻击
      const safePath = join(WORKSPACE_DIR, filename);
      const resolvedPath = resolve(safePath);
      
      if (!resolvedPath.startsWith(WORKSPACE_DIR)) {
        return `安全错误:不允许访问工作目录外的文件`;
      }
      
      const content = await readFile(resolvedPath, "utf-8");
      return content;
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code === "ENOENT") {
        return `文件不存在:${filename}`;
      }
      return `文件读取失败:${error instanceof Error ? error.message : "未知错误"}`;
    }
  },
  {
    name: "read_file",
    description: "读取工作目录中的文件内容。只能访问 ./workspace 目录下的文件。",
    schema: z.object({
      filename: z.string().describe("文件名(相对于工作目录),如 'data.txt' 或 'docs/readme.md'"),
    }),
  }
);

安全要点:

  • ✅ 限制访问目录(WORKSPACE_DIR
  • ✅ 防止路径遍历攻击(../ 逃逸)
  • ✅ 详细的错误提示

3、发送通知(钉钉)

import { tool } from "@langchain/core/tools";
import { z } from "zod";

const sendDingTalk = tool(
  async ({ message, urgency = "normal" }) => {
    const webhookUrl = process.env.DINGTALK_WEBHOOK!;
    
    const body = {
      msgtype: "text",
      text: {
        content: urgency === "urgent" ? `【紧急】${message}` : message,
      },
      at: urgency === "urgent" ? { isAtAll: true } : {},
    };

    const response = await fetch(webhookUrl, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });

    if (response.ok) {
      return "通知发送成功";
    }
    return `通知发送失败:HTTP ${response.status}`;
  },
  {
    name: "send_dingtalk_notification",
    description: "向钉钉群发送通知消息。紧急消息会 @所有人。",
    schema: z.object({
      message: z.string().describe("通知内容"),
      urgency: z.enum(["normal", "urgent"])
        .optional()
        .describe("紧急程度,urgent 会 @所有人,慎用"),
    }),
  }
);

七、工具设计的最佳实践

掌握这些最佳实践,能让你的工具更加可靠和易用。

💡 实践 1:工具描述要精确

LLM 完全依赖 description 来判断什么时候该用这个工具。

❌ 模糊的描述:
description: "搜索信息"

// ✅ 清晰的描述:
description: "使用 Google 搜索互联网上的最新信息,返回前 5 条搜索结果的标题、摘要和链接。适合查询最新事件、获取外部知识、验证事实信息。不适合查询本地文件或数据库内容。"

好的描述应该包含: 功能说明(做什么)、使用场景(何时用)、输出格式(返回什么)、限制条件(不能做什么)。

💡 实践 2:参数要有 describe

Zod Schema 的 .describe() 说明会出现在发给 LLM 的工具定义中,直接影响 LLM 能否正确填写参数。

// ❌ 没有描述:
schema: z.object({ q: z.string() })

//✅ 有清晰描述:
schema: z.object({
  query: z.string().describe("搜索关键词,使用具体的搜索词,避免过于宽泛。例如用 'TypeScript 泛型教程' 而不是 'TypeScript'"),
  language: z.enum(["zh", "en"]).optional().describe("结果语言,默认 zh(中文)"),
})

⚠️ 实践 3:返回适量信息

工具返回的内容会占用 LLM 的上下文窗口(Context Window)。返回内容过多会消耗大量 Token(增加成本),还可能超出上下文限制或稀释重要信息,导致 LLM 忽略关键细节。

解决方案是在工具层做截断和过滤,只返回与任务相关的核心信息或提供分页和摘要功能。

// ❌ 返回全部结果(可能有上千条)
return JSON.stringify(allResults);

// ✅ 只返回前 10 条,并告知总数
return JSON.stringify({
  total: allResults.length,
  results: allResults.slice(0, 10),
  note: "仅显示前 10 条结果,如需更多请使用分页参数"
});

⚠️ 实践 4:工具命名要语义化

工具名称会影响 LLM 的理解。

// ❌ 不好的命名
name: "func1"
name: "do_something"

//✅ 好的命名:
name: "search_web"
name: "get_weather"
name: "calculate_expression"

命名规范: 使用动词 + 名词结构、用小写字母和下划线、名称要能准确反映功能。

⚠️ 实践 5:幂等性设计

工具调用应该是幂等的(多次调用产生相同结果),或者明确标注副作用。

✅ 幂等的工具:

// 查询操作是幂等的
name: "search_database"
description: "查询数据(只读操作,不会产生副作用)"

⚠️ 有副作用的工具要明确标注:

name: "send_email"
description: "发送邮件(会产生实际动作,每次调用都会发送一封邮件)"

八、本章小结

工具系统是 AI Agent 的核心能力所在。这一章我们学习了:

📝 核心知识点回顾

知识点关键要点
工具的本质LLM 决定调用,程序负责执行,形成"思考-行动-观察"循环
tool() API执行函数 + 元数据(name、description、schema)
异步工具处理 I/O 操作(网络请求、数据库查询)
错误处理工具内部处理 vs 中间件统一处理 vs 混合策略
动态工具选择基于权限或上下文动态提供工具
MCP 协议连接外部工具生态的标准方式
最佳实践精确的描述、参数说明、返回内容控制、幂等性设计

🎯 动手练习

尝试完成以下练习,巩固所学知识:

练习 1:创建搜索工具 创建一个网络搜索工具,使用免费的搜索引擎 API(如 DuckDuckGo 或 SerpAPI),返回前 3 条搜索结果的标题、摘要和链接。

练习 2:改进错误处理 为天气查询工具添加更详细的错误处理:

  • 城市不存在
  • API 超时
  • 网络连接失败
  • API 返回异常数据

测试每种情况下 Agent 的行为。

练习 3:动态工具权限 实现一个简单的权限系统:

  • Guest 用户:只能使用搜索工具
  • User 用户:可以使用搜索、计算器、天气
  • Admin 用户:可以使用所有工具

练习 4:MCP 初体验 安装并运行官方的文件系统 MCP 服务器,让 Agent 能够读取和写入文件。测试以下场景:

  • 读取 README.md 并总结
  • 创建一个新文件并写入内容
  • 尝试访问工作目录外的文件(应该被阻止)

📚 延伸阅读


下一章:《第六章 —— 记忆与状态管理(Memory & State)》