306 lines
18 KiB
Markdown
Executable File
306 lines
18 KiB
Markdown
Executable File
# 阶段一 · 小点 2:Python 高级编程之进阶语法
|
||
|
||
> 所属:阶段一 前置核心基础(必备打底)
|
||
> 定位:这里的语法不是"可选加分项",而是你读懂框架源码的钥匙。LangChain 的 `@tool`、tenacity 的 `@retry`、asyncio 的流式生成——全都建立在这五样东西上。
|
||
|
||
## 精简大纲
|
||
|
||
1. 装饰器:重试、日志埋点、权限校验、函数计时
|
||
2. 生成器 / 迭代器:节省内存、流式返回、适配 SSE
|
||
3. 上下文管理器:with 语法、资源自动释放
|
||
4. 类型注解:TypedDict / Optional / Union / Literal
|
||
5. 闭包:作用域、状态保存、回调底层原理
|
||
|
||
## 学习内容详情
|
||
|
||
### 1. 装饰器(@ 语法糖)
|
||
|
||
#### 1.1 一张图看懂本质
|
||
|
||
```mermaid
|
||
graph LR
|
||
A[你的原始函数 func] -->|被装饰| B[装饰器 wrapper]
|
||
B --> C[自动包一层: 加日志/重试/计时]
|
||
C --> D[返回一个 新函数]
|
||
D --> E[下次调用 func 时=调新函数]
|
||
```
|
||
|
||
> 一句话:`@xxx` 就是 `func = xxx(func)`。装饰器不动原函数代码,只在外面包一层"横切逻辑"。
|
||
> 为什么 Agent 里离不开它?tenacity 的 `@retry`、LangChain 的 `@tool` 全是装饰器。**看不懂装饰器就看不懂框架**。
|
||
|
||
> 🔬 **深度理解 · 装饰器(函数是一等对象)**:
|
||
> 1. **本质**:装饰器不是语法魔法,`@timer` 就是 `func = timer(func)` 的简写——函数本身是可被当参数传、可被返回的"一等对象",装饰器做的只是"用新函数替换旧名字"。
|
||
> 2. **机制**:`timer` 接收 `func`、返回 `wrapper`;`wrapper` 内部用 `*args/**kwargs` 收集所有参数再透传给 `func`,从而在调用前后"埋点(计时 / 日志 / 重试)"而不改 `func` 源码。`functools.wraps(func)` 会把 `func` 的 `__name__/__doc__` 复制给 wrapper,否则被装饰后函数名全变 wrapper,影响日志与调试。
|
||
> 3. **为什么重要**:横切逻辑(鉴权 / 重试 / 日志)天然适合装饰器——一次声明全局生效,无需侵入每个业务函数。LangChain 的 `@tool`、tenacity 的 `@retry` 全建筑其上。
|
||
> 4. **易错点**:若省略 `functools.wraps`,stacktrace 里清一色是 `wrapper`,排查真函数极难;带参数的装饰器(`@retry(max=3)`)本质是多套一层"返回装饰器的函数",第一遍能理解"有这层关系"即可。
|
||
|
||
```python
|
||
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)`)时,外面要多包一层函数。不理解可以先用着,能用是目标。
|
||
|
||
```python
|
||
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 到底发生了什么
|
||
|
||
```mermaid
|
||
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 里的直接收益**:模型分片输出、大文档分块处理、逐条流式返回给前端。
|
||
|
||
> 🔬 **深度理解 · 生成器与惰性求值**:
|
||
> 1. **本质**:普通函数是"急迫求值"——一次算完返回;生成器是"惰性求值"——每次 `next()` 只算到下一个 `yield` 然后挂起,不把结果一次性全存内存。
|
||
> 2. **机制**:带 `yield` 的函数被调用时并不立即执行函数体,而是返回一个生成器对象;执行到 `yield` 时保存当前整个执行现场(局部变量、指令位置),`next()` 再把它恢复并继续。所以"一次性耗尽"是必然的——它记录了已算到哪,没有"重头再算"。
|
||
> 3. **为什么重要**:Agent 要处理超大文档、SSE 流式输出,若一次读全内存会 OOM;生成器把"逐个产生"与"逐个消费"解耦,让内存常驻 O(1),这也是流式返回前端的底层依赖。
|
||
> 4. **易错点**:生成器只能用一次(耗尽后要重新创建);`for x in gen` 结束后 gen 已空;别想在生成器里 `return` 返回值当结果返回,用 `yield from` 或迭代才对。
|
||
|
||
```python
|
||
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
|
||
```
|
||
|
||
```python
|
||
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 原理图
|
||
|
||
```mermaid
|
||
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` 装饰器最简洁。
|
||
|
||
```python
|
||
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。
|
||
|
||
```python
|
||
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 作用域捕获图
|
||
|
||
```mermaid
|
||
graph TD
|
||
A[外层函数 outer] -->|内部定义了变量 count| B[内层函数 inner]
|
||
B -->|inner 引用 outer 的 count| C[形成闭包]
|
||
C -->|返回 inner 后, count 存留在内存| D[每次调用 inner 都能改 count]
|
||
style C fill:#e6eeff
|
||
```
|
||
|
||
- 内层函数捕获外层变量并"随身携带",即使外层函数已经返回,这份私有状态依然存在。
|
||
- **Agent 场景**:为每个会话独立计数(尝试次数、轮次)、回调绑定底层原理。
|
||
|
||
> 🔬 **深度理解 · 闭包(Closure)**:
|
||
> 1. **本质**:一个"绑定并随身携带外层自由变量"的内层函数。外层函数返回后,被捕获的变量并不会销毁,而是随内层函数继续存活。
|
||
> 2. **机制**:函数对象的 `__closure__` 里存着被捕获变量的 cells;内层读外层变量可直接读;若要**修改**必须写 `nonlocal`(声明"我改的是外层那个变量"),否则 Python 认为你在内层新建同名局部变量→触发 `UnboundLocalError`。
|
||
> 3. **为什么重要**:为每个会话/Action 维护独立私有状态(计数、轮次)而不污染全局,正是闭包典型用法;它也是装饰器、回调、事件驱动的底层原理。
|
||
> 4. **易错点**:闭包捕获的是变量"本身"(cell)而非"值快照"——若在循环里造闭包并期待它们分别记住每一次的 `i`,会因为都引用同一个 cell 而全部指向最终值;要修正可用默认参数绑定或工厂函数。
|
||
|
||
```python
|
||
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`
|
||
|
||
## 本节配套思考题(快速入门的检验)
|
||
|
||
1. 去掉代码里的 `@functools.wraps(func)`,观察被装饰函数的 `__name__` 变成了什么,为什么框架里几乎都保留它?
|
||
2. `next(gen)` 和 `for x in gen` 都怎么推进生成器?生成器"用一次就耗尽",你该如何理解?
|
||
3. 写一个闭包:让多次调用依次返回 0,1,1,2,3,5...(斐波那契),并说明它如何"记住"上一步状态。
|
||
|
||
## 本节常见面试题(深度解析)
|
||
|
||
> 针对本节核心知识的面试高频点,配合"本质+机制"式理解,能让你答得既有深度又有广度。
|
||
|
||
### 面试题 1:装饰器的本质是什么?为什么 `@functools.wraps(func)` 几乎不可或缺?
|
||
- **面试官想考察**:是否真正看透 `@` 只是语法糖、装饰器=函数一等对象的复用,而不是背模板。
|
||
- **专业作答(含深度)**:
|
||
1. **本质**:`@timer` 等价于 `func = timer(func)`——函数是可传参、可返回的一等对象,装饰器用"新函数替换旧名字",不动原函数源码地注入横切逻辑。
|
||
2. **机制**:`timer(func)` 返回 wrapper;wrapper 用 `*args/**kwargs` 透传参数并在前后埋点;`return result` 保证调用方拿到原结果。
|
||
3. **wraps 的必要性**:不写 `functools.wraps`,被装饰函数的 `__name__/__doc__` 会变成 wrapper 的,导致日志、堆栈、文档全部失真,极难排查。
|
||
4. **Agent 联系**:tenacity `@retry`、LangChain `@tool` 都是它,理解了装饰器才看得懂框架。
|
||
- **加分亮点 / 深度追问**:可主动提"带参数装饰器 = 再包一层"的写法,以及装饰器执行时机发生在"定义时而非调用时",这两个点常被继续追问。
|
||
|
||
### 面试题 2:为什么生成器能省内存?"生成器用一次就耗尽"该如何理解?
|
||
- **面试官想考察**:是否理解惰性求值与执行现场保存,而非只会用 `yield`。
|
||
- **专业作答(含深度)**:
|
||
1. **省内存的本质**:惰性求值,每次 `next()` 只算到下一个 `yield`,内存常驻 O(1),不像列表一次全装。
|
||
2. **耗尽的原因**:生成器保存"已执行到哪"的执行现场(局部变量+指令位置),单向向前推进,不能回头,故只能消费一次。
|
||
3. **场景**:大文档分块、SSE 流式推送、逐 token 渲染——都是"产生"与"消费"解耦的体现。
|
||
4. **权衡**:省内存的代价是"不可随机访问、只能顺序、用过即废";需要多次遍历就重新创建。
|
||
- **加分亮点 / 深度追问**:可提 `yield from`、`send`/`close` 等进阶能力,并说明"迭代器协议(__iter__/__next__)"是生成器背后的统一抽象。
|
||
|
||
### 面试题 3:闭包里为什么有时要用 `nonlocal`?不写会怎样?
|
||
- **面试官想考察**:是否真正理解 LEGB 作用域规则与闭包捕获机制,而不是背 UnboundLocalError。
|
||
- **专业作答(含深度)**:
|
||
1. **捕获机制**:内层引用外层变量会被包进 `__closure__` 的 cell,成为"自由变量",外部函数都返回了它还能存活。
|
||
2. **nonlocal 的必要性**:读外层变量可直接读;但一旦在内存函数里对它做赋值(`count += 1`),Python 默认把它当作"新建局部变量",与 cell 中的外层变量冲突→`UnboundLocalError`。写 `nonlocal count` 明确声明"我改的就是那层变量",才允许修改被捕获的 cell。
|
||
3. **为何不用 global**:global 改的是模块级全局,闭包强调的是"随函数实例独立携带的私有状态"——用 nonlocal 才能让每个会话/计数各自独立。
|
||
4. **联系**:装饰器的状态保持、回调绑定、会话计数器、流式游标,都可借闭包实现。
|
||
- **加分亮点 / 深度追问**:可提"闭包捕获的是引用而非快照"这个陷阱——循环中造闭包共享同一 cell 会全部指向最终值,可引导讨论默认参数绑定等解法。 |