LangChain Tools 和 Toolkits:封装第一个工具集

33 阅读8分钟

为什么需要封装工具集?

一个真实的开发场景

我们正在开发一个AI助手,需要让 AI 能够:

  • 读取文件
  • 写入文件
  • 列出目录
  • 删除文件

手写代码的方式

// 每个工具都要重复写验证逻辑
const readFile = async (path: string) => {
  if (!path) throw new Error("缺少路径");
  if (typeof path !== "string") throw new Error("路径必须是字符串");
  try {
    return await fs.readFile(path, "utf-8");
  } catch (err) {
    return "读取失败";
  }
};

const writeFile = async (path: string, content: string) => {
  if (!path) throw new Error("缺少路径");
  if (typeof content !== "string") throw new Error("内容必须是字符串");
  // ... 重复的代码
};

带来的问题

  • 参数验证代码重复
  • 错误处理不一致
  • 与AI集成需要手动适配
  • 难以复用和测试

LangChain Tools 的价值

使用 LangChain,我们就可以统一工具接口:

const readFileTool = new DynamicStructuredTool({
  name: "read_file",
  description: "读取文件内容",
  schema: z.object({ path: z.string() }),
  func: async ({ path }) => fs.readFile(path, "utf-8")
});

LangChain Tools 解决了什么?

问题手写方案LangChain 方案
参数验证每个工具重复写Zod自动验证
错误处理格式不统一自动格式化
AI集成手动解析自动适配
类型安全手动维护TypeScript原生
可测试性需要mock标准接口

Tool 基础:从零开始封装工具

Tool 的核心结构

一个 LangChain 工具包含三个核心要素:

  • name:工具名称,AI通过它选择工具
  • description:工具描述,AI判断何时使用
  • func:实际执行的函数

最简单的工具:DynamicTool

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

const simpleTool = new DynamicTool({
  name: "get_time",
  description: "获取当前时间",
  func: async () => {
    return new Date().toLocaleString();
  }
});

// 使用
const result = await simpleTool.invoke("");
console.log("当前时间:", result);

带参数的工具

// 接收参数的工具
const echoTool = new DynamicTool({
  name: "echo",
  description: "返回用户输入的内容",
  func: async (input: string) => {
    return `你说的是: ${input}`;
  }
});

// 使用
const result = await echoTool.invoke("Hello World!");
console.log( result);

带参数验证的工具:DynamicStructuredTool(推荐)

使用 Zod 进行参数验证,让工具更可靠:

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

// 天气查询工具
const weatherTool = new DynamicStructuredTool({
  name: "get_weather",
  description: "获取指定城市的天气信息,返回温度、天气状况",
  schema: z.object({
    city: z.string().describe("城市名称,如:北京、上海"),
    unit: z.enum(["celsius", "fahrenheit"]).optional().describe("温度单位")
  }),
  func: async ({ city, unit = "celsius" }) => {
    // 模拟天气数据
    const weatherData: Record<string, any> = {
      "北京": { temp: 22, condition: "晴" },
      "上海": { temp: 18, condition: "雨" },
      "武汉": { temp: 25, condition: "阴" }
    };
    
    const data = weatherData[city];
    if (!data) {
      return `未找到城市 "${city}" 的天气信息`;
    }
    
    const temp = unit === "celsius" ? `${data.temp}°C` : `${data.temp * 9/5 + 32}°F`;
    return `${city}今天${data.condition},温度${temp}`;
  }
});

// 使用
const result = await weatherTool.invoke({ city: "北京" });
console.log(result);

为什么需要参数验证?

没有验证有 Zod 验证
AI可能传错参数类型自动校验并提示
工具内部需要手动检查验证在入口完成
错误信息不友好统一格式化错误
AI 无法理解为什么失败AI 能看懂验证错误

深入 DynamicStructuredTool

Zod Schema 详解

基础类型

const basicSchema = z.object({
  name: z.string(),           // 字符串
  age: z.number(),            // 数字
  isActive: z.boolean(),      // 布尔值
  tags: z.array(z.string())   // 数组
});

带约束的类型

