多工具使用-从硬编码到注册表

    |     2026年8月17日   |   agent技术, AI大模型应用   |     0 条评论   |    3

上篇你学会了 Function Calling——让 AI 从”会说”变成”会干”。但当工具从 2 个变成 10 个、20 个,代码就开始失控了。这篇讲怎么用工具注册表模式,把工具管理这件事变得干净利落。


痛点:工具一多,代码就乱

小明最近很得意。Function Calling 跑通了,AI 能查天气、能搜新闻,老板夸了他两句。

然后老板说:”再加几个功能吧——查股票、查快递、算汇率、翻译、查数据库……先上十个。”

小明心想这还不简单?复制粘贴呗。打开代码一看,傻眼了——

每加一个工具,要改 3 个地方

改动位置 干什么 举例
工具列表 tools 加一段 JSON Schema 定义 告诉模型有这个工具
映射表 available_functions 加一行函数映射 "query_stock": query_stock
执行逻辑 加 if-else 或修改循环 处理这个工具的特殊逻辑
# 硬编码时代的日常:每加一个工具,三个地方都要改
tools = [
  {"type""function""function": {"name""get_weather""description""...""parameters": {...}}},
  {"type""function""function": {"name""get_news""description""...""parameters": {...}}},
  # 想加第十个?继续复制粘贴……
]

available_functions = {
  "get_weather": get_weather,
  "get_news": get_news,
  # 继续加……
}

# 执行逻辑里还有特殊处理
if func_name == "get_news" and not func_args.get("query"):
  func_args["query"] = user_message  # 搜索关键词缺失时用用户原话

小明去找老张:”这不对劲啊。十个工具就是 30 处改动,漏改一个就出 bug。而且 descriptionparameters 散落在代码各处,找个定义要翻半天。”

老张说:”你遇到的是经典的工程化问题。工具少的时候硬编码没问题,超过 3 个就该上注册表了。”

“注册表?”

“一句话——新增工具只需 register() 一行,其余全自动。”


核心思路:把”工具定义”和”对话编排”解耦

老张在白板上画了张图:

硬编码模式:
  tools = [...] ← 手动维护
  available_functions = {...} ← 手动维护
  执行逻辑 ← 手动写 if-else
  Agent 编排 ← 和上面三坨耦合在一起

注册表模式:
  tools/weather.py   ← 工具实现(独立文件)
  tools/news.py    ← 工具实现(独立文件)
     ↓
  register()     ← 一行注册
     ↓
  _REGISTRY      ← 全局注册表(唯一数据源)
     ↓
  get_openai_tools() ← 自动生成 tools 列表
  execute()      ← 统一执行入口
     ↓
  Agent        ← 只管编排,不关心具体有哪些工具

“看到了吧?Agent 代码不再关心’有哪些工具’,它只管调 get_openai_tools() 拿列表、调 execute() 执行。工具的增删改和对话逻辑完全解耦。

小明问:”怎么做到的?”

“三个组件:ToolSpec 存定义、Registry 管注册、execute 做执行。逐个讲。”


组件一:ToolSpec——工具的”身份证”

老张说:”每个工具需要携带很多信息——函数本身、给模型看的描述、参数定义、中文名、参数回退策略。用一个 dataclass 把它们打包:”

from dataclasses import dataclass, field
from typing import Callable

@dataclass
class ToolSpec:
  func: Callable                  # 实际 Python 函数
  description: str                  # 给模型看的描述
  parameters: dict                  # JSON Schema 参数定义
  label: str = ""                   # 简短中文名(UI 展示用)
  fill_from_user: list[str] = field(default_factory=list)  # 参数回退

逐字段解释:

字段 作用 举例
func 真正干活的 Python 函数 get_current_weather_online
description 模型靠它判断何时调用 "获取指定城市当前的天气情况"
parameters JSON Schema,描述参数结构 {"type": "object", "properties": {...}}
label 中文名,给 UI 或日志展示用 "天气"
fill_from_user 参数缺失时用用户原话填充 ["query"]

小明问:”这个 fill_from_user 是什么意思?”

