【手搓 Agent 第2.3关】搭建 Agent 进阶能力:工具注册中心架构重构

上一关我们完整落地了工业级网络搜索工具,让 Agent 具备了联网实时检索、故障熔断、防过度调用的成熟能力,补齐了智能体获取外部实时信息的核心短板。至此,Stage2 基础工具链路已经跑通。

接下来,我们希望给 Agent 装上"笔"、"数据库钥匙"和"独立大脑",也就是 File I/O(文件读写)、Text-to-SQL(数据库查询)、和 Code Execution(代码执行器)。但是,当前 Agent 代码架构存在致命工程隐患:所有工具调度、异常捕获、权限判断逻辑全部堆砌在主循环函数中,代码臃肿耦合、扩展性极差,并不容易扩展工具。

为了避免老旧架构彻底失控,出现逻辑混乱、报错难排查、新增工具成本极高的问题,本篇将着眼调整旧结构。本篇不新增任何业务功能,只完成底层架构解耦重构,搭建标准化工具注册中心,统一工具注册、安全校验、异常捕获、执行调度规范,同时优化 RAG 知识库懒加载机制,为后续所有高阶工具的低成本、规范化接入打下基础。

因此,我们在这第 2.3 关的计划如下:

阶段 内容 难度 耗时 核心目标
阶段 0 工具注册中心架构重构(前置必备) 1h 解耦主循环与工具逻辑,统一管理所有工具 Schema、执行函数、安全校验、异常处理
阶段 1 File I/O 文件读写工具 最低 2-3h 实现受限目录文档写入,防御路径穿越,支持新建 / 追加、MD/TXT/PDF 导出
阶段 2 Text-to-SQL 双工具 3-4h 数据库结构动态获取、SQL 语句安全拦截、只读账号隔离、SQL 错误自动回传给模型调试
阶段 3 Code Execution 代码沙箱执行 4h+ 超时控制、AST 静态风险代码拦截、捕获标准输出 / 错误,本地测试安全方案

一、阶段 0:工具注册中心架构重构

1. 重构思路

  1. 新建 ToolRegistry 注册类:统一注册所有工具,保存「函数 + 权限校验函数 + 工具名称映射」
  2. 抽离通用工具安全校验、异常捕获逻辑,从主循环剥离
  3. 统一工具返回标准格式(成功 / 错误模板),LLM 更容易解析工具结果
  4. 后续新增工具仅需注册函数 + 补充 tools 数组的 function schema,不用修改主循环核心逻辑

2. 具体步骤

导入新增库

python 复制代码
from typing import Callable, Dict, Any, Optional
import re
from pathlib import Path
import time

解释:

  1. from typing import Callable, Dict, Any, Optional:Python类型注解专用库,只给代码做类型标注,不影响运行,方便 IDE 提示、静态检查。

    • Callable:代表可调用对象(函数、方法、lambda),用于标注变量是一个函数
    • Dict:字典类型标注,Dict[键类型, 值类型],等价新版dict
    • Any:任意类型,代表不限制数据类型,什么值都能存
    • Optional可选类型 ,等价 类型 | None,表示变量可以是指定类型也可以是 None
  2. import re:Python正则表达式标准库,专门做文本匹配、提取、替换、校验。

    • 常用场景:手机号 / 邮箱校验; 批量替换文本、提取数字、关键词过滤
  3. from pathlib import Path:Python3.4+ 现代文件路径工具,替代老旧os.path,面向对象操作文件 / 文件夹。

    • 优势:代码更简洁、跨 Windows/macOS/Linux 自动适配斜杠
  4. import time:时间处理标准库,获取时间戳、程序延时、计时。

    • time.sleep(2):暂停 2 秒
    • time.time():获取当前时间戳(浮点秒数,用于代码耗时统计)

新增工具注册中心

我们需要在代码的一开始新增一个 ,即工具注册与执行的管理类用来统一管理程序里的各种工具函数,实现注册、安全校验、执行、异常捕获的一体化处理。这也是非常实用的模块化工具管理组件。

  1. 首先,我们在代码开头定义ToolRegistry类,这是整个工具注册中心的核心,专门负责工具的存储、注册、查询和执行。
