叶小钗

第 06 篇 · 工具系统:可插拔的工具注册机制

购买课程的同学再花 1 元 RMB 买下这个商品,里面有惊喜:

Image
上一篇文章中,我们把工具是写死在 app/tools.py 里的。三个工具都写在了一个文件里面,工具执行逻辑,参数定义,工具申明都在这里面。

如果我们要再添加第四个工具,比如要加一个 web_search,就需要做如下的修改:

  • 在文件里写一个 tool_web_search(query) 函数

  • 把它加进 TOOL_FUNCTIONS 字典

  • 在 TOOL_DEFINITIONS 列表里再写一份 JSON 描述

三个地方都要改。工具一多,这个文件会膨胀到几百行,多个人协作时,三处改动遗漏一处就会出莫名其妙的 bug。

这一篇我们重构工具系统,每个工具独立一个文件,丢进 tools/ 目录就能用,启动时做一个自动发现,后面的Agent就可以自动发现可用工具,后面添加工具,就不用改动其他文件。

Image

工具格式约定

我们要做一个工具的自动发现机制,首先我们需要一个扫描器,来自动扫描发现工具。

那么扫描器怎么知道某个文件是一个工具?工具的元信息(name、description、参数)从哪取?怎么执行它?

我们定一套简单的约定。每个工具文件必须导出两个东西:

  • DEFINITION:一个 dict 字典,描述这个工具是什么、参数是什么

  • execute:一个函数,传 args 进去,返回字符串结果

只要满足这两个东西,注册器扫描到就能用。具体看一个例子:

# 假设这就是 read_file_tools.py 的样子
DEFINITION = {
"id": "read_file",
"name": "read_file",
"description": "读取工作目录下某个文件的完整内容",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
    },
}


defexecute(args: dict, **kwargs) -> str:
    path = args.get("path", "")
    ...
return content

DEFINITION 里面的 id 和 name 字段。id 是工具的唯一标识(用来做配置和索引),name 是给模型看的函数名。它们一般来说都是一样的,分开配置也有一些好处,比如以后想给同一个工具配置不同的 name 也能做到。

execute 接受 args: dict 和 kwargs。args 是模型传过来的参数字典,kwargs 是给主流程传上下文用的(比如 session_id、当前 agent 是谁),工具用不到可以忽略。这个签名留着扩展余地。

返回必须是字符串,失败了也是字符串(以 "Error:" 开头),不要 raise 异常。第 04 篇讲过为什么这样做,异常会让 Agent 循环挂掉,而字符串错误可以让模型自己看看错误消息,自己想办法做后续处理。

整个目录结构会变成这样:

backend/app/tools/
├── __init__.py              # 注册器
├── list_files_tools.py     # 一个工具一个文件
├── read_file_tools.py
├── write_file_tools.py
└── web_search_tools.py     # 想加新工具就丢一个文件进来

还有一个约定就是,文件名以 _tools.py 结尾,注册器扫的就是这个后缀。

Image