“还记得你之前搜索工具那个特殊处理吗?模型有时候不传 query 参数,你想用用户原话兜底。之前是硬编码在执行逻辑里的 if-else,现在变成一个配置项——注册时声明 fill_from_user=["query"],执行时自动兜底。”

“原来是把特殊逻辑变成了配置项。”

“对。配置优于编码——能配置的就不要写死。”


组件二:Registry——注册表

注册函数

_REGISTRY: dict[str, ToolSpec] = {}

def register(func, description, parameters, label="", fill_from_user=None):
  _REGISTRY[func.__name__] = ToolSpec(
    func=func,
    description=description,
    parameters=parameters,
    label=label or func.__name__,
    fill_from_user=fill_from_user or [],
  )

“核心就是一个字典——_REGISTRY。键是函数名,值是 ToolSpecregister() 做的事就是把函数和它的元信息打包存进去。”

“用函数名 func.__name__ 作为键,保证和模型返回的函数名天然对应——模型说调 get_current_weather_online,你直接在注册表里查这个名字。”

自动生成 OpenAI Tools

def get_openai_tools() -> list[dict]:
  return [
    {
      "type""function",
      "function": {
        "name": name,
        "description": spec.description,
        "parameters": spec.parameters,
      },
    }
    for name, spec in _REGISTRY.items()
  ]

小明说:”这不就是遍历注册表,把每个 ToolSpec 转成 OpenAI 要求的格式吗?”

“对。以前你手动维护 tools 列表,现在自动生成。注册表是唯一数据源,get_openai_tools() 只是个格式转换器。永远不会出现’tools 列表里有但映射表里没有’这种不一致问题。”

统一执行入口

def execute(name, args, user_message="") -> str:
  if name not in _REGISTRY:
    return json.dumps({"error"f"未知工具: {name}"}, ensure_ascii=False)

  spec = _REGISTRY[name]

  # 参数回退:模型漏传时用用户原话填充
  for key in spec.fill_from_user:
    if not args.get(key) and user_message:
      args[key] = user_message

  result = spec.func(**args)
  return json.dumps(result, ensure_ascii=False)

老张逐行讲解:

  1. 查注册表 — 函数名不在表里?返回错误 JSON,不会 KeyError 崩溃
  2. 参数回退 — 遍历 fill_from_user,缺失的参数用用户原话补上
  3. 执行函数spec.func(**args) 解包参数调用真正的函数
  4. 序列化结果json.dumps 转字符串,ensure_ascii=False 保留中文

“以前你的执行逻辑内联在 Agent 循环里,现在统一收到 execute() 一个函数里。Agent 只管调 execute(),不关心参数回退、错误处理这些细节。”

小明恍然:”所以注册表做了三件事——存定义、转格式、管执行。”

“说到点子上了。”


注册工具:只需两行

老张说:”看实际怎么用。在 tool_registry.py 底部:”

from tools.weather import get_current_weather_online
from tools.news import get_news_by_tavily

register(
  get_current_weather_online,
  label="天气",
  description="获取指定城市当前的天气情况",
  parameters={
    "type""object",
    "properties": {
      "city": {"type""string""description""城市名称,例如:北京、上海"}
    },
    "required": ["city"],
  },
)

register(
  get_news_by_tavily,
  label="搜索",
  description="搜索指定关键词的最新新闻和实时资讯",
  parameters={
    "type""object",
    "properties": {
      "query": {"type""string""description""搜索关键词"}
    },
    "required": ["query"],
  },
  fill_from_user=["query"],   # query 缺失时用用户原话
)

新增工具只需两步

  1. tools/ 下写函数实现(独立文件,不碰其他代码)
  2. import + register(...)

“想加个计算器?新建 tools/calculator.py,写个 calculate 函数,然后:”

from tools.calculator import calculate

register(
  calculate,
  label="计算器",
  description="计算数学表达式的值",
  parameters={
    "type""object",
    "properties": {
      "expression": {"type""string""description""数学表达式,例如:123 * 456"}
    },
    "required": ["expression"],
  },
  fill_from_user=["expression"],
)

“完事。Agent 代码一行不用改。”

小明对比了一下之前的做法:

