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

0 阅读12分钟

📖 本章学习目标

  • ✅ 理解工具系统的核心机制(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 分析任务<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}"`;
  }
}

代码解读:

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

第二步:定义参数 Schema

import { z } from "zod";

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

为什么需要 Schema?

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

第三步:组合成完整工具

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. 输出格式是什么?(返回内容)

❌ 模糊的描述:

description: "搜索信息"

✅ 清晰的描述:

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

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

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

第一步:了解 API

Open-Meteo 提供两个接口:

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

第二步:编写执行函数

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 异常
    • 返回友好的错误消息

第三步:测试工具

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、返回复杂内容

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

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、工具内部处理(简单场景)

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

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

优势:

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

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!,
      });
    }
  },
});

四、工具注册与管理

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:连接外部工具生态

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

核心优势:

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

2、MCP vs 直接定义工具

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

💡 选择建议

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

3、安装 MCP 适配器

pnpm add @langchain/mcp-adapters

4、连接 MCP 服务器

MCP 支持两种传输方式:

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

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

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(远程服务器)

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

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 { 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 中使用

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、网页内容抓取

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)》