SharedResources 与 Params 模式 本节摘要:工具要干活,需要资源——bash 工具需要终端后端、readfile 需要文件系统句柄、webfetch 需要 HTTP 客户端。如果每个工具都自己持有这些资源,既浪费(重复创建)又混乱(状态不一致)。Grok Build 的解法是 SharedResources / Params 模式:框架维护一块类型擦除的共享资源池,工具按需取用,通过 Params 机制注入。本节会讲清这个模式是什么、它如何工作、为什么需要它,以及它带来的几个工程好处。理解这一节,你就理解了工具「怎么拿到执行所需的弹药」。 一、问题:工具需要资源 先看问题。
本节摘要:工具要干活,需要资源——bash 工具需要终端后端、read_file 需要文件系统句柄、web_fetch 需要 HTTP 客户端。如果每个工具都自己持有这些资源,既浪费(重复创建)又混乱(状态不一致)。Grok Build 的解法是 SharedResources / Params 模式:框架维护一块类型擦除的共享资源池,工具按需取用,通过 Params 机制注入。本节会讲清这个模式是什么、它如何工作、为什么需要它,以及它带来的几个工程好处。理解这一节,你就理解了工具「怎么拿到执行所需的弹药」。
先看问题。一个工具要执行,往往需要一些「重资源」或「有状态资源」:
这些资源有几个共同特点:
如果让每个工具自己创建并持有这些资源,会有几个问题:
SharedResources / Params 模式就是为解决这些问题而生。
Grok Build 的解法是:框架维护一个 SharedResources——一块「类型擦除的共享资源池」。它大致是这样:
SharedResources { # 类型擦除的容器,按类型存取资源 resources: HashMap<TypeId, Box<dyn Any + Send + Sync>> } impl SharedResources { fn insert<T: 'static + Send + Sync>(&mut self, resource: T) { # 按类型 ID 存 self.resources.insert(TypeId::of::<T>(), Box::new(resource)) } fn get<T: 'static + Send + Sync>(&self) -> Option<&T> { # 按类型 ID 取 self.resources.get(&TypeId::of::<T>()) .and_then(|r| r.downcast_ref::<T>()) } }
特点:
get::<TerminalBackend>()Box<dyn Any> 存储,但取用时 downcast 回强类型典型存入的资源
会话启动时,框架把各种资源存入:
let mut resources = SharedResources::new() resources.insert(TerminalBackend::new(...)) # 终端后端 resources.insert(FileSystemHandle::new(...)) # 文件系统句柄 resources.insert(HttpClient::new(config)) # HTTP 客户端 resources.insert(current_cwd.clone()) # 当前工作目录 resources.insert(notifier_handle) # 通知句柄(给 UI 发消息) resources.insert(api_key_provider) # API key 提供器 # ... 其他资源
工具执行时,从 SharedResources 取自己需要的:
impl Tool for BashTool { fn execute(&self, ctx, args) -> ToolStream<...> { # 从共享资源池取终端后端 let terminal = ctx.shared_resources.get::<TerminalBackend>() let cwd = ctx.shared_resources.get::<Cwd>() # 用这些资源执行 terminal.run_command(args.command, cwd, ...) } }
SharedResources 是「所有工具共享」的资源池。但有些资源是「某类工具专属」或「需要特定配置」的。这时用 Params 机制。
回顾第 03 节,注册工具有两种方式:
b.register::<ListDirTool>() # 自包含 b.register_with_params::<BashTool, BashParams>(...) # 需要参数
register_with_params::<T, P>() 的 P 就是 Params——一组工具专属的参数。它的典型形态:
struct BashParams { terminal_backend: Arc<TerminalBackend>, # 终端后端(从 SharedResources 来) timeout: Duration, # 超时配置 cwd: Arc<Path>, # 当前工作目录 notifier: NotifierHandle, # 通知句柄 }
Params 与 SharedResources 的关系
Params 通常是「从 SharedResources 里挑出这个工具需要的,加上工具专属的配置,打包成一个强类型结构」:
# 会话启动时,为需要 Params 的工具构造它们 let bash_params = BashParams { terminal_backend: shared_resources.get::<TerminalBackend>().clone(), timeout: config.bash_timeout, cwd: shared_resources.get::<Cwd>().clone(), notifier: shared_resources.get::<Notifier>().clone(), } # 注册时注入 registry.register_with_params::<BashTool, BashParams>(bash_params)
工具内部通过 Params 拿到强类型的资源:
impl Tool for BashTool { type Params = BashParams # 声明需要的 Params 类型 fn execute(&self, ctx, args) -> ToolStream<...> { # 从 ctx 取 Params let params: &BashParams = ctx.params() # 用 params 里的资源 params.terminal_backend.run_command(...) } }
Params 的好处:
type Params = BashParams,清楚地表明自己依赖什么把 SharedResources 与 Params 放在一起,这个模式是分两层的:
第一层:SharedResources(共享资源池) - 会话级,所有工具共享 - 类型擦除(按 TypeId 存取) - 存「重资源」:终端后端、HTTP 客户端、文件系统、通知句柄等 ↓ 选取 + 加配置 第二层:Params(工具专属参数) - 工具级,每个需要 Params 的工具一份 - 强类型(具体 struct) - 含工具需要的资源切片 + 工具专属配置 ↓ 注入工具 工具执行 - 通过 ctx.params() 拿到强类型 Params - 用 Params 里的资源执行
这两层配合,既保证了资源的共享(SharedResources 一份),又给了工具强类型的访问接口(Params)。
把这个模式放回更大的设计语境,它体现了几个值得借鉴的原则:
原则一:资源共享
重资源(终端、HTTP、数据库句柄)创建一份,多方共享。这避免了重复创建的浪费,也保证了状态一致(同一份终端后端,所有 bash 调用看到同样的子进程状态)。
原则二:依赖显式化
工具通过 type Params = ... 声明自己依赖什么。这让工具的依赖清晰可见——看一眼就知道「这个工具需要终端后端、cwd、通知句柄」。显式的依赖便于理解、测试、维护。
原则三:类型擦除集中
SharedResources 内部用 Box<dyn Any> 类型擦除,但这种复杂度被封装在 SharedResources 内部。工具通过 Params 拿到的是强类型,不直接接触类型擦除。这是「类型擦除集中化」原则的又一次体现(回顾第 01 节的双 trait 设计)。
原则四:配置与资源统一注入
Params 把「资源」(终端后端)与「配置」(timeout)统一注入。工具不必区分「这个值是从资源池来的」还是「从配置来的」——它们都在 Params 里,统一访问。这简化了工具代码。
原则五:测试友好
测试工具时,可以构造 mock 的 Params(如 mock 的终端后端),注入工具,验证行为。这种「依赖注入」让工具变得可测试,不依赖真实的 OS 资源。
把 SharedResources / Params 模式与其他可能的方案对比,能更清楚地理解它的位置:
对比一:全局单例
资源作为全局单例,工具直接访问。问题:多会话隔离困难(两个会话同时跑,全局单例会冲突),测试不友好(无法注入 mock)。
对比二:每个工具自己创建
工具在 execute 内部创建所需资源。问题:重复创建、状态不一致、生命周期难管(每次执行完是销毁还是保留?)。
对比三:通过函数参数传递
把所有资源作为 execute 的参数传递。问题:参数列表爆炸(每个工具要传一长串),大多数工具只用其中几个,接口臃肿。
SharedResources / Params 的优势:
综合来看,这个模式在「资源共享、依赖显式、测试友好、接口简洁」之间取得了好的平衡。
用一个具体例子把这个模式走一遍。假设会话启动:
─── 会话启动 ─── 1. 框架创建 SharedResources: resources = SharedResources::new() resources.insert(TerminalBackend::new(...)) # 创建一份终端后端 resources.insert(HttpClient::new(config)) # 创建一份 HTTP 客户端 resources.insert(cwd) # 当前工作目录 resources.insert(notifier) # 通知句柄 2. 为需要 Params 的工具构造 Params: bash_params = BashParams { terminal: resources.get::<TerminalBackend>(), cwd: resources.get::<Cwd>(), timeout: config.bash_timeout, notifier: resources.get::<Notifier>(), } read_params = ReadParams { fs: resources.get::<FileSystem>(), cwd: resources.get::<Cwd>(), } web_params = WebParams { http: resources.get::<HttpClient>(), ... } 3. 注册工具,注入 Params: registry.register_with_params::<BashTool, BashParams>(bash_params) registry.register_with_params::<ReadFileTool, ReadParams>(read_params) registry.register_with_params::<WebFetchTool, WebParams>(web_params) registry.register::<ListDirTool>() # 自包含,不需要 Params ─── 模型调用 bash 工具 ─── 4. 框架找到 BashTool,构造 ctx(含 bash_params) 5. BashTool.execute 从 ctx.params() 拿到 BashParams 6. 用 params.terminal 执行命令,用 params.cwd 决定在哪儿跑 7. 执行过程用 params.notifier 通知 UI 进度 8. 超时由 params.timeout 控制 ─── 同一会话后续调用 read_file ─── 9. 找到 ReadFileTool,ctx 含 read_params 10. ReadFileTool.execute 从 ctx.params() 拿 ReadParams 11. 用 params.fs 读文件,用 params.cwd 解析相对路径 注意:bash 与 read_file 共享同一份 cwd(都从 SharedResources 的同一个 Cwd 来), 保证了「当前目录」在会话内一致。
这个例子展示了资源共享(多个工具用同一份 cwd)、依赖显式(每个工具声明自己的 Params)、注入(注册时传入)的全过程。
最后澄清 Params 与上一节的 ToolCallContext 的关系。两者都是工具执行时能拿到的「环境」,但分工不同:
ToolCallContext:每次工具调用构造,含调用级的信息:
Params:会话级构造(会话启动时注入),含会话级的资源与配置:
简言之:ToolCallContext 是「这次调用」的临时环境,Params 是「这个会话」的稳定资源。工具两者都用——从 Params 拿稳定的资源,从 ToolCallContext 拿这次调用的临时信息(尤其是取消与权限)。
下一节,我们把前五节的知识用起来——看看如何在自己的 fork 里写一个自定义工具,通过 out-of-tree 工具包注册进 Grok Build。