对比项 硬编码版 注册表版
新增工具 改 3 处代码 register() 1 处
参数回退 手写 if-else fill_from_user 配置
工具列表 手动维护,容易不一致 get_openai_tools() 自动生成
执行逻辑 内联在 Agent 循环中 execute() 统一入口
工具实现 散落在主文件里 独立文件,互不干扰

“差距很明显。”小明说,”工具越多,注册表的优势越大。”


Agent 类:干净的编排逻辑

老张说:”注册表把脏活都干了,Agent 类就变得很干净——它不再关心具体有哪些工具:”

class Agent:
  def run(self, user_message: str) -> str:
    messages = [
      {"role""system""content"self.system_prompt},
      {"role""user""content": user_message},
    ]

    response = self.client.chat.completions.create(
      model=MODEL,
      messages=messages,
      tools=get_openai_tools(),  # 从注册表自动获取
      tool_choice="auto",
    )

    if not response.choices[0].message.tool_calls:
      return response.choices[0].message.content

    messages.append(response.choices[0].message)

    for tool_call in response.choices[0].message.tool_calls:
      name = tool_call.function.name
      args = json.loads(tool_call.function.arguments)
      content = execute(name, args, user_message)  # 统一执行
      messages.append({
        "role""tool",
        "tool_call_id": tool_call.id,
        "name": name,
        "content": content,
      })

    final = self.client.chat.completions.create(model=MODEL, messages=messages)
    return final.choices[0].message.content

“对比你之前硬编码的版本,结构一模一样——两轮调用、messages 累积、tool 消息追加。区别只有两处:”

  1. tools=get_openai_tools() — 不再传硬编码列表,从注册表自动获取
  2. execute(name, args, user_message) — 不再手动查映射表 + 手动执行,统一入口

“Agent 代码从此稳定不动。加工具、删工具、改工具,都只在 tools/ 目录和 register() 调用里折腾,Agent 连重新部署都不用。”

小明问:”那入口文件呢?”

“更简单了,启动时打印一下当前注册了哪些工具:”

from agent import Agent
from tool_registry import list_tool_labels

def main():
  agent = Agent(verbose=False)
  tools = " / ".join(list_tool_labels())
  print(f"=== 工具注册表 Agent ===")
  print(f"工具:{tools}")
  print("输入 quit 退出\n")

  while True:
    user_input = input("你:").strip()
    if not user_input:
      continue
    if user_input.lower() in ("quit""exit"):
      break
    result = agent.run(user_input)
    print(f"Agent:{result}\n")
=== 工具注册表 Agent ===
工具:天气 / 搜索
输入 quit 退出

“加个计算器后,启动自动变成 工具:天气 / 搜索 / 计算器——你不用改一行入口代码。”


项目结构:各归各位

老张画了完整的目录结构:

示例/
├── main.py        ← 入口,启动 Agent
├── agent.py       ← Agent 类(对话编排)
├── config.py      ← API 配置(密钥、模型名、System Prompt)
├── tool_registry.py   ← 工具注册表(核心)
└── tools/
  ├── __init__.py    ← 包初始化
  ├── weather.py     ← 天气工具实现
  └── news.py      ← 搜索工具实现

“每个文件职责单一:”

