02 - 工具系统:Agent 的手脚 github 源码(欢迎star)

目标

理解工具系统的设计和实现,让 Agent 能够执行实际操作。


1. 为什么需要工具?

类比:人的能力

只有大脑,没有手脚

  • 你能思考,但不能拿东西
  • 你能计划,但不能执行
  • 你知道怎么做饭,但切不了菜

大脑 + 手脚

  • 思考 + 执行 = 完成任务

LLM 就是大脑,工具就是手脚

实际例子

用户:创建一个文件

没有工具的 LLM:
"抱歉,我无法操作文件系统"

有工具的 Agent:
[调用 write_file 工具]
"文件已创建!"

2. 工具系统设计

2.1 核心思想

注册表模式:统一管理所有工具

┌─────────────────────────────────────────┐
│           ToolRegistry                  │
│         (工具注册表/工具箱)            │
├─────────────────────────────────────────┤
│                                         │
│  tools = {                              │
│    "read_file": Tool,                   │
│    "write_file": Tool,                  │
│    "list_files": Tool,                  │
│    "bash": Tool,                        │
│    ...                                  │
│  }                                      │
│                                         │
├─────────────────────────────────────────┤
│  + register(tool)  注册新工具           │
│  + get(name)       获取工具             │
│  + list()          列出所有工具         │
│  + toOpenAIFormat()转换为 API 格式     │
│                                         │
└─────────────────────────────────────────┘

2.2 为什么用注册表?

好处 1:统一管理

// 不用注册表
const tool1 = createReadFileTool();
const tool2 = createWriteFileTool();
const tool3 = createListFilesTool();
// 散落在各处,难以管理

// 用注册表
const registry = new ToolRegistry();
registry.register(createReadFileTool());
registry.register(createWriteFileTool());
registry.register(createListFilesTool());
// 统一入口,清晰明了

好处 2:动态扩展

// 可以运行时添加工具
registry.register(myCustomTool);

好处 3:解耦

// Agent 不需要知道有哪些工具
// 只需要知道从注册表获取
const tool = registry.get(toolName);

3. 完整代码实现

创建文件 src/tools/registry.ts

/**
 * 工具注册表 - Agent 与外部世界交互的桥梁
 */

import { Tool, ToolParameter } from '../core/types.js';
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, mkdirSync } from 'fs';
import { execSync } from 'child_process';
import { join, resolve, dirname } from 'path';

// ============================================================================
// 第一部分:ToolRegistry 类
// ============================================================================

/**
 * 工具注册表
 * 
 * 职责:
 * 1. 存储所有可用工具
 * 2. 提供工具查询接口
 * 3. 转换工具格式(供 LLM API 使用)
 */
export class ToolRegistry {
  /**
   * 使用 Map 存储工具
   * key: 工具名称(字符串)
   * value: Tool 对象
   * 
   * 为什么用 Map 不用 Array?
   * - Map.get() 是 O(1) 复杂度,直接通过 key 查找
   * - Array.find() 是 O(n) 复杂度,需要遍历
   */
  private tools = new Map<string, Tool>();

  /**
   * 注册工具
   * @param tool 要注册的工具对象
   */
  register(tool: Tool): void {
    // 以工具名称为 key,存入 Map
    this.tools.set(tool.name, tool);
  }

  /**
   * 获取工具
   * @param name 工具名称
   * @returns Tool 对象,如果不存在返回 undefined
   */
  get(name: string): Tool | undefined {
    return this.tools.get(name);
  }

  /**
   * 获取所有工具
   * @returns Tool 数组
   */
  list(): Tool[] {
    // Array.from 将 Map 的 values 转换为数组
    return Array.from(this.tools.values());
  }

  /**
   * 转换为 OpenAI 格式的工具定义
   * 
   * 为什么需要转换?
   * 因为 LLM API(如 OpenAI、DeepSeek)需要特定格式的工具描述
   * 而我们的内部格式可能不同
   * 
   * 内部格式 -> OpenAI 格式
   */
  toOpenAIFormat(): Array<{
    type: 'function';
    function: {
      name: string;
      description: string;
      parameters: {
        type: 'object';
        properties: Record<string, unknown>;
        required: string[];
      };
    };
  }> {
    return this.list().map(tool => ({
      type: 'function' as const,
      function: {
        name: tool.name,
        description: tool.description,
        parameters: {
          type: 'object' as const,
          // 转换参数格式
          properties: Object.entries(tool.parameters).reduce(
            (acc, [key, param]) => {
              acc[key] = {
                type: param.type,
                description: param.description,
              };
              return acc;
            },
            {} as Record<string, unknown>
          ),
          // 提取必需参数
          required: Object.entries(tool.parameters)
            .filter(([, param]) => (param as ToolParameter).required !== false)
            .map(([key]) => key),
        },
      },
    }));
  }
}

