Rust AI Agent 工具调用重构:统一 Tool trait、动态 ToolBox 与 Agent Loop
本讲通过统一工具接口、自动生成 JSON Schema、动态工具箱和可循环执行的 agent loop,解决手写 schema、硬编码
if/else、无法自我纠错三个痛点,并为下一讲 MCP 解决行业级工具生态问题做铺垫。
背景:从两个工具到一个 agent 工具调用框架
杨旭继续讲使用 Rust 开发 AI agent。前面几节已经分别完成了一个计算器工具和一个网络搜索工具。本节先简单查看它们的代码定义,再对工具调用部分进行重构。
现有实现暴露出三个问题。
问题一:手写 JSON Schema 很痛苦
- 工具定义中包含手写的 JSON。
- 大语言模型要求把工具参数以 JSON Schema 的格式传给它。
- 现在针对每个工具都要手动拼写很长一串 JSON 结构,里面有一堆字段和嵌套内容。
- 参数比较少时还可以接受。
- 如果参数稍微多一点,或者后期需要频繁更改,手动维护就极其容易出错。
- 这是一个难点,也是一个痛点。
问题二:工具调用使用硬编码 if/else if
- 工具调用部分目前使用
if、else if来判断模型调用的到底是哪个工具。 - 这种写法是硬编码,扩展性比较差。
- 当模型返回一个工具调用请求时,如果采用这种写法,就意味着每新增一个工具,都必须去修改 agent 的核心执行代码。
- 这一点严重违反了开闭原则。
问题三:代码只跑一轮,无法自我纠错
- 当前代码调完一次工具后,就把结果塞给模型,然后直接返回。
- 如果模型传错了参数,或者模型需要先搜索再计算,也就是需要连续调用多次工具,那么现在的代码就不合适。
- 因此必须对代码进行一定程度的调整和重构。
重构目标
本节主要目标是和大家一起一步一步稍微重构这个项目,解决上述三个问题:
- 用统一接口替代每个工具手写 JSON Schema。
- 用动态工具箱消除
if/else硬编码。 - 加入 agent loop,使工具调用可以连续执行,并让模型根据错误信息自我调整。
方法与步骤
第一步:定义统一的 Tool trait
首先定义一个统一的接口,也就是一个工具的 trait,或者叫工具的规范。
原文提到在 tools 文件夹里建立一个工具 trait 文件,口播为“就叫 to 吧,不加 S,点 rs”,文件名在文本中没有完整确认。由于 trait 涉及异步操作,需要添加一个异步 trait 相关的 crate,原文口播为 a sync treat/create,实际应指 async-trait 这类 crate。Rust 原生不支持在 trait 里直接写 async 方法。
添加完相关 crate 后开始写代码,并且不要忘记添加模块。
这个 Tool trait 定义的方法包括:
name:返回工具名称。description:返回工具描述。parameters:返回参数定义。execute:传入参数并执行工具,是异步方法。definition:返回工具定义,内部使用自身的name、description、parameters等信息。
这个 trait 本身不复杂,重点在于后续用它统一封装不同工具。
第二步:实现计算器工具
在计算器部分新建一个实现文件。原文提到文件为 IMPL.rs 一类,并提醒 impl 是 Rust 关键字,需要特殊处理。模块方面涉及 calculator 模块。
实现计算器工具时,关键变化如下:
CalculatorArgs是原来就有的参数结构。- 给它加上
derive(JsonSchema),也就是原文口播的“Jason schema”。 - 这样它就能自动生成 JSON Schema,不再需要手写。
- 定义
CalculatorTool结构体,没有字段。 - 为
CalculatorTool实现Tooltrait。
几个方法的作用:
name、description很简单,直接返回名称和描述。parameters使用schema_for!宏自动生成 JSON Schema,返回类型是ValueJSON 值类型。execute是真正执行工具的方法。
execute 的输入是大语言模型传来的 JSON 字符串,输出是字符串。其内部逻辑是:
- 把参数转化成强类型。
- 调用 calculator 函数拿到结果。
- 判断是否成功。
- 如果成功,就把结果转成字符串返回。
- 如果失败,依然要把错误信息包装成一个普通字符串,放在
Ok里返回。
这里的关键原因是:这个字符串最终要返回给大语言模型看。虽然出现错误,但也需要让大语言模型看到这次工具调用出错了,它可以据此调整思路,例如重新问用户问题,或者换一个算法。而不是让整个 Rust 程序层面报错,或者中断对话。
也就是说,这是业务上的失败,它变成了正常的一种返回内容,而不是程序层面的错误。这是本段最重要的设计点。
第三步:实现网络搜索工具
网络搜索工具的参数也已经添加了 JSON Schema。WebSearchTool 使用同样的逻辑,原文没有再展开讲解。
第四步:优化 tool.rs,引入动态工具箱 ToolBox
在 tool.rs 中做了优化,改成使用动态的工具箱 ToolBox。
ToolBox 实际上就是一个 HashMap:
- key 是
String。 - 值是
Box<dyn Tool>。 - 它是一个类型别名,因此使用了动态分发。
这样做的好处是:从今以后,如果想给 agent 增加新工具,例如发送邮件或查询数据库,只需要在 build_tool_box 函数里的 vector 中 Box::new 一个新的工具加进去即可。整个 agent 的核心调用逻辑一行代码都不需要动。
这就可以消除前面提到的 if/else 硬编码问题。
build_tool_box 的作用是返回一个 ToolBox,也就是一个 hash map。流程是:
- 先构造一个
toolsvector。 - vector 里放着
Box::new各种工具。 - 然后
into_iter()。 - 再
map成需要返回的类型形状。 - 最后
collect。
原文认为这个过程很简单。
第五步:修改 complete
接下来需要修改 complete 函数。
主要修改点:
- 参数类型改成
ToolBox。 - 修改后原来相关位置会报错,也需要一起调整。
- 从
ToolBox中取工具定义时,由于返回类型是一个Result,理论上有可能失败。 - 如果失败,就跳过那个工具,其他工具的定义还可以正常返回。
然后继续写代码,加入一个循环。原文称这是“最简单的 agent 执行循环”。
检查工具调用这一块的逻辑是:
- 首先检查大模型是否发起了工具调用,也就是
tool_calls。 - 把大模型作为 assistant 想要调用工具的消息添加到消息历史记录里。
- 遍历所有的工具调用请求。
- 这次修改的不同之处在于:从
ToolBox里动态查找工具并执行。 - 如果能找到工具,就执行。
- 执行结果放在
Ok里。 - 如果是错误的,就返回错误信息。
- 如果没找到工具,就 log 一下错误。
- 最后把这个工具执行的结果当作
tool消息,也就是 tool message,再追加到聊天历史记录里。 - 关键点:走到这里不退出,继续下一轮 loop。
另一个分支是:如果大模型没有请求工具调用,就说明它已经拿到足够的信息,给出了最终答案,这个时候就 return。
第六步:修改与新增 example
之前的 tool_call_complete example 有错误,需要修改。修改之后错误消失。
针对今天修改的内容,新增一个 example,叫 simple_agent_loop。代码直接贴进去。
系统提示是:
你是一个全能助手,需要信息时请用搜索工具,需要计算时请用计算器工具,工具返回结果后请直接使用这些结果回答,不要说不知道。
第一个测试是:
- 2026 世界杯决赛比分是多少。
第二个测试是:
- 一个计算任务,它乘以它等于多少。原文没有给出具体数值。
测试结果:
- 第二个答案没问题。
- 第一个问题的答案也没有问题。
案例与数据:Agent Loop 的自我调整
在第二个问题中,出现了一个有趣的现象。
问两个数相乘等于多少时,模型第一次试图调用计算器,参数转写为“信号/型号”。计算器执行时不支持这个写法,于是返回了错误信息。
然后模型又发起了第二次调用。因为现在有了一个简单的 agent 循环,发生错误时大模型也知道错在哪里了。所以模型在下一轮调用时没有使用“信号/型号”,而是使用了正确的单词,这一次计算就成功了。
这体现了 agent loop 这个循环的强大之处:
- 它不仅支持连续调用多个工具。
- 还具备根据工具返回的错误信息进行自我调整的能力。
需要注意:原文没有明确第一次参数的具体符号,也没有明确模型第二次使用的“正确单词”具体是什么,只说明从错误写法调整到了正确写法。
行业痛点:为什么需要统一标准
回头再看 PPT,写两个工具看起来好像挺简单。定义一个工具,写个函数转成 schema,几分钟就能搞定。
但真正到了实际项目的规模,会冒出四个很实际的问题。
痛点一:理解陌生接口的成本很高
大多数服务根本不是为大模型场景而设计的。各家的鉴权方式、分页逻辑、返回格式都不一样。原文口播中“健全方式”从上下文看应指认证/鉴权相关方式。动手写代码之前,先要啃一堆文档。
痛点二:容错代码往往比核心逻辑还长
例如:
- 网络超时怎么办。
- 怎么重试。
- 被限流了怎么退避。
- 服务出故障了怎么给出有意义的错误信息。
这些代码的代码量经常是核心功能的好几倍。
痛点三:团队之间重复造轮子
例如公司里市场部要一个搜索工具,客服这边也要一个。然后各自闷头写同一个能力,可能出现好几个版本,谁都没有复用谁的。
痛点四:维护负担随工具数量线性增长
接口升级、依赖库过期、安全漏洞等问题都会出现。工具一多,维护成本压根就压不住。
这几个问题不是这个项目独有的,而是整个行业都在面对的通病。
传统软件行业的类比与 MCP 引出
面对刚才说的四个痛点,传统软件行业早就遇到过一模一样的困难,并且已经给出了成熟解法。
- Web 后端使用 REST,统一了不同服务之间的 Web 通信方式。
- 包管理器统一了代码怎么打包、分发和复用。
而智能体的工具生态现在正处于同样的阶段。它缺一份大家都认同的统一标准。
这一点正是 Anthropic 提出的 MCP,也就是 Model Context Protocol 所要解决的问题:
- 一份工具只需要开发一次。
- 按照统一的协议封装好。
- 任何支持 MCP 协议的 agent 都能直接拿来用。
- 不用每个团队都重新造一遍轮子。
所以下一讲会正式进入 MCP 的世界,看看能不能把刚才讲的四个痛点一个一个拆解掉。
限制与待确认问题
- 原文是口播或转写内容,没有展示完整代码。
- 异步 trait 相关 crate 的具体名称、工具 trait 文件名、计算器实现文件名、具体命令和完整方法签名,文本中没有完全确认。
- 计算器第一次错误调用时的具体参数符号、模型第二次使用的“正确单词”、第二个计算测试的具体数值,原文没有明确。
- MCP 的具体协议内容、实现方式,以及它如何逐个解决四个行业痛点,本节尚未展开。
- 四个行业痛点目前只是提出,并引出 MCP 方向,尚未在本节中验证解决。
行动清单
如果复现本节重构,可以按以下顺序推进:
- 定义一个统一的
Tooltrait,并添加异步 trait 支持。 - 为 Calculator 参数结构添加
JsonSchemaderive,使用schema_for!生成参数 schema。 - 实现 Calculator 与 WebSearch 的
Tooltrait;在execute中,业务错误也以Ok(错误字符串)返回给模型。 - 用
HashMap<String, Box<dyn Tool>>构建ToolBox,通过build_tool_box集中注册工具。 - 修改
complete:接收ToolBox,循环处理tool_calls,动态查找并执行工具,追加tool消息,无工具调用时返回最终答案。 - 新增
simple_agent_loop,测试连续调用与错误自我调整。 - 下一讲进入 MCP,关注工具生态统一标准。
总结
本节从三个具体痛点出发完成重构:
- 统一接口消除了手写 JSON Schema。
- 动态
ToolBox消除了if/else硬编码。 - agent loop 支持连续调用多个工具,并支持根据工具错误信息自我纠错。
两个测试验证了搜索与计算场景,并观察到模型根据工具返回的错误信息调整参数。更大规模的实际问题则引出行业级需求:MCP 作为统一协议,目标是工具开发一次,支持 MCP 的 agent 直接复用。下一讲将围绕 MCP 拆解四个痛点。