# 阶段一 · 小点 4:Python 高级编程之核心工具库 > 所属:阶段一 前置核心基础(必备打底) > 定位:理论的尽头是"能稳定地干活"。这一节教你四件几乎每个 Agent 项目都用的工具:pydantic(数据校验)、loguru(日志)、tenacity(重试)、python-dotenv(配置)。它们是后面所有阶段的地基。 ## 精简大纲 1. pydantic v2:数据校验、JSON Schema、脏 JSON 兜底 2. loguru:结构化日志,全链路输入输出追踪 3. tenacity:LLM 网络抖动重试策略 4. python-dotenv:环境变量管理 5. pandas / numpy:数据分析 Agent 基础 6. 工程规范:分层结构、pytest / ruff / mypy、依赖管理 ## 学习内容详情 ### 1. pydantic v2(重点) #### 1.1 它在 Agent 里的三个用处 ```mermaid graph LR A{pydantic} --> B[工具入参 Schema] A --> C[Agent 状态定义] A --> D[解析大模型的 JSON 输出] B --> E[自动生成 JSON Schema 喂给模型] ``` - `BaseModel`:声明一个类 = 数据结构 + 校验规则 + JSON Schema 三合一。 - 三件套动词: - `model_validate(...)`:校验并转换输入(把脏数据规整成合法模型)。 - `model_dump_json()`:序列化为 JSON 字符串输出。 - `model_json_schema()`:导出 JSON Schema,直接喂给大模型参考。 - 场景:工具入参 Schema、Agent 状态定义、解析大模型 JSON 输出。 > 🔬 **深度理解 · pydantic 声明式校验(BaseModel)**: > 1. **本质**:把"数据结构 + 校验规则 + 文档"在一个类声明里搞定,构造/校验时自动强制校验,替代手写的一长串 `if not isinstance(...)` 手工校验。 > 2. **机制**:pydantic 基于类型声明在 `model_validate`/实例化时按字段类型与 `Field` 约束逐个强校验,不符即抛 `ValidationError`,并支持类型强制(coercion,如把字符串 `"26"` 转成 int);底层用 Rust 实现(Pydantic Core)保证性能。 > 3. **为什么重要**:大模型输出永远"可疑",工具入参、Agent 状态都需要强契约;`model_json_schema()` 还能自动导出 JSON Schema 喂给大模型,做到"程序校验 + 模型参考"双用、一份定义。 > 4. **易错点**:`Field(..., min_length=1)` 里的省略号表示"必填、无默认值",与 `default=...` 二选一不是一回事;v2 用 `model_validate`/`model_dump_json`,别再用已废弃的 `parse_obj`/`.dict()` 写法。 ```python from pydantic import BaseModel, Field, ValidationError class ToolCall(BaseModel): """ 声明一个"合法工具调用"该长什么样。 声明即校验:字段类型 + 范围 + 描述一步到位。 """ tool_name: str = Field(..., min_length=1, max_length=50, description="要调用的工具名") arguments: dict = Field(default_factory=dict, description="传给工具的参数") request_id: str | None = Field(default=None, description="可选的追踪ID, 可为空") # 1. model_validate → 校验并转成模型 call = ToolCall.model_validate({ "tool_name": "search", # 合法 "arguments": {"query": "AI"}, # 合法 }) print(call.tool_name) # search # 2. 出错的输入直接抛 ValidationError try: ToolCall.model_validate({"tool_name": "", "arguments": {}}) except ValidationError as e: print("校验失败:", e.errors()[0]["loc"], e.errors()[0]["msg"]) # 3. model_dump_json → 序列化回 JSON print(call.model_dump_json()) # 4. model_json_schema → 生成 JSON Schema, 可直接喂给大模型 # print(call.model_json_schema()) ``` #### 1.2 脏 JSON 兜底(模型输出永远可疑) 大模型输出的 JSON 常常带着 ```json 包裹 / 注释 / 截断。直接 `json.loads` 很容易崩,正确姿势是"先恢复、再校验、失败让模型重写"。 ```python import json, re from pydantic import BaseModel, ValidationError def extract_json(text: str) -> str: """把模型输出里的脏 JSON 恢复成纯净 JSON 字符串。""" text = text.strip() # 1) 剥掉 ```json ... ``` 包裹 text = re.sub(r"^```[a-zA-Z]*\s*", "", text) text = re.sub(r"\s*```$", "", text) # 2) 去掉注释行 //... 或 /*...*/(模型常自己加注释) text = re.sub(r"//[^\n]*", "", text) text = re.sub(r"/\*[\s\S]*?\*/", "", text) # 3) 只取第一个 {...} 到最外层闭合(简化版) start, end = text.find("{"), text.rfind("}") if start != -1 and end != -1: return text[start:end + 1] return text class Weather(BaseModel): city: str temp: float def safe_parse(text: str): """安全解析: 先恢复再校验, 失败就交给上层"让模型重写"兜底。""" cleaned = extract_json(text) try: return Weather.model_validate(json.loads(cleaned)) except (json.JSONDecodeError, ValidationError) as e: print(f"解析失败: {e} → 走'让模型重写'兜底链路") return None # 上层捕获 None 后让模型重新输出 # 模拟一段"脏"模型输出: 带注释 + 代码块包裹 raw = '''```json // 天气查询结果 {"city": "北京", "temp": 26} # 温度单位是摄氏度 ```''' w = safe_parse(raw) print(w.city, w.temp) # 北京 26.0 ``` > ⏳ **短期可不深究**:手写 `extract_json` 里"剥注释 / 剥代码块 / 取首尾花括号"这类正则恢复细节属于锦上添花,第一遍掌握"extract → pydantic 校验 → 失败让模型重写"这条链路即可,正则部分贴模板用,不必背。 ### 2. loguru(结构化日志) 调试 Agent 最痛苦的是"这条消息它到底怎么答的?那一步返回了什么?"。logging 要配半天,loguru 一行搞定。 ```python from loguru import logger # 按级别打点: 全链路输入输出都可追踪 logger.debug("进入工具 search") logger.info("开始调用模型, prompt=%s", "你好") logger.warning("模型返回超时, 将重试") # 警告 logger.error("调用失败: %s", "429 Too Many Requests") # 错误 # 结构化日志: 每个关键环节都留痕, 便于脚本/日志平台检索 try: result = call_business() # 假设是业务逻辑 logger.info("业务完成 result={}", result) except Exception as e: logger.exception("业务异常") # 自动带上堆栈, 排查神器 ``` > **一条经验**:给 Agent 的每个工具入口、每次模型调用、每次工具返回都打一条 INFO 日志,出问题时先看日志时间线,90% 能定位。 ### 3. tenacity(重试) #### 3.1 重试三要素 ```mermaid graph LR A[重试策略] --> B[何时停: 最大次数/最长时长] A --> C[等多久: 固定/指数退避] A --> D[重试什么: 只重试特定异常] ``` - 三要素一个都不能少:**何时停**(最大次数)、**等多久**(退避策略)、**重试什么**(只针对网络类异常——业务参数错误重试一万次也不会成功)。 > 🔬 **深度理解 · 重试三要素与指数退避**: > 1. **本质**:重试不是"失败就再来一次"这么简单,而是一套"何时停 / 等多久 / 重试什么"的策略决策,缺一个都会踩坑。 > 2. **机制**:`stop` 控制终止条件(次数/总时长封顶);`wait` 控制两次间的等待,`wait_exponential` 按 1→2→4 翻倍并封顶,从而打散重试时刻避免"重试风暴";`retry` 用异常类型/谓词决定"哪些异常值得重试"——网络类瞬时错误值得,参数类必败错误不值得。 > 3. **为什么重要**:LLM 调用最常见的失败是瞬时网络抖动与 429 限流,指数退避让重试恰当地绕过抖动又能快速止血;不设停更上限会无限重试耗尽配额与连接。 > 4. **易错点**:只设次数不设等待会"立即风暴式重试",反而加剧服务商封禁;把所有异常都重试,会把"参数错误"这类必败请求白试 N 次还拖慢主线。 ```python import httpx from tenacity import ( retry, stop_after_attempt, wait_exponential, retry_if_exception_type, ) class RateLimitError(Exception): # 自定义: 服务商限流 pass @retry( stop=stop_after_attempt(3), # 总共最多尝试3次(含首次, 即最多重试2次) wait=wait_exponential(multiplier=1, max=8), # 等待 2s→4s→8s 指数退避(max=8封顶) retry=retry_if_exception_type((httpx.TimeoutException, httpx.ConnectError, RateLimitError)), # ↑ 只重试"网络类 + 限流"异常; 参数错误(ValueError等)不重试 ) def call_llm(messages): resp = httpx.post( "https://api.example.com/chat", json={"messages": messages}, timeout=30, ) if resp.status_code == 429: raise RateLimitError("限流了") # 会退避重试 resp.raise_for_status() return resp.json() ``` ### 4. python-dotenv(环境变量) 密钥 / 模型地址不能硬编码进代码,必须靠环境变量区分开发 / 生产环境。 ```python # .env 文件内容示例(不进 git): # OPENAI_API_KEY=sk-xxxx # BASE_URL=https://api.openai.com import os from dotenv import load_dotenv load_dotenv() # 读取项目根目录的 .env API_KEY = os.getenv("OPENAI_API_KEY") # 读不到会返回 None assert API_KEY, "请先配置 OPENAI_API_KEY" # 启动即检测, 缺配置立刻报错 BASE_URL = os.getenv("BASE_URL", "https://api.openai.com") print(f"模型地址: {BASE_URL}") ``` > 硬性规范:密钥宁愿不写进代码;`.env` 必须加入 `.gitignore`,防止密钥泄露到仓库。 ### 5. pandas / numpy(数据分析 Agent 基础) 当 Agent 要做数据分析("帮我看看这些销售数据"),pandas 是把自然语言查询翻译成结构化过滤/聚合的主力。 ```python import pandas as pd df = pd.read_csv("sales.csv") # 数据筛选: 把用户"看看销量大于100的"翻译成语义过滤 filtered = df[df["sales"] > 100] # 聚合: 按区域分组求销量总和 by_region = df.groupby("region")["sales"].sum().reset_index() # 关键注意: 别把整个 DataFrame 塞给大模型! # 只返回摘要 {shape, 列名, 采样行, 聚合结果} → 控制 token 成本 summary = { "shape": df.shape, "columns": list(df.columns), "sample": df.head(3).to_dict("records"), "total_sales": int(df["sales"].sum()), } print(summary) ``` > ⚠️ **token 成本警示**:`print(df)` 会输出数千行。正确姿势是只把摘要喂给模型,需要明细时再让模型用工具精准查询小窗口。 > ⏳ **短期可不深究**:pandas/numpy 的完整 API 属于"数据分析 Agent"的补充方向,与主线 Agent 核心弱相关,第一遍只需掌握"选列 / groupby 聚合 / 只喂摘要控 token"三个点,不打算转型数据分析可先跳过深挖。 ### 6. 项目分层结构(工程化第一步) #### 6.1 一张图看懂分层 ```mermaid graph TD subgraph agent_project src --> config[config.py 环境配置] src --> tools[tools/ 工具层: 检索/SQL] src --> agent[agent/ 编排层: graph/state] src --> memory[memory/ 记忆层: redis/向量库] src --> api[api/ FastAPI接入层] src --> utils[utils/ 通用工具] end tests[根目录 tests/ 单元测试] pyproject[pyproject.toml 依赖声明] ``` ```text agent_project/ ├── pyproject.toml # 依赖管理(现代替代 requirements.txt) ├── src/ │ ├── config.py # 环境变量与配置 │ ├── tools/ # 工具层(retrieval.py / sql_run.py ...) │ ├── agent/ # Agent 编排层(graph.py / state.py ...) │ ├── memory/ # 记忆层(redis_store.py / vector_store.py ...) │ ├── api/ # FastAPI 接入层 │ └── utils/ # 通用工具(logger.py / parser.py ...) └── tests/ # 单元测试 ``` - **质量门禁**:`pytest tests/ -v` + `ruff check src/` + `mypy src/` 三步到位。 - **依赖声明**:用 pyproject.toml(langgraph / openai / pydantic / python-dotenv / loguru / tenacity / fastapi)。 ## 本节自检 - [ ] 能用 pydantic 校验模型输出的脏 JSON(含 ```json 包裹 / 注释 / 截断) - [ ] 能说清 tenacity 重试三要素并用在 LLM 调用上 - [ ] 已搭好分层项目骨架 + 虚拟环境 + 单测 + ruff ## 本节配套思考题(快速入门的检验) 1. `Field(..., min_length=1)` 里省略号 `...` 表示什么?和 `default=None` 有何区别? 2. tenacity 的 `retry` 装饰器如果不指定 `retry=`,默认会重试所有异常。为什么说"只重试网络类异常"更稳妥? 3. 为什么建议把 pandas 的 DataFrame 只喂摘要而非整个表给模型?大概权衡了哪两个因素? ## 本节常见面试题(深度解析) > 针对本节核心知识的面试高频点,配合"本质+机制"式理解,能让你答得既有深度又有广度。 ### 面试题 1:pydantic 相比手写 if-else 校验,到底强在哪?它在 Agent 里的定位是什么? - **面试官想考察**:是否理解"声明式校验"的价值,而不只是听过 pydantic 这个名字。 - **专业作答(含深度)**: 1. **声明式 vs 命令式**:pydantic 用类型+Field 声明"应该长什么样",框架自动生成校验与类型强制;手写一堆 `if not isinstance` 冗余且易漏分支。 2. **三合一**:一个 `BaseModel` = 数据结构 + 校验规则 + JSON Schema,三份用途一份定义。 3. **与 Agent 的衔接**:工具入参 Schema(喂给模型)、Agent 状态强类型、解析模型输出的脏 JSON——三处高危点都靠它兜底。 4. **校验失败策略**:抛 `ValidationError`(结构错)与 JSON 解析失败并走"让模型重写"链路。 - **加分亮点 / 深度追问**:可提 pydantic v2 的 Rust 底层性能、`model_json_schema` 与 function calling tools 定义的关系,以及自定义 `@field_validator` 的合法性校验。 ### 面试题 2:给 LLM 调用配置重试时,为什么"只重试网络类异常"比"重试所有异常"更稳妥? - **面试官想考察**:是否真正理解重试三要素,能否区分"值得重试"与"必败"两类异常。 - **专业作答(含深度)**: 1. **三要素**:何时停(stop)、等多久(wait/指数退避)、重试什么(retry 条件)——三要素缺一不可。 2. **分类判断**:超时/连接错误/429 属瞬时系统问题,重试大概率成功;参数错误(如格式不对、模型名写错)属确定性失败,重试一万次也白费,反而浪费配额、拖延主线。 3. **指数退避的作用**:按 1s→2s→4s 翻倍并封顶,打散所有实例的重试时刻,规避"重试风暴"加剧服务商限流。 4. **兜底**:设置次数/总时长上限,配合超时硬保护,防止无限挂起。 - **加分亮点 / 深度追问**:可延伸"重试幂等性"——重试前要确认接口是否幂等(写入类操作重复执行会否重复入库),以及 429 的 `Retry-After` 响应头可用来校准退避时长。 ### 面试题 3:为什么 Agent 项目要做分层?"工具层 / 编排层 / 记忆层"分层的收益是什么? - **面试官想考察**:是否具备工程化意识,能否说清"分层"解决的具体问题而非空喊架构。 - **专业作答(含深度)**: 1. **关注点分离**:配置、工具、编排、记忆、接入、通用工具各管各的职责,改动互不牵连,避免单文件无人敢动。 2. **可测试性**:分层让每层可单独 mock/单测(如把真实 LLM client 替换成 mock),配合 pytest + ruff + mypy 形成质量门禁。 3. **可演进性**:新增一个工具/换一种记忆后端,只需改动对应层,不碰上层编排逻辑。 4. **Agent 落地**:是把"模型会答"变成"工程可维护、可 review、可长期跑"的前提。 - **加分亮点 / 深度追问**:可延伸依赖反转(上层依赖接口而非具体实现)、依赖注入在 Agent 服务里的作用,以及分层过度带来的样板代码(YAGNI 权衡)。