// ============================================================================
// 第二部分:内置工具实现
// ============================================================================

/**
 * 创建文件系统工具
 * 
 * @param workspace 工作目录(安全沙箱)
 * @returns Tool 数组
 */
export function createFileSystemTools(workspace: string): Tool[] {
  /**
   * 安全路径检查函数
   * 
   * 目的:防止目录遍历攻击
   * 
   * 攻击场景:
   * 用户输入:../../../etc/passwd
   * 预期行为:拒绝,不允许访问工作目录外的文件
   */
  const safePath = (inputPath: string): string => {
    // resolve 将相对路径转换为绝对路径
    // 例如:workspace="/home/user", inputPath="../etc" 
    // 解析后:"/etc"(逃出工作目录)
    const resolved = resolve(workspace, inputPath);
    
    // 安全检查:解析后的路径必须以工作目录开头
    if (!resolved.startsWith(resolve(workspace))) {
      throw new Error('路径超出工作目录范围');
    }
    
    return resolved;
  };

  return [
    // 工具 1:读取文件
    {
      name: 'read_file',
      description: '读取文件内容,支持相对路径。用于查看文件内容、代码、配置文件等。',
      parameters: {
        path: {
          type: 'string',
          description: '文件路径,如 "src/index.ts" 或 "README.md"',
          required: true,
        },
      },
      execute: async ({ path }) => {
        try {
          // 安全检查
          const fullPath = safePath(path as string);
          
          // 检查文件是否存在
          if (!existsSync(fullPath)) {
            return `错误: 文件不存在 ${path}`;
          }
          
          // 检查是否为文件(不是目录)
          const stat = statSync(fullPath);
          if (stat.isDirectory()) {
            return `错误: ${path} 是目录,不是文件`;
          }
          
          // 检查文件大小(防止读取过大文件)
          if (stat.size > 1024 * 1024) {
            return `错误: 文件过大 (>1MB),请分批读取`;
          }
          
          // 读取文件内容
          return readFileSync(fullPath, 'utf-8');
        } catch (error: any) {
          return `错误: ${error.message}`;
        }
      },
    },

    // 工具 2:写入文件
    {
      name: 'write_file',
      description: '写入文件内容,自动创建不存在的目录。用于创建或覆盖文件。',
      parameters: {
        path: {
          type: 'string',
          description: '文件路径,如 "src/utils/helper.ts"',
          required: true,
        },
        content: {
          type: 'string',
          description: '要写入的文件内容',
          required: true,
        },
      },
      execute: async ({ path, content }) => {
        try {
          const fullPath = safePath(path as string);
          
          // 自动创建父目录
          // dirname 获取父目录路径
          // mkdirSync 创建目录,recursive: true 表示递归创建
          mkdirSync(dirname(fullPath), { recursive: true });
          
          // 写入文件
          writeFileSync(fullPath, content as string, 'utf-8');
          
          return `已写入: ${path}`;
        } catch (error: any) {
          return `错误: ${error.message}`;
        }
      },
    },

    // 工具 3:列出目录
    {
      name: 'list_files',
      description: '列出目录内容,显示文件和子目录。用于浏览文件系统。',
      parameters: {
        path: {
          type: 'string',
          description: '目录路径,默认为当前目录',
          required: false,
        },
      },
      execute: async ({ path = '.' }) => {
        try {
          const fullPath = safePath(path as string);
          
          if (!existsSync(fullPath)) {
            return `错误: 目录不存在 ${path}`;
          }
          
          // readdirSync 读取目录内容
          // withFileTypes: true 返回详细信息(是文件还是目录)
          const items = readdirSync(fullPath, { withFileTypes: true });
          
          // 格式化输出
          // [D] 表示目录,[F] 表示文件
          return items
            .map(item => `${item.isDirectory() ? '[D]' : '[F]'} ${item.name}`)
            .join('\n') || '(空目录)';
        } catch (error: any) {
          return `错误: ${error.message}`;
        }
      },
    },
  ];
}

/**
 * 创建 Shell 工具
 * 
 * @param workspace 工作目录
 * @param allowUnsafe 是否允许危险操作(默认 false)
 */
