数据截至 (上游 commit 65b4508389c8)
第 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-agent/src/tool/mod.rs:162):
// 示意,摘自 crates/rig-agent/src/tool/mod.rs:162 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: IntoToolOutput; // 输出类型(转成给模型的规范呈现)
fn description(&self) -> String; // 模型可读的描述
fn parameters(&self) -> serde_json::Value; // 参数的 JSON Schema
fn call(&self, context: &mut ToolContext, args: Self::Args) // 真正执行
-> impl Future<Output = Result<Self::Output, Self::Error>>;
}
写一个加法工具长这样(crates/rig-agent/src/test_utils/tools.rs:30 的 MockAddTool,测试里的真实实现):
// 示意,摘自 crates/rig-agent/src/test_utils/tools.rs:30 MockAddTool
impl Tool for MockAddTool {
const NAME: &'static str = "add";
type Error = MockToolError;
type Args = MockOperationArgs; // { x: i32, y: i32 }
type Output = i32;
fn description(&self) -> String { "Add x and y together".to_string() }
fn parameters(&self) -> serde_json::Value { json!({ /* JSON schema */ }) }
async fn call(&self, _context: &mut ToolContext, args: Self::Args)
-> Result<Self::Output, Self::Error> {
Ok(args.x + args.y) // 拿到的已经是强类型参数,不用手动解析 JSON
}
}
关键价值:强类型。 call 收到的是 MockOperationArgs(已经从 JSON 解析好),返回 i32(会被转成模型呈现)——你写工具时完全不碰 JSON 解析,编译器帮你查参数结构。
call 还收一个 &mut ToolContext:运行时注入的值(token、会话 ID)走这里(见 3.7)。另一个与旧版的差别:description / parameters 现在是同步方法,不再接收当前 prompt——工具定义对整轮固定,想按语境调 整就到 hook 里做(第 5 章)。
3.3 类型擦除:从公共 ToolDyn 到私有的分发边界
问题还是那个问题:Tool 的关联类型(Args/Output/Error)每个工具都不同,没法把它们塞进同一个 Vec 一起管理。Rust 的标准解法是类型擦除——但 Rig 新版做了一次大收缩:对象安全的分发特征是私有的(trait 文档原话「Rig's object-safe dispatch boundary is private」,crates/rig-agent/src/tool/mod.rs:162 文档注释),不再暴露一个公共的 ToolDyn。
公共面只留一个 DynamicTool——「一个闭包就是一个工具」(crates/rig-agent/src/tool/mod.rs:386):
Tool (强类型,编译期已知) DynamicTool (运行期定义)
┌───────────────┐ 注册 ┌──────────────────────┐
│ Args=AddArgs │ ──────────────► │ name/description/ │
│ Output=i32 │ 私有擦除边界 │ parameters + 闭包回调 │
└───────────────┘ (ErasedTool) └──────────────────────┘
两类工具最终都进同一个 ToolSet,按名字派发
模块文档顶部有一张「迁移对照表」,把这次工具 API 收缩总结得很直白(crates/rig-agent/src/tool/mod.rs:97 起):并行的多个 call* 方法并成一个 Tool::call;公共动态分发特征换成 DynamicTool;并行错误类型换成 ToolExecutionError + ToolErrorKind;字符串分发与结构化分发两条路并成 ToolSet::execute。
还有一条设计立场值得记:模型可见的输出在整个分发链路上保持类型化——渲染成文本是终点站(provider/遥测)的事,Rig 绝不「先序列化成字符串、再解析回来」 reconstruct 富内容(crates/rig-agent/src/tool/mod.rs:110 模块文档)。旧版 ToolDyn 那种「String 进 String 出」的桥接被连根拔掉了。
3.4 工具集:ToolSet 与 ToolServerHandle
多个工具凑一起就是 ToolSet(crates/rig-agent/src/tool/mod.rs:614)——本质是一个「名字 → 工具注册项」的有序表(IndexMap,保持注册顺序)。派发就是按名字查表再调(ToolSet::execute,crates/rig-agent/src/tool/mod.rs:737):查不到就返回一个带模型反馈的 not_found 结果,查到就把 JSON 参数交还给擦除边界里的强类型工具执行。
Agent 上持有的不是裸 ToolSet,而是 ToolServerHandle(crates/rig-agent/src/tool/server.rs:263):
// 示意,摘自 crates/rig-agent/src/tool/server.rs:263
pub struct ToolServerHandle(Arc<RwLock<ToolServerState>>);
用 Arc<RwLock<...>> 包着,因为工具集可能在运行时变化(尤其是 MCP 场景——远程工具服务器可能动态增删工具),需要共享可变、并发安全。这也是为什么 Agent 能 Clone:克隆的是句柄,底层工具集共享。
3.5 少写样板:rig_tool 宏
手写 impl Tool 要定义参数结构体、写 parameters 的 JSON schema,样板不少。rig-derive crate 提供 #[rig_tool] 属性宏(crates/rig-derive/src/lib.rs,文档示例在 :46 起)帮你生成这些:
// 示意,摘自 crates/rig-derive/src/lib.rs 文档示例
#[rig_tool(description = "Perform basic arithmetic operations")]
fn calculator(x: i32, y: i32, operation: String) -> Result<i32, rig::tool::ToolExecutionError> {
/* 按运算符计算 */
}
宏会从函数签名推出参数 schema(非 Option 参数即必填,Option<T> 即可选,:104 文档)、从属性拿描述与自定义工具名,自动生成工具实现。这是「约定优于配置」——大多数工具不需要手写 schema。
3.6 非法工具调用恢复(工具侧视角)
第 2 章从状态机侧讲过这套恢复,这里从「你能怎么控制它」的角度再看一遍。当模型调了一个不存在或不被允许的工具,AgentRun 会把一个 InvalidToolCallContext 交给你的 hook,你返回一个 InvalidToolCallAction 决定怎么办(crates/rig-agent/src/agent/hook.rs:1124 枚举定义;状态机侧入口 resolve_invalid_tool_call,crates/rig-agent/src/agent/run/mod.rs:1178):
模型调了 "serch"(拼错了) —— 允许列表里只有 "search"
│
▼
你的 hook 拿到 context (工具名/参数/可用工具列表/对话历史)
│ 返回五选一 ↓
┌────┬─────────┬──────────┬─────────┬────────┐
▼ ▼ ▼ ▼ ▼
Fail Retry Repair Skip Stop
直接 回滚重来 改名为 跳过并给 取消整个
报错 +反馈 "search" 合成结果 run
| 动作 | 什么时候用 |
|---|---|
Fail | 严格模式,任何幻觉工具都终止(所有 hook 都不表态时的默认行为) |
Retry { feedback } | 给模型一段纠正提示让它重试(消耗总轮数预算) |
Repair { tool_name } | 你能判断模型想调哪个(如拼写纠错),直接改名 |
Skip { reason } | 忽略这次调用,塞个合成结果让对话继续 |
Stop { reason } | 以给定理由取消整个 run |
设计哲学值得记:Rig 把「模型会犯错」当成设计前提,而不是异常。 幻觉工具名是 LLM 的常态,框架给了五档可编程的兜底,而不是简单地崩掉。这套恢复语义 blocking 和 streaming 两条路径都实现了(流式版 resolve_streamed_invalid_tool_call,crates/rig-agent/src/agent/run/mod.rs:1482),保证一致。
3.7 运行时注入:ToolContext
有些工具执行时需要「每次调用才知道」的东西——认证 token、会话 ID、数据库连接。这些不该写死在工具里,也不该走模型参数(模型不该看到 token)。新版的答案是把运行时上下文直接做成 call 的参数:ToolContext(crates/rig-agent/src/tool/extensions.rs:157,文档 :142)——一个按类型存取的容器:
// 示意,基于 crates/rig-agent/src/tool/extensions.rs ToolContext 的公共 API
pub struct ToolContext { inbound: TypeMap, result: TypeMap }
// 调用方注入(驱动器每轮透传):
context.insert(session_id);
// 工具里按类型读取:
let session: &SessionId = context.require()?;
// 还能挂「只给宿主看、不给模型看」的结果元数据:
context.insert_result(execution_metadata);
要点:
- 注入和读取按类型键控(
insert/get/require),没有字符串 key 的运行时强转。 - 容器分两栏:
inbound是调用方注入的值,result是工具挂的宿主专属元数据——两者都不发给模型(crates/rig-agent/src/tool/extensions.rs:142文档)。 - 驱动器持有它并给每次分发克隆一份快照(
AgentRunner::tool_context,crates/rig-agent/src/agent/runner.rs:163;crates/rig-agent/src/tool/mod.rs:735文档「The tool receives a snapshot of inbound context」)。
旧版这套靠 Tool::call_with_extensions 可选覆写 + ToolCallExtensions 值包;新版并进了唯一的 call 签名,不存在「忘了覆写那条路径收不到上下文」的分叉。
3.8 MCP 接入
MCP(Model Context Protocol,模型上下文协议,一种让 agent 连外部工具服务器的标准协议)在 rmcp feature 下接入(crates/rig-agent/src/tool/rmcp.rs;该 feature 只支持原生平台,wasm 下会直接 compile_error!,crates/rig-agent/src/tool/mod.rs:140)。一个 MCP server 上的远程工具,被适配成本地工具对象,塞进同一个 ToolServerHandle。
对 agent 循环来说,MCP 工具和本地 Rust 工具没有区别——都是按名字派发的注册项。这就是第 3.4 节用 Arc<RwLock<...>> 的回报:本地工具、MCP 工具混在一个工具集里,运行时还能变。
3.9 本章小结与去向
Tool特征用关联类型强类型化参数/输出/错误,你写工具不碰 JSON 解析。- 公共
ToolDyn没了:对象安全分发边界转私有,运行期定义工具用闭包式的DynamicTool;输出全程类型化。 ToolSet是有序注册表,按名字派发(ToolSet::execute);Agent持ToolServerHandle(Arc<RwLock>)以支持运行时可变的工具集。- 非法工具调用有 Fail/Retry/Repair/Skip/Stop 五档可编程恢复——「模型会犯错」是设计前提。
ToolContext按类型注入运行时值(token/会话)与挂结果元数据;MCP 工具和本地工具在循环里一视同仁。- 有一类特殊「工具」是向量检索——RAG 的动态工具/动态上下文 → 第 4 章。
- hook 怎么写、怎么拦截工具调用 → 第 5 章。
代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 工具特征 | crates/rig-agent/src/tool/mod.rs | Tool |
| 迁移对照表(API 收缩) | crates/rig-agent/src/tool/mod.rs | 模块文档 |
| 运行期定义工具 | crates/rig-agent/src/tool/mod.rs | DynamicTool |
| 工具集 | crates/rig-agent/src/tool/mod.rs | ToolSet / ToolSet::execute |
| 工具集句柄 | crates/rig-agent/src/tool/server.rs | ToolServerHandle |
| 属性宏 | crates/rig-derive/src/lib.rs | rig_tool |
| 运行时上下文 | crates/rig-agent/src/tool/extensions.rs | ToolContext |
| 可移植工具契约 | crates/rig-core/src/tool/portable.rs | PortableTool / ToolOutput |
| MCP 接入 | crates/rig-agent/src/tool/rmcp.rs | (rmcp feature) |
| 非法调用恢复 | crates/rig-agent/src/agent/run/mod.rs | resolve_invalid_tool_call |
| 恢复动作枚举 | crates/rig-agent/src/agent/hook.rs | InvalidToolCallAction |