Files
python-agent/docs/01-阶段一-前置核心基础/02-Python高级编程之进阶语法.md
T
2026-09-22 15:42:25 +08:00

306 lines
18 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 阶段一 · 小点 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 会全部指向最终值,可引导讨论默认参数绑定等解法。