export function createShellTools(workspace: string, allowUnsafe = false): Tool[] {
  return [
    {
      name: 'bash',
      description: allowUnsafe
        ? '执行 bash 命令(无限制模式,谨慎使用)'
        : '执行 bash 命令(安全模式:只允许读操作)',
      parameters: {
        command: {
          type: 'string',
          description: '要执行的命令,如 "ls -la" 或 "cat file.txt"',
          required: true,
        },
        timeout: {
          type: 'number',
          description: '超时时间(秒),默认 30',
          required: false,
        },
      },
      execute: async ({ command, timeout = 30 }) => {
        try {
          // 安全模式检查
          if (!allowUnsafe) {
            // 定义危险命令关键字
            const unsafeKeywords = [
              'rm',           // 删除
              '>', '>>',      // 重定向(可能覆盖文件)
              '|',            // 管道(可能执行复杂操作)
              ';', '&&', '||', // 命令连接
              'eval', 'exec', // 代码执行
            ];
            
            // 检查命令是否包含危险关键字
            const isUnsafe = unsafeKeywords.some(keyword =>
              (command as string).includes(keyword)
            );
            
            if (isUnsafe) {
              return '错误: 安全模式下不允许此操作。危险命令包括: rm, >, |, ;, eval 等';
            }
          }
          
          // 执行命令
          const output = execSync(command as string, {
            cwd: workspace,                    // 在工作目录执行
            timeout: (timeout as number) * 1000, // 转换为毫秒
            encoding: 'utf-8',                 // 返回字符串
          });
          
          return output || '(无输出)';
        } catch (error: any) {
          return `错误: ${error.message}`;
        }
      },
    },
  ];
}

4. 代码详解

4.1 安全设计

为什么需要 safePath?

攻击场景:
用户输入:../../../etc/passwd
意图:读取系统敏感文件

safePath 处理:
1. resolve("/workspace", "../../../etc/passwd") -> "/etc/passwd"
2. 检查:"/etc/passwd".startsWith("/workspace") -> false
3. 抛出错误:"路径超出工作目录范围"

正常输入:
用户输入:docs/readme.md
resolve("/workspace", "docs/readme.md") -> "/workspace/docs/readme.md"
检查通过!

4.2 错误处理

每个工具都有 try-catch:

execute: async ({ path }) => {
  try {
    // 可能出错的操作
    return readFileSync(path, 'utf-8');
  } catch (error: any) {
    // 捕获错误,返回友好的错误信息
    return `错误: ${error.message}`;
  }
}

为什么不让错误抛出?

因为错误会返回给 LLM,LLM 可以根据错误信息调整策略:

AI: 读取文件
工具返回: "错误: 文件不存在 config.json"
AI: [思考] 文件不存在,我应该先创建它
AI: [调用 write_file] 创建默认配置

4.3 格式转换详解

toOpenAIFormat() {
  return this.list().map(tool => ({
    type: 'function',
    function: {
      name: tool.name,
      description: tool.description,
      parameters: {
        type: 'object',
        // 转换参数
        properties: {
          path: { type: 'string', description: '文件路径' },
          content: { type: 'string', description: '文件内容' }
        },
        required: ['path', 'content']  // 必需参数
      }
    }
  }));
}

转换示例

// 内部格式
{
  name: 'write_file',
  parameters: {
    path: { type: 'string', description: '...', required: true },
    content: { type: 'string', description: '...', required: true }
  }
}

// OpenAI API 格式
{
  type: 'function',
  function: {
    name: 'write_file',
    description: '...',
    parameters: {
      type: 'object',
      properties: {
        path: { type: 'string', description: '...' },
        content: { type: 'string', description: '...' }
      },
      required: ['path', 'content']
    }
  }
}

5. 如何创建自定义工具

5.1 最简单的工具

const helloTool: Tool = {
  name: 'hello',
  description: '打招呼工具,用于向用户问好',
  parameters: {
    name: {
      type: 'string',
      description: '用户名字',
      required: true,
    },
  },
  execute: async ({ name }) => {
    return `你好,${name}!很高兴见到你。`;
  },
};

// 注册
registry.register(helloTool);

5.2 带可选参数的工具

const calculateTool: Tool = {
  name: 'calculate',
  description: '计算数学表达式',
  parameters: {
    expression: {
      type: 'string',
      description: '数学表达式,如 "2 + 3"',
      required: true,
    },
    precision: {
      type: 'number',
      description: '小数位数,默认 2',
      required: false,  // 可选
    },
  },
  execute: async ({ expression, precision = 2 }) => {
    const result = eval(expression as string);  // 注意:实际使用需要安全检查
    return result.toFixed(precision as number);
  },
};

5.3 调用外部 API 的工具

const weatherTool: Tool = {
  name: 'get_weather',
  description: '获取指定城市的天气',
  parameters: {
    city: {
      type: 'string',
      description: '城市名称,如 "北京"',
      required: true,
    },
  },
  execute: async ({ city }) => {
    const response = await fetch(
      `https://api.weather.com/v1/current?city=${encodeURIComponent(city as string)}`
    );
    const data = await response.json();
    return `天气:${data.weather},温度:${data.temperature}°C`;
  },
};

6. 练习

练习 1:创建计算工具

实现一个支持加减乘除的计算器工具。

练习 2:创建时间工具

实现一个返回当前时间的工具。

练习 3:安全改进

改进 safePath 函数,处理更多边界情况:

  • 符号链接
  • 空路径
  • 特殊字符

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