const constrainedSchema = z.object({
  name: z.string().min(1).max(100),           // 长度限制
  age: z.number().min(0).max(150),            // 数值范围
  email: z.string().email(),                  // 邮箱格式
  url: z.string().url(),                      // URL格式
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/) // 正则匹配
});

可选和默认值

const optionalSchema = z.object({
  required: z.string(),                        // 必填
  optional: z.string().optional(),             // 可选
  withDefault: z.string().default("默认值")     // 带默认值
});

枚举类型

const enumSchema = z.object({
  status: z.enum(["pending", "done", "cancelled"]),
  priority: z.enum(["low", "normal", "high"]).default("normal")
});

description 的重要性

工具的 description 直接决定 AI 调用工具的准确率:

差的描述

const badTool = new DynamicStructuredTool({
  name: "get_weather",
  description: "查询天气",
  schema: z.object({ city: z.string() }),
  func: async ({ city }) => { /* ... */ }
});

好的描述

const goodTool = new DynamicStructuredTool({
  name: "get_weather",
  description: `获取指定城市的实时天气信息。

使用场景:
- 用户询问天气、温度、是否下雨时
- 用户问"今天冷不冷"、"需要带伞吗"时

返回信息:
- 温度(摄氏度或华氏度)
- 天气状况(晴/多云/雨/雪)
- 湿度百分比
- 风速

注意:如果用户没有指定城市,请先询问城市名称。`,
  schema: z.object({
    city: z.string().describe("城市名称,如:北京、上海、深圳"),
    unit: z.enum(["celsius", "fahrenheit"]).optional().describe("温度单位")
  }),
  func: async ({ city, unit = "celsius" }) => { /* ... */ }
});

错误处理的最佳实践

const robustTool = new DynamicStructuredTool({
  name: "read_file",
  description: "读取文件内容",
  schema: z.object({
    path: z.string().describe("文件路径")
  }),
  func: async ({ path }) => {
    try {
      const content = await fs.readFile(path, "utf-8");
      
      // 限制返回长度,避免Token超限
      if (content.length > 5000) {
        return `${content.slice(0, 5000)}\n...(文件内容过长,已截断)`;
      }
      
      return content;
    } catch (error: any) {
      // 返回结构化错误,让AI能理解
      if (error.code === "ENOENT") {
        return `错误:文件 "${path}" 不存在。请检查文件路径是否正确。`;
      }
      if (error.code === "EACCES") {
        return `错误:没有权限读取文件 "${path}"。`;
      }
      return `错误:读取文件失败 - ${error.message}`;
    }
  }
});

Toolkit:将相关工具组织成工具集

为什么需要 Toolkit?

当我们的 AI 需要多个相关工具时,Toolkit 提供了:

  • 统一管理:相关工具放在一起
  • 命名空间:避免工具名冲突
  • 批量注册:一次性注册所有工具
  • 共享配置:共享基础路径、认证信息等

文件系统 Toolkit 完整实现

让我们一步步构建一个完整的文件管理工具集:

步骤1:定义工具接口和共享配置

import { Tool, StructuredTool } from "@langchain/core/tools";
import { DynamicStructuredTool } from "@langchain/core/tools";
import { z } from "zod";
import * as fs from "fs/promises";
import * as path from "path";

// 工具集配置
interface FileSystemToolkitConfig {
  basePath?: string;           // 安全根目录
  maxFileSize?: number;        // 最大文件读取大小
  allowedExtensions?: string[]; // 允许的文件扩展名
  enableDelete?: boolean;      // 是否启用删除功能
  enableWrite?: boolean;       // 是否启用写入功能
}

// 文件系统工具集
class FileSystemToolkit {
  private tools: Tool[];
  private config: Required<FileSystemToolkitConfig>;
  
  constructor(config: FileSystemToolkitConfig = {}) {
    // 默认配置
    this.config = {
      basePath: config.basePath || process.cwd(),
      maxFileSize: config.maxFileSize || 1024 * 1024, // 1MB
      allowedExtensions: config.allowedExtensions || ['.txt', '.md', '.json', '.js', '.ts'],
      enableDelete: config.enableDelete ?? true,
      enableWrite: config.enableWrite ?? true
    };
    
    this.tools = this.createTools();
  }
  
