返回首页

Python 装饰器在工具注册里的真实用途

为什么 @tool 能拿到函数的名字、注释和类型签名?从闭包讲到装饰器,用 30 行还原一个自己的 @tool。

从一个疑问开始

用 LangChain 的 @tool 时,我一直有个疑问:模型是怎么知道 我的函数叫 get_weather、参数叫 city、描述是那句 docstring 的?

明明我只写了一个普通函数加一个装饰器,什么配置都没传:

@tool
        def get_weather(city: str) -> str:
            """查询指定城市的当前天气。"""
            return f"{city}:晴,18℃"
        

答案在装饰器里。@tool 做的事,是在函数定义完成后立刻读取它的 元信息(名字、docstring、类型注解),打包成一个对象。

要理解它,得先理解两件事:函数也是对象,以及闭包。

基础一:函数是对象

Python 里函数是一等公民,可以赋值、可以当参数传、可以当返回值:

def greet(name):
            """打个招呼。"""
            return f"你好,{name}"

        f = greet                    # 不加括号,f 就是那个函数对象本身
        print(f("小明"))             # 你好,小明

        print(greet.__name__)        # greet
        print(greet.__doc__)         # 打个招呼。
        

注意 __name__ 和 __doc__——这两个属性 就是 @tool 需要的东西。

基础二:闭包

闭包是"函数 + 它记住的外部变量"。看个例子:

def make_counter():
            count = 0                    # 外层变量

            def inner():
                nonlocal count           # 声明要修改外层变量
                count += 1
                return count

            return inner                 # 返回内层函数(还没执行)

        c = make_counter()
        print(c(), c(), c())             # 1 2 3
        

关键点:inner 被返回时,它记住了 count 这个变量。 装饰器就是靠这个机制,把"原始函数"存在闭包里。

写一个最朴素的装饰器

def my_decorator(func):
            def wrapper(*args, **kwargs):
                print(f"[调用] {func.__name__}")
                result = func(*args, **kwargs)
                print(f"[返回] {result}")
                return result
            return wrapper


        @my_decorator
        def add(a, b):
            return a + b

        add(1, 2)
        # [调用] add
        # [返回] 3
        

@my_decorator 等价于 add = my_decorator(add)。 add 这个名字现在指向 wrapper, 而真正的原函数被闭包捕获了。

关键:functools.wraps

上面有个问题——add.__name__ 现在变成了 "wrapper", docstring 也丢了:

print(add.__name__)   # wrapper  ← 原本应该是 add
        print(add.__doc__)    # None     ← 原本应该是函数的说明
        

这正好解释了为什么 @tool 必须保留原函数的元信息—— 如果名字和 docstring 丢了,模型就什么都不知道了。 解决办法是 functools.wraps:

import functools

        def my_decorator(func):
            @functools.wraps(func)          # 把 func 的元信息复制到 wrapper 上
            def wrapper(*args, **kwargs):
                print(f"[调用] {func.__name__}")
                return func(*args, **kwargs)
            return wrapper
        

加上之后,add.__name__ 恢复成 "add",docstring 也回来了。

还原一个自己的 @tool

现在可以动手了。目标:读函数元信息,生成工具描述对象。

import functools
        import inspect
        import json

        # 类型注解 → JSON Schema 类型的映射
        TYPE_MAP = {str: "string", int: "integer", float: "number", bool: "boolean"}


        class Tool:
            def __init__(self, func):
                self.func = func
                self.name = func.__name__
                self.description = (func.__doc__ or "").strip()
                self.parameters = self._build_schema()

            def _build_schema(self):
                sig = inspect.signature(self.func)
                props, required = {}, []

                for pname, param in sig.parameters.items():
                    ann = param.annotation
                    props[pname] = {
                        "type": TYPE_MAP.get(ann, "string"),
                        "description": pname,
                    }
                    if param.default is inspect.Parameter.empty:
                        required.append(pname)

                return {"type": "object", "properties": props, "required": required}

            def to_schema(self):
                """转成 OpenAI 兼容的工具定义。"""
                return {
                    "type": "function",
                    "function": {
                        "name": self.name,
                        "description": self.description,
                        "parameters": self.parameters,
                    },
                }

            def __call__(self, **kwargs):
                return self.func(**kwargs)


        def tool(func):
            """把普通函数包装成 Tool 对象。"""
            return Tool(func)
        

用起来和 LangChain 一模一样:

@tool
        def get_weather(city: str, days: int = 1) -> str:
            """查询指定城市的天气。

            Args:
                city: 城市名称
                days: 查询未来几天,默认 1
            """
            return f"{city} 未来 {days} 天:晴"


        print(json.dumps(get_weather.to_schema(), ensure_ascii=False, indent=2))
        

输出:

{
          "type": "function",
          "function": {
            "name": "get_weather",
            "description": "查询指定城市的天气。\n\n    Args:\n        city: 城市名称\n        days: 查询未来几天,默认 1",
            "parameters": {
              "type": "object",
              "properties": {
                "city": {"type": "string", "description": "city"},
                "days": {"type": "integer", "description": "days"}
              },
              "required": ["city"]
            }
          }
        }
        

看 required 里只有 city—— 因为 days 有默认值,被正确识别成了可选参数。 这就是 inspect.signature 的功劳。

三个实际收获

1. 类型注解不是装饰,是接口契约

我原来觉得 city: str 这种写法纯属多余。现在明白了: 它会被转成 JSON Schema 发给模型。 写 city: str,模型就知道该填字符串;不写,就只能靠猜。

2. docstring 的格式会影响效果

注意上面输出里的 \n 和缩进——docstring 是原样发出去的。 用 Google 风格的 Args: 段落,模型更容易理解参数含义。

3. 装饰器可以同时做多件事

真实框架里的 @tool 还会做参数校验、异步支持、错误处理。 但核心机制就是上面这 40 行:闭包保存原函数,元信息生成 schema。

小结

  • 函数是对象,__name__ / __doc__ 可以被读取
  • 装饰器靠闭包保存原函数,靠 functools.wraps 保留元信息
  • inspect.signature 能拿到参数名、类型注解和默认值
  • 类型注解会变成 JSON Schema,直接决定模型怎么填参数
  • 看懂了装饰器,@tool 就不再是魔法