工具系统设计

github:Schoober AI SDK GitHub 仓库
各位看官求🌟一下,小的先在此谢过
ReAct 循环中,LLM 负责"想",工具负责"做"。工具系统的设计直接决定了 Agent 的能力边界。schoober-ai-sdk 的工具系统需要解决三个问题:

  1. 定义:如何让 LLM 知道有哪些工具可用、参数是什么
  2. 执行:如何在流式响应中调用工具,并将结果反馈给 LLM
  3. 管理:如何注册、实例化、缓存和销毁工具

1. Tool 接口:工具的契约

所有工具都实现 Tool 接口,这是整个系统的核心契约:

interface Tool {
    name: string;                  // 唯一标识,LLM 通过此名称调用工具
    displayName?: string;          // UI 展示名称

    getDescription(): Promise<ToolDescription>;   // 告诉 LLM 这个工具做什么
    getParameters(): Promise<z.ZodSchema>;         // 告诉 LLM 需要什么参数
    execute(params, context, isPartial): Promise<void>;  // 执行逻辑
    validate(params): Promise<ValidationResult>;   // 参数校验
}

几个设计决策值得说明:

为什么 getDescriptiongetParameters 是异步的? 因为工具的描述和参数可能依赖外部状态。比如一个数据库查询工具,它的可用表名列表需要在运行时从数据库获取,写死在代码里不现实。

为什么参数用 Zod Schema 而不是 JSON Schema? Zod 在 TypeScript 生态中同时解决了类型定义和运行时校验两个问题。定义一个 z.object({ city: z.string() }),既能自动推导出 TypeScript 类型,又能在执行前做参数校验。生成 prompt 时再通过 z.toJSONSchema() 转为 LLM 能理解的文本格式。

为什么 execute 没有返回值? 这是一个刻意的设计。工具不通过返回值传递结果,而是通过两条独立的通道:

工具执行
  ├─ setToolResult()    → 写入消息历史,供 LLM 下一轮推理使用
  └─ sendToolStatus()   → 推送 UI 状态,供前端展示进度

这两条通道的职责完全不同:setToolResult 面向 LLM,内容是结构化数据;sendToolStatus 面向用户,内容是人类可读的进度提示。分离后,工具可以给 LLM 返回 JSON 格式的精确数据,同时给用户展示"正在查询天气…"这样的友好文案。

2. BaseTool:工具的基类

BaseTool 是所有工具的抽象基类,封装了与 TaskExecutor 交互的细节。开发者只需实现三个抽象方法:

abstract class BaseTool implements Tool {
    abstract name: string;
    abstract getDescription(): Promise<ToolDescription>;
    abstract getParameters(): Promise<z.ZodSchema>;
    abstract execute(params, context, isPartial): Promise<void>;
}

2.1 工具状态机

工具执行不是一个瞬时动作,它有明确的状态流转。sendToolStatus 方法驱动这个状态机:

WAIT  →  DOING  →  SUCCESS
                →  ERROR

每个状态对应一个用户可感知的阶段:

// 阶段 1:等待(参数还在流式解析中)
await this.sendToolStatus(requestId, ToolStatus.WAIT, {
    showTip: '正在准备查询...',
    params: { city: '北京' },
});

// 阶段 2:执行中
await this.sendToolStatus(requestId, ToolStatus.DOING, {
    showTip: '正在查询北京的天气...',
});

// 阶段 3:完成
await this.sendToolStatus(requestId, ToolStatus.SUCCESS, {
    result: { temperature: 22, condition: '晴' },
});

状态信息在同一次执行过程中是累积合并的,而非每次覆盖。比如在 WAIT 阶段设置的 params,到 SUCCESS 阶段仍然保留。这让前端不需要自己维护状态拼接逻辑:

private mergeStatusOptions(options?: ToolStatusOptions): Partial<ToolInfo> {
    // params: 深度合并(新值合并到旧值)
    if (options.params !== undefined) {
        const existingParams = this._currentStatus?.params || {};
        merged.params = { ...existingParams, ...options.params };
    }
    // result: 直接覆盖(只在终态出现)
    if (options.result !== undefined) {
        merged.result = options.result;
    }
    // ...
}

到达终态(SUCCESS 或 ERROR)后,缓存自动清理,为下一次调用做好准备。

2.2 isPartial:流式参数解析

execute 方法的第三个参数 isPartial 是工具系统中最精巧的设计之一。

LLM 的响应是流式的,工具调用的参数也在逐步解析。当 MessageParser 解析到一个 tool_use 块但参数尚未完整时,isPartialtrue。此时工具会被调用,但参数可能只有一部分:

async execute(params, context, isPartial) {
    if (isPartial) {
        // 参数未完整,只展示等待状态
        await this.sendToolStatus(context.requestId, ToolStatus.WAIT, {
            showTip: '正在准备...',
            params,  // 可能只有部分字段
        });
        return;  // 不执行实际逻辑
    }

    // 参数完整,开始真正执行
    await this.sendToolStatus(context.requestId, ToolStatus.DOING, { ... });
    // ... 执行逻辑
}

