跳转到内容

第 8 节 · ToolRegistry:让加工具像加函数一样简单

一句话回答

ToolRegistry 是一个"装饰器 + 反射"的小工具,让你写一个普通 Python 函数 → 自动成为 LLM 能调用的工具。

它不是魔法,是把第 7 节那 30 行 schema JSON 从手写挪到了自动生成

NOTE

到 2026 年,很多框架已经内置类似能力:OpenAI Agents SDK 的 function_tool、Claude Agent SDK 的工具定义、MCP server 的 tools/list,本质上都在做同一件事:把可执行能力整理成模型能理解的工具目录。本节自己写一个极简版,是为了看清底层原理。

没有 ToolRegistry 时的痛

回到第 7 节:每加一个工具,你都要写两份东西。

python
# 1. 函数本体
def get_weather(city: str) -> str:
    return f"{city} 晴 25°C"

# 2. 一份 30 行的 schema(重复信息)
{
    "type": "function",
    "function": {
        "name": "get_weather",                   # ← 函数名重复一次
        "description": "查询指定城市的当前天气",
        "strict": True,
        "parameters": {
            "type": "object",
            "properties": {
                "city": {                         # ← 参数名重复一次
                    "type": "string",             # ← 参数类型重复一次
                    "description": "城市名",
                }
            },
            "required": ["city"],                # ← required 重复一次
            "additionalProperties": False,
        },
    },
}

工具一多,重复代码爆炸:参数名、参数类型、required 列表,每加一个工具都要在两个地方写一遍。一旦改了函数签名忘了改 schema,模型就调错。

ToolRegistry 长什么样

ToolRegistry 三步注册:写函数 → 装饰器 → LLM 自取

python
registry = ToolRegistry()

@registry.tool("查询指定城市的当前天气")
def get_weather(city: str) -> str:
    return f"{city} 晴 25°C"

就这两行name / parameters / required 全部从函数签名自动读出来。

从工程视角看,ToolRegistry 其实做了三件事:

职责作用
注册收集哪些函数可以暴露给模型
生成 schema把 Python 签名翻译成 JSON Schema
调度执行收到 tool_calls 后按工具名找到函数并调用

也就是说,它既是工具目录,也是工具路由器

调 LLM 时:

python
resp = client.chat.completions.create(
    model=MODEL,
    messages=messages,
    tools=registry.to_schemas(),    # ← 自动生成所有 schema
)

for tc in resp.choices[0].message.tool_calls:
    args = json.loads(tc.function.arguments)
    result = registry.invoke(tc.function.name, args)   # ← 自动调度

它是怎么"自动"的:inspect

ToolRegistry 用 inspect.signature() 读函数签名,把它翻译成 OpenAI schema:

python
import inspect

def get_weather(city: str) -> str:
    ...

sig = inspect.signature(get_weather)
for pname, param in sig.parameters.items():
    print(pname, param.annotation, param.default)

# city <class 'str'> <Parameter.empty>

把这些信息翻译成 schema 就行:

函数签名OpenAI schema
city: strproperties.city.type = "string"
count: intproperties.count.type = "integer"
score: float = 0.5properties.score.type = "number"不在 required 里(有默认值)
flag: boolproperties.flag.type = "boolean"

极简实现:80 行以内

python
import inspect
from typing import Any, Callable

class ToolRegistry:
    _TYPE_MAP = {
        str: "string", int: "integer", float: "number",
        bool: "boolean", list: "array", dict: "object",
    }

    def __init__(self) -> None:
        self._funcs: dict[str, Callable] = {}
        self._descs: dict[str, str] = {}

    # ① 装饰器:注册函数
    def tool(self, description: str):
        def deco(func):
            self._funcs[func.__name__] = func
            self._descs[func.__name__] = description
            return func
        return deco

    # ② 自动生成 schema 列表
    def to_schemas(self) -> list[dict]:
        out = []
        for name, func in self._funcs.items():
            sig = inspect.signature(func)
            properties, required = {}, []
            for pname, p in sig.parameters.items():
                properties[pname] = {
                    "type": self._TYPE_MAP.get(p.annotation, "string"),
                    "description": f"参数 {pname}",
                }
                if p.default is inspect.Parameter.empty:
                    required.append(pname)
            out.append({
                "type": "function",
                "function": {
                    "name": name,
                    "description": self._descs[name],
                    "parameters": {
                        "type": "object",
                        "properties": properties,
                        "required": required,
                        "additionalProperties": False,
                    },
                },
            })
        return out

    # ③ 按工具名调度
    def invoke(self, name: str, args: dict[str, Any]) -> str:
        if name not in self._funcs:
            return f"未知工具: {name}"
        try:
            return str(self._funcs[name](**args))
        except Exception as e:
            return f"工具 {name} 执行出错: {e}"