  private createTools(): Tool[] {
    const tools: Tool[] = [
      this.createReadFileTool(),
      this.createListDirectoryTool(),
      this.createFileInfoTool()
    ];
    
    if (this.config.enableWrite) {
      tools.push(this.createWriteFileTool());
    }
    
    if (this.config.enableDelete) {
      tools.push(this.createDeleteFileTool());
    }
    
    return tools;
  }
  
  // 后续实现各个工具...
  
  getTools(): Tool[] {
    return this.tools;
  }
}

步骤2:实现读取文件工具

private createReadFileTool(): DynamicStructuredTool {
  return new DynamicStructuredTool({
    name: "read_file",
    description: `读取文件内容。支持的文件类型: ${this.config.allowedExtensions.join(", ")}。文件大小限制: ${this.config.maxFileSize / 1024}KB。`,
    schema: z.object({
      path: z.string().describe("文件路径,相对于根目录或绝对路径"),
      encoding: z.enum(["utf-8", "ascii", "base64"]).optional().describe("文件编码,默认utf-8")
    }),
    func: async ({ path: filePath, encoding = "utf-8" }) => {
      // 安全检查:确保路径在安全目录内
      const fullPath = this.safeResolvePath(filePath);
      if (!fullPath) {
        return `错误:不允许访问 "${filePath}",超出安全目录范围`;
      }
      
      // 检查文件扩展名
      const ext = path.extname(fullPath).toLowerCase();
      if (!this.config.allowedExtensions.includes(ext)) {
        return `错误:不支持的文件类型 "${ext}"。允许的类型: ${this.config.allowedExtensions.join(", ")}`;
      }
      
      try {
        const stats = await fs.stat(fullPath);
        
        // 检查文件大小
        if (stats.size > this.config.maxFileSize) {
          return `错误:文件过大 (${stats.size} 字节),超过限制 ${this.config.maxFileSize} 字节`;
        }
        
        const content = await fs.readFile(fullPath, encoding as BufferEncoding);
        
        // 限制返回长度,避免Token超限
        const maxReturnLength = 5000;
        if (content.length > maxReturnLength) {
          return `文件内容 (${content.length} 字符,已截断):\n---\n${content.slice(0, maxReturnLength)}\n...\n---\n提示:文件较长,仅显示前 ${maxReturnLength} 字符。`;
        }
        
        return `文件内容:\n---\n${content}\n---`;
      } catch (error: any) {
        if (error.code === "ENOENT") {
          return `错误:文件 "${filePath}" 不存在。`;
        }
        if (error.code === "EACCES") {
          return `错误:没有权限读取文件 "${filePath}"。`;
        }
        return `错误:读取文件失败 - ${error.message}`;
      }
    }
  });
}

步骤3:实现写入文件工具

private createWriteFileTool(): DynamicStructuredTool {
  return new DynamicStructuredTool({
    name: "write_file",
    description: `写入文件内容。如果文件不存在会创建,如果存在会覆盖。`,
    schema: z.object({
      path: z.string().describe("文件路径"),
      content: z.string().describe("要写入的内容"),
      createDirectories: z.boolean().optional().describe("是否自动创建父目录,默认true")
    }),
    func: async ({ path: filePath, content, createDirectories = true }) => {
      const fullPath = this.safeResolvePath(filePath);
      if (!fullPath) {
        return `错误:不允许写入 "${filePath}",超出安全目录范围`;
      }
      
      // 检查文件大小
      if (content.length > this.config.maxFileSize) {
        return `错误:内容过大 (${content.length} 字节),超过限制 ${this.config.maxFileSize} 字节`;
      }
      
      try {
        // 创建父目录
        if (createDirectories) {
          const dir = path.dirname(fullPath);
          await fs.mkdir(dir, { recursive: true });
        }
        
        await fs.writeFile(fullPath, content, "utf-8");
        
        const stats = await fs.stat(fullPath);
        return `✅ 文件 "${filePath}" 写入成功。大小: ${stats.size} 字节,位置: ${fullPath}`;
      } catch (error: any) {
        if (error.code === "EACCES") {
          return `错误:没有权限写入文件 "${filePath}"。`;
        }
        return `错误:写入文件失败 - ${error.message}`;
      }
    }
  });
}

