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

322 lines
17 KiB
Markdown
Executable File
Raw Permalink 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.
# 阶段一 · 小点 1:Python 高级编程之基础巩固
> 所属:阶段一 前置核心基础(必备打底)
> 定位:本节不教"语法怎么用",而是教你"哪些语法坑会咬到你"。很多知识学起来简单,但只有你在 Agent 工程里踩过坑才知道为什么要这么写。
## 精简大纲
1. 核心数据结构:list / dict / set / tuple 底层特性,字典合并、集合运算、可变对象共享坑
2. 面向对象编程:类、实例、继承、多态、魔术方法
3. 异常处理:try-except-else-finally,精准捕获、因果链保留
4. 文件 IO:文本 / 二进制、大文件流式读取
5. 正则表达式:提取模型输出片段、清洗 LLM 脏输出
## 学习内容详情
### 1. 核心数据结构(Agent 高频场景)
#### 1.1 先建立一张"选型速查表"
```mermaid
graph LR
A[需要什么样的数据容器?] --> B{能变吗? 需要去重吗?}
B -->|有序且元素唯一| C[set 集合运算·去重]
B -->|键值对/映射| D[dict 字典·上下文合并]
B -->|固定不变| E[tuple 元组·做缓存key]
B -->|可增删改| F[list 列表·工具列表]
F --> G[使用中要小心·可变]
D --> G
C --> G
E --> H[安全·可做key]
style G fill:#ffe6e6
style H fill:#e6ffe6
```
- **字典合并**:Agent 常需把多份上下文、多个工具返回结果拼成一个大 dict,用 `{**a, **b}`(通用)或 `a | b`(Python 3.9+)。
- **集合运算**:拿"系统已注册工具名集合"和"任务需要的工具名集合"做差集,就知道还缺哪些工具。
- **可变 vs 不可变**:list/dict/set 可变(内容能改);str/tuple/int 不可变(改一次就产生新对象)。
> ⚠️ **第一阶段最重要的一个坑(务必记住):**
> Agent 的**状态对象**会传给很多函数。可变对象被某个函数"顺手"改掉后,其他地方根本察觉不到——这是隐性 bug 的头号来源。
```python
# ========== 反例:共享可变对象被悄悄改掉 ==========
tools = ["search", "sql"] # 全局工具列表
def add_tool_bad(tools, new_tool):
# 直接 append 会修改原有列表 !!
# 调用方拿到的 tools 也被改了,连日志都查不出来是谁改的
tools.append(new_tool)
return tools
result = add_tool_bad(tools, "memory")
print(tools) # ['search', 'sql', 'memory'] ← 原列表被污染了
# ========== 正例:传副本,改动不回流 ==========
def add_tool_ok(tools: list[str], new_tool: str) -> list[str]:
# 用 list(tools) 复制一份,append 的是副本
new_tools = list(tools) # 副本,与原列表断开联系
new_tools.append(new_tool) # 只改副本
return new_tools # 调用方决定要不要覆盖原变量
result = add_tool_ok(tools, "memory")
print(tools) # ['search', 'sql'] ← 原列表毫发无损 ✅
```
- 不可变对象(tuple / str)可做 dict 的 key,常用于缓存 Agent 配置的"指纹"(如模型名+版本号组成的 key)。
```python
# 用 tuple 当 key 缓存配置计算结果
cache = {}
config_key = ("gpt-4o", "v2", 128_000) # 元组可哈希,适合当 key
cache[config_key] = {"温度": 0.2, "max_tokens": 2048}
```
> 🔬 **深度理解 · 可变对象与不可变对象**:
> 1. **本质**:可变对象(list/dict/set)的"内容"能原地被改、内存地址(id)保持不变;不可变对象(tuple/str/int)任何"修改"都只能重新创建一个新对象,原对象绝不变化。
> 2. **机制**:不可变对象能做 dict 的 key,本质是因为它"可哈希(hashable)"——hash 值在其生命周期内恒定,dict 靠 hash 定位槽位;list 可变会导致 hash 漂移,同一个 key 会查不到,所以 Python 直接禁止它做 key(运行时抛 TypeError: unhashable type)。
> 3. **为什么重要**:Agent 的状态对象、messages、工具列表常作为入参到处传递,某函数"顺手"改动共享可变对象会造成"明明没改却变了"的隐性 bug、且难追溯。理解引用语义是把副作用控制(传副本、纯函数风格)做对的根基。
> 4. **易错点**:`a = b` 对可变对象只是拷贝"引用"而非内容,二者共享同一份数据;`tuple` 内部若嵌了可变的 list 就不可哈希,Python 不允许这种 tuple 作为 dict 的 key。
#### 1.2 推荐一个"背诵级"示例:一本书教你的字典/列表推导
```python
# 推导式 = 一行生成列表/字典,Agent 处理数据时极其常用
triples = [i * 3 for i in range(10)] # [0, 3, 6, ...]
squares = {x: x * x for x in range(5)} # {0:0, 1:1, 2:4, 3:9, 4:16}
# 过滤:只要偶数
evens = [x for x in range(10) if x % 2 == 0]
```
### 2. 面向对象编程(OOP)
#### 2.1 为什么 Agent 抽象层全都是"类"
框架里"工具基类 BaseTool"、"Agent 基类 BaseAgent"几乎都是同一套结构:父类写通用逻辑,子类只填差异。这就是**继承 + 多态**。
```mermaid
classDiagram
class BaseTool {
<<abstract>>
+name : str
+description : str
+run(params) * # 抽象方法,子类必须实现
}
class SearchTool {
+name = "search"
+run(params) 调搜索API
}
class SQLTool {
+name = "sql"
+run(params) 执行SQL
}
BaseTool <|-- SearchTool
BaseTool <|-- SQLTool
```
> 关键点:上层代码只依赖 `BaseTool`,不管它是 SearchTool 还是 SQLTool——这就是"多态",让新增工具不碰老的调度逻辑。
#### 2.2 带详细注释的完整示例
```python
from typing import Any
class BaseTool:
"""所有工具的基类。定义通用接口,子类只需要补 run()。"""
name: str = "base" # 工具名:大模型靠它识别调用哪个工具
description: str = "" # 工具说明:喂给大模型,决定何时触发
def __init__(self):
# 《魔术方法__init__》:在对象被创建时自动执行
print(f"[{self.name}] 工具已注册")
def __str__(self):
# 《魔术方法__str__》:print(对象) 时显示什么
return f"BaseTool(name={self.name})"
def run(self, params: dict[str, Any]) -> str:
# 抽象逻辑:父类不实现,抛错提醒子类必须覆写
raise NotImplementedError("子类必须实现 run()")
class SearchTool(BaseTool):
# 继承:自动获得 name/description/__str__/run 的存在
name = "search"
description = "在搜索引擎中查找信息"
def run(self, params):
# 覆写父类的 run,实现真正的搜索逻辑
query = params.get("query", "")
return f"模拟搜索:{query} 返回 3 条结果"
# 多态用法:上层用基类类型接住,运行时却是子类行为
def execute_tool(tool: BaseTool, params: dict):
# 传进来的是 BaseTool 子类,.run() 会自动分发到对应实现
print(f"执行工具 {tool.name}")
print(tool.run(params)) # 动态分发:搜完就查,不用在 execute 里写 if/else
execute_tool(SearchTool(), {"query": "AI Agent"})
```
> 🔬 **深度理解 · 多态与动态分发**:
> 1. **本质**:把"同一接口、不同实现"这件事做到底——上层只写一份依赖 `BaseTool.run()` 的调度逻辑,实际执行哪个子类的实现由运行时对象的真实类型决定。
> 2. **机制**:Python 没有编译期类型强约束,`execute_tool(tool: BaseTool)` 里的 `.run()` 走的是运行时方法查找(method resolution):先在实际对象的真实类型(SearchTool)里找 run,找不到再沿继承链往父类找,找到即调用。
> 3. **为什么重要**:没有多态,新增工具就要在调度处堆 `if tool.name=="search" ... elif "sql" ...` 的分支,越加越乱;有了多态,新增工具=写一个新子类,老调度逻辑一行不改,这正是 Agent 框架里"工具即插即用"的基石。
> 4. **易错点**:多态只发生在"调用实例方法"的场景;子类若不覆写 `run()`,调用时会触发父类的 `raise NotImplementedError`,接口契约要靠自觉实现——这也是"抽象基类 + NotImplementedError"能约束子类的原理。
### 3. 异常处理
写 Agent 最常遇到三类错误:**业务错误**(参数不合法)、**网络错误**(API 超时)、**模型错误**(输出不符合预期)。笼统一把抓会让排查变成大海捞针。
```python
import requests
try:
resp = requests.get("https://api.example.com/chat", timeout=10)
resp.raise_for_status() # 状态码非 2xx 就抛 HTTPError
data = resp.json()
except requests.exceptions.Timeout as e:
# 精准捕获:明确是超时 → 走重试策略
print(f"[网络错误] 请求超时: {e}")
except requests.exceptions.ConnectionError as e:
# 精准捕获:连接失败 → 检查网络/代理
print(f"[网络错误] 连接失败: {e}")
except requests.exceptions.HTTPError as e:
# 精准捕获:4xx/5xx → 根据状态码给不同处理
print(f"[HTTP错误] 状态码: {e.response.status_code}")
except ValueError as e:
# JSON 解析失败:返回的不是合法 JSON
print(f"[解析错误] 无法解析响应: {e}")
except Exception as e:
# 兜底捕获:能不放就不放,但至少留日志定位
print(f"[未知错误] 需排查: {e}")
else:
# 没抛异常才执行:到这里才确信数据是好的
print(f"正常拿到数据: {str(data)[:100]}")
finally:
# 无论成功失败都执行:释放连接/资源
print("请求结束,清理资源")
```
> **转抛的进阶写法**:内部包一层再抛上去时用 `raise ... from e`,保留完整因果链,日志里能追到最底层原因。
```python
def load_data(source: str):
try:
raw = open(source, "r").read()
except FileNotFoundError as e:
# 把"原文件没找到"这个原因带到上层,而不是吞掉
raise RuntimeError(f"加载数据源 {source} 失败") from e
```
### 4. 文件 IO
一个大模型跑出来的"上下文文档"可能 100MB。如果一次 `read()` 全读进内存,轻则内存暴涨,重则 OOM 崩溃。**流式读取**是必须掌握的。
```python
# ========== 反例:一次全读进内存 ==========
with open("big_doc.txt", "r", encoding="utf-8") as f:
content = f.read() # 100MB 全进内存,危险!
# ========== 正例:每 8192 字节一块,逐块处理 ==========
CHUNK_SIZE = 8192 # 8KB 一块,够用且不爆内存
total_chars = 0
with open("big_doc.txt", "r", encoding="utf-8") as f:
while True:
chunk = f.read(CHUNK_SIZE) # 每次最多读 8KB
if not chunk: # 读到结尾返回空串,退出循环
break
total_chars += len(chunk) # 对 chunk 做处理(分块喂给模型/统计)
# 这里可以: 统计词频 / 分块做 embedding / 检查敏感词
print(f"共读取 {total_chars} 字符")
```
> **补充**:`with open(...)` 就是"上下文管理器"(下一节会细讲),保证无论是否异常都自动关闭文件句柄。配置文件用 `.json/.yaml/.toml` 加载时,同样建议逐字段解析而非全部 `json.load` 后无脑用。
> ⏳ **短期可不深究**:针对 `.json/.yaml/.toml` 多种配置格式的逐字段解析属于"锦上添花",第一遍把 `with open` 流式读取握牢就够用,真接到多格式配置再补不迟。
### 5. 正则表达式
大模型返回的内容往往是"带着小尾巴的文本",比如外面包了一层 markdown 代码块。要用正则把它清洗成能直接 `json.loads` 的纯净内容。
````python
import re
import json
raw_model_output = '''```json
{"city": "北京", "weather": "晴", "temp": 26}
```'''
# 场景1:剥掉外面的 ```json ``` 包裹(最常见)
cleaned = re.sub(r"^```json\s*|\s*```$", "", raw_model_output)
# ^```json\s* → 匹配开头的 ```json 和换行
# \s*```$ → 匹配结尾的 ``` 和前面空白
# | → 二者任选其一替换成空
print(repr(cleaned))
# '{"city": "北京", "weather": "晴", "temp": 26}'
# 场景2:从任意文本里提取第一个 {...} 匹配的 JSON 对象
text_embedded = "模型说:天气不错,数据如下 {"city":"北京","temp":26} 请查收"
match = re.search(r"\{.*?\}", text_embedded) # \{\} 匹配花括号,.*? 非贪婪
if match:
data = json.loads(match.group(0)) # 解析成 python dict
print(data["city"], data["temp"]) # 北京 26
````
> ⚠️ **贪婪 vs 非贪婪是这个坑的重灾区:**
> `.*`(贪婪)会尽可能吞更多,遇到两段无关文本会把中间全吃掉;`.*?`(非贪婪)在遇到第一个 `}` 就停下,适合提取单个 JSON 对象。包含嵌套花括号的复杂 JSON 仍会误判,更稳的做法见小点 4 的 pydantic 脏 JSON 兜底。
> ⏳ **短期可不深究**:手写正则去"剥嵌套花括号的复杂 JSON"属于偏 hacky 的冷门技巧,第一遍会用 pydantic 兜底链路即可,别在手写正则钻牛角尖。
## 本节自检
- [ ] 能说明可变 / 不可变对象对共享状态的坑,并写出"传副本"写法
- [ ] 能画出 BaseTool → SearchTool/SQLTool 的继承关系并说明多态好处
- [ ] 能写出"超时 / 连接 / HTTP / 解析 / 兜底"五段式异常处理
- [ ] 能用手写正则从带 \`\`\`json 包裹的模型输出中提取 JSON,并说清贪婪 vs 非贪婪
- [ ] 能按 8KB 分块流式读取大文件
## 本节配套思考题(快速入门的检验)
1. 你手头有个全局 `user_profile: dict`,多个工具函数都要读取并"顺手更新",你会怎么防止污染?
2. 为什么 `tuple` 能当 dict 的 key,而 `list` 不能?提示:可哈希与可变性。
3. 模型返回 `{"request_id":"abc","answer":"...","cost":{"total":0.5}}` 里嵌套了一个字典,用一行正则提取并 `json.loads` 手写在下面,验证是否成功。
## 本节常见面试题(深度解析)
> 针对本节核心知识的面试高频点,配合"本质+机制"式理解,能让你答得既有深度又有广度。
### 面试题 1:讲一讲可变对象与不可变对象的区别,以及它们为什么会在 Agent 场景下引发共享状态 bug?
- **面试官想考察**:是真的理解引用语义,还是只会背"list 可变、tuple 不可变";能否联系到工程里传参副作用的隐患。
- **专业作答(含深度)**:
1. **区别的本质**:可变对象允许原地改内容(id 不变),不可变对象任何"修改"都产生新对象。关键在于 `a = b` 对可变对象拷贝的是"引用"而非"内容"。
2. **能否做 dict key 的本质**:可哈希且 hash 不漂移才行;不可变对象 hash 稳定,可变对象 hash 会变,故被禁。
3. **Agent 场景陷阱**:状态对象、messages、工具列表传给多个函数时,某函数直接 `append/update` 会悄悄污染所有持有该引用的调用方,且无报错难追溯。**对策**:函数内传副本(`list(tools)` / `{**d}`)或写成"不改入参、返回新值"的纯函数风格。
4. **进阶联想**:这也是 `frozenset` 等不可变数据结构,以及函数式不可变风格在 Agent 框架里受欢迎的原因。
- **加分亮点 / 深度追问**:可主动提 `tuple` 内嵌 `list` 仍不可哈希,以及"浅拷贝 vs 深拷贝(`list(tools)` 只拷第一层)"的边界——这是很常见的追问点。
### 面试题 2:异常处理中 try / except / else / finally 各什么时候执行?转抛时为什么要用 `raise ... from e`?
- **面试官想考察**:是否只知道 try/except,能否准确说清 else/finally 语义,并在意"因果链"这类工程细节。
- **专业作答(含深度)**:
1. **时序**:try 先执行;抛异常且被 except 命中则执行 except;**else 仅当 try 无异常时执行**(往往用于"确认拿到干净数据"后续处理);**finally 无论成败必执行**(释放资源)。两者的差别正好是触发条件的"有/无异常"。
2. **精准捕获优先**:分别捕获 Timeout/ConnectionError/HTTPError/ValueError,再留兜底 `except Exception`,避免"一网打尽"难排查。
3. **因果链的意义**:直接包一层再抛会丢掉最底层原因;`raise RuntimeError(...) from e` 把原异常挂为 `__cause__`,日志里能一路追到根因。
- **加分亮点 / 深度追问**:可提 except 里裸 `raise`(不带表达式)会"原样继续抛出、不覆盖栈",以及业务层只该抛出对调用方有意义的异常层级,这些能体现工程思维。
### 面试题 3:Agent 的 Tool 层为什么适合用"继承 + 多态"来组织?不这么做的坏处是什么?
- **面试官想考察**:是否理解抽象/多态对扩展性的价值,而非只会写类的语法。
- **专业作答(含深度)**:
1. **统一契约**:父类 BaseTool 定义一致的 `name/description/run()` 接口,让调度层、大模型触发层都只依赖抽象接口。
2. **扩展不碰旧码(开闭原则)**:新增工具只是新增子类,老调度逻辑无需改动——这正是"多态让 if/else 消失"的意义。
3. **与 Agent 的衔接**:工具的 `description` 会喂给大模型决定触发时机,统一接口也便于统一注入 JSON Schema。
4. **反面**:不用多态,调度处会堆 `if name=="search"... elif "sql"...`,加工具就要改调度函数,代码易腐化、易漏分支。
- **加分亮点 / 深度追问**:可提"组合优于继承"的讨论——统一接口不一定要靠类继承,用 Protocol/接口协议同样能实现,被追问时可说明两者的权衡。