1000行代码实现极简版openclaw(附源码)(3)
工具系统:为Agent赋能执行能力 本文介绍了Agent工具系统的设计原理和实现方法,让AI不仅能思考还能执行实际任务。核心内容: 工具的必要性:类比人类大脑需要手脚配合,LLM作为大脑需要工具系统才能完成实际任务(如文件操作) 注册表设计: 采用中央注册表统一管理所有工具 实现动态扩展和解耦 提供工具查询和格式转换功能 安全实现: 内置文件系统工具 通过路径解析和安全检查防止目录遍历攻击 限制操
·
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 函数,处理更多边界情况:
- 符号链接
- 空路径
- 特殊字符
更多推荐


所有评论(0)