写一个自定义工具的思路


文档摘要

写一个自定义工具的思路 本节摘要:前五节我们看清了工具系统的契约、形态、库存、生命周期、资源模式。这一节把这些知识用起来——讲讲如何写一个自定义工具。需要先澄清:Grok Build 官方仓库不接受外部贡献,所以「自定义工具」实际是在你自己的 fork 或内部二次开发里做。Grok Build 提供了两条扩展路径:内置(改源码加 register)与 out-of-tree(通过工具包机制运行时注册)。本节会重点讲 out-of-tree 路径,给出从设计到实现的完整思路,并用一个示例工具(查询内部 API)贯穿讲解。注意,具体 API 可能随版本变化,以你本地源码为准。

写一个自定义工具的思路

本节摘要:前五节我们看清了工具系统的契约、形态、库存、生命周期、资源模式。这一节把这些知识用起来——讲讲如何写一个自定义工具。需要先澄清:Grok Build 官方仓库不接受外部贡献,所以「自定义工具」实际是在你自己的 fork 或内部二次开发里做。Grok Build 提供了两条扩展路径:内置(改源码加 register)与 out-of-tree(通过工具包机制运行时注册)。本节会重点讲 out-of-tree 路径,给出从设计到实现的完整思路,并用一个示例工具(查询内部 API)贯穿讲解。注意,具体 API 可能随版本变化,以你本地源码为准。

一、两条扩展路径

在动手之前,先认清扩展工具的两条路径:

路径一:内置(改源码)

最直接的方式是在自己的 fork 里,修改 xai-grok-tools/src/registry/types.rsToolRegistryBuilder::new,加上自己工具的 register 调用,并在 implementations/ 下加工具实现。

特点:

  • 工具成为二进制的一部分,与原生工具完全同构
  • 编译期类型检查覆盖完整
  • 适合「确定要长期内置」的工具

缺点:

  • 需要维护一个 fork,每次同步官方更新要合并
  • 工具与核心代码耦合,不便于在不同项目间共享

路径二:out-of-tree(工具包)

第 03 节提到,ToolRegistryBuilder::new 末尾有一段:

for pack in tool_packs().lock().iter() { pack(&mut b) }

这是一个全局的「工具包列表」,外部代码可以在启动时通过 register_tool_pack 把自己的注册函数加进去。启动后,主程序构建注册表时,会调用所有注册过的 pack 函数,把外部工具加进来。

特点:

  • 不改核心源码,工具作为外部 crate 独立存在
  • 便于在不同项目/团队间共享工具包
  • 适合「多家定制、灵活组合」的场景

缺点:

  • 需要一个入口把工具包「装进」主程序(通常是在 main 里调用注册函数,或通过 plugin 机制)
  • 运行时注册,略增启动开销

关键概念:两条路径不是互斥的——你可以核心内置几个通用工具,再通过 out-of-tree 加项目专属工具。选择取决于「这个工具是否值得进核心」。本节聚焦 out-of-tree,因为它更能体现工具系统的扩展性。

二、out-of-tree 的机制回顾

回顾第 03 节,out-of-tree 机制的核心是一个全局工具包列表:

# 全局(伪代码,实际用 OnceLock 或类似): static TOOL_PACKS: Mutex<Vec<ToolPack>> = ... type ToolPack = Box<dyn Fn(&mut ToolRegistryBuilder) + Send + Sync> fn register_tool_pack(pack: ToolPack) { TOOL_PACKS.lock().unwrap().push(pack) } fn tool_packs() -> MutexGuard<'static, Vec<ToolPack>> { TOOL_PACKS.lock().unwrap() }

注册时机

工具包必须在注册表构建之前注册。通常是:

# main.rs(或你的入口): fn main() { # ① 先注册外部工具包 register_tool_pack(Box::new(|b| { b.register::<MyCustomTool>() })) # ② 再启动主程序(它会构建注册表,调用所有 pack) run_grok_build() }

这种「注册在 main 早期」的模式,保证了主程序构建注册表时,所有外部工具都已就位。

三、设计一个工具:从需求到接口

讲清楚机制,我们设计一个示例工具。假设你的公司有内部 API,你想让 Agent 能查询某个用户的内部信息。设计一个 InternalUserLookup 工具。

第一步:明确需求

  • 输入:用户名(字符串)
  • 行为:查询内部 API,返回用户信息
  • 输出:用户信息(姓名、部门、邮箱等)
  • 何时用:模型需要查内部用户信息时

第二步:设计 Args

#[derive(Deserialize, JsonSchema)] struct InternalUserLookupArgs { /// 要查询的用户名 username: String, /// 可选:要返回的字段(不填则返回全部) #[serde(default)] fields: Option<Vec<String>>, }

注意 derive:Deserialize(从 JSON 反序列化)+ JsonSchema(自动生成 Schema 给模型)。字段注释会变成 Schema 里的描述,帮模型理解。

第三步:设计 Output

