18 KiB
Executable File
阶段一 · 小点 2:Python 高级编程之进阶语法
所属:阶段一 前置核心基础(必备打底) 定位:这里的语法不是"可选加分项",而是你读懂框架源码的钥匙。LangChain 的
@tool、tenacity 的@retry、asyncio 的流式生成——全都建立在这五样东西上。
精简大纲
- 装饰器:重试、日志埋点、权限校验、函数计时
- 生成器 / 迭代器:节省内存、流式返回、适配 SSE
- 上下文管理器:with 语法、资源自动释放
- 类型注解:TypedDict / Optional / Union / Literal
- 闭包:作用域、状态保存、回调底层原理
学习内容详情
1. 装饰器(@ 语法糖)
1.1 一张图看懂本质
graph LR
A[你的原始函数 func] -->|被装饰| B[装饰器 wrapper]
B --> C[自动包一层: 加日志/重试/计时]
C --> D[返回一个 新函数]
D --> E[下次调用 func 时=调新函数]
一句话:
@xxx就是func = xxx(func)。装饰器不动原函数代码,只在外面包一层"横切逻辑"。 为什么 Agent 里离不开它?tenacity 的@retry、LangChain 的@tool全是装饰器。看不懂装饰器就看不懂框架。
🔬 深度理解 · 装饰器(函数是一等对象):
- 本质:装饰器不是语法魔法,
@timer就是func = timer(func)的简写——函数本身是可被当参数传、可被返回的"一等对象",装饰器做的只是"用新函数替换旧名字"。- 机制:
timer接收func、返回wrapper;wrapper内部用*args/**kwargs收集所有参数再透传给func,从而在调用前后"埋点(计时 / 日志 / 重试)"而不改func源码。functools.wraps(func)会把func的__name__/__doc__复制给 wrapper,否则被装饰后函数名全变 wrapper,影响日志与调试。- 为什么重要:横切逻辑(鉴权 / 重试 / 日志)天然适合装饰器——一次声明全局生效,无需侵入每个业务函数。LangChain 的
@tool、tenacity 的@retry全建筑其上。- 易错点:若省略
functools.wraps,stacktrace 里清一色是wrapper,排查真函数极难;带参数的装饰器(@retry(max=3))本质是多套一层"返回装饰器的函数",第一遍能理解"有这层关系"即可。
import time
import functools
def timer(func):
"""计时装饰器:帮任何函数统计耗时,不改原函数一行代码。"""
@functools.wraps(func) # 关键!保留原函数的 __name__/docstring
def wrapper(*args, **kwargs): # *args 收所有位置参数,**kwargs 收所有关键字参数
start = time.perf_counter()
result = func(*args, **kwargs) # 调用原函数,把参数原样传进去
elapsed = time.perf_counter() - start
print(f"{func.__name__} 耗时 {elapsed*1000:.2f} ms")
return result # 把原函数的结果原样返回
return wrapper
# 用法一:@ 语法糖(等价于 process_data = timer(process_data))
@timer
def process_data(n: int) -> int:
# 这里假装做了点耗时的计算
time.sleep(0.05)
return n * 2
# 用法二:显式调用(二者完全等价,理解 @ 的真相)
# process_data = timer(process_data)
print(process_data(10)) # 输出: process_data 耗时约 50 ms,然后 20
带参数的装饰器(进阶):当装饰器本身需要参数(如
@retry(max_attempts=3))时,外面要多包一层函数。不理解可以先用着,能用是目标。
def retry(max_attempts: int = 3):
"""生产:给 LLM 调用加一个简易重试的装饰器。"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs) # 成功就直接返回
except Exception as e:
print(f"第 {attempt} 次失败: {e}")
if attempt == max_attempts: # 最后一次失败就抛出
raise
return wrapper
return decorator
@retry(max_attempts=3)
def call_llm(messages):
# 模拟一个偶尔超时的模型调用
import random
if random.random() < 0.5:
raise ConnectionError("网络抖动")
return "模型回答成功"
⏳ 短期可不深究:装饰器"多层包裹 + 带参数"的写法(
def retry(max_attempts)→def decorator(func)→def wrapper(...)这样套三层)第一遍只需看出"有这层包外层的关系",真需要时直接用框架现成的 tenacity / LangChain 装饰器,不必自己凭空搭。
2. 生成器 / 迭代器(流式输出关键)
2.1 yield 到底发生了什么
sequenceDiagram
participant C as 外层调用方
participant G as 生成器(带yield的函数)
C->>G: 第一次 next() / for 进来
G-->>C: 执行到第一个 yield,产出值并挂起
Note over C,G: 内存只保存当前状态,而非全部数据
C->>G: 再 next() 回到上次暂停处
G-->>C: 从暂停处继续,到下一个 yield 再产出
- 带
yield的函数是惰性求值:不一次性全部算出,而要一个才吐一个。内存只占当前元素。 - Agent 里的直接收益:模型分片输出、大文档分块处理、逐条流式返回给前端。
🔬 深度理解 · 生成器与惰性求值:
- 本质:普通函数是"急迫求值"——一次算完返回;生成器是"惰性求值"——每次
next()只算到下一个yield然后挂起,不把结果一次性全存内存。- 机制:带
yield的函数被调用时并不立即执行函数体,而是返回一个生成器对象;执行到yield时保存当前整个执行现场(局部变量、指令位置),next()再把它恢复并继续。所以"一次性耗尽"是必然的——它记录了已算到哪,没有"重头再算"。- 为什么重要:Agent 要处理超大文档、SSE 流式输出,若一次读全内存会 OOM;生成器把"逐个产生"与"逐个消费"解耦,让内存常驻 O(1),这也是流式返回前端的底层依赖。
- 易错点:生成器只能用一次(耗尽后要重新创建);
for x in gen结束后 gen 已空;别想在生成器里return返回值当结果返回,用yield from或迭代才对。
def read_chunks(path: str, size: int = 8192):
"""生成器:逐块读取文件,一块都不浪费内存。"""
with open(path, "r", encoding="utf-8") as f:
while True:
chunk = f.read(size)
if not chunk: # 读到末尾
break
yield chunk # 产出当前块,暂停;下次从这继续
# 用法:for 循环就是反复 next() 的过程
for i, block in enumerate(read_chunks("big.txt")):
print(f"第 {i} 块: 前10字符={block[:10]}")
# 可以在这把 block 分块喂给模型做 embedding
if i > 2: # 演示只要前 3 块
break
def stream_answer(text):
"""生成器:模拟模型分片输出,Agent 的 SSE 流式就长这样。"""
words = text.split(" ")
for w in words:
yield w + " " # 每次 yield 一个词,前端逐词打字机渲染
for piece in stream_answer("你好 我是 AI 助手 正在 流式 输出"):
print(piece, end="") # 每来一块打一个词,无换行
print()
生成器表达式:把方括号换成圆括号就是惰性的,如
(x*x for x in range(10)),多用于大数据量遍历。
⏳ 短期可不深究:生成器表达式的"惰性"与列表推导的"立即求值"差异第一遍只需get到方向即可,真遇到超大 range 遍历时才需要深入它省内存的细节,不用刻意背。
3. 上下文管理器(with)
3.1 原理图
graph TD
A[with obj as x:] --> B[进入: 自动调用 obj.__enter__]
B --> C[拿到 __enter__ 的返回值 x]
C --> D[执行 with 内的代码块]
D -->|正常结束| E[退出: 自动调用 __exit__ 释放资源]
D -->|中途抛异常| E2[退出: 照样调用 __exit__, 释放资源]
- 保证资源被释放,即使中途抛异常也一样。文件、数据库连接、HTTP 客户端都靠它自动关闭。
- 自定义场景用
@contextmanager装饰器最简洁。
from contextlib import contextmanager
@contextmanager
def open_db_conn(dsn: str):
# 进入 with 块时执行(拿资源)
conn = connect(dsn) # 假设有 connect 函数
print("数据库连接已建立")
try:
yield conn # with xx as c 里的 c 就是它
finally:
# 退出 with 块(无论成败)必执行(释放资源)
conn.close() # 保证连接一定被关,防泄漏
print("数据库连接已关闭")
# 用法
with open_db_conn("postgres://...") as db:
db.execute("SELECT 1") # 用连接干活,用完自动关
进阶:
__exit__可以返回 True 表示"吞掉这个异常"。但 Agent 里别乱吞(连同小点1的裸 except 一样),留因果链才好排查。
⏳ 短期可不深究:
__exit__返回 True"主动吞异常"属于偏冷门边缘能力,第一遍能说出"它有这选项、但生产别乱用"就够了,日常@contextmanager+finally已覆盖绝大多数释放资源的场景。
4. 类型注解
类型注解让你 / IDE / 静态检查工具(mypy)三者共用一套"数据形状"契约。注意:它只做提示,运行时默认不强制;要运行时必校验,请配合小点 4 的 pydantic。
from typing import TypedDict, Optional, Union, Literal
# TypedDict:给"形状已知的 dict"结构化提示
class ToolResult(TypedDict):
tool_name: str # 必填字段:工具名
output: str # 必填字段:工具输出
cost: Optional[float] # 可选字段:成本,可能为 None
def run_tool(tool: str) -> ToolResult:
# 返回字典时,IDE 会帮你检查 key 是否拼错
return {"tool_name": tool, "output": "搜到3条", "cost": 0.01}
# Literal:把所有合法取值写死,参数只准传这些
def load_config(env: Literal["dev", "test", "prod"]) -> dict:
# env 只准是 dev/test/prod,传其他 IDE 立刻标红
return {"env": env}
# Union / Optional:参数类型可以是多个
def merge(a: Union[int, str], b: Optional[int] = None) -> str:
# a 是 int 或 str;b 可为 None
return str(a) + (str(b) if b is not None else "")
和 Agent 的关系:LangGraph 的
State定义、pydantic 的模型字段,全以这套注解为语法基础。看懂它,后面阶段三的阶段状态机就顺了。
5. 闭包(Closure)
5.1 作用域捕获图
graph TD
A[外层函数 outer] -->|内部定义了变量 count| B[内层函数 inner]
B -->|inner 引用 outer 的 count| C[形成闭包]
C -->|返回 inner 后, count 存留在内存| D[每次调用 inner 都能改 count]
style C fill:#e6eeff
- 内层函数捕获外层变量并"随身携带",即使外层函数已经返回,这份私有状态依然存在。
- Agent 场景:为每个会话独立计数(尝试次数、轮次)、回调绑定底层原理。
🔬 深度理解 · 闭包(Closure):
- 本质:一个"绑定并随身携带外层自由变量"的内层函数。外层函数返回后,被捕获的变量并不会销毁,而是随内层函数继续存活。
- 机制:函数对象的
__closure__里存着被捕获变量的 cells;内层读外层变量可直接读;若要修改必须写nonlocal(声明"我改的是外层那个变量"),否则 Python 认为你在内层新建同名局部变量→触发UnboundLocalError。- 为什么重要:为每个会话/Action 维护独立私有状态(计数、轮次)而不污染全局,正是闭包典型用法;它也是装饰器、回调、事件驱动的底层原理。
- 易错点:闭包捕获的是变量"本身"(cell)而非"值快照"——若在循环里造闭包并期待它们分别记住每一次的
i,会因为都引用同一个 cell 而全部指向最终值;要修正可用默认参数绑定或工厂函数。
def make_counter():
"""闭包工厂:给每个会话生成一个独立的计数器。"""
count = 0 # 外层私有变量
def inc():
nonlocal count # 关键!声明"我要改外层变量",否则 UnboundLocalError
count += 1
return count
return inc # 返回内层函数,count 被它带走
sessionA_counter = make_counter() # 会话A的独立计数
sessionB_counter = make_counter() # 会话B的独立计数(与A互不影响)
print(sessionA_counter()) # 1
print(sessionA_counter()) # 2
print(sessionB_counter()) # 1 ← B 从自己的 0 开始,不受 A 影响
⚠️ 最常见的报错:内层函数直接
count += 1而不写nonlocal count,会报UnboundLocalError: local variable 'count' referenced before assignment。因为 Python 认为你在内层局部新建了一个同名变量。记住:要改外层就得nonlocal。
本节自检
- 能手写一个计时装饰器(含
functools.wraps),并解释@tool大致在做什么 - 能写一个 yield 生成器逐块读取大文件,并说清它为何省内存
- 会用
@contextmanager保证资源被释放,即使抛异常 - 能说出 TypedDict / Literal / Optional 各自解决什么问题
- 能解释闭包为何能"携带"私有状态,并知道何时要写
nonlocal
本节配套思考题(快速入门的检验)
- 去掉代码里的
@functools.wraps(func),观察被装饰函数的__name__变成了什么,为什么框架里几乎都保留它? next(gen)和for x in gen都怎么推进生成器?生成器"用一次就耗尽",你该如何理解?- 写一个闭包:让多次调用依次返回 0,1,1,2,3,5...(斐波那契),并说明它如何"记住"上一步状态。
本节常见面试题(深度解析)
针对本节核心知识的面试高频点,配合"本质+机制"式理解,能让你答得既有深度又有广度。
面试题 1:装饰器的本质是什么?为什么 @functools.wraps(func) 几乎不可或缺?
- 面试官想考察:是否真正看透
@只是语法糖、装饰器=函数一等对象的复用,而不是背模板。 - 专业作答(含深度):
- 本质:
@timer等价于func = timer(func)——函数是可传参、可返回的一等对象,装饰器用"新函数替换旧名字",不动原函数源码地注入横切逻辑。 - 机制:
timer(func)返回 wrapper;wrapper 用*args/**kwargs透传参数并在前后埋点;return result保证调用方拿到原结果。 - wraps 的必要性:不写
functools.wraps,被装饰函数的__name__/__doc__会变成 wrapper 的,导致日志、堆栈、文档全部失真,极难排查。 - Agent 联系:tenacity
@retry、LangChain@tool都是它,理解了装饰器才看得懂框架。
- 本质:
- 加分亮点 / 深度追问:可主动提"带参数装饰器 = 再包一层"的写法,以及装饰器执行时机发生在"定义时而非调用时",这两个点常被继续追问。
面试题 2:为什么生成器能省内存?"生成器用一次就耗尽"该如何理解?
- 面试官想考察:是否理解惰性求值与执行现场保存,而非只会用
yield。 - 专业作答(含深度):
- 省内存的本质:惰性求值,每次
next()只算到下一个yield,内存常驻 O(1),不像列表一次全装。 - 耗尽的原因:生成器保存"已执行到哪"的执行现场(局部变量+指令位置),单向向前推进,不能回头,故只能消费一次。
- 场景:大文档分块、SSE 流式推送、逐 token 渲染——都是"产生"与"消费"解耦的体现。
- 权衡:省内存的代价是"不可随机访问、只能顺序、用过即废";需要多次遍历就重新创建。
- 省内存的本质:惰性求值,每次
- 加分亮点 / 深度追问:可提
yield from、send/close等进阶能力,并说明"迭代器协议(iter/next)"是生成器背后的统一抽象。
面试题 3:闭包里为什么有时要用 nonlocal?不写会怎样?
- 面试官想考察:是否真正理解 LEGB 作用域规则与闭包捕获机制,而不是背 UnboundLocalError。
- 专业作答(含深度):
- 捕获机制:内层引用外层变量会被包进
__closure__的 cell,成为"自由变量",外部函数都返回了它还能存活。 - nonlocal 的必要性:读外层变量可直接读;但一旦在内存函数里对它做赋值(
count += 1),Python 默认把它当作"新建局部变量",与 cell 中的外层变量冲突→UnboundLocalError。写nonlocal count明确声明"我改的就是那层变量",才允许修改被捕获的 cell。 - 为何不用 global:global 改的是模块级全局,闭包强调的是"随函数实例独立携带的私有状态"——用 nonlocal 才能让每个会话/计数各自独立。
- 联系:装饰器的状态保持、回调绑定、会话计数器、流式游标,都可借闭包实现。
- 捕获机制:内层引用外层变量会被包进
- 加分亮点 / 深度追问:可提"闭包捕获的是引用而非快照"这个陷阱——循环中造闭包共享同一 cell 会全部指向最终值,可引导讨论默认参数绑定等解法。