python 复制代码
# -------------------------- 新增:工具注册中心 --------------------------
class ToolRegistry:
    def __init__(self):
        # key: 工具函数名,value: 执行函数 + 前置安全校验函数
        self.tools_map: Dict[str, Dict[str, Any]] = {}

类的__init__初始化方法里,创建了一个空字典tools_map,这个字典是核心存储结构:键是工具的函数名称,值是另一个字典,里面存放工具的执行函数和前置安全校验函数,代码里的类型注解是为了规范数据类型,让代码更易读、更严谨。

  1. 接下来是register注册方法,它的作用是把工具函数注册到中心里。
python 复制代码
    def register(self, func_name: str, execute_func: Callable, security_check: Optional[Callable] = None):
        """注册工具:名称、执行函数、可选前置安全校验函数"""
        self.tools_map[func_name] = {
            "exec": execute_func,
            "security_check": security_check
        }

这里需要传入三个参数:工具的名字、工具的核心执行函数、可选的前置安全校验函数(默认可以不传)。方法内部会把这两个函数打包成字典,以工具名为键,存入tools_map中,完成工具的注册。

  1. 然后是get_tool查询方法,很简单,根据传入的工具名字,从tools_map里查找对应的工具信息,找到就返回工具字典,找不到就返回 None,是一个辅助查询的方法。
python 复制代码
    def get_tool(self, func_name: str) -> Optional[Dict[str, Any]]:
        return self.tools_map.get(func_name)
  1. 接着是最核心的run_tool方法,这是统一执行所有工具的入口,所有工具都通过这个方法调用。
python 复制代码
    def run_tool(self, func_name: str, **kwargs) -> str:
        """统一执行入口:先安全校验,再执行工具,统一捕获异常"""
        tool_info = self.get_tool(func_name)
        if not tool_info:
            return f"【工具错误】不存在工具 {func_name}"
        
        # 执行前置安全校验
        check_func = tool_info.get("security_check")
        if check_func is not None:
            check_res = check_func(**kwargs)
            if check_res is not None:
                # 校验不通过,返回错误信息,直接终止工具执行
                return f"【安全拦截】{check_res}"
        
        # 执行工具主逻辑,统一捕获异常
        try:
            result = tool_info["exec"](**kwargs)
            return f"【工具执行成功】\n{result}"
        except Exception as e:
            err_msg = str(e)
            return f"【工具执行失败】错误信息:{err_msg}"

它做了四件关键的事:

第一,先通过get_tool检查工具是否存在,不存在就直接返回工具不存在的错误提示;

第二,执行前置安全校验,如果注册时传入了安全校验函数,就先运行这个函数,把调用参数传进去,如果校验函数返回了内容,就说明校验不通过,直接返回安全拦截的提示,不再执行工具;

第三,校验通过后,尝试执行工具的核心逻辑,用try包裹执行过程,避免程序崩溃;

第四,统一处理结果:执行成功就返回成功提示和结果,执行出错就捕获异常,返回失败提示和错误信息。

  1. 最后,全局实例化了ToolRegistry类,创建了一个叫tool_registry的全局对象,整个程序里都可以直接用这个对象来注册新工具、运行已注册的工具,不需要重复创建实例,使用起来非常方便。
python 复制代码
# 全局实例化工具注册器
tool_registry = ToolRegistry()

简单总结这个组件的价值:把分散的工具函数统一管理,强制加上安全校验,统一处理异常和返回格式,让工具的调用更安全、更规范、更易维护。

安全校验函数

  • 安全校验函数就是一个普通可调用函数,是工具真正执行之前运行的前置钩子函数。它不做业务本身,专门做参数合法性、权限、风险检查。
  • 约定契约:校验函数返回None = 校验通过直接放行;返回字符串 = 校验失败,字符串内容就是拦截提示文本。
  • 举例:工具是删除文件,执行函数负责真正删文件;安全校验函数就检查:目标路径是不是系统高危目录、传入路径参数是否为空、是否禁止该操作。
  • 它是可选参数,不是必填。普通低风险工具可以不传,传None即可;高危只看输入函数就可以判断是否违规的工具才需要绑定校验逻辑。
  • 正常是:先写工具业务函数,再写配套安全校验函数(普通独立函数),注册的时候把两个函数对象一起传给 register

