第 3 章 · 工具系统与 MCP
本章讲什么: 模型缺「手脚」——它只会说话,不会真的查数据库、调 API。工具系统就是给模型装手脚。本章讲 Rig 怎么把一个普通 Rust 函数变成「模型能调的工具」,怎么按名字派发,以及模型乱调工具时框架怎么兜底。
3.1 先建 直觉:工具是什么
工具调用的完整链条是这样的(承接第 2 章的多轮循环):
模型看到工具定义(名字+描述+参数schema)
│ 决定 "我要调 add,参数 {x:2, y:3}"
▼
Rig 按名字找到 add 工具 → 把 JSON 参数反序列化成 Rust 类型 → 执行
▼
拿到结果 5 → 序列化成字符串 → 作为工具结果塞回对话
▼
模型基于结果继续
所以一个工具要提供三样东西:一个名字(模型用它来点名)、一份定义(告诉模型这工具干嘛、参数长啥样)、一个执行函数(真正干活)。这正是 Tool 特征的三个核心。
3.2 核心特征:Tool
Tool 特征用关联类型把「参数」「输出」「错误」都强类型化(crates/rig-core/src/tool/mod.rs:116):
// 示意,摘自 crates/rig-core/src/tool/mod.rs:116 Tool
pub trait Tool: Sized + WasmCompatSend + WasmCompatSync {
const NAME: &'static str; // 工具名(分派用,需在 ToolSet 内唯一)
type Error: std::error::Error + ...; // 出错类型
type Args: for<'a> Deserialize<'a>; // 参数类型(从模型给的 JSON 反序列化)
type Output: Serialize; // 输出类型(序列化回给模型)
fn definition(&self, prompt: String) // 返回工具定义(名字+描述+JSON schema)
-> impl Future<Output = ToolDefinition>;
fn call(&self, args: Self::Args) // 真正执行
-> impl Future<Output = Result<Self::Output, Self::Error>>;
}
写一个加法工具长这样(crates/rig-core/src/tool/mod.rs:63 的文档示例):
// 示意,摘自 crates/rig-core/src/tool/mod.rs 文档示例
impl Tool for Adder {
const NAME: &'static str = "add";
type Error = MathError;
type Args = AddArgs; // { x: i32, y: i32 }
type Output = i32;
async fn definition(&self, _prompt: String) -> ToolDefinition {
ToolDefinition { name: "add".into(), description: "Add x and y".into(),
parameters: serde_json::json!({ /* JSON schema */ }) }
}
async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
Ok(args.x + args.y) // 拿到的已经是强类型 AddArgs,不用手动解析 JSON
}
}
关键价值:强类型。 call 收到的是 AddArgs(已经从 JSON 解析好),返回 i32(会被序列化)——你写工具时完全不碰 JSON 解析,编译器帮你查参数结构。
definition 还收一个 prompt: String 参数:允许工具根据当前用户 prompt 定制自己的定义(比如按语境改描述),这是个不显眼但灵活的口子。
3.3 类型擦除:ToolDyn
问题来了:Tool 的关联类型(Args/Output/Error)每个工具都不同,没法把它们塞进同一个 Vec 一起管理。Rust 的标准解法是类型擦除——Rig 用 ToolDyn 特征(crates/rig-core/src/tool/mod.rs:201):
Tool (强类型) ToolDyn (类型已擦除)
┌─────────────┐ 自动实现 ┌──────────────────┐
│ Args=AddArgs │ ──────────────► │ call(args: String)│ 参数/返回都变成 String
│ Output=i32 │ (blanket impl) │ -> Result<String>│
└─────────────┘ └──────────────────┘
ToolDyn 里的方法签名全是 String 进、String 出(crates/rig-core/src/tool/mod.rs:209)——因为这些值本来就是「模型的输出」和「给模型的输入」,本质是文本。有个 blanket impl impl<T: Tool> ToolDyn for T(crates/rig-core/src/tool/mod.rs:232):任何 Tool 自动就是 ToolDyn,转换里做 JSON 序列化/反序列化的桥接。于是你能把一堆不同类型的工具装进 Vec<Box<dyn ToolDyn>>。
输出序列化有个小巧处(serialize_tool_output,crates/rig-core/src/tool/mod.rs:225):如果输出序列化后是 JSON 字符串就直接用,否则转成字符串。避免给模型的工具结果被套上多余的引号。
3.4 工具集:ToolSet 与 ToolServerHandle
多个工具凑一起就是 ToolSet(crates/rig-core/src/tool/mod.rs:358)——本质是一个「名字 → ToolDyn」的表。派发就是按名字查表再调(ToolSet::call,crates/rig-core/src/tool/mod.rs:455)。
Agent 上持有的不是裸 ToolSet,而是 ToolServerHandle(crates/rig-core/src/tool/server.rs:149):
// 示意,摘自 crates/rig-core/src/tool/server.rs:149
pub struct ToolServerHandle(Arc<RwLock<ToolServerState>>);
用 Arc<RwLock<...>> 包着,因为工具集可能在运行时变化(尤其是 MCP 场景——远程工具服务器可能动态增删工具),需要共享可变、并发安全。这也是为什么 Agent 能 Clone:克隆的是句柄,底层工具集共享。
3.5 少写样板:rig_tool 宏
手写 impl Tool 要定义参数结构体、写 definition 的 JSON schema,样板不少。rig-derive crate 提供 #[rig_tool] 属性宏(crates/rig-derive/src/lib.rs,文档示例在 :353)帮你生成这些:
// 示意,摘自 crates/rig-derive/src/lib.rs 文档示例
#[rig_tool(description = "Perform basic arithmetic operations")]
fn add(x: i32, y: i32) -> i32 { x + y }
宏会从函数签名推出参数 schema、从属性拿描述,自动生成 Tool 实现。这是「约定优于配置」——大多数工具不需要手写 schema。
3.6 非法工具调用恢复(工具侧视角)
第 2 章从状态机侧讲过这套恢复,这里从「你能怎么控制它」的角度再看一遍。当模型调了一个不存在或不被允许的工具,AgentRun 会把一个 InvalidToolCallContext 交给你的 hook,你返回一个 InvalidToolCallHookAction 决定怎么办(crates/rig-core/src/agent/run/mod.rs:839):
模型调了 "serch"(拼错了) —— 允许列表里只有 "search"
│
▼
你的 hook 拿到 context (工具名/参数/可用工具列表/对话历史)
│ 返回四选一 ↓
┌────┬─────────┬──────────┬─────────┐
▼ ▼ ▼ ▼
Fail Retry Repair Skip
直接 回滚重来 改名为 跳过并给
报错 +反馈 "search" 合成结果
| 动作 | 什么时候用 |
|---|---|
Fail | 严格模式,任何幻觉工具都终止 |
Retry { feedback } | 给模型一段纠正提示让它重试(受重试预算限制) |
Repair { tool_name } | 你能判断模型想调哪个(如拼写纠错),直接改名 |
Skip { reason } | 忽略这次调用,塞 个合成结果让对话继续 |
设计哲学值得记:Rig 把「模型会犯错」当成设计前提,而不是异常。 幻觉工具名是 LLM 的常态,框架给了四档可编程的兜底,而不是简单地崩掉。这套恢复语义 blocking 和 streaming 两条路径都实现了(流式版 resolve_streamed_invalid_tool_call,crates/rig-core/src/agent/run/mod.rs:1146),保证一致。
3.7 运行时注入:ToolCallExtensions
有些工具执行时需要「每次调用才知道」的东西——认证 token、会话 ID、数据库连接。这些不该写死在工具里,也不该走模型参数(模型不该看到 token)。Rig 的答案是 call_with_extensions(crates/rig-core/src/tool/mod.rs:163):
// 示意,摘自 crates/rig-core/src/tool/mod.rs:163
fn call_with_extensions(&self, args: Self::Args, _extensions: &ToolCallExtensions)
-> impl Future<Output = Result<Self::Output, Self::Error>> {
self.call(args) // 默认忽略 extensions,退化成普通 call
}
驱动器每轮把 ToolCallExtensions 透传给每个工具(crates/rig-core/src/agent/runner.rs:281)。工具覆写这个方法就能读到调用方注入的运行时值。文档特别强调了覆写契约(crates/rig-core/src/tool/mod.rs:154):一旦覆写,动态分发下所有路径都走这里,别把逻辑拆在 call 和 call_with_extensions 两处。
3.8 MCP 接入
MCP(Model Context Protocol,模型上下文协议,一种让 agent 连外部工具服务器的标准协议)在 rmcp feature 下接入(crates/rig-core/src/tool/rmcp.rs)。一个 MCP server 上的远程工具,被适配成实现 ToolDyn 的本地对象,塞进同一个 ToolServerHandle。
对 agent 循环来说,MCP 工具和本地 Rust 工具没有区别——都是按名字派发的 ToolDyn。这就是第 3.4 节用 Arc<RwLock<...>> 的回报:本地工具、MCP 工具混在一个工具集里,运行时还能变。
3.9 本章小结与去向
Tool特征用关联类型强类型化参数/输出/错误,你写工具不碰 JSON 解析。ToolDyn做类型擦除(String 进 String 出),blanket impl 让任何Tool自动可动态分发。ToolSet按名字派发;Agent持ToolServerHandle(Arc<RwLock>)以支持运行时可变的工具集。- 非法工具调用有 Fail/Retry/Repair/Skip 四档可编程恢复——「模型会犯错」是设计前提。
ToolCallExtensions注入运行时值(token/会话);MCP 工具和本地工具在循环里一视同仁。- 有一类特殊「工具」是向量检索——RAG 的动态工具/动态上下文 → 第 4 章。
- hook 怎么写、怎么拦截工具调用 → 第 5 章。
代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 工具特征 | crates/rig-core/src/tool/mod.rs | Tool |
| 类型擦除 | crates/rig-core/src/tool/mod.rs | ToolDyn |
| Tool→ToolDyn 桥接 | crates/rig-core/src/tool/mod.rs | impl<T: Tool> ToolDyn for T |
| 输出序列化 | crates/rig-core/src/tool/mod.rs | serialize_tool_output |
| 工具集 | crates/rig-core/src/tool/mod.rs | ToolSet / ToolSet::call |
| 工具集句柄 | crates/rig-core/src/tool/server.rs | ToolServerHandle |
| 属性宏 | crates/rig-derive/src/lib.rs | rig_tool |
| 运行时注入 | crates/rig-core/src/tool/mod.rs | Tool::call_with_extensions |
| 运行时值容器 | crates/rig-core/src/tool/extensions.rs | ToolCallExtensions |
| MCP 接入 | crates/rig-core/src/tool/rmcp.rs | (rmcp feature) |
| 非法调用恢复 | crates/rig-core/src/agent/run/mod.rs | resolve_invalid_tool_call |