这是「装饰器」系列的 上篇:只讲装饰器是什么、为什么成立、内部怎么运转。
TL;DR
- 它是 :一个"接收函数、返回新函数"的函数;对被装饰函数
f来说,@decorator等价于f = decorator(f)。- 解决了:重试、缓存、计时、日志这类和业务无关却到处都要写的"横切逻辑",只写一次、到处套用。
- 核心机制 :1. 不带参数用两层 ,带配置用三层 ;2. wrapper 靠闭包 记住原函数和配置,还能在缓存命中时短路 (不调用原函数);3. @wraps 把原函数的元数据(名字、文档、注解)复制到 wrapper 上,并留下 wrapped 追溯链。
- 教学 vs 工程 :本文手写的缓存是教学版(无界、仅进程内有效);生产应优先用
functools.lru_cache/functools.cache,并补齐线程安全、过期、容量。- 适合谁:想彻底搞懂 Python 装饰器的人。
配套可跑示例:所有示例均在 Python 3.14 实跑;标注"示意"的耗时 / 内存地址会随机器浮动。
前言
我是一名自学 AI Agent 开发的学习者。这篇文章不是搬运教程,而是我啃装饰器时的一份思考笔记 :我不想只记住"装饰器就是套娃",而是从"我到底想解决什么问题"出发,一步步推到语法糖、闭包、@wraps,让我能达到我能使用装饰器实现我想要达到的效果。
但我很清楚,自学最容易出现"局部自洽、整体有坑"。所以我把自己的理解过程、踩过的错误直觉、仍然存疑的点都直接写在文中。如果哪里理解偏了、有更地道的工程写法,恳请路过的大佬指正,当然也希望我的理解能对正在学习装饰器的人有帮助,希望大家能积极评论。
一、先问个问题
你在写 Agent,一个节点要调 LLM。真实环境里 LLM 会超时 、会返回 429 限流、偶尔格式还不对。于是你写了个重试循环。
然后你发现:除了重试,你还想给每个节点计时 、把重复的 embedding / 响应缓存 、把每次调用记日志。
问题来了------这些动作和"节点到底干什么"毫无关系,却要在十几个节点里各抄一遍。能不能只写一次,然后像贴标签一样贴到每个函数上?
这就是装饰器存在的理由。
二、没有装饰器会怎样
2.1 困境:横切逻辑污染业务代码
"重试、计时、日志、缓存"在术语上叫横切关注点(cross-cutting concerns)------它们横切在所有业务函数上。如果手写:
python
import time
def call_llm(prompt):
start = time.time() # 计时
for attempt in range(3): # 重试
try:
result = do_request(prompt) # do_request:示意的底层请求函数
break
except Exception:
continue
log(...) # log:示意的日志函数
return result
每个函数都塞这么一坨,业务逻辑被淹没;想把重试从 3 次改成 5 次,得改十几处。这违反开闭原则 :对扩展开放、对修改关闭。
当有一些重复的业务逻辑想要在对应的函数上实现,我认为可以考虑用装饰器解决(缓存,重试,计数,计时等),当然这只是业务选型的一环,没有说一定要用装饰器,只是会了多一个参考点。
2.2 关键前提:函数是"一等公民"
要造上面那台"增强机器",我先确认了一件事:我能不能把一个函数,像普通数据一样交给另一段代码?
这里有个我以前混淆过的点。Python 常说"一切皆对象",但"是对象"还不够,我真正需要的是函数成为一等公民(first-class citizen)------它能和数字、字符串一样享有完整"权利":
- 赋值给变量;
- 当作参数传进另一个函数;
- 当作另一个函数的返回值;
- 放进列表、字典等容器。
我特意把两个说法分开:
- 一切皆对象讲"形态"------运行时几乎所有东西都是对象,有 id、有属性、有地址;
- 一等公民讲"权利"------能不能被自由地传递、返回;
- 对装饰器真正起作用的是后者。
反例帮我记住:C 语言里函数不是完整一等公民------不能在运行时创建、不能当返回值、没有闭包,只能靠函数指针做有限传递,所以写不出 Python 这种装饰器。
确认这个前提成立,才有后面那个自然的想法:写一个函数,把原函数喂给它,让它返回一个"增强过的新函数"。 这个"增强函数的函数",就是装饰器。
我的理解 :装饰器能成立,靠的不是"函数是对象",而是"函数能被传进去、再被吐出来"。
延伸问题:除了函数,Python 里还有哪些东西看起来是一等公民、其实有限制?我隐约觉得类方法 / 绑定方法(bound method)这里有微妙差别,但还没完全理清,求指点。
三、核心机制
3.1 最小本质:装饰器 = 函数替换语法糖
python
# my_decorator:任意"接收函数、返回函数"的可调用对象;这里只演示等价关系
@my_decorator
def say_hello():
print("原函数逻辑")
# 100% 等价于:
def say_hello():
print("原函数逻辑")
say_hello = my_decorator(say_hello) # 这一行赋值是 @ 自动做的
我一开始觉得 @ 很神秘,把它拆开后发现没有任何魔法,只做两步:调用装饰器 → 把返回值重新绑到函数名上。
3.2 装饰器结构1:不带参数:两层结构
两层结构:decorator 接收原函数 → 创建并返回 wrapper。
示例1:计时装饰器(实验demo)
python
import time
from functools import wraps
def timing(func): # 外层:收原函数
@wraps(func)
def wrapper(*args, **kwargs): # 内层:真正替换原函数
start = time.perf_counter() # 【前】
result = func(*args, **kwargs) # 【中】调用闭包里的原函数
print(f"[timing] {func.__name__} 耗时 {(time.perf_counter()-start)*1000:.2f} ms") # 【后】
return result
return wrapper
@timing
def llm_call(prompt):
time.sleep(0.05)
return f"LLM 回复:{prompt}"
if __name__ == "__main__":
print(llm_call("你好"))
【已实测】 运行 llm_call("你好")(耗时为示意,随机器浮动):
text
# 示意输出,实际耗时随机器浮动
[timing] llm_call 耗时 50.22 ms
LLM 回复:你好
注意 wrapper 的结构是 前 / 中(调原函数)/ 后 。原函数不是在 wrapper "之后"跑,而是在 wrapper 体内被调用------所以你能在它执行之后拿到结果、算耗时。
我自己有过的问题:我最初以为"装饰器逻辑先跑完,才轮到原函数",后来才发现我把模型想反了,即原函数不是在 wrapper 之后接力跑,而是 wrapper 在自己体内调用它,所以我能在调用前看一次表、调用后再看一次表,两个时间点一减就是耗时。
⚠️ 关键注意点 :wrapper 里调用原函数时一定要写
return func(*args, **kwargs)。 如果只写func(...)不 return,原函数的返回值会被"吃掉",调用方拿到None。
3.3 装饰器结构2:带配置:三层结构
装饰器本身需要参数时,得在外面再套一层 ,变成三层嵌套:
示例2:重试装饰器(实验demo)
python
import time
from functools import wraps
def retry(max_attempts=3, backoff=0.1):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_exc = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as exc:
last_exc = exc
print(f"[retry] {func.__name__} 第 {attempt} 次失败:{exc}")
if attempt < max_attempts:
wait = backoff * 2 ** (attempt - 1)
print(f" 等待 {wait:.2f}s 后重试")
time.sleep(wait)
raise RuntimeError("重试耗尽") from last_exc
return wrapper
return decorator
_state = {"n": 0}
@retry(max_attempts=3, backoff=0.05)
def flaky_task():
"""前两次失败、第三次成功的不稳定任务。"""
_state["n"] += 1
if _state["n"] < 3:
raise TimeoutError("LLM 超时")
return "任务成功"
if __name__ == "__main__":
print(flaky_task())
三层各自的职责:
| 层 | 接收什么 | 干什么 |
|---|---|---|
| 工厂(最外) | 装饰器配置 | 通过闭包把配置传给内层,返回 decorator |
| decorator(中) | 被装饰的原函数 | 定义 wrapper,把配置和原函数一起闭包进去,返回 wrapper |
| wrapper(内) | 调用时的参数 | 写附加逻辑、调用原函数、返回结果 |
【已实测】 一个前两次超时、第三次成功的任务,@retry(max_attempts=3, backoff=0.05):
text
[retry] flaky_task 第 1 次失败:LLM 超时
等待 0.05s 后重试
[retry] flaky_task 第 2 次失败:LLM 超时
等待 0.10s 后重试
任务成功
3.4 闭包:wrapper 靠什么"记住"原函数和配置
我的理解 :内层函数不管外层函数的生命周期如何------哪怕外层已经执行完、返回了------内层都能直接拿到外层函数的参数。原因是这些被内层引用的外层变量(叫自由变量 )没有随外层栈帧一起销毁,而是被装进一个单元格(cell)由内层持有,这就是闭包 ,记录在 wrapper.__closure__ 里。
放到装饰器里,wrapper 正是靠闭包拿到它需要的两样东西:
- 三层结构:从最外层工厂拿配置形参(重试次数、退避),从中层 decorator 拿原函数;
- 两层结构:直接从 decorator 拿原函数。
【已实测】打印闭包内容(真实输出含 cell / 对象内存地址、每次不同,这里做示意):
text
# 示意输出:真实打印形如 <cell at 0x...: function object at 0x...>,地址随进程变化
两层 wrapper 的闭包:[real 函数对象] # 只有原函数
三层 wrapper 的闭包:[real 函数对象, 3] # 原函数 + 配置
这就是为什么 wrapper 在原函数定义结束之后依然能调用它------原函数被闭包留住了。
闭包的两个细节,我特意理清过:
-
是引用,不是复制出新对象:外层那个变量改了,内层看到的也跟着改------闭包绑定的是变量本身,不是当时的值(这也是循环里创建闭包会拿到"最终值"而不是"当时值"的原因)。
-
内层若直接给这个变量"赋值",它会被判定为内层自己的局部变量 ,再去读就抛
UnboundLocalError,并不会改到外层。产生原因是一条编译期 就定好的规则:Python 在函数定义时就决定哪些名字是局部变量------只要函数体里存在对某个变量的赋值,这个变量就被当成局部变量,不管你在那行赋值之前有没有读过它。 所以
count += 1(等价count = count + 1)里的 count 被当成局部变量,右边读它时还没赋值,就报错了。要让内层真正改外层变量,需要显式写
nonlocal 变量名;如果只是改 list / dict 这类可变对象的内容(append、改键),因为没有重新绑定变量,则不需要 nonlocal。
3.5 wrapper 还能"短路":不调用原函数
前 / 中 / 后之外,wrapper 还有一个能力:直接返回,根本不调用原函数。缓存命中、权限拒绝、熔断时都用得到。
python
from functools import wraps
def memoize(func):
store = {}
@wraps(func)
def wrapper(*args, **kwargs):
# 位置参数 + 排序后的关键字参数,拼成可哈希 key,避免 kwargs 顺序 / 冲突
key = (args, tuple(sorted(kwargs.items())))
if key in store: # 命中 -> 短路,不调用原函数
print(f"[cache] 命中 {args},直接返回,不调用 {func.__name__}")
return store[key]
print(f"[cache] 未命中 {args},调用 {func.__name__} 并缓存")
# 打印只展示位置参数,实际 key 里包含排序后的 kwargs
result = func(*args, **kwargs) # 未命中才调用原函数
store[key] = result
return result
return wrapper
@memoize
def embed(text):
return f"embedding({text})"
if __name__ == "__main__":
embed("hello") # 未命中
embed("hello") # 命中
embed("world") # 未命中
【已实测】:
text
[cache] 未命中 ('hello',),调用 embed 并缓存
[cache] 命中 ('hello',),直接返回,不调用 embed
[cache] 未命中 ('world',),调用 embed 并缓存
教学版 vs 工程版的差距(别拿教学版直接上生产):
- 上面是教学版:用 dict 做无界缓存,只在进程内有效、重启即失;
- 标准库已有现成实现:
functools.lru_cache做有界 缓存(可限容量),functools.cache做无界缓存(3.9+); - 参数若不可哈希(如 list、dict),上面的 key 会直接报错,需要先把参数序列化成字符串(如 JSON)再当 key;
- 真上生产还要补齐:线程安全、缓存过期(TTL)、最大容量淘汰------这些正是我实战篇与"依赖抽象"专题要解决的问题。
3.6 @wraps:让 wrapper "看起来像"原函数
为什么需要它
我学装饰器时的切入点其实是好奇:明明调用中间隔了一个函数,为什么调用方完全"没感觉"?搞懂函数替换后,我顺着这个问题注意到一个细节------wrapper 毕竟是手动新建的独立函数对象,它和原函数是两个对象,名字、文档字符串默认都是 wrapper 自己的。也就是说,不处理的话,"透明"只做到了行为层,身份层并不透明。为了确认这一点,我把 3.2 的 timing 内部那行 @wraps 暂时去掉,再跑一遍:
python
# 沿用 3.2 定义的 timing,仅去掉它内部的 @wraps
@timing
def llm_call(prompt):
"""这是文档字符串"""
...
print(llm_call.__name__) # 得到 'wrapper',而不是 'llm_call'
print(llm_call.__doc__) # 得到 None
所以 wraps 并不是"让装饰器能跑"------不写它,装饰器照样工作;它负责把"透明"补完整。
更准确地说:
@wraps 补的是元数据透明 ,不是对象透明 。它让 wrapper 在名字、文档、注解、签名展示上像原函数,并用 __wrapped__ 留下追溯链;但 wrapper 仍是独立对象,执行时调用的是闭包里的 func。
它到底做了什么(update_wrapper 源码)
【已实测,Python 3.14】默认要复制的元数据:
python
WRAPPER_ASSIGNMENTS = (
'__module__', # 所属模块
'__name__', # 函数名
'__qualname__', # 完整限定名(类里的方法带类名)
'__doc__', # 文档字符串
'__annotations__', # 类型注解
'__type_params__', # 泛型类型参数(3.12+ 才有,见参考资料 PEP 695)
)
WRAPPER_UPDATES = ('__dict__',)
update_wrapper 的伪代码:
python
def update_wrapper(wrapper, wrapped):
for attr in WRAPPER_ASSIGNMENTS:
try:
value = getattr(wrapped, attr)
except AttributeError:
pass # 原函数没这属性就跳过
else:
setattr(wrapper, attr, value)
for attr in WRAPPER_UPDATES:
getattr(wrapper, attr).update(getattr(wrapped, attr, {}))
wrapper.__wrapped__ = wrapped
return wrapper
代码理解:
1.遍历 WRAPPER_ASSIGNMENTS,用 setattr 把原函数的标量元数据逐个覆盖到 wrapper 上; 2.遍历 WRAPPER_UPDATES,用 wrapper.__dict__.update(wrapped.__dict__) 做浅合并;
3.最后单独设 wrapper.wrapped = wrapped。
举例说明 dict 的浅合并(不是递归合并):
text
wrapper 原有:keep=..., shared={'nested':'wrapper值'}
原函数带来: added=..., shared={'nested':'原函数值'}
update 后: keep 保留 / added 新增 / shared 整个变成 {'nested':'原函数值'}(不递归)
3.7 装饰器在什么时候执行:三个时点
搞清楚"对象什么时候被创建、名字什么时候被替换",能避免很多困惑。我按时间顺序拆成三个时点:
text
阶段1:模块加载执行期,def 逐行创建函数对象(此时装饰器尚未触发)
只创建最外层的工厂函数,比如@retry的最外层是def retry(...)(两层结构则最外层是 decorator)
函数体内部的 decorator / wrapper 一律不创建
阶段2:装饰器执行时(@ 语法糖触发,只执行一次)
以 @retry(max_attempts=3) 为例:
1. 求值 retry(max_attempts=3) -> 创建 decorator(闭包持有配置)
2. decorator(原函数) -> 创建 wrapper
wrapper 上的 @wraps(原函数) 立刻执行 update_wrapper:
拷元数据(setattr)/ __dict__ 浅合并 / wrapper.__wrapped__ = 原函数
3. 返回 wrapper 这个函数对象
两层结构:直接 decorator(原函数) -> 创建 wrapper -> 返回
【原函数体不执行,只是生成 wrapper】
阶段3:外层 @ 收尾
原函数名 = wrapper(重绑),透明替换原函数名
------ 整个过程原函数体一次都没执行
之后每次 原函数():才真正跑 wrapper
记忆规则:def 写在谁的函数体里,就只有谁被调用时,这个 def 才执行、才创建对象。
四、误解与适用边界
误解 1:wrapper 是靠 wrapped 来执行原函数的
这是我之前学习搞错的一点,后来复习的时候发现矛盾点纠正了:wrapper 执行原函数靠的是闭包里的 func,运行时根本不读 wrapped。 wrapped 只是挂在明面上的标签,用途是给外部(人、inspect、测试)追溯、解包,拿到没被装饰过的原函数。
【已实测 · 诱饵实验】故意让 wrapped 指向另一个函数:
python
def dec(func):
def wrapper(*a, **kw):
return func(*a, **kw) # 调用闭包 func
def decoy():
return "我是诱饵"
wrapper.__wrapped__ = decoy # 标签故意挂到别处
return wrapper
def real():
return "我是真正被执行的原函数"
f = dec(real)
结果:
text
调用 f() -> 我是真正被执行的原函数 # 走闭包
调用 f.__wrapped__() -> 我是诱饵 # 读标签
两者可以指向完全不同的函数,证明执行只认闭包。正常用 wraps 时 wrapped 恰好也指向原函数,所以容易误以为"执行的是它"。
误解 2:把"原函数名 = wrapper"和"wrapper.wrapped = 原函数"当成同一个赋值
我第一次学这个概念以为这两句是一回事,后来复习才分清:它们是方向相反、位置不同的两个赋值。
| 赋值 | 谁做的 | 方向 | 含义 |
|---|---|---|---|
| 原函数名 = wrapper | 外层 @ | 名字 f -> wrapper | 让外部名字改指向 wrapper |
| wrapper.wrapped = 原函数 | 内层 @wraps | wrapper.wrapped -> 原函数 | 在 wrapper 身上存好原函数,方便追溯 |
- 外层 @ 只负责重绑名字,它不把原函数往 wrapper 身上存;
- 但要分清 wrapper "持有原函数"的两条路径:执行时能调用它,靠的是闭包里的 func------这跟 wraps 无关,不写 wraps 也照样执行 ;
__wrapped__则是 wraps 额外在明面上挂的一个引用,只给外部追溯用; - 所以不写 wraps,闭包里照样留着 func、行为正常,只是少了
__wrapped__这个标签,名字 / doc 也还是 wrapper 的。
适用边界
- 装饰器解决的是横切逻辑复用,不要用它包装复杂的业务分支判断。
- 一个函数叠太多层装饰器会难调试;每层职责要单一,必要时用 wrapped 解包。
- 装饰器是实现细节:它让"装饰器的增删改"和"调用方代码"完全解耦,调用方不该感知到它的存在------这正是"透明替换"的意义。
五、参考资料
- Python 官方文档 functools.wraps / update_wrapper:docs.python.org/3/library/f...
- PEP 695(类型参数语法,3.12+):peps.python.org/pep-0695/
- 完整可跑示例(正文 timing / retry / cache / 诱饵实验,含零依赖 self_contained_cache.py):blog-demos/03-decorator/
- 运行环境:Windows,Python 3.14;LLM / embedding 均为本地模拟,未发起真实网络请求,也无性能基准数据。
- 👉 实战见下篇:《Agent 实战:用装饰器做结果缓存与重试容错》