使用 Rust 开发 AI Agent:工具调用(Tool Calling)详解
工具调用是 AI Agent 循环中最关键的一块拼图,它解决大语言模型「只能动嘴、不能动手」的根本问题,也是后续 MCP 与多工具协调的基础,需要彻底吃透。
核心要点
大语言模型的三类局限
| 局限类型 | 具体表现 | 典型例子 |
|---|---|---|
| 时间局限 | 知识永远定格在训练完成时刻 | 无法回答今天的天气 |
| 交互局限 | 只能生成文字,无法执行实际操作 | 不能订票、发邮件、控制设备 |
| 功能局限 | 不适合需要确定性结果的任务 | 精确大数计算易出错、无法跑代码、无法生成图片 |
工具的三类分类(按弥补的局限划分)
- 信息增强型工具:如搜索引擎、股票行情接口、公司内部知识库。作用是把最新信息重新塞回 LLM 的上下文中。
- 动作执行型工具:如订票系统、邮件日历、智能家居控制。作用是把 LLM 的决定变成对现实世界的真实动作。
- 领域专精型工具:如计算器、代码解释器、绘图引擎。作用让模型只负责「想清楚要做什么」,精确执行交给专业工具。
工具的另一维度分类(按造工具的主体)
| 类型 | 特点 | 优点 | 缺点 |
|---|---|---|---|
| 自定义工具 | 自己开发、自己维护,完全按项目需求 | 灵活、可控 | 稳定性与安全性问题需自己负责 |
| 内置工具(平台方提供) | 如 Cluade 或 ChatGPT 中的联网搜索 | 零开发成本 | 黑盒,无法修改内部逻辑,平台调整策略时无从应对 |
注:视频内容重点讲解自定义工具,原因有二:一是只有搞懂内部原理,Agent 出问题时才能排查是「模型选错了工具」还是「工具本身执行错了」;二是真实项目中,公司自己的系统永远要靠自定义工具接入。
详细解析与核心原理
工具调用的比喻
将大语言模型想象为一位被封锁在密室里的谋士:
- 他通读天下所有书籍(训练数据),见识极广,但屋子没有窗户。
- 唯一能与外界沟通的方式是从门缝底下递小纸条。
- 他不知道门外有什么工具、如何使用,全靠我们提前写好一份清晰的「说明书」从门缝塞给他,告诉他:有哪些工具、每个工具干什么、需要传什么参数。
- 这份说明书就是工具定义(Schema)。
整个工具调用机制本质上就是围绕「我们怎么写好这张纸条、怎么处理它传递出来的纸条」展开。
工具调用的五步循环流程
用户提问 → 大模型推理(判断能否直接回答或需借工具) → 生成工具调用请求(不执行) → 宿主程序接收并调用工具 → 结果拼回对话历史交还模型 → 模型基于结果给出最终答案或进入下一轮循环拆解为具体步骤:
- 用户提出问题。
- 大模型推理:判断能否直接回答,或是否需要借用工具。若需要,生成结构化的工具调用请求(此时不执行,只是生成一段描述)。
- 宿主程序执行:由 Rust 代码(宿主程序/宿主系统)接收请求并真正调用对应工具。大模型本身无法运行任何代码,宿主系统负责把工具真正「跑起来」。
- 结果回传:工具执行完的结果拼回对话历史,交还大模型。
- 循环判断:大模型基于结果判断直接给出最终答案,还是需再调用一次工具,进入下一轮循环。
- 循环的终止条件为「不再需要调用工具」。
关键设计:这一循环直到不再需要工具为止才结束。后续写 Agent 循环的代码时,核心逻辑就是这个流程。
方法与步骤:工具调用的实现
第一步:定义工具(写工具说明,即 Schema)
定义工具需说清楚三件事:
| 要素 | 说明 | 要点 |
|---|---|---|
| 名称(Name) | 模型用来识别调用哪个工具的唯一标识 | 取名要「见名知意」 |
| 用途描述(Description) | 说明工具用途、适用场景 | 描述越清楚,模型选错工具的几率越低 |
| 参数结构(Parameters) | 需要哪些参数、每个参数类型、是否必填、取值范围限制 | 参数结构需明确列举 |
实用的判断标准:假如一位刚入职、什么都不懂的实习生,光看这份说明书就能正确调用该工具,那么大模型大概率也能正确调用它。此条标准在编写自己的工具时会被反复用到。
其中,tool_calls 是一个列表而非单个工具调用。例如用户问题「1+2 等于多少?另外 3×4 等于多少?」包含两个独立计算,LLM 有能力一次性识别出这两个需求并生成多个工具调用。
第二步:设置并执行工具
上方的流程图是整个架构的骨架,从代码实现角度还需要注意这样的细节——在 complete.rs 中执行调用时,LLM 会自己判断是否需要工具,以及需要调用哪个工具;当检测到结果消息含 content 字段时则提取文本答案。工具调用的参数从消息中提取后,需要将 LLM 回复压入历史中,并获得对应的函数执行结果。
对话历史中的角色体系
在代码实现中,消息以不同角色被追加进历史:
| 角色 | 含义 |
|---|---|
system | 预先设定的规则或「人设」 |
user | 用户自己提出的问题 |
assistant | 大语言模型自己的回复(AI 的回复) |
tool | 工具调用结果;tool_calls 字段说明该 assistant 消息请求调用了哪些工具 |
实现步骤小结
- 创建
tools目录与calculator子目录,新建definition.rs文件定义工具 Schema。 - 新建
execute.rs实现计算器工具本身(判断加减乘除、除法特殊情况处理)。 - 在
tools.rs中提供vector返回当前所有可用工具(目前只有计算器一个)。 - 修改
complete.rs的对话请求逻辑:
- 增加工具入参;
- 克隆工具与消息历史(后续仍需用);
- 请求发送前要把定义的 tool 加入请求体;
- 响应获取后提取其中 message,判断其
tool_calls字段;
- 若需工具调用:
- 用
loop遍历tool_calls列表; - 判定工具类型(函数类型)→ 取函数名和参数(string 类型的 JSON)→ 反序列化参数;
- 调用对应函数(如 calculator),得到结果后把工具调用的结果连同其 ID 加回消息历史;
- 使用更新后的消息历史再次发送请求——因为大语言模型自己并不知道计算结果显示的具体的计算结果。
- 若无需工具调用,则直接提取
message.content字符串返回。
具体代码示例(示意性描述):
- 循环遍历每个
tool_call,判断函数是否是calculator,若是则取出三个参数,反序列化后调用 execute 函数。 - 无论成功与否,把工具调用结果(含函数调用 ID)加回消息历史。
- 循环外再次发请求给模型(仅当发生了工具调用时),模型基于工具执行后的消息来生成最终回答。
第三个运行示例
增加 examples/tool_call_complete.rs 测试示例,提出了两个问题:
- 尼泊尔的首都是哪里?—— 直接知识可答。
- 5876 × 675 = ?—— 需工具精确计算。
运行结果:第二个问题时触发了 calculator 工具调用,打印参数后拿到正确大数计算结果后,LLM 最终给出正确最终答案。
案例与项目结构(参考资料)
以下为实现视频所述架构的组织结构图(根据叙述整理示意)。
project/
├── examples/ # 示例代码目录
│ ├── structured_call_complete.rs # 旧有的结构化代码调用示例
│ └── tool_call_complete.rs # 工具调用演示
├── src/
│ ├── complete.rs # 主对话请求实现(非流式)
│ └── tools/
│ ├── mod.rs # 模块声明,工具列表集合
│ └── calculator/
│ ├── mod.rs # 子模块
│ ├── definition.rs # 计算器工具的定义
│ └── execute.rs # 计算器工具的执行逻辑
└── ...(原有结构)代码细节:
- 计算器工具执行逻辑(execute.rs):实现了加减乘除四则运算及除法特殊情况处理,若不是有效的运算符则返回错误。
- 结构化定义 加反序列化支持:
execute.rs中定义了计算器函数参数的STRUCT,需要能反序列化,共三个参数。 complete.rs中消息历史的添加是通过messages.push(...)实现的,工具结果消息需传tool_call_id及内容。
限制与待确认问题
- 视频中处理「若函数类型以外(如
custom类型)的调用暂不支持,假设都视为函数类型处理」。 OPENROUTER配置改动背景:原GPT-OSS 120B的free端点已失效,改用将结尾free去掉后的名字才可用。- 详细错误信息与交互在视频中未完整列出(如部分错误细节省略),完整实现需结合已有工程上下文阅读逐步实践。
总结与行动清单
- 大语言模型的三大局限(时间、交互、功能),每一类都有对应类型的工具来补齐短板。
- 确定要使用的是自定义工具(而非内置工具),因为要排查问题、适配公司真实系统必须理解内部原理。
- 写好工具定义(Schema)是第一优先级——名字清晰(见名知义)、描述准确(减少模型选错率)、参数结构完备(类型、必填、枚举范围)。
- 执行前应写好清晰的「说明书」,用「刚入职的实习生观看是否能正确使用」来检验工具说明书的可操作性。
- Agent 的循环逻辑的核心:模型 → 判断工具需求 → 宿主程序执行 → 把结果回传 → 模型继续判断,直到不再需要工具为止。
- 动手实现:按「定义工具 → 实现工具函数 → 改造请求循环 → 添加 example 测试两个不同性质问题」的流程做一遍;注意工具调用需要再发一次请求,消息历史要把 assistant 消息和函数调用结果按正确角色和管理次序填入。
参考关键代码片段
以下为视频中所述的主要代码逻辑(风格仅供参考,不具备完全执行的可靠性,建议对照视频理解原逻辑)。
// tool_calls 判断与执行大致逻辑(伪代码)
let mut messages = vec![/*...用户消息...*/];
loop {
let response = send(&tools.clone(), &messages.clone()).await?;
let message = response.choices[0].message;
messages.push(message.clone()); // 以 assistant 角色加入
if let Some(tool_calls) = message.tool_calls {
for call in tool_calls {
let name = &call.function.name;
let args: Args = serde_json::from_str(&call.function.arguments)?;
if name == "calculator" {
let result = calculate(&args);
messages.push(ToolMessage{ tool_call_id: &call.id, content: result });
}
}
continue; // 再次发请求
} else {
return message.content;
}
}注:上方代码段为视频内容根据流程的逻辑化整理型呈现,只为帮助理解。视频中出现过一处语法修正的细节(删除了代码中一处多余字段的错误部分),核对后确认代码可运行。具体实现细节请以你自己运行实验的结果为准。