步骤4:实现删除文件工具

private createDeleteFileTool(): DynamicStructuredTool {
  return new DynamicStructuredTool({
    name: "delete_file",
    description: `删除文件。此操作不可恢复,请谨慎使用。`,
    schema: z.object({
      path: z.string().describe("要删除的文件路径"),
      confirm: z.boolean().describe("确认删除,必须为true才能执行")
    }),
    func: async ({ path: filePath, confirm }) => {
      if (!confirm) {
        return `操作取消:需要设置 confirm: true 才能删除文件。`;
      }
      
      const fullPath = this.safeResolvePath(filePath);
      if (!fullPath) {
        return `错误:不允许删除 "${filePath}",超出安全目录范围`;
      }
      
      try {
        // 检查文件是否存在
        await fs.access(fullPath);
        
        await fs.unlink(fullPath);
        return `✅ 文件 "${filePath}" 已删除。`;
      } catch (error: any) {
        if (error.code === "ENOENT") {
          return `错误:文件 "${filePath}" 不存在。`;
        }
        if (error.code === "EACCES") {
          return `错误:没有权限删除文件 "${filePath}"。`;
        }
        return `错误:删除文件失败 - ${error.message}`;
      }
    }
  });
}

步骤5:实现列出目录工具

private createListDirectoryTool(): DynamicStructuredTool {
  return new DynamicStructuredTool({
    name: "list_directory",
    description: `列出目录下的文件和子目录。`,
    schema: z.object({
      path: z.string().optional().describe("目录路径,默认为当前目录"),
      recursive: z.boolean().optional().describe("是否递归列出子目录,默认false"),
      showHidden: z.boolean().optional().describe("是否显示隐藏文件,默认false")
    }),
    func: async ({ path: dirPath = ".", recursive = false, showHidden = false }) => {
      const fullPath = this.safeResolvePath(dirPath);
      if (!fullPath) {
        return `错误:不允许访问 "${dirPath}",超出安全目录范围`;
      }
      
      try {
        const items = await fs.readdir(fullPath);
        
        // 过滤隐藏文件
        let filtered = items;
        if (!showHidden) {
          filtered = items.filter(item => !item.startsWith("."));
        }
        
        if (filtered.length === 0) {
          return `目录 "${dirPath}" 为空`;
        }
        
        if (recursive) {
          // 递归列出所有文件
          const allFiles: string[] = [];
          const walk = async (dir: string, prefix: string = "") => {
            const files = await fs.readdir(dir);
            for (const file of files) {
              if (!showHidden && file.startsWith(".")) continue;
              const full = path.join(dir, file);
              const stat = await fs.stat(full);
              const relativePath = path.relative(fullPath, full);
              allFiles.push(`${prefix}${file}${stat.isDirectory() ? "/" : ""}`);
              if (stat.isDirectory()) {
                await walk(full, `${prefix}  `);
              }
            }
          };
          await walk(fullPath);
          return `📁 ${dirPath}\n${allFiles.join("\n")}`;
        }
        
        // 获取每个项目的详细信息
        const itemsWithInfo = await Promise.all(
          filtered.map(async (item) => {
            const itemPath = path.join(fullPath, item);
            try {
              const stat = await fs.stat(itemPath);
              const type = stat.isDirectory() ? "📁" : "📄";
              const size = stat.isFile() ? ` (${stat.size} B)` : "";
              const modified = stat.mtime.toLocaleDateString();
              return `${type} ${item}${size} - 修改于 ${modified}`;
            } catch {
              return `❓ ${item}`;
            }
          })
        );
        
        return `📁 ${dirPath} (${filtered.length}项):\n${itemsWithInfo.join("\n")}`;
      } catch (error: any) {
        if (error.code === "ENOENT") {
          return `错误:目录 "${dirPath}" 不存在。`;
        }
        if (error.code === "ENOTDIR") {
          return `错误:"${dirPath}" 不是一个目录。`;
        }
        return `错误:列出目录失败 - ${error.message}`;
      }
    }
  });
}