够你今天用了。Day5 我们会把它再扩展(异步执行 / 参数校验 / 工具集分组),但今天不需要

TIP

这份极简实现故意不默认打开 strict: true,因为它用"有默认值 = 非 required"表示可选参数,而 OpenAI strict 模式要求所有 properties 都列入 required,真正可选字段要用 nullable 类型表达。生产版 ToolRegistry 通常会专门生成 strict schema,并用 Pydantic/Zod/JSON Schema 做参数校验。

设计上的几个取舍

取舍 1:description 必填,不读 docstring

很多框架会去解析 docstring 自动当 description。我们今天故意让 description 写在装饰器里:

python
@registry.tool("查询指定城市的当前天气")    # ← 显式写
def get_weather(city: str) -> str:
    """获取天气。"""                       # ← 不读这个

为什么?因为 docstring 经常写得"对人类讲",而 LLM 看的 description 经常需要更面向调用决策("什么时候该用我")。强制显式写一份给 LLM 看,避免歧义。

取舍 2:所有工具返回 str

python
def calculator(expr: str) -> str:
    return str(eval(expr))    # ← 强转字符串

为什么?因为 tool 消息的 content 字段就是字符串。返回 dict / int 你最后还得 json.dumps。统一约定为 str,调用方少踩一个坑。

取舍 3:异常被捕获、转成字符串返回

python
def invoke(self, name, args):
    try:
        return str(self._funcs[name](**args))
    except Exception as e:
        return f"工具 {name} 执行出错: {e}"

为什么?因为工具失败不应该让整个 Agent 崩。把错误转成字符串塞回 messages,让模型自己看到错误,自己决定下一步(重试 / 换参数 / 放弃)。

这是 Coding Agent 健壮性的关键 design——你会在 Day5 工具集和 Day6 整合里反复看到。

一些可能的扩展(今天不做)

  • 参数 enum:city: Literal["北京", "上海"] → schema 自动生成 enum
  • 异步工具:async def 函数 → await registry.ainvoke(...)
  • 工具命名空间:@registry.tool("...", group="git")
  • pydantic 模型作为参数类型,自动生成嵌套 schema
  • 工具 guardrail:调用前检查权限,调用后过滤敏感输出
  • 工具结果截断:默认只返回高信号摘要,避免把大日志塞进上下文

这些不是今天必须的。Day5 / Day6 在真做 Coding Agent 时再加。

NOTE

如果你想接入 MCP 生态或使用 strict: true 模式(第 7 节提到),不需要推翻 ToolRegistry 的设计。MCP server 暴露的 tools/list 也是一组工具名、描述和 input schema;ToolRegistry 可以继续作为本地函数目录,再由适配层翻译成 OpenAI tools 或 MCP tools。

2026 年的新问题:工具太多怎么办?

当工具只有 3 个时,tools=registry.to_schemas() 很自然。但真实 Coding Agent 可能接几十个 MCP server、上百个工具:

问题后果常见解法
工具定义太多schema 占满上下文,变慢变贵按任务只加载相关工具
工具功能重叠模型不知道该选哪个命名空间 + 清晰 description
工具返回太大结果挤爆上下文分页、过滤、摘要、截断
工具权限不同误调用高风险动作白名单、审批、sandbox

所以生产里的 ToolRegistry 不只是"少写 JSON",还负责工具治理

  1. 哪些工具对当前任务可见
  2. 哪些工具需要用户确认
  3. 哪些工具结果要截断或脱敏
  4. 哪些工具调用要写入审计日志

这也是为什么 2026 年的 Agent 框架会强调 tool search、allowed tools、guardrails、tracing 和 evals。

动手试试

运行 demo_08_tool_registry.py

bash
cd labs/02-tools-and-agent-loop
python demo_08_tool_registry.py

它会:

  1. 注册 3 个工具
  2. 打印自动生成的 schemas——你能看到 inspect 推出来的类型对不对
  3. 跟 LLM 跑一次完整 function calling,让模型同时调三个工具

小结

概念一句话理解
ToolRegistry"装饰器 + 反射"做出来的工具注册表
@registry.tool(desc)一行注册一个工具
to_schemas()自动从函数签名生成 OpenAI schema
invoke(name, args)按名分发执行,异常自动捕获返回字符串
关键设计description 必填 / 工具返回 str / 异常不上抛
2026 扩展工具治理:按需加载 / 权限审批 / 结果截断 / 调用审计

下一节:注册表有了,怎么把它放进循环里跑起来——最小 Agent Loop 的 5 个核心点

Released under the MIT License.