深入浅出 LangChain —— 第五章:工具系统

📖 本章学习目标

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

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

大语言模型本身是"封闭"的------它只能根据训练数据生成文本,没有办法主动获取信息或操作外部系统。

类比理解: LLM 就像一个被关在图书馆里的学者:

  • 📚 他知道很多知识(训练数据)
  • ❌ 但他无法上网查最新信息
  • ❌ 也无法帮你发邮件或操作数据库

工具(Tools) 打破了这个封闭性,就像给学者配备了:

  • 🌐 互联网接入(搜索工具)
  • 📧 邮件客户端(通信工具)
  • 💻 电脑终端(系统操作工具)

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 分析任务
决定需要查天气 L-->>A: tool_call(get_weather, {city: "北京"}) Note over A: LLM 给出工具名和参数
但不实际执行 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工具函数包装
typescript 复制代码
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、简单示例:计算器工具

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

第一步:定义执行函数

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

代码解读:

  • 接收字符串形式的数学表达式
  • 使用 eval() 计算结果(仅用于演示)
  • 捕获错误并返回友好的错误消息
  • 返回值必须是字符串

第二步:定义参数 Schema

typescript 复制代码
import { z } from "zod";

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

为什么需要 Schema?

  • ✅ 确保 LLM 传入正确类型的参数
  • ✅ 提供参数说明,帮助 LLM 理解如何使用
  • ✅ 在运行时自动校验参数

第三步:组合成完整工具

typescript 复制代码
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 参数结构和校验规则 ⭐⭐⭐⭐ 保证类型安全

💡 最佳实践:如何写好工具描述

好的描述应该回答三个问题:

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

❌ 模糊的描述:

typescript 复制代码
description: "搜索信息"

✅ 清晰的描述:

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

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

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

第一步:了解 API

Open-Meteo 提供两个接口:

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

第二步:编写执行函数

typescript 复制代码
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"),
    }),
  }
);

代码分步解读:

  1. 第 5-15 行:地理编码

    • 将城市名转换为经纬度
    • 处理找不到城市的情况
  2. 第 17-20 行:获取天气

    • 使用经纬度查询实时天气
    • 获取温度、降水、风速等数据
  3. 第 22-28 行:格式化结果

    • 将数据组织为 JSON 字符串
    • 包含所有关键信息
  4. 第 29-31 行:错误处理

    • 捕获网络错误或 API 异常
    • 返回友好的错误消息

第三步:测试工具

typescript 复制代码
import { createAgent } from "langchain";

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

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

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

4、返回复杂内容

工具不仅可以返回字符串,还可以返回包含多个内容块的数组(如文本 + 图片)。

typescript 复制代码
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("图表标题"),
    }),
  }
);

支持的 content 类型:

类型 用途 示例
text 纯文本 { type: "text", text: "结果" }
image_url 图片 { type: "image_url", image_url: { url: "..." } }
audio_url 音频 { type: "audio_url", audio_url: { url: "..." } }

三、工具的错误处理

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

1、策略对比

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

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

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

typescript 复制代码
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 级别统一处理。

typescript 复制代码
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 根据错误消息决定下一步行动

优势:

  • ✅ DRY 原则:错误处理逻辑只写一次
  • ✅ 统一管理:所有工具的错误格式一致
  • ✅ 日志记录:可以在中间件里统一记录错误日志

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

结合两种方式:

  • 工具内部处理业务逻辑错误(如"找不到城市")
  • 中间件处理系统级错误(如网络超时、权限不足)
typescript 复制代码
// 工具内部:处理预期的业务错误
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!,
      });
    }
  },
});

四、工具注册与管理

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

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

typescript 复制代码
import { createAgent } from "langchain";

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

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

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

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

实现思路

typescript 复制代码
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、动态工具选择(基于上下文)

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

typescript 复制代码
// 按类别组织工具
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:连接外部工具生态

Model Context Protocol(MCP) 是 Anthropic 于 2024 年提出的开放协议,旨在标准化 LLM 与外部工具的连接方式。

1、什么是 MCP?

可以把 MCP 理解为 AI 工具领域的"USB 接口"

flowchart LR subgraph LangChain["LangChain Agent"] Agent["Agent"] Client["MultiServerMCPClient"] end subgraph MCP_Servers["MCP 服务器(可来自任何地方)"] S1["文件系统 MCP
读写本地文件"] S2["GitHub MCP
操作代码仓库"] S3["数据库 MCP
查询数据库"] S4["自定义 MCP
你自己的服务"] end Client <-->|MCP 协议| S1 Client <-->|MCP 协议| S2 Client <-->|MCP 协议| S3 Client <-->|MCP 协议| S4 Agent --> Client style Client fill:#f6ffed,stroke:#52c41a,stroke-width:3px

核心优势:

  • 🔌 即插即用:任何遵循 MCP 协议的工具都可以直接使用
  • 🌍 跨应用复用:一个 MCP 服务器可以被多个 AI 应用共享
  • 🛠️ 社区生态:可以直接使用社区现有的 MCP 服务器(GitHub、Slack、PostgreSQL 等)

2、MCP vs 直接定义工具

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

💡 选择建议

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

3、安装 MCP 适配器

bash 复制代码
pnpm add @langchain/mcp-adapters

4、连接 MCP 服务器

MCP 支持两种传输方式:

方式 1:stdio(本地子进程)

适合本地工具或命令行工具:

typescript 复制代码
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. 第 6-12 行:配置文件系统 MCP 服务器

    • 使用 npx 运行官方的文件系统服务器
    • 限制访问目录为 /path/to/directory
  2. 第 14-18 行:配置自定义数学服务器

    • 运行本地的 Node.js 脚本
  3. 第 21 行:获取所有工具

    • 自动发现并转换 MCP 工具为 LangChain 格式
  4. 第 24-27 行:创建并使用 Agent

方式 2:HTTP/SSE(远程服务器)

适合部署在云端的工具服务:

typescript 复制代码
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 服务器为例:

第一步:初始化项目

bash 复制代码
mkdir database-mcp-server && cd database-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk

第二步:编写服务器代码

typescript 复制代码
// mcp-servers/database-server.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  ListToolsRequestSchema,
  CallToolRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

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

// 声明工具列表
server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: "query_users",
      description: "根据条件查询用户列表",
      inputSchema: {
        type: "object",
        properties: {
          name: { 
            type: "string", 
            description: "用户姓名(模糊匹配)" 
          },
          limit: { 
            type: "number", 
            description: "返回条数,默认 10" 
          },
        },
      },
    },
  ],
}));

