跳到主要内容

第 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 Tcrates/rig-core/src/tool/mod.rs:232):任何 Tool 自动就是 ToolDyn,转换里做 JSON 序列化/反序列化的桥接。于是你能把一堆不同类型的工具装进 Vec<Box<dyn ToolDyn>>

输出序列化有个小巧处(serialize_tool_outputcrates/rig-core/src/tool/mod.rs:225):如果输出序列化后是 JSON 字符串就直接用,否则转成字符串。避免给模型的工具结果被套上多余的引号。


3.4 工具集:ToolSet 与 ToolServerHandle

多个工具凑一起就是 ToolSetcrates/rig-core/src/tool/mod.rs:358)——本质是一个「名字 → ToolDyn」的表。派发就是按名字查表再调(ToolSet::callcrates/rig-core/src/tool/mod.rs:455)。

Agent 上持有的不是裸 ToolSet,而是 ToolServerHandlecrates/rig-core/src/tool/server.rs:149):

// 示意,摘自 crates/rig-core/src/tool/server.rs:149
pub struct ToolServerHandle(Arc<RwLock<ToolServerState>>);

Arc<RwLock<...>> 包着,因为工具集可能在运行时变化(尤其是 MCP 场景——远程工具服务器可能动态增删工具),需要共享可变、并发安全。这也是为什么 AgentClone:克隆的是句柄,底层工具集共享。


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_callcrates/rig-core/src/agent/run/mod.rs:1146),保证一致。


3.7 运行时注入:ToolCallExtensions

有些工具执行时需要「每次调用才知道」的东西——认证 token、会话 ID、数据库连接。这些不该写死在工具里,也不该走模型参数(模型不该看到 token)。Rig 的答案是 call_with_extensionscrates/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):一旦覆写,动态分发下所有路径都走这里,别把逻辑拆在 callcall_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 按名字派发;AgentToolServerHandleArc<RwLock>)以支持运行时可变的工具集。
  • 非法工具调用有 Fail/Retry/Repair/Skip 四档可编程恢复——「模型会犯错」是设计前提。
  • ToolCallExtensions 注入运行时值(token/会话);MCP 工具和本地工具在循环里一视同仁。
  • 有一类特殊「工具」是向量检索——RAG 的动态工具/动态上下文 → 第 4 章。
  • hook 怎么写、怎么拦截工具调用 → 第 5 章。

代码地图

主题文件符号
工具特征crates/rig-core/src/tool/mod.rsTool
类型擦除crates/rig-core/src/tool/mod.rsToolDyn
Tool→ToolDyn 桥接crates/rig-core/src/tool/mod.rsimpl<T: Tool> ToolDyn for T
输出序列化crates/rig-core/src/tool/mod.rsserialize_tool_output
工具集crates/rig-core/src/tool/mod.rsToolSet / ToolSet::call
工具集句柄crates/rig-core/src/tool/server.rsToolServerHandle
属性宏crates/rig-derive/src/lib.rsrig_tool
运行时注入crates/rig-core/src/tool/mod.rsTool::call_with_extensions
运行时值容器crates/rig-core/src/tool/extensions.rsToolCallExtensions
MCP 接入crates/rig-core/src/tool/rmcp.rs(rmcp feature)
非法调用恢复crates/rig-core/src/agent/run/mod.rsresolve_invalid_tool_call