文件 职责 改动频率
main.py 启动入口 几乎不改
agent.py 对话编排逻辑 几乎不改
config.py API 配置 换模型时改
tool_registry.py 注册表核心 + 注册调用 加工具时改(加两行)
tools/*.py 工具函数实现 加工具时新建文件

小明注意到:”工具实现放在独立文件里?”

“对。每个工具一个文件,互相不干扰。weather.py 里只有天气逻辑,news.py 里只有搜索逻辑。你想改天气工具,打开 weather.py 改就是了,不用担心碰坏别的工具。”

“这和写代码的模块化原则是一样的——单一职责、高内聚低耦合。”


参数回退:一个容易被忽略的细节

老张说:”注册表里有个设计值得单独讲——fill_from_user。”

“什么场景会用到?”

“用户说’帮我搜一下最新的 AI 进展’。模型可能正确提取了 query="AI 进展",也可能偷懒不传参数,只返回一个空的 arguments: {}。”

“之前你的做法是:”

# 硬编码:每个工具单独写 if
if func_name == "get_news" and not func_args.get("query"):
  func_args["query"] = user_message

“这种写法的问题——每加一个需要回退的工具,就要加一段 if。工具多了,if-else 越来越长。”

“注册表的做法是把回退策略配置化:”

# 注册时声明:query 缺失时用用户原话兜底
register(
  get_news_by_tavily,
  label="搜索",
  description="搜索指定关键词的最新新闻和实时资讯",
  parameters={...},
  fill_from_user=["query"],   # ← 一行配置搞定
)

“执行时 execute() 自动处理:”

for key in spec.fill_from_user:
  if not args.get(key) and user_message:
    args[key] = user_message

“不用写 if,不用改 Agent 代码,不用关心是哪个工具。声明式优于命令式——你告诉系统’这个参数缺失时怎么办’,系统自己处理。”

方式 代码量 可维护性
硬编码 if-else 每个工具 N 行 低,容易遗漏
fill_from_user 配置 每个工具 1 行 高,一目了然

跑起来看看

小明把注册表版代码跑了起来:

cd 示例 && python main.py
=== 工具注册表 Agent ===
工具:天气 / 搜索
输入 quit 退出

你:成都今天天气怎么样?
Agent:成都目前气温 30°C,阴天,湿度 65%,风速 10km/h。

你:搜一下最新的 AI 大模型进展
Agent:根据搜索结果,近期 AI 大模型领域有以下进展……

你:你好
Agent:你好!我是一个智能助手,可以帮你查询天气和搜索最新资讯,请问有什么可以帮您的?

小明加了计算器工具后,重新运行:

=== 工具注册表 Agent ===
工具:天气 / 搜索 / 计算器

“没改 Agent 一行代码,工具列表自动更新了。”

老张说:”这就是注册表的价值——工具的增删改和对话逻辑彻底分离。你的 Agent 代码写一次就不用动了,后面全是配置和新增文件。”


四个设计原则

老张总结了注册表模式背后的四个设计原则:

1. 单一数据源

注册表是唯一的数据源。get_openai_tools() 从它生成工具列表,execute() 从它查找函数。永远不会出现”列表里有但映射表里没有”的不一致问题。

2. 配置优于编码

参数回退用 fill_from_user 配置,不用写 if-else。能配置的就不要写死,因为配置比代码更容易修改、更容易审查。

3. 声明式注册

register() 是声明式的——你声明”这个函数是个工具,长这样”,系统自动处理后续。不需要你写”先把这个加到列表里,再把它加到映射表里,再处理它的特殊逻辑”。

4. 开放封闭原则

对扩展开放(加新工具只需 register),对修改封闭(Agent 代码不用改)。这是工程设计的基本原则——加功能不应该改老代码

原则 注册表怎么做到的
单一数据源 _REGISTRY 是唯一数据源,列表和执行都从它派生
配置优于编码 fill_from_user 替代 if-else
声明式注册 register() 一行声明,系统自动处理
开放封闭 加工具 = 新文件 + register,Agent 不动

总结

上篇你学会了 Function Calling 的两轮对话机制。这篇你学会了当工具变多时,怎么用注册表模式管理它们——

核心就三件事:

  1. ToolSpec 把函数、描述、参数、回退策略打包成一个数据结构
  2. register() 一行注册,自动进入全局注册表
  3. get_openai_tools() + execute() 从注册表自动生成工具列表和统一执行入口

Agent 代码从此稳定不动,工具的增删改全是配置和独立文件——新增工具只需一行 register()

小明把注册表重构完成后,老张问他感觉怎么样。

小明说:”以前加工具像做手术——要翻开 Agent 代码,小心翼翼地改三处地方,生怕碰坏别的逻辑。现在像插 USB——写好驱动文件,插上就行,系统自动识别。”

老张笑了:”这个比喻不错。注册表就是工具的 USB 接口——热插拔,即插即用。

“就像从 USB 进化到了无线——协议没变,传输方式升级了。”

转载请注明来源:多工具使用-从硬编码到注册表
本文链接地址:https://ai.zhousir.top/?p=3628
回复 取消