步骤6:实现文件信息工具

private createFileInfoTool(): DynamicStructuredTool {
  return new DynamicStructuredTool({
    name: "file_info",
    description: `获取文件或目录的详细信息。`,
    schema: z.object({
      path: z.string().describe("文件或目录路径")
    }),
    func: async ({ path: filePath }) => {
      const fullPath = this.safeResolvePath(filePath);
      if (!fullPath) {
        return `错误:不允许访问 "${filePath}",超出安全目录范围`;
      }
      
      try {
        const stats = await fs.stat(fullPath);
        const isDirectory = stats.isDirectory();
        
        const info = {
          name: path.basename(fullPath),
          fullPath,
          type: isDirectory ? "目录" : "文件",
          size: isDirectory ? null : `${stats.size} 字节`,
          created: stats.birthtime.toLocaleString(),
          modified: stats.mtime.toLocaleString(),
          accessed: stats.atime.toLocaleString(),
          permissions: stats.mode.toString(8).slice(-3)
        };
        
        if (isDirectory) {
          const files = await fs.readdir(fullPath);
          info["filesCount"] = files.length;
          info["files"] = files.slice(0, 20).join(", ");
          if (files.length > 20) {
            info["files"] += ` 等 ${files.length} 项`;
          }
        }
        
        return JSON.stringify(info, null, 2);
      } catch (error: any) {
        if (error.code === "ENOENT") {
          return `错误:路径 "${filePath}" 不存在。`;
        }
        return `错误:获取信息失败 - ${error.message}`;
      }
    }
  });
}

步骤7:安全路径解析

private safeResolvePath(inputPath: string): string | null {
  // 解析绝对路径
  const resolved = path.resolve(this.config.basePath, inputPath);
  
  // 安全检查:确保解析后的路径在安全目录内
  if (!resolved.startsWith(this.config.basePath)) {
    return null;
  }
  
  return resolved;
}

使用 FileSystemToolkit

// 创建工具集
const toolkit = new FileSystemToolkit({
  basePath: "./my-workspace",
  maxFileSize: 512 * 1024, // 512KB
  allowedExtensions: ['.txt', '.md', '.json', '.js'],
  enableDelete: true,
  enableWrite: true
});

// 获取所有工具
const tools = toolkit.getTools();
console.log(`已加载 ${tools.length} 个工具:`, tools.map(t => t.name));
// 输出: 已加载 5 个工具: ['read_file', 'write_file', 'delete_file', 'list_directory', 'file_info']

// 单独使用工具
const readTool = tools.find(t => t.name === "read_file");
const result = await readTool?.invoke({ path: "test.txt" });

将工具集集成到 Agent

创建文件管理 Agent

import { ChatOpenAI } from "@langchain/openai";
import { AgentExecutor, createOpenAIFunctionsAgent } from "langchain/agents";
import { ChatPromptTemplate, MessagesPlaceholder } from "@langchain/core/prompts";
import { BufferMemory } from "langchain/memory";

async function createFileAgent() {
  // 1. 创建工具集
  const toolkit = new FileSystemToolkit({
    basePath: "./workspace",
    maxFileSize: 1024 * 1024, // 1MB
    allowedExtensions: ['.txt', '.md', '.json', '.js', '.ts'],
    enableDelete: true,
    enableWrite: true
  });
  
  // 2. 获取工具
  const tools = toolkit.getTools();
  
  // 3. 创建模型
  const model = new ChatOpenAI({
    model: "gpt-4",
    temperature: 0,
    configuration: {
      baseURL: process.env.OPENAI_BASE_URL
    }
  });
  
  // 4. 创建记忆
  const memory = new BufferMemory({
    returnMessages: true,
    memoryKey: "chat_history"
  });
  
  // 5. 创建提示模板
  const prompt = ChatPromptTemplate.fromMessages([
    ["system", `你是一个文件管理助手,可以帮助用户管理文件。

可用工具:
${tools.map(t => `- ${t.name}: ${t.description}`).join("\n")}

规则:
1. 删除文件前,需要用户确认
2. 所有操作结果都会返回详细信息
3. 文件操作限定在安全目录内`],
    new MessagesPlaceholder("chat_history"),
    ["human", "{input}"],
    new MessagesPlaceholder("agent_scratchpad")
  ]);
  
  // 6. 创建 Agent
  const agent = await createOpenAIFunctionsAgent({
    llm: model,
    tools,
    prompt
  });
  
  // 7. 创建执行器
  const executor = new AgentExecutor({
    agent,
    tools,
    memory,
    verbose: true,
    maxIterations: 5
  });
  
  return executor;
}