这个机制让前端可以在参数解析过程中就开始展示工具卡片和部分参数,而非等到所有参数就绪后才出现。对于参数较多的工具(比如代码编辑工具需要传入完整的 diff),用户能更早看到反馈。

2.3 setToolResult vs sendToolStatus

这两个方法容易混淆,用一张表说清楚:

setToolResult sendToolStatus
受众 LLM(下一轮推理输入) 前端 UI(用户可见)
写入位置 消息历史(ApiMessage, role=user) 用户消息流(UserMessage, type=tool)
内容格式 字符串(通常是 JSON) 结构化 ToolInfo 对象
调用时机 工具执行完毕,结果确定后 执行全程(WAIT/DOING/SUCCESS/ERROR)
必要性 必须调用,否则 LLM 看不到结果 可选,但不调用则前端无感知

一个完整的工具执行流程中,这两个方法的调用顺序通常是:

sendToolStatus(WAIT)     ←  参数解析中,告知前端
sendToolStatus(DOING)    ←  开始执行,告知前端
setToolResult(result)    ←  结果写入消息历史,告知 LLM
sendToolStatus(SUCCESS)  ←  执行成功,告知前端

3. 工具注册与实例化

3.1 ToolRegistry:工厂注册表

工具不是在 Agent 初始化时就全部实例化的。ToolRegistry 存储的是工厂函数,而非工具实例:

class DefaultToolRegistry implements ToolRegistry {
    private factories: Map<string, ToolFactory> = new Map();

    registerFactory(name: string, factory: ToolFactory): void {
        this.factories.set(name, factory);
    }

    // 需要实例时才调用工厂函数
    async get(name: string): Promise<Tool | undefined> {
        const factory = this.factories.get(name);
        return factory ? await factory(undefined) : undefined;
    }
}

Agent.registerTool() 封装了注册细节,支持两种方式:

// 方式 1:传入类(自动包装为工厂函数)
agent.registerTool(WeatherTool);
// 内部等价于: registry.registerFactory('get_weather', () => new WeatherTool())

// 方式 2:传入带工厂函数的配置(适合需要注入依赖的场景)
agent.registerTool({
    name: 'database_query',
    factory: async (context) => {
        const tool = new DatabaseQueryTool();
        await tool.connectToDatabase(process.env.DB_URL);
        return tool;
    },
});

方式 2 的工厂函数接收 ToolContext 参数,可以根据任务上下文动态创建工具实例。比如不同用户可能连接不同的数据库。

3.2 ToolManager:两级缓存

ToolManager 是工具执行的入口,它在 ToolRegistry 之上添加了缓存和执行管理。缓存策略是两级的:

请求来了: executeTool(toolUse, context)
    │
    ▼
Level 1: toolUseInstanceCache (ToolUse.id → Tool)
    │  命中 → 直接使用(同一次流式调用复用同一个实例)
    │  未命中 ↓
    │
Level 2: 用户注册工具 (ToolRegistry)
    │  有工厂 → 调用工厂创建实例 → 缓存
    │  无工厂 ↓
    │
Level 3: systemTools (系统内置工具)
    │  有 → 使用系统工具
    │  无 → 抛出 TOOL_NOT_FOUND

Level 1 缓存的存在是因为 isPartial 机制:同一个工具调用会被 execute 多次(参数逐步完整),每次都创建新实例既浪费资源,也会丢失之前积累的状态(比如 _currentStatus)。通过 ToolUse.id 做缓存,保证流式解析过程中始终使用同一个工具实例。

3.3 系统工具 vs 用户工具

SDK 内置了两个系统工具:

工具 职责
attempt_completion 标记任务完成,退出 ReAct 循环
new_task 创建子任务,委派给子 Agent 执行

系统工具和用户注册工具的关系是:用户工具优先,可覆盖同名系统工具。这在 ToolManager.getOrCreateToolInstance 中体现:

// 用户注册的工具优先
const factory = this.toolRegistry.getFactory(toolName);
if (factory) {
    return await factory(context);
}

// 系统工具作为兜底
if (this.systemTools.has(toolName)) {
    return this.systemTools.get(toolName)!;
}

在 prompt 生成阶段也是同样的策略——Agent.buildSystemPrompt 会过滤掉被用户工具覆盖的系统工具定义:

const registeredToolNames = new Set(registeredTools.map(t => t.name));
// 过滤掉被用户覆盖的系统工具
const filteredAdditionalTools = additionalTools
    .filter(t => !registeredToolNames.has(t.name));
// 合并:过滤后的系统工具 + 用户注册的工具
const allTools = [...filteredAdditionalTools, ...registeredTools];

这意味着如果用户注册了一个自定义的 attempt_completion 工具,SDK 会在 prompt 和执行层面都使用用户版本。

4. 工具执行的细节处理

4.1 参数校验与类型修正

