从一个疑问开始
用 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就不再是魔法