// 使用
async function main() {
  const agent = await createFileAgent();
  
  // 创建文件
  const result1 = await agent.invoke({
    input: "创建一个文件 test.txt,内容为 Hello World"
  });
  console.log(result1.output);
  
  // 读取文件
  const result2 = await agent.invoke({
    input: "读取 test.txt 的内容"
  });
  console.log(result2.output);
  
  // 列出目录
  const result3 = await agent.invoke({
    input: "列出当前目录下的所有文件"
  });
  console.log(result3.output);
}

调试与测试

单元测试示例

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import * as fs from "fs/promises";
import * as path from "path";

describe("FileSystemToolkit", () => {
  const testDir = path.join(__dirname, ".test-workspace");
  let toolkit: FileSystemToolkit;
  
  beforeEach(async () => {
    // 创建测试目录
    await fs.mkdir(testDir, { recursive: true });
    toolkit = new FileSystemToolkit({ basePath: testDir });
  });
  
  afterEach(async () => {
    // 清理测试目录
    await fs.rm(testDir, { recursive: true, force: true });
  });
  
  it("应该能创建文件", async () => {
    const writeTool = toolkit.getTools().find(t => t.name === "write_file");
    const result = await writeTool?.call(JSON.stringify({
      path: "test.txt",
      content: "Hello"
    }));
    
    expect(result).toContain("写入成功");
    
    // 验证文件确实创建了
    const content = await fs.readFile(path.join(testDir, "test.txt"), "utf-8");
    expect(content).toBe("Hello");
  });
  
  it("应该能读取文件", async () => {
    // 先创建文件
    await fs.writeFile(path.join(testDir, "test.txt"), "Test Content");
    
    const readTool = toolkit.getTools().find(t => t.name === "read_file");
    const result = await readTool?.call(JSON.stringify({ path: "test.txt" }));
    
    expect(result).toContain("Test Content");
  });
  
  it("应该在删除文件前要求确认", async () => {
    // 创建文件
    await fs.writeFile(path.join(testDir, "test.txt"), "Test");
    
    const deleteTool = toolkit.getTools().find(t => t.name === "delete_file");
    
    // 没有确认
    const result1 = await deleteTool?.call(JSON.stringify({ path: "test.txt", confirm: false }));
    expect(result1).toContain("取消");
    
    // 有确认
    const result2 = await deleteTool?.call(JSON.stringify({ path: "test.txt", confirm: true }));
    expect(result2).toContain("已删除");
    
    // 验证文件已删除
    await expect(fs.access(path.join(testDir, "test.txt"))).rejects.toThrow();
  });
});

最佳实践总结

工具设计原则

原则说明示例
单一职责每个工具只做一件事读文件和写文件分开
明确描述 让AI知道何时使用"当用户需要创建文件时使用"
友好错误返回可理解的错误信息返回"文件不存在"而非抛出异常
参数验证使用Zod验证所有输入z.string().min(1)
安全第一限制工具可访问的资源限制在安全目录内

Toolkit 设计原则

原则说明
配置化通过配置控制行为(只读/可写)
可扩展支持添加新工具
命名空间避免工具名冲突
共享资源共享数据库连接、文件系统等

安全检查清单

  • 文件操作限制在安全目录内
  • 删除、更新操作需要确认
  • 敏感操作记录日志
  • 限制可访问的文件类型
  • 设置文件大小限制

结语

你想为你的业务场景封装什么工具集?欢迎在评论区分享!

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!