16 KiB
Executable File
阶段一 · 小点 4:Python 高级编程之核心工具库
所属:阶段一 前置核心基础(必备打底) 定位:理论的尽头是"能稳定地干活"。这一节教你四件几乎每个 Agent 项目都用的工具:pydantic(数据校验)、loguru(日志)、tenacity(重试)、python-dotenv(配置)。它们是后面所有阶段的地基。
精简大纲
- pydantic v2:数据校验、JSON Schema、脏 JSON 兜底
- loguru:结构化日志,全链路输入输出追踪
- tenacity:LLM 网络抖动重试策略
- python-dotenv:环境变量管理
- pandas / numpy:数据分析 Agent 基础
- 工程规范:分层结构、pytest / ruff / mypy、依赖管理
学习内容详情
1. pydantic v2(重点)
1.1 它在 Agent 里的三个用处
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):
- 本质:把"数据结构 + 校验规则 + 文档"在一个类声明里搞定,构造/校验时自动强制校验,替代手写的一长串
if not isinstance(...)手工校验。- 机制:pydantic 基于类型声明在
model_validate/实例化时按字段类型与Field约束逐个强校验,不符即抛ValidationError,并支持类型强制(coercion,如把字符串"26"转成 int);底层用 Rust 实现(Pydantic Core)保证性能。- 为什么重要:大模型输出永远"可疑",工具入参、Agent 状态都需要强契约;
model_json_schema()还能自动导出 JSON Schema 喂给大模型,做到"程序校验 + 模型参考"双用、一份定义。- 易错点:
Field(..., min_length=1)里的省略号表示"必填、无默认值",与default=...二选一不是一回事;v2 用model_validate/model_dump_json,别再用已废弃的parse_obj/.dict()写法。
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 很容易崩,正确姿势是"先恢复、再校验、失败让模型重写"。
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 一行搞定。
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 重试三要素
graph LR
A[重试策略] --> B[何时停: 最大次数/最长时长]
A --> C[等多久: 固定/指数退避]
A --> D[重试什么: 只重试特定异常]
- 三要素一个都不能少:何时停(最大次数)、等多久(退避策略)、重试什么(只针对网络类异常——业务参数错误重试一万次也不会成功)。
🔬 深度理解 · 重试三要素与指数退避:
- 本质:重试不是"失败就再来一次"这么简单,而是一套"何时停 / 等多久 / 重试什么"的策略决策,缺一个都会踩坑。
- 机制:
stop控制终止条件(次数/总时长封顶);wait控制两次间的等待,wait_exponential按 1→2→4 翻倍并封顶,从而打散重试时刻避免"重试风暴";retry用异常类型/谓词决定"哪些异常值得重试"——网络类瞬时错误值得,参数类必败错误不值得。- 为什么重要:LLM 调用最常见的失败是瞬时网络抖动与 429 限流,指数退避让重试恰当地绕过抖动又能快速止血;不设停更上限会无限重试耗尽配额与连接。
- 易错点:只设次数不设等待会"立即风暴式重试",反而加剧服务商封禁;把所有异常都重试,会把"参数错误"这类必败请求白试 N 次还拖慢主线。
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(环境变量)
密钥 / 模型地址不能硬编码进代码,必须靠环境变量区分开发 / 生产环境。
# .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 是把自然语言查询翻译成结构化过滤/聚合的主力。
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 一张图看懂分层
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 依赖声明]
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
本节配套思考题(快速入门的检验)
Field(..., min_length=1)里省略号...表示什么?和default=None有何区别?- tenacity 的
retry装饰器如果不指定retry=,默认会重试所有异常。为什么说"只重试网络类异常"更稳妥? - 为什么建议把 pandas 的 DataFrame 只喂摘要而非整个表给模型?大概权衡了哪两个因素?
本节常见面试题(深度解析)
针对本节核心知识的面试高频点,配合"本质+机制"式理解,能让你答得既有深度又有广度。
面试题 1:pydantic 相比手写 if-else 校验,到底强在哪?它在 Agent 里的定位是什么?
- 面试官想考察:是否理解"声明式校验"的价值,而不只是听过 pydantic 这个名字。
- 专业作答(含深度):
- 声明式 vs 命令式:pydantic 用类型+Field 声明"应该长什么样",框架自动生成校验与类型强制;手写一堆
if not isinstance冗余且易漏分支。 - 三合一:一个
BaseModel= 数据结构 + 校验规则 + JSON Schema,三份用途一份定义。 - 与 Agent 的衔接:工具入参 Schema(喂给模型)、Agent 状态强类型、解析模型输出的脏 JSON——三处高危点都靠它兜底。
- 校验失败策略:抛
ValidationError(结构错)与 JSON 解析失败并走"让模型重写"链路。
- 声明式 vs 命令式:pydantic 用类型+Field 声明"应该长什么样",框架自动生成校验与类型强制;手写一堆
- 加分亮点 / 深度追问:可提 pydantic v2 的 Rust 底层性能、
model_json_schema与 function calling tools 定义的关系,以及自定义@field_validator的合法性校验。
面试题 2:给 LLM 调用配置重试时,为什么"只重试网络类异常"比"重试所有异常"更稳妥?
- 面试官想考察:是否真正理解重试三要素,能否区分"值得重试"与"必败"两类异常。
- 专业作答(含深度):
- 三要素:何时停(stop)、等多久(wait/指数退避)、重试什么(retry 条件)——三要素缺一不可。
- 分类判断:超时/连接错误/429 属瞬时系统问题,重试大概率成功;参数错误(如格式不对、模型名写错)属确定性失败,重试一万次也白费,反而浪费配额、拖延主线。
- 指数退避的作用:按 1s→2s→4s 翻倍并封顶,打散所有实例的重试时刻,规避"重试风暴"加剧服务商限流。
- 兜底:设置次数/总时长上限,配合超时硬保护,防止无限挂起。
- 加分亮点 / 深度追问:可延伸"重试幂等性"——重试前要确认接口是否幂等(写入类操作重复执行会否重复入库),以及 429 的
Retry-After响应头可用来校准退避时长。
面试题 3:为什么 Agent 项目要做分层?"工具层 / 编排层 / 记忆层"分层的收益是什么?
- 面试官想考察:是否具备工程化意识,能否说清"分层"解决的具体问题而非空喊架构。
- 专业作答(含深度):
- 关注点分离:配置、工具、编排、记忆、接入、通用工具各管各的职责,改动互不牵连,避免单文件无人敢动。
- 可测试性:分层让每层可单独 mock/单测(如把真实 LLM client 替换成 mock),配合 pytest + ruff + mypy 形成质量门禁。
- 可演进性:新增一个工具/换一种记忆后端,只需改动对应层,不碰上层编排逻辑。
- Agent 落地:是把"模型会答"变成"工程可维护、可 review、可长期跑"的前提。
- 加分亮点 / 深度追问:可延伸依赖反转(上层依赖接口而非具体实现)、依赖注入在 Agent 服务里的作用,以及分层过度带来的样板代码(YAGNI 权衡)。