写一个自定义工具的思路 本节摘要:前五节我们看清了工具系统的契约、形态、库存、生命周期、资源模式。这一节把这些知识用起来——讲讲如何写一个自定义工具。需要先澄清: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.rs 的 ToolRegistryBuilder::new,加上自己工具的 register 调用,并在 implementations/ 下加工具实现。
特点:
缺点:
路径二:out-of-tree(工具包)
第 03 节提到,ToolRegistryBuilder::new 末尾有一段:
for pack in tool_packs().lock().iter() { pack(&mut b) }
这是一个全局的「工具包列表」,外部代码可以在启动时通过 register_tool_pack 把自己的注册函数加进去。启动后,主程序构建注册表时,会调用所有注册过的 pack 函数,把外部工具加进来。
特点:
缺点:
关键概念:两条路径不是互斥的——你可以核心内置几个通用工具,再通过 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 工具。
第一步:明确需求
第二步:设计 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)打包。
有了 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(¶ms.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 质量直接影响模型何时、如何调用它。description 要清楚地告诉模型「这个工具干什么」「什么时候该用」「参数什么意思」。写得差的 description 会导致模型乱调或不调。
自定义工具与内置工具一样,要走鉴权管线(第 6 章详谈)。这意味着:
InternalUserLookup(*) 允许设计自定义工具时,要考虑它的 ToolKind 分类(是否只读?)与默认权限行为,让用户用得安心。
特别提醒:敏感操作
如果工具做敏感操作(如删数据、改配置、访问机密),要:
写自定义工具时,几个常见模式值得借鉴:
模式一:包装现有 API
最常见——把一个内部或第三方 API 包装成工具。要点:
模式二:复合工具
一个工具做「多步骤」的事。要点:
模式三:有状态工具
工具维护跨调用的状态(如一个会话级的「工作区」)。要点:
模式四:只读查询工具
最常见的「安全」工具——只查询不修改。要点:
工具写完要测试。几个层次:
单元测试
直接构造工具实例与 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,让模型真的调用它。观察:
这种「与模型协作的测试」是 Agent 工具特有的——工具的好坏,最终要看模型能不能用好它。
最后,别忘了文档与维护:
本节讲的是「自定义工具」(out-of-tree 工具包)。Grok Build 还有其他扩展机制,它们与自定义工具的关系:
选择哪种扩展机制,取决于你要扩展什么:能力(自定义工具/MCP)、知识(Skill)、还是打包分发(插件)。
至此,第五章全部完成。你已经从契约、形态、库存、生命周期、资源模式到自定义工具,完整理解了 Grok Build 的工具系统。下一章,我们转向更广阔的扩展生态——MCP、Skills、插件、Hooks、沙箱,看清 Grok Build 如何被外部能力扩展,以及它的多层安全防线。