将原有工具函数注册到注册中心

在两个函数定义后加上注册到注册中心的代码:

python 复制代码
# 将原有工具注册到注册中心(无安全前置校验,传None)
tool_registry.register("query_knowledge_base", query_knowledge_base, None)
tool_registry.register("web_search", web_search, None)

注意:即使已经有工具注册中心,仍然需要写 tools 数组。因为 tools 数组是写给大模型看的,让大模型明白有什么工具可以调用。而 ToolRegistry 工具注册中心是执行层,只负责当收到模型发来工具名字时进行调用。如果没有 tools 数组,则大模型不知道有哪些工具,就不能指挥 ToolRegistry

3. 原有 RAG 工具流程优化

现在的写法下,程序一启动,立刻加载 BGE embedding、打开 ChromaDB。哪怕全程不调用知识库,启动就要加载模型、消耗内存。

优点是第一次调用 RAG 速度快;缺点是哪怕不用 RAG 也要承担加载开销;如果 Chroma 数据库损坏,整个 Agent 程序直接启动崩溃,而不是调用工具的时候才报错

因此,在新的阶段开始前,我们先调整一下代码结构,将 RAG 改成懒加载。不要每次调用工具都重新初始化一遍(会极慢)。全局放变量初始为 None,进入 query_knowledge_base 函数时判断,如果实例是 None,才执行一次初始化;如果实例不是 None,则直接使用。

因此我们在全局位置定义两个初始变量,保留 persist_directory 这个字符串常量并让程序在读取其位置时采用相对该脚本的相置,其他部分全部移入query_knowledge_base 函数,并在函数中加入 global 声明。

全局位置:

python 复制代码
embeddings = None
vectordb = None
SCRIPT_DIR = Path(__file__).parent.absolute()
persist_directory = str(SCRIPT_DIR / "chroma_db_wheat")

进入函数后:

python 复制代码
def query_knowledge_base(query: str) -> str:

    global embeddings, vectordb

    if embeddings is None or vectordb is None:
        if not Path(persist_directory).exists():
            return "本地向量知识库不存在,请先运行 Stage_1/prepare_knowledge_base.py 生成知识库。"

        print("正在连接本地向量知识库...")
        embeddings = HuggingFaceEmbeddings(
            model_name="BAAI/bge-small-zh-v1.5",
            model_kwargs={
                'device': 'cpu',
                'local_files_only': True #这样就只需要用本地离线缓存模型了,节省验证时间
            },
            encode_kwargs={'normalize_embeddings': True}
)
        vectordb = Chroma(persist_directory=persist_directory, embedding_function=embeddings)    

    print(f"\n[执行工具] 正在向量数据库中搜索与 '{query}' 相关的资料...")

效果:程序启动不加载向量库;第一次调用 query_knowledge_base 工具的时候才加载模型与数据库;后续重复调用复用实例,不会重复加载。

注意:在进入函数后必须先加 global 声明,即 global embeddings, vectordb。这个声明的作用是将函数内部赋值作用到全局变量,从而保证懒加载的成功。

二、本篇总结 & 下期预告

本篇我们完成了 Agent 核心架构的关键升级,彻底告别了杂乱的硬编码工具调度模式。通过 ToolRegistry 工具注册中心,实现了工具逻辑与主循环的完全解耦,统一了全量工具的注册、校验、执行、报错输出规范;同时完成了存量 RAG、网络搜索工具的适配迁移,优化了知识库懒加载机制,解决了程序启动冗余开销、单体代码臃肿、扩展性差等工程痛点,且全程兼容原有所有功能,做到重构无破坏性迭代。

稳固的底层架构已经搭建完成,具备了批量接入高阶工具的能力。下一关我们将基于全新的工具注册中心,落地第一个进阶业务工具------安全可控的 File I/O 文件读写工具,让 Agent 拥有自主整理、生成、导出本地文档的能力,解锁智能报告生成核心能力。