LLM 生成的参数并不总是类型正确的。一个常见问题是布尔值:LLM 可能返回字符串 "true" 而非布尔值 true,导致 Zod 校验失败。

ToolManager 在校验失败后会尝试自动修正:

if (toolUse.validated === false) {
    // 尝试转换布尔值字符串
    const convertedParams = this.convertBooleanStrings(toolUse.rawParams);
    const result = schema.safeParse(convertedParams);

    if (result.success) {
        // 转换后验证通过,使用修正后的参数
        toolUse.params = result.data;
        toolUse.validated = true;
    } else {
        // 仍然失败,写入错误消息让 LLM 重试
        await taskExecutor.insertApiMessage({
            role: 'user',
            content: JSON.stringify({ error: errorMessage }),
            source: 'tool',
        });
        throw new AgentSDKError(errorMessage, 'TOOL_VALIDATION_ERROR');
    }
}

convertBooleanStrings 会递归处理嵌套对象和数组,将所有 "true"/"false" 字符串转为对应的布尔值。这个处理是透明的,工具开发者不需要关心。

4.2 超时控制

每个工具执行都有超时保护,通过 Promise.race 实现:

private async executeWithTimeout(tool, params, context, isPartial): Promise<void> {
    await Promise.race([
        tool.execute(params, context, isPartial),
        new Promise<void>((_, reject) => {
            setTimeout(() => {
                reject(new AgentSDKError(
                    `Tool execution timeout after ${this.executionTimeout}ms`,
                    'TOOL_TIMEOUT'
                ));
            }, this.executionTimeout);
        })
    ]);
}

默认超时时间是 10 分钟,可通过 ToolManagerConfig.executionTimeout 配置。超时后抛出的错误会进入 ReActEngine 的错误追踪流程——如果同一个工具连续超时 3 次,任务会被暂停。

4.3 执行统计

ToolManager 为每个工具维护执行统计:

interface ToolExecutionStats {
    toolName: string;
    executionCount: number;     // 总执行次数
    successCount: number;       // 成功次数
    failureCount: number;       // 失败次数
    totalDuration: number;      // 总耗时
    averageDuration: number;    // 平均耗时
}

每次执行完成后自动更新,无论成功或失败。这些数据可以用于监控工具性能、发现慢工具、或在 prompt 中提示 LLM 避免使用频繁失败的工具。

5. createSimpleTool:快捷创建

对于不需要内部状态管理的简单工具,继承 BaseTool 显得太重了。createSimpleTool 提供了函数式的创建方式:

const calculatorTool = createSimpleTool({
    name: 'calculator',
    description: {
        displayName: '计算器',
        description: '执行数学运算',
    },
    parameters: z.object({
        expression: z.string().describe('数学表达式'),
    }),
    execute: async (params, context, isPartial) => {
        if (isPartial) return;
        // 简单逻辑直接写在这里
    },
});

它内部创建了一个匿名的 BaseTool 子类,将配置对象的字段映射到对应的抽象方法。适合一次性的、无状态的工具。需要注意的是,createSimpleTool 中无法使用 this.setToolResultthis.sendToolStatus(因为 this 指向不同),复杂工具仍然需要继承 BaseTool

6. 从工具定义到 Prompt

工具定义最终需要转化为 LLM 能理解的文本。这个过程由 generateToolsPrompt 完成:

Tool 实例
  → getDescription()  →  工具名称、描述、分类
  → getParameters()   →  Zod Schema → z.toJSONSchema() → 可读文本
  → 拼接为 Markdown 格式的工具定义

生成的 prompt 格式:

# Available Tools

You have access to the following 3 tool(s):

## get_weather
Display Name: 天气查询
Description: 查询指定城市的实时天气信息

Parameters:
  - city: string (required) - 城市名称,如"北京"、"上海"

## attempt_completion
Display Name: 完成任务
Description: 在完成任务后使用此工具来展示最终结果...

这段 prompt 会在每轮 ReAct 循环中通过 Agent.buildSystemPrompt() 注入到 systemPrompt 中。

7. 小结

工具系统的分层设计:

开发者视角          SDK 内部                    LLM 视角
───────────        ─────────                  ──────────
BaseTool           ToolRegistry               systemPrompt 中的
  ├ getDescription   (工厂注册)                工具定义文本
  ├ getParameters       ↓                         ↓
  └ execute          ToolManager               tool_use 响应
                     (缓存+执行)                   ↓
                        ↓                     setToolResult
                     TaskExecutor              → 消息历史
                     (消息协调)                → 下一轮推理

核心设计原则:

  • 双通道通信setToolResult 面向 LLM,sendToolStatus 面向用户,职责分离
  • 延迟实例化:注册的是工厂函数而非实例,按需创建,支持依赖注入
  • 流式友好isPartial 机制让工具在参数未完整时就能提供反馈,提升用户体验
  • 用户优先:用户注册的工具可以覆盖系统工具,在 prompt 和执行层面统一生效
Logo

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

更多推荐