跳转到内容

声明式工具定义模式

Gemini CLI — 声明式工具定义模式

学习目标

  • 理解 AnyDeclarativeTool 接口的三要素设计
  • 掌握两阶段(build → execute)工具调用的优势
  • 分析 Zod 验证、工具别名和注册表的工作方式

项目实践

声明式工具三要素

Gemini CLI 中每个工具通过 AnyDeclarativeTool 接口定义:

interface AnyDeclarativeTool {
name: string; // 工具名称
displayName: string; // 显示名称(UI 友好)
description: string; // 工具描述(注入到系统提示)
schema: FunctionDeclaration; // JSON Schema 参数定义
build(params): ToolInvocation; // 验证参数,创建调用实例
}

三要素

  1. 声明(schema):描述工具接受什么参数
  2. 验证(build):将原始参数验证为具体类型,失败时返回错误
  3. 执行(execute):执行工具操作,返回结果

两阶段调用:build → execute

LLM: functionCall("read_file", { file_path: "main.ts" })
Stage 1: ToolRegistry.getTool("read_file").build(params)
├── Zod schema 验证参数
├── 构建 ToolInvocation 实例
├── 如果验证失败 → 返回 ErroredToolCall(不执行)
└── 如果验证成功 → 返回 ValidatingToolCall
Stage 2: ValidatingToolCall.invocation.execute(options)
├── 执行实际的文件读取
├── 返回 ToolResult(结果或错误)
└── 结果格式化为 LLM 可理解的 functionResponse

为什么分离

  • Policy 检查:Scheduler 在 Validating 阶段做策略检查,此时工具尚未执行
  • 用户确认shouldConfirmExecute() 在确认前调用,用户可以拒绝
  • 并行优化:验证阶段可以快速完成,执行阶段可能耗时较长,分离后可以在验证完成后并行执行
  • 错误隔离:参数验证错误 vs 执行错误,区分处理

ToolInvocation 抽象

interface ToolInvocation<TParams, TResult> {
params: TParams;
getDescription(): string; // 执行前描述(用于确认对话框)
getDisplayTitle?(): string; // 简洁标题(用于 UI 展示)
getExplanation?(): string; // 对话解释
toolLocations(): ToolLocation[]; // 影响的文件路径(用于沙箱策略)
execute(options: ExecuteOptions): Promise<TResult>;
}

BaseToolInvocation 提供通用实现:

  • 构造函数接收参数、工具名称、MessageBus
  • getDescription() 默认实现为 JSON 字符串化参数
  • execute() 需要子类实现

Zod 参数验证

每个工具使用 Zod schema 验证参数:

const ReadFileSchema = z.object({
file_path: z.string().min(1, "file_path is required"),
start_line: z.number().optional(),
end_line: z.number().optional(),
});
class ReadFileTool extends BaseDeclarativeTool {
schema = ReadFileSchema;
build(params: unknown): ToolInvocation {
const parsed = this.schema.parse(params);
return new ReadFileInvocation(parsed);
}
}

验证失败处理build() 中 Zod 抛出异常,Scheduler 捕获后创建 ErroredToolCall,返回详细错误信息给 LLM。

工具别名

TOOL_LEGACY_ALIASES 支持向后兼容:

const TOOL_LEGACY_ALIASES: Record<string, string> = {
"read": "read_file",
"write": "write_file",
// ...
};

当 LLM 使用旧名称调用工具时,ToolRegistry 自动映射到新名称,确保向后兼容。

ToolRegistry 统一管理

class ToolRegistry {
private tools: Map<string, AnyDeclarativeTool> = new Map();
register(tool: AnyDeclarativeTool): void {
this.tools.set(tool.name, tool);
}
getTool(name: string): AnyDeclarativeTool | undefined {
// 先查原始名称,再查别名
return this.tools.get(name) || this.tools.get(TOOL_LEGACY_ALIASES[name]);
}
getAllToolNames(): string[] {
return Array.from(this.tools.keys());
}
}

注册的工具类型

  • 内置工具:read_file, write_file, edit, shell, grep, glob, ls 等
  • MCP 工具:通过 MCP 协议动态发现的工具(mcp_* 前缀)
  • Agent 工具:子 Agent 封装(agent_* 前缀)
  • 扩展工具:Extension 注册的工具

陷阱与对策

参数验证与执行的环境差异

问题build() 阶段可用的环境变量在执行阶段可能变化。

对策execute() 接收 ExecuteOptions,包含当前 AbortSignal、输出更新回调等,确保执行时使用最新环境。

工具名称冲突

问题:内置工具和 MCP 工具可能同名。

对策:MCP 工具使用 mcp_{server}_{tool} 前缀命名空间,内置工具使用简短名称,不会冲突。


设计取舍

声明式 vs 命令式工具

方案优势代价
声明式统一接口、自动验证、可序列化每个工具需要编写 schema 和实现类
命令式灵活、实现快速参数验证不统一、难以做通用策略检查

Gemini CLI 的选择:声明式,因为需要统一的工具接口供 LLM 发现、Policy Engine 检查、Scheduler 调度。

Zod vs 运行时类型检查

方案优势代价
Zod类型推导、组合性好、错误信息详细增加依赖、schema 编写有学习曲线
手动检查无额外依赖、灵活容易遗漏边界、错误信息不一致

Gemini CLI 的选择:Zod,因为类型推导使得工具参数类型与 TypeScript 类型系统一致,减少运行时错误。


参考来源