大模型的短板很明确 -- 它只能生成文字,不能执行操作。想让 Agent 真正有用,得给它装上"手",也就是工具(Tool)。
但这里有个容易被忽略的问题:大模型输出的是字符串,工具需要的是类型化的参数。 字符串怎么变成整数?怎么变成结构化对象?中间的转换、校验、异常处理谁来管?
这篇文章拆解的就是这套机制。核心内容分三块:
1)三层架构下的工具注册与实现方式 2)从字符串到类型参数的转换链路(Schema 机制) 3)完整调用流程:映射 -> 校验 -> 执行 -> 异常兜底
工具模块不是简单的一个类搞定所有事,而是分了三层,每层有明确的职责边界。
第一层:抽象基类
定义一个抽象的工具基类,规定每个具体工具必须实现的接口。比如 execute 方法就是核心 -- 每个工具继承这个基类后,必须自己实现"到底干什么"。除此之外,基类还可以定义一些钩子方法:before_execute(调用前准备)、after_execute(调用后清理),形成一套标准的生命周期。
第二层:注册层(聚合根)
注册类是整个工具模块的管理中心。它负责三件事:统一注册所有可用工具、按名称查找并分发调用、编排标准执行流程。你可以把它理解成一个"工具调度器" -- 上层只跟它打交道,不需要知道底层有哪些具体工具。
新增一个工具?继承基类,实现接口,往注册表里登记一下,完事。这就是开闭原则的体现。
第三层:调用层
这是业务侧真正使用的地方。大模型经过推理后决定要调用某个工具,输出一段类似 {"tool": "shell", "params": {"command": "ls", "timeout": "60"}} 的文本。调用层的任务就是把这段文本交给注册层,由注册层完成后续的全流程处理。
三层的关系很简单:调用层发指令 -> 注册层找工具 -> 基类定契约 -> 具体工具干活的。
整个工具模块里最精巧的设计,就是参数类型的转换机制。
大模型输出的参数全是字符串。但工具的入参可能是整数、布尔值、列表甚至嵌套对象。直接把字符串塞进去,轻则类型报错,重则静默出错。所以必须有一套可靠的类型转换方案。
方案的核心是 Schema(模式声明)。
每个工具在初始化时,通过装饰器或配置的方式声明自己的参数 Schema。比如一个 Shell 工具可能这样定义:
command: 字符串类型,必填workdir: 字符串类型,可选timeout: 整数类型,默认值 60定义的Schema 是这样:
{
"type": "object",
"properties": {
"command": {"type": "string", "description": "..."},
"timeout": {"type": "integer", "minimum": 1, "maximum": 600}
},
"required": ["command"]
}有了这份 Schema 之后,后面的事情就顺理成章了:
第一步:参数映射。 把大模型输出的字典里的值,按照 Schema 里声明的字段名一一对应提取出来。
第二步:类型转换。 根据每个字段的目标类型做转换。timeout="60" 这种情况,Schema 说它是整数,就自动转成 60;如果本来就是字符串且目标也是字符串,原样放行。
第三步:参数校验。 转换完还不够 -- 必填字段有没有漏?数值范围对不对?格式合不合规?全部依据 Schema 的规则来检查。校验不过,直接返回错误信息,不让它进到执行环节。
这三步都是在调用具体工具之前完成的,相当于一道闸门。闸门之外的是不可信的原始字符串,闸门之内的是已经过验证的类型安全参数。
这里需要注意的是,有两种格式,一种是给大模型看的,一种是大模型生成的。
格式 | 用途 | 示例 |
|---|---|---|
JSON Schema | 定义工具参数的结构和约束(发给大模型) | {"type":"object","properties":{"command": {"type": "string", "description": "..."},"timeout": {"type": "integer", "minimum": 1, "maximum": 600}}},"required":["command"]} |
Tool Call | 大模型返回的实际调用请求 | {"name":"exec","arguments":{"command":"ls -s","timeout":"60"}} |
把上面的内容串起来,一次工具调用的完整链路是这样的:
大模型输出调用指令(文本格式的 JSON) -> 调用层收到指令,提取工具名和参数 -> 注册层根据工具名找到对应实例 -> 根据 Schema 进行参数映射和类型转换 -> 参数校验,不通过则返回错误 -> 校验通过,调用工具的 execute 方法,传入类型化参数 -> 工具执行完毕,返回结果 -> 注册层做后处理(异常捕获、结果包装) -> 结果回传给大模型,继续后续推理
有两个地方值得特别注意:
异常时的兜底策略。 如果工具执行报错,不会让整个流程崩掉,而是返回一条清晰的错误提示给大模型。大模型拿到错误信息后可以自行判断 -- 是换一种方式重新调用这个工具,还是换一个工具,或者干脆不用工具直接回答。这种"让大模型自己决定怎么做"的设计,正是 ReAct 模式的精髓所在。
正常时的结果透传。 如果一切顺利,工具的结果会原封不动地返回给大模型,作为下一轮推理的上下文输入。大模型拿到结果之后继续思考,可能再次调用工具,也可能给出最终答案。
工具模块的设计思路其实并不复杂:用抽象类定规矩,用注册表管调度,用 Schema 保类型安全。 三层各管一摊,边界清晰,扩展方便。
真正花功夫的是每个具体工具的实现 -- 怎么定义参数、怎么处理边界情况、怎么保证执行的可靠性。但这些是工程细节问题,框架层面只要把注册机制和调用流程固定下来,后续加新工具就是一个"继承基类 + 声明 Schema + 注册登记"的标准动作。
对于正在做 Agent 二次开发的同学来说,这套模式的参考价值在于:它展示了如何在"灵活的大模型输出"和"严格的程序接口"之间架一座靠谱的桥。