为什么需要封装工具集?
一个真实的开发场景
我们正在开发一个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 设计原则
| 原则 | 说明 |
|---|---|
| 配置化 | 通过配置控制行为(只读/可写) |
| 可扩展 | 支持添加新工具 |
| 命名空间 | 避免工具名冲突 |
| 共享资源 | 共享数据库连接、文件系统等 |
安全检查清单
- 文件操作限制在安全目录内
- 删除、更新操作需要确认
- 敏感操作记录日志
- 限制可访问的文件类型
- 设置文件大小限制
结语
你想为你的业务场景封装什么工具集?欢迎在评论区分享!
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!