// 处理工具调用
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name === "query_users") {
    const { name, limit = 10 } = request.params.arguments as {
      name?: string;
      limit?: number;
    };

    // 执行数据库查询
    const users = await db.query(
      "SELECT id, name, email FROM users WHERE name LIKE ? LIMIT ?",
      [`%${name || ""}%`, limit]
    );

    // 返回结果
    return {
      content: [{ type: "text", text: JSON.stringify(users) }],
    };
  }
  
  throw new Error(`未知工具:${request.params.name}`);
});

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

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

代码分步解读:

  1. 第 10-13 行:创建服务器实例

    • 指定服务器名称和版本
    • 声明支持 tools 能力
  2. 第 16-33 行:声明工具列表

    • 定义工具名称、描述
    • 定义输入参数的 JSON Schema
  3. 第 36-56 行:处理工具调用

    • 根据工具名执行对应逻辑
    • 返回标准格式的响应
  4. 第 59-61 行:启动服务器

    • 使用 stdio 传输模式
    • 等待连接

第三步:在 LangChain 中使用

typescript 复制代码
const client = new MultiServerMCPClient({
  database: {
    transport: "stdio",
    command: "node",
    args: ["./mcp-servers/database-server.js"],
  },
});

const mcpTools = await client.getTools();
const agent = createAgent({ model: "openai:gpt-4o", tools: mcpTools });

六、实用工具示例集

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

1、网页内容抓取

typescript 复制代码
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、文件读写(带安全限制)

typescript 复制代码
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、发送通知(钉钉)

typescript 复制代码
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 来判断什么时候该用这个工具。

❌ 模糊的描述:

typescript 复制代码
description: "搜索信息"

✅ 清晰的描述:

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

好的描述应该包含:

  • 功能说明(做什么)
  • 使用场景(何时用)
  • 输出格式(返回什么)
  • 限制条件(不能做什么)

💡 实践 2:参数要有 describe

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

❌ 没有描述:

typescript 复制代码
schema: z.object({ q: z.string() })

✅ 有清晰描述:

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

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

工具返回的内容会占用 LLM 的上下文窗口(Context Window)

问题:

  • 返回内容过多 → 消耗大量 Token(增加成本)
  • 可能超出上下文限制
  • 稀释重要信息,导致 LLM 忽略关键细节

解决方案:

  • 在工具层做截断和过滤
  • 只返回与任务相关的核心信息
  • 提供分页或摘要功能
typescript 复制代码
// ❌ 返回全部结果(可能有上千条)
return JSON.stringify(allResults);

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

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

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

❌ 不好的命名:

typescript 复制代码
name: "func1"
name: "do_something"

✅ 好的命名:

typescript 复制代码
name: "search_web"
name: "get_weather"
name: "calculate_expression"

命名规范:

  • 使用动词 + 名词结构
  • 用小写字母和下划线
  • 名称要能准确反映功能

⚠️ 实践 5:幂等性设计

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

✅ 幂等的工具:

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

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

typescript 复制代码
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)》

相关推荐
爱吃巧克力的程序媛4 小时前
计算机图形学---在OpenGL中,什么是归一化 UV 坐标?
人工智能·计算机视觉·uv
m0_380167144 小时前
清算热力图怎么看?如何用来判断行情走向
大数据·人工智能·区块链
迁旭4 小时前
OpenAI API 请求与响应 核心总结
人工智能·机器学习·语言模型·gpt-3
名不经传的养虾人4 小时前
从0到1:企业级AI项目迭代日记 Vol.14|正式版上线第一周:一个403、一次重构、一个新方向
人工智能·ai编程·ai创业·企业ai·多agent协作
努力努力再努力FFF4 小时前
建筑师想探索AI生成设计,需要具备哪些基础知识?
人工智能
小张同学8244 小时前
Python并发编程实战用多线程和协程加速智能体执行效率
开发语言·人工智能·python
深海鱼在掘金4 小时前
深入浅出 LangChain —— 第四章:提示词工程
人工智能·langchain·agent
郑寿昌4 小时前
UE6 AI加速Lumen光线追踪降噪技术解析
人工智能·游戏引擎
sheji1054 小时前
割草机器人实物拆解报告
人工智能·机器人·智能硬件