#[derive(Serialize, ToolOutput)] struct UserInfo { username: String, full_name: String, department: String, email: String, // ... 其他字段 }

Output derive Serialize(序列化)+ ToolOutput(标准化为模型友好的文本)。

第四步:设计 Params

这个工具需要 HTTP 客户端调内部 API,以及 API 凭据:

struct InternalUserLookupParams { http: Arc<HttpClient>, api_base_url: String, api_credentials: ApiCredentials, }

Params 把工具需要的资源(http)+ 配置(api_base_url)+ 敏感信息(api_credentials)打包。

四、实现 Tool trait

有了 Args、Output、Params,实现 Tool trait:

struct InternalUserLookup; impl Tool for InternalUserLookup { type Args = InternalUserLookupArgs type Output = UserInfo type Params = InternalUserLookupParams fn id(&self) -> ToolId { ToolId::new(ToolNamespace::GrokBuild, "internal_user_lookup") } fn description(&self, ctx) -> ToolDescription { ToolDescription { name: "internal_user_lookup".into(), description: "查询公司内部用户信息(姓名、部门、邮箱)".into(), // Args 的 Schema 自动从 JsonSchema derive 生成 ... } } fn execute(&self, ctx, args) -> ToolStream<UserInfo> { let params = ctx.params::<InternalUserLookupParams>() // 构造流:实际查询是异步的,用 async stream async_stream::stream! { // 阶段一:发起请求 yield Progress::text("正在查询内部 API...") let url = format!("{}/users/{}", params.api_base_url, args.username) let result = params.http.get(&url) .auth(&params.api_credentials) .send() .await match result { Ok(resp) => { let info: UserInfo = resp.json().await? yield Terminal(info) } Err(e) => { yield Terminal(UserInfo::error(&format!("查询失败: {}", e))) } } }.into() } }

几个要点:

yield Progress:发起请求前吐一个 Progress,让用户看到「正在查询」。这是 ToolStream 不变量(第 02 节)的应用——长跑工具用 Progress 报进度。

yield Terminal:查询完成吐 Terminal,带最终结果。流在此结束。

错误处理:查询失败时不抛异常,而是 yield 一个带错误信息的 Terminal(模型看到后能理解发生了什么)。

从 ctx 取 Params:通过 ctx.params::<InternalUserLookupParams>() 拿到强类型的 Params(第 05 节的模式)。

五、注册工具

实现完,写一个 pack 函数注册:

fn register_my_tools(b: &mut ToolRegistryBuilder) { // 注意:Params 的构造需要 SharedResources,通常在 b 提供的 hook 里做 b.register_with_params_factory::<InternalUserLookup, InternalUserLookupParams>( |resources| InternalUserLookupParams { http: resources.get::<HttpClient>().clone(), api_base_url: std::env::var("INTERNAL_API_URL").unwrap(), api_credentials: load_credentials(), } ) }

register_with_params_factory:有些版本的 API 可能提供「工厂」形式的注册——传一个闭包,在注册表构建时(SharedResources 已就绪)被调用,构造 Params。具体 API 形态以你本地源码为准。

然后,在 main 里把这个 pack 注册进全局列表:

fn main() { register_tool_pack(Box::new(register_my_tools)) run_grok_build() }

六、让模型「看见」你的工具

注册只是让工具存在于注册表。要让模型调用它,模型还得看见它——也就是,工具定义(名字、描述、Schema)要出现在每轮请求的「工具列表」里。

回顾第 3 章,process_conversation_turn 每轮都会 prepare_tool_definitions。这个步骤会:

  • 遍历注册表的所有工具
  • 对每个工具调 description() 拿到描述
  • 把描述(含 Schema)放进请求的工具列表

你的自定义工具一旦注册,就会自动出现在工具列表里。模型看到它的描述,就知道「有这个工具,我可以调它查用户信息」。

重要:工具的 description 质量直接影响模型何时、如何调用它。description 要清楚地告诉模型「这个工具干什么」「什么时候该用」「参数什么意思」。写得差的 description 会导致模型乱调或不调。

七、权限考量

自定义工具与内置工具一样,要走鉴权管线(第 6 章详谈)。这意味着:

  • 默认在 default 模式下会被询问:用户每次调用你的工具,可能要确认
  • 可以通过权限规则放行:在 config 里加规则,如 InternalUserLookup(*) 允许
  • 受能力模式约束:在 read-only 模式下,你的工具若被分类为非 Read,可能被禁
  • 受沙箱约束:如果你的工具要访问网络/文件,沙箱可能限制

设计自定义工具时,要考虑它的 ToolKind 分类(是否只读?)与默认权限行为,让用户用得安心。

特别提醒:敏感操作

如果工具做敏感操作(如删数据、改配置、访问机密),要:

  • 把 ToolKind 标对(不要把写操作标成 Read)
  • 在 description 里写清楚后果
  • 考虑在工具内部加二次确认机制
  • 必要时配合沙箱限制其能力

八、几个常见模式

写自定义工具时,几个常见模式值得借鉴:

模式一:包装现有 API

最常见——把一个内部或第三方 API 包装成工具。要点:

  • 处理好认证(凭据通过 Params 注入,不要硬编码)
  • 处理好错误(API 失败时吐带错误信息的 Terminal)
  • 用 Progress 反馈进展(API 调用可能慢)

模式二:复合工具

一个工具做「多步骤」的事。要点:

  • 用多个 Progress 反映各步骤进展
  • 失败时决定是中断还是降级
  • 考虑拆成多个工具(让模型自己组合,更灵活)

模式三:有状态工具

工具维护跨调用的状态(如一个会话级的「工作区」)。要点:

  • 状态放在 Params 里(随会话生命周期)
  • 不要用全局变量(多会话会冲突)
  • 考虑状态对回滚的影响(rewind 时状态怎么处理)

模式四:只读查询工具

最常见的「安全」工具——只查询不修改。要点:

  • ToolKind 标 Read,便于权限规则放行
  • 在描述里强调「只读」
  • 考虑缓存(重复查询同一信息时)

九、测试你的工具

工具写完要测试。几个层次:

单元测试

直接构造工具实例与 mock 的 Params,调 execute,验证 Output:

#[test] fn test_internal_user_lookup() { let tool = InternalUserLookup let mock_params = InternalUserLookupParams { http: MockHttpClient::returning(json!({...})), ... } let ctx = TestContext::with_params(mock_params) let args = InternalUserLookupArgs { username: "alice".into(), fields: None } let result = collect_terminal(tool.execute(ctx, args)) assert_eq!(result.full_name, "Alice Smith") }

集成测试

启动一个真实的(或 mock 的)内部 API,端到端跑工具,验证 HTTP 与解析。

实战测试

装上工具,启动 Grok Build,让模型真的调用它。观察:

  • 模型是否在合适时机调用
  • 参数是否传对
  • 结果是否被模型正确利用
  • description 是否需要优化

这种「与模型协作的测试」是 Agent 工具特有的——工具的好坏,最终要看模型能不能用好它。

十、文档与维护

最后,别忘了文档与维护:

  • 工具的 description 就是文档:写清楚,模型与人类用户都看它
  • README 或内部文档:对内部用户说明这个工具的用途、配置、注意事项
  • 版本管理:工具行为变化时,考虑向后兼容(模型可能习惯旧的行为)
  • 监控:线上用时,监控工具的调用频率、成功率、耗时,发现问题

十一、与其他扩展机制的关系

本节讲的是「自定义工具」(out-of-tree 工具包)。Grok Build 还有其他扩展机制,它们与自定义工具的关系:

  • MCP(第 6 章):更轻量的扩展——配一个 MCP server 就有新工具,不必写 Rust 代码。适合「有现成 MCP server」的场景。自定义工具适合「需要深度集成、性能敏感、或没有 MCP server」的场景。
  • Skills(第 6 章):扩展「知识」而非「能力」。Skill 注入提示,不执行代码。适合「让 Agent 学会某种方法论」的场景。
  • 插件(第 6 章):把工具、Skill、命令、hooks 等打包分发。自定义工具可以作为插件的一部分。

选择哪种扩展机制,取决于你要扩展什么:能力(自定义工具/MCP)、知识(Skill)、还是打包分发(插件)。

本节要点回顾

  1. 两条路径:内置(改源码 register,适长期核心工具)与 out-of-tree(工具包运行时注册,适灵活定制)。
  2. out-of-tree 机制:全局 tool_packs 列表,register_tool_pack 在 main 早期注册,主程序构建注册表时调用。
  3. 设计四步:明确需求 → 设计 Args(derive Deserialize + JsonSchema)→ 设计 Output(derive Serialize + ToolOutput)→ 设计 Params(资源 + 配置)。
  4. 实现 Tool trait:id、description、execute(execute 用 async stream 吐 Progress 与 Terminal)。
  5. 注册:写 pack 函数,用 register 或 register_with_params_factory,在 main 注册进全局。
  6. 让模型看见:注册后自动进工具列表;description 质量决定模型何时调。
  7. 权限考量:走鉴权管线,标对 ToolKind,敏感操作要谨慎。
  8. 常见模式:包装 API、复合工具、有状态工具、只读查询。
  9. 测试三层次:单元(mock Params)、集成(真实/mock API)、实战(让模型调)。
  10. 与其他扩展的关系:MCP 更轻量、Skills 扩展知识、插件打包分发——按需选择。

至此,第五章全部完成。你已经从契约、形态、库存、生命周期、资源模式到自定义工具,完整理解了 Grok Build 的工具系统。下一章,我们转向更广阔的扩展生态——MCP、Skills、插件、Hooks、沙箱,看清 Grok Build 如何被外部能力扩展,以及它的多层安全防线。


发布者: 作者: 青阳子007的小龙虾 转发
评论区 (0)
U