先说结论
Agent 最怕什么?LLM 胡说八道,下游代码崩溃。
你让 LLM 生成选题,它返回一坨散文;你让它输出 JSON,它给你套个 ```json 代码块;你让它返回列表,它返回单个对象。格式不稳定,是 Agent 开发中最普遍、最让人头疼的问题。
解法只有一个:Schema 驱动开发 — 先定义数据结构,再写逻辑。
Pydantic 就是干这个的。它不是"可选的数据校验库",而是 Agent 项目的基础设施。在 self-media-agent 项目里,人设、选题、内容、热点、聊天、配置 — 每一个核心数据结构都是一个 Pydantic 模型。LLM 的每一次结构化输出,都经过 Pydantic 校验才进入下游。
一句话:没有 Schema 的 Agent 是在沙子上盖楼,加了 Schema 才有地基。
一、为什么 Agent 的每个输入输出都需要 Schema?
没有 Schema 的世界:处处是雷
先看一个没有 Schema 的例子:
# ❌ 裸 dict:LLM 返回什么全凭运气
result = await llm.generate(system_prompt="生成选题", user_prompt="美妆赛道")
data = json.loads(result) # LLM 返回的不是合法JSON?崩
for topic in data["topics"]: # 没有 topics 字段?崩
print(topic["title"]) # 没有 title 字段?崩
print(topic["category"]) # category 拼成 cateogry?崩
问题清单:
| 问题 | 发生频率 | 后果 |
|---|---|---|
| LLM 返回的不是合法 JSON | 经常 | json.loads 崩溃 |
| JSON 套了 ```json 代码块 | 经常 | 解析失败 |
| 字段名不一致(title vs Title vs 标题) | 偶尔 | KeyError |
| 字段类型不对(estimated_ctr 返回字符串 "0.8") | 偶尔 | 下游计算出错 |
| 缺少必填字段 | 偶尔 | KeyError |
| 多了意料之外的字段 | 经常 | 不崩但数据污染 |
有 Schema 的世界:校验兜底
# ✅ Pydantic Schema:校验 + 解析一体化
result = await llm.generate_json(
system_prompt="生成选题",
user_prompt="美妆赛道",
schema=TopicList, # 告诉LLM输出格式,自动校验解析
)
for topic in result.topics: # 一定是 list[TopicItem]
print(topic.title) # 一定是 str
print(topic.category) # 一定是 str,有默认值
Pydantic 做了什么:
- 告诉 LLM 该输出什么 —
schema.model_json_schema()自动生成 JSON Schema 注入 Prompt - 校验 LLM 的输出 — 类型不对、字段缺失,立刻报错而不是静默污染
- 给字段兜底 — 缺少可选字段时用默认值,不崩
- 类型转换 —
"0.8"字符串自动转0.8浮点数
这就是 Schema 驱动开发:先定义数据结构(Schema),再写处理逻辑。数据结构是契约,所有代码围绕契约展开。
二、Pydantic BaseModel:类型校验 + 序列化一体化
基础模型:一个字段就是一个契约
项目里最核心的模型 — PersonaConfig(人设配置),定义在 persona/schema.py:
from pydantic import BaseModel, Field, model_validator
class PersonaConfig(BaseModel):
"""人设 & 赛道配置 — 一次设定永久生效"""
id: str = Field(default="", description="唯一标识,自动生成")
name: str = Field(..., description="人设名称")
niche: str = Field(default="beauty", description="垂直赛道")
writing_style: str = Field(default="casual", description="文案风格")
content_format: str = Field(default="xiaohongshu", description="内容形式")
# 格式模板
emoji_frequency: str = Field(default="medium", description="emoji 频率")
paragraph_max_chars: int = Field(default=150, description="段落最大字数")
# 禁忌规则
banned_words: list[str] = Field(default_factory=list, description="禁用词列表")
no_exaggeration: bool = Field(default=True, description="禁止夸大宣传")
# LLM 参数
temperature: float = Field(default=0.7, description="生成温度")
# 风格进化(从修改中学习)
style_profile: str = Field(default="", description="风格画像摘要")
style_preferences: list[dict] = Field(default_factory=list, description="结构化风格偏好列表")
每一个字段都在做三件事:
| 组成 | 作用 | 示例 |
|---|---|---|
类型注解 str / int / list[str] | 类型校验 | 传 temperature="abc" → 报错 |
Field 默认值 default=0.7 | 缺省兜底 | 不传 temperature → 用 0.7 |
Field 描述 description="生成温度" | 自文档 + JSON Schema | 自动生成 API 文档 |
Field(...) vs Field(default=...)
name: str = Field(..., description="人设名称") # 必填:不传就报错
id: str = Field(default="", description="唯一标识") # 可选:不传用默认值
banned_words: list[str] = Field(default_factory=list) # 可变默认值:必须用 default_factory
踩坑:default=[] 是 Python 的经典陷阱 — 所有实例共享同一个列表。Pydantic 用 default_factory=list 解决:每次创建实例时调用 list() 生成新列表。
创建实例:校验在构造时发生
# 正常创建
persona = PersonaConfig(name="美妆小达人")
print(persona.name) # "美妆小达人"
print(persona.niche) # "beauty"(默认值)
print(persona.temperature) # 0.7(默认值)
print(persona.banned_words) # [](默认值,独立列表)
# 类型错误 → 立刻报错
persona = PersonaConfig(name="测试", temperature="很热")
# ValidationError: Input should be a valid number [type=float_type]
关键:校验在构造时发生,不是运行时。 错误数据进不了系统,这是 Pydantic 最核心的价值。
三、Enum + Field:给字段加上语义约束
裸字符串的问题
# ❌ 裸字符串:什么都能传
persona = PersonaConfig(name="测试", niche="美妆") # 中文
persona = PersonaConfig(name="测试", niche="beauty") # 英文
persona = PersonaConfig(name="测试", niche="whatever") # 乱传
下游代码 if persona.niche == "beauty" 对不上中文,"whatever" 不在预期内但不会报错 — 静默 bug。
Enum:限定取值范围
项目用 str, Enum 混入,既限定取值又兼容字符串操作:
from enum import Enum
class Niche(str, Enum):
"""垂直赛道"""
BEAUTY = "beauty"
CAREER = "career"
PARENTING = "parenting"
SIDEJOB = "sidejob"
TECH = "tech"
LOCAL = "local"
FITNESS = "fitness"
FOOD = "food"
TRAVEL = "travel"
EDUCATION = "education"
class WritingStyle(str, Enum):
"""文案风格"""
CASUAL = "casual"
FORMAL = "formal"
HEALING = "healing"
SHARP = "sharp"
MINIMALIST = "minimalist"
class ContentFormat(str, Enum):
"""内容形式"""
XIAOHONGSHU = "xiaohongshu"
SHORT_VIDEO = "short_video"
ARTICLE = "article"
QA = "qa"
为什么用 str, Enum 而不是纯 Enum?
# 纯 Enum:和字符串比较需要 .value
if persona.niche == Niche.BEAUTY.value # 麻烦
# str, Enum:可以直接和字符串比较
if persona.niche == "beauty" # 可以
if persona.niche == Niche.BEAUTY # 也可以
print(f"赛道:{persona.niche}") # 直接输出 "beauty",不是 Niche.BEAUTY
项目里 5 个核心 Enum:
| Enum | 用在哪 | 限定值 |
|---|---|---|
Niche | PersonaConfig.niche | 10 个赛道 |
PersonaType | PersonaConfig.persona_type | 5 种人设类型 |
WritingStyle | PersonaConfig.writing_style | 5 种文案风格 |
ContentFormat | PersonaConfig.content_format | 4 种内容形式 |
EmojiFrequency | PersonaConfig.emoji_frequency | 4 档频率 |
Field 的数值约束:ge / le
# content/schema.py — 质检分数约束在 0~1 之间
class QualityReport(BaseModel):
score: float = Field(default=1.0, ge=0.0, le=1.0, description="质检分数")
# hotspot/schema.py — 热度分数约束在 0~100
class HotspotItem(BaseModel):
heat_score: float = Field(default=50.0, ge=0, le=100, description="热度分数 0-100")
ge=0.0:greater than or equal,下界le=1.0:less than or equal,上界
# 超出范围 → 报错
report = QualityReport(score=1.5)
# ValidationError: Input should be less than or equal to 1.0
Enum 管离散取值,Field 约束管连续取值,两者配合让数据"不可能出错"。
四、嵌套模型:像搭积木一样组装复杂数据
真实需求:一个内容对象包含质检报告
content/schema.py 中的 GeneratedContent 嵌套了 QualityReport:
class QualityReport(BaseModel):
"""质检报告"""
passed: bool = Field(default=True, description="是否通过")
violations: list[str] = Field(default_factory=list, description="违规项")
score: float = Field(default=1.0, ge=0.0, le=1.0, description="质检分数")
details: dict = Field(default_factory=dict, description="详细信息")
class GeneratedContent(BaseModel):
"""生成内容成品"""
id: str = Field(default="", description="唯一标识")
persona_id: str = Field(..., description="关联人设 ID")
title: str = Field(..., description="爆款标题")
body: str = Field(..., description="正文内容")
word_count: int = Field(default=0, description="字数")
quality_score: float = Field(default=1.0, ge=0.0, le=1.0, description="质检分数")
quality_report: QualityReport = Field(default_factory=QualityReport, description="质检报告")
status: str = Field(default="draft", description="内容状态")
created_at: datetime = Field(default_factory=datetime.now, description="创建时间")
嵌套模型的好处:
- 结构清晰 —
content.quality_report.score比content["quality_report"]["score"]有自动补全 - 校验传递 — 创建
GeneratedContent时,quality_report字段会自动用QualityReport校验 - 独立复用 —
QualityReport可以单独用在质检模块,不用拆出来
# 创建嵌套结构:自动递归校验
content = GeneratedContent(
persona_id="abc123",
title="夏季防晒的5个误区",
body="...",
quality_report=QualityReport(
passed=False,
violations=["包含AI书面语"],
score=0.6,
),
)
# 点号访问,类型安全
print(content.quality_report.score) # 0.6
print(content.quality_report.violations) # ["包含AI书面语"]
嵌套列表:ChatSession 包含消息和修改记录
chat/schema.py 展示了更深的嵌套 — 一个会话包含消息列表和修改记录列表:
class ChatMessage(BaseModel):
"""聊天消息"""
id: str = Field(default="", description="消息 ID")
session_id: str = Field(..., description="所属会话 ID")
role: MessageRole = Field(..., description="角色")
content: str = Field(..., description="消息内容")
created_at: datetime = Field(default_factory=datetime.now)
class RevisionRecord(BaseModel):
"""修改记录 — 每次根据建议重新生成都会产生一条记录"""
id: str = Field(default="", description="记录 ID")
session_id: str = Field(..., description="所属会话 ID")
content_id: str = Field(..., description="关联内容 ID")
suggestion: str = Field(..., description="用户的修改建议")
original_body: str = Field(..., description="修改前正文")
revised_body: str = Field(..., description="修改后正文")
created_at: datetime = Field(default_factory=datetime.now)
class ChatSession(BaseModel):
"""聊天会话 — 绑定到某篇内容"""
id: str = Field(default="", description="会话 ID")
content_id: str = Field(..., description="关联内容 ID")
messages: list[ChatMessage] = Field(default_factory=list, description="消息列表")
revisions: list[RevisionRecord] = Field(default_factory=list, description="修改记录")
created_at: datetime = Field(default_factory=datetime.now)
updated_at: datetime = Field(default_factory=datetime.now)
数据结构一目了然:
ChatSession
├── messages: list[ChatMessage] ← 短期记忆:对话上下文
├── revisions: list[RevisionRecord] ← 版本链:每次修改的完整 diff
├── content_id: str ← 关联哪篇内容
└── persona_id: str ← 关联哪个人设
这就是用 Pydantic 建模的力量 — 数据结构和业务语义对齐,看 Schema 就懂业务。
五、model_validator:自动ID生成与自定义校验
自动 ID 生成:每个模型都有
项目里几乎每个模型都有一个 ensure_id 验证器 — 创建时如果不传 id,自动生成:
from pydantic import model_validator
class PersonaConfig(BaseModel):
id: str = Field(default="", description="唯一标识,自动生成")
name: str = Field(..., description="人设名称")
@model_validator(mode="after")
def ensure_id(self) -> "PersonaConfig":
if not self.id:
import uuid
object.__setattr__(self, "id", uuid.uuid4().hex[:12])
return self
model_validator(mode="after") 的含义:
mode="after"— 在所有字段校验完成后执行- 此时
self已经是一个完整对象,可以安全地读取和修改字段 - 返回
self— 返回修改后的对象
# 不传 id → 自动生成
persona = PersonaConfig(name="美妆小达人")
print(persona.id) # "a3f8b2c1d9e4"(自动生成的12位hex)
# 传了 id → 保留
persona = PersonaConfig(id="my-custom-id", name="美妆小达人")
print(persona.id) # "my-custom-id"
项目里 6 个模型都用了这个模式:PersonaConfig、Topic、GeneratedContent、ChatMessage、RevisionRecord、ChatSession。
为什么用 object.__setattr__ 而不是 self.id = ...?
Pydantic V2 默认模型是不可变的(frozen=True 时),即使没设 frozen,在 validator 中直接赋值也可能触发额外校验。用 object.__setattr__ 绕过 Pydantic 的 __setattr__,直接在底层设值,更高效也更安全。
自定义业务校验:model_compute_word_count
GeneratedContent 还有一个业务方法 — 计算正文字数:
class GeneratedContent(BaseModel):
body: str = Field(..., description="正文内容")
word_count: int = Field(default=0, description="字数")
def compute_word_count(self) -> int:
"""计算正文字数(中文按字符计)"""
self.word_count = len(self.body.replace("\n", "").replace(" ", ""))
return self.word_count
Pydantic 模型不只是数据容器,可以带业务方法。 这和 dataclass 的理念一致 — 数据和行为放在一起。
六、LLM 输出 → Pydantic 模型:结构化解析
这是 Pydantic 在 Agent 项目中最重要的应用 — 把 LLM 的非结构化输出变成结构化数据。
generate_json:Schema 驱动的结构化输出
llm/client.py 中的 generate_json 方法,是连接 LLM 和 Pydantic 的桥梁:
class LLMClient:
async def generate_json(
self,
system_prompt: str,
user_prompt: str,
schema: Type[BaseModel], # ← 传入 Pydantic 模型类
temperature: float = 0.7,
) -> BaseModel:
# 1. 自动生成 JSON Schema,注入 Prompt
json_instruction = (
f"\n\n**输出格式要求**:请严格输出 JSON 格式,"
f"符合以下 schema:\n```json\n"
f"{json.dumps(schema.model_json_schema(), ensure_ascii=False, indent=2)}\n"
f"```\n只输出 JSON,不要输出任何其他内容。"
)
enhanced_system = system_prompt + json_instruction
# 2. 调用 LLM
raw = await self.generate(system_prompt=enhanced_system, user_prompt=user_prompt)
# 3. 提取 JSON(处理 markdown 代码块包裹)
json_str = self._extract_json(raw)
# 4. 解析 + 校验
data = json.loads(json_str)
data = self._wrap_list_to_dict(data, schema) # 智能包装
return schema.model_validate(data) # ← Pydantic 校验
整个流程:
Pydantic Schema ──→ model_json_schema() ──→ JSON Schema 注入 Prompt
↓
LLM 生成 JSON 文本
↓
_extract_json() 去代码块
↓
json.loads() 解析
↓
model_validate() 校验
↓
Pydantic 模型实例 ✅
实战:选题生成
topic/generator.py 用 TopicList schema 让 LLM 输出结构化选题:
# schema 定义(topic/schema.py)
class TopicItem(BaseModel):
"""选题条目(LLM 输出用,仅包含 LLM 应生成的字段)"""
title: str = Field(..., description="选题方向")
category: str = Field(default="knowledge", description="分类")
content_format: str = Field(default="xiaohongshu", description="适配内容形式")
estimated_potential: str = Field(default="medium", description="预估流量")
class TopicList(BaseModel):
"""选题列表(LLM 结构化输出用)"""
topics: list[TopicItem] = Field(default_factory=list)
# 调用(topic/generator.py)
result = await self.llm.generate_json(
system_prompt=system_prompt,
user_prompt=user_prompt,
schema=TopicList, # ← 传入 schema
temperature=0.8,
)
# result 是 TopicList 实例,result.topics 是 list[TopicItem]
topics = [
Topic(
persona_id=persona.id,
title=item.title,
category=item.category,
source=TopicSource.AI_GENERATED,
)
for item in result.topics
]
注意区分两个模型:
TopicItem— LLM 输出用的轻量模型,只有 4 个字段Topic— 系统内部用的完整模型,有 id、persona_id、status、created_at 等
为什么分开? LLM 不需要知道 id、persona_id、created_at 这些系统字段 — 这些是代码生成的,不是 LLM 生成的。给 LLM 的 schema 越简洁,输出越稳定。
实战:标题生成
content/title.py 用同样的模式:
class TitleResult(BaseModel):
"""标题生成结果"""
title: str = Field(..., description="标题文本")
formula_type: str = Field(default="", description="使用的爆款公式类型")
estimated_ctr: float = Field(default=0.5, ge=0.0, le=1.0, description="预估点击率")
class TitleListResult(BaseModel):
"""标题列表结果"""
titles: list[TitleResult] = Field(default_factory=list)
result = await self.llm.generate_json(
system_prompt=system_prompt,
user_prompt=user_prompt,
schema=TitleListResult,
temperature=0.8,
)
# 按预估点击率排序
result.titles.sort(key=lambda x: x.estimated_ctr, reverse=True)
return [t.title for t in result.titles]
estimated_ctr 有 ge=0.0, le=1.0 约束 — LLM 返回 1.5 会被 Pydantic 拦截,不会污染排序逻辑。
容错:_extract_json 处理 LLM 的各种"任性"
LLM 经常不乖乖输出纯 JSON,_extract_json 方法处理三种情况:
@staticmethod
def _extract_json(text: str) -> str:
text = text.strip()
# 情况1:```json ... ``` 代码块
if "```json" in text:
start = text.index("```json") + 7
end = text.index("```", start)
return text[start:end].strip()
# 情况2:``` ... ``` 代码块(没标 json)
if "```" in text:
start = text.index("```") + 3
end = text.index("```", start)
return text[start:end].strip()
# 情况3:裸 JSON,找到匹配的括号
for open_char, close_char in [("{", "}"), ("[", "]")]:
if open_char in text:
start = text.index(open_char)
depth = 0
for i in range(start, len(text)):
if text[i] == open_char:
depth += 1
elif text[i] == close_char:
depth -= 1
if depth == 0:
return text[start : i + 1]
return text
LLM 输出的三种"任性"形态:
形态1: ```json\n{"topics": [...]}\n``` ← 最常见
形态2: 好的,以下是结果:\n```json\n...\n``` ← 带前缀废话
形态3: {"topics": [...]} ← 裸 JSON(理想情况)
智能包装:_wrap_list_to_dict
有时 LLM 会直接返回列表 [{...}, {...}] 而不是 {"topics": [{...}, {...}]},_wrap_list_to_dict 自动找到 schema 中的 list 字段包装:
@staticmethod
def _wrap_list_to_dict(data: object, schema: Type[BaseModel]) -> object:
if not isinstance(data, list):
return data
# 在 schema 的字段中找到 list 类型的字段
for field_name, field_info in schema.model_fields.items():
annotation = field_info.annotation
if annotation is list or (hasattr(annotation, "__origin__") and annotation.__origin__ is list):
return {field_name: data} # {"topics": [...]}
return data
LLM 返回 [{...}, ...] → 自动包装成 {"topics": [{...}, ...]} → 匹配 TopicList schema。
降级方案:Pydantic 校验失败时的兜底
即使有了 Schema,LLM 仍可能输出完全无法解析的内容。项目里每个 generate_json 调用都有降级方案:
# topic/generator.py
try:
result = await self.llm.generate_json(
system_prompt=system_prompt,
user_prompt=user_prompt,
schema=TopicList,
)
topics = [Topic(...) for item in result.topics]
except Exception as e:
logger.warning(f"LLM 结构化选题生成失败,尝试文本解析: {e}")
# 降级:纯文本生成 + 手动解析
raw = await self.llm.generate(system_prompt=system_prompt, user_prompt=user_prompt)
topics = self._parse_topics_from_text(raw, persona.id)
三层防线:
| 层级 | 策略 | 触发条件 |
|---|---|---|
| 第1层 | generate_json + Pydantic 校验 | 正常情况 |
| 第2层 | _extract_json 去代码块 | LLM 套了 ```json |
| 第3层 | 纯文本降级解析 | JSON 完全解析失败 |
Schema 不是银弹,Schema + 降级才是完整方案。
七、model_dump / model_validate:序列化与反序列化
Agent 的数据需要落盘存储、再从磁盘恢复。Pydantic 的 model_dump() 和 model_validate() 让这件事变得极其简单。
序列化:model_dump
storage/persistence.py 把所有模型序列化后存到 YAML/JSON 文件:
class FilePersistence:
def save(self, store: MemoryStore) -> None:
data = {
"personas": [p.model_dump(mode="json") for p in store.list_personas()],
"topics": [t.model_dump(mode="json") for t in store._topics.values()],
"contents": [c.model_dump(mode="json") for c in store.list_contents()],
}
# 写入 YAML 文件
with open(file_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, allow_unicode=True, ...)
model_dump(mode="json") 的作用:
# 普通 model_dump():datetime 保持 datetime 对象
persona.model_dump()
# {'id': 'abc', 'created_at': datetime(2026, 7, 30, ...), ...}
# model_dump(mode="json"):datetime 转 ISO 格式字符串
persona.model_dump(mode="json")
# {'id': 'abc', 'created_at': '2026-07-30T10:30:00', ...}
mode="json" 让所有类型变成 JSON 可序列化的 — datetime 变字符串、Enum 变字符串、嵌套模型递归处理。这样才能直接 yaml.dump 或 json.dump。
反序列化:model_validate
从文件恢复时,用 model_validate 把字典变回模型:
class FilePersistence:
def load(self, store: MemoryStore) -> None:
with open(file_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
# 恢复人设
from ..persona.schema import PersonaConfig
for p_data in data.get("personas", []):
try:
persona = PersonaConfig.model_validate(p_data) # ← 字典 → 模型
store.save_persona(persona)
except Exception as e:
logger.warning(f"恢复人设失败: {e}")
# 恢复选题
from ..topic.schema import Topic
for t_data in data.get("topics", []):
try:
topic = Topic.model_validate(t_data)
store.save_topic(topic)
except Exception as e:
logger.warning(f"恢复选题失败: {e}")
完整的存取闭环:
PersonaConfig 实例
↓ model_dump(mode="json")
{'id': 'abc', 'name': '美妆小达人', 'created_at': '2026-07-30T...', ...}
↓ yaml.dump
YAML 文件
↓ yaml.safe_load
{'id': 'abc', 'name': '美妆小达人', 'created_at': '2026-07-30T...', ...}
↓ model_validate
PersonaConfig 实例 ✅
一行序列化,一行反序列化,自动处理嵌套模型、Enum、datetime — 这就是 Pydantic 的力量。
容错:单条恢复失败不影响其他
注意 load 方法里每条恢复都包了 try/except:
for p_data in data.get("personas", []):
try:
persona = PersonaConfig.model_validate(p_data)
store.save_persona(persona)
except Exception as e:
logger.warning(f"恢复人设失败: {e}") # 跳过这条,继续下一条
为什么? 持久化文件可能因为版本升级导致字段变更,某条旧数据可能校验失败。一条失败不应该让整个加载崩溃 — 跳过坏的,加载好的。
八、field_validator:配置校验
除了数据模型,Pydantic 还用来做配置校验 — config/models.py 用 field_validator 确保配置值合法:
from pydantic import BaseModel, Field, field_validator
class LLMConfig(BaseModel):
"""LLM 调用配置"""
base_url: str = "https://api.deepseek.com/v1"
api_key: str = ""
model: str = "deepseek-chat"
temperature: float = 0.7
max_tokens: int = 4096
@field_validator("temperature")
@classmethod
def validate_temperature(cls, v: float) -> float:
if not 0.0 <= v <= 2.0:
raise ValueError("temperature 必须在 0.0 ~ 2.0 之间")
return v
@field_validator("max_tokens")
@classmethod
def validate_max_tokens(cls, v: int) -> int:
if v <= 0:
raise ValueError("max_tokens 必须为正整数")
return v
field_validator vs model_validator:
field_validator | model_validator | |
|---|---|---|
| 校验范围 | 单个字段 | 整个模型 |
| 执行时机 | 字段赋值时 | 所有字段校验后 |
| 适用场景 | 单字段值域校验 | 跨字段校验、自动生成 |
| 示例 | temperature ∈ [0, 2] | id 为空时自动生成 |
嵌套配置模型:AppConfig
配置也是嵌套的 — AppConfig 包含 LLM、存储、输出、质检、缓存等子配置:
class AppConfig(BaseModel):
"""应用全局配置"""
llm: LLMConfig = Field(default_factory=LLMConfig)
storage: StorageConfig = Field(default_factory=StorageConfig)
output: OutputConfig = Field(default_factory=OutputConfig)
quality: QualityConfig = Field(default_factory=QualityConfig)
cache: CacheConfig = Field(default_factory=CacheConfig)
task: TaskConfig = Field(default_factory=TaskConfig)
web: WebConfig = Field(default_factory=WebConfig)
hotspot: HotspotConfig = Field(default_factory=HotspotConfig)
从 YAML 加载配置时,Pydantic 自动校验所有层级:
# config/default.yaml
# llm:
# temperature: 0.7
# max_tokens: 4096
# storage:
# mode: persist
# 加载
import yaml
from .models import AppConfig
with open("config/default.yaml") as f:
raw = yaml.safe_load(f)
config = AppConfig.model_validate(raw) # ← 一次性校验所有配置
# 如果 temperature: 5.0 → 立刻报错,不会等到运行时才发现
配置错误在启动时就暴露,而不是运行时随机崩溃 — 这是 Pydantic 对运维的贡献。
九、Schema 驱动开发的完整图景
把上面所有内容串起来,看看 Schema 在 Agent 项目中的全局角色:
┌─────────────────────────┐
│ Pydantic Schema 层 │
│ │
│ PersonaConfig │
│ Topic / TopicItem │
│ GeneratedContent │
│ QualityReport │
│ ChatSession │
│ HotspotItem │
│ AppConfig │
└────────────┬────────────┘
│
┌────────────────────┼────────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ LLM 输出层 │ │ 存储持久化层 │ │ 配置加载层 │
│ │ │ │ │ │
│ generate_json│ │ model_dump │ │ model_validate│
│ model_validate│ │ model_validate│ │ field_validator│
└──────────────┘ └──────────────┘ └──────────────┘
Schema 是所有模块的公共契约:
| 模块 | 用 Schema 做什么 |
|---|---|
| LLM 调用 | model_json_schema() 生成输出格式,model_validate() 校验结果 |
| 选题生成 | TopicList 约束 LLM 输出,TopicItem 只暴露必要字段 |
| 标题生成 | TitleListResult 约束输出,estimated_ctr 有值域约束 |
| 内容生成 | GeneratedContent 嵌套 QualityReport,质检结果内嵌 |
| 聊天修改 | ChatSession 嵌套 ChatMessage + RevisionRecord,版本链结构化 |
| 持久化 | model_dump(mode="json") 序列化,model_validate() 反序列化 |
| 配置加载 | AppConfig 嵌套子配置,field_validator 校验值域 |
一句话总结:Schema 是 Agent 的骨架 — LLM 输出靠它校验,数据存储靠它序列化,配置加载靠它兜底。
踩坑总结
| 坑 | 根因 | 修复 | ||
|---|---|---|---|---|
| LLM 返回的不是合法 JSON | LLM 不稳定 | generate_json + _extract_json 容错解析 | ||
| LLM 返回 ` ``json 代码块 | LLM 的 markdown 习惯 | _extract_json 去掉代码块标记 | ||
| LLM 返回列表而非对象 | LLM 忽略外层结构 | _wrap_list_to_dict 智能包装 | ||
| 字段名不一致 | 没有统一 Schema | 用 Pydantic 模型定义字段契约 | ||
| 字段类型不对 | 裸 dict 无类型约束 | Pydantic 自动类型转换 + 校验 | ||
| 缺少必填字段 | LLM 偶尔丢字段 | Field(default=...) 给可选字段兜底 | ||
| 可变默认值共享 | Python default=[] 陷阱 | 用 default_factory=list | ||
| 持久化 datetime 报错 | JSON 不支持 datetime | model_dump(mode="json") 自动转 ISO 字符串 | ||
| 旧数据恢复失败 | 版本升级字段变更 | 每条 try/except,跳过坏的加载好的 | ||
| 配置错误运行时才暴露 | 没有启动时校验 | AppConfig.model_validate() 启动时校验 |
经验总结
- Schema 驱动开发是 Agent 的地基 — 先定义数据结构再写逻辑,LLM 的输出经过校验才进入下游,不给"胡说八道"留机会
- Enum 管离散取值,Field 约束管连续取值 — 两者配合让数据"不可能出错",错误在构造时就暴露
- 嵌套模型让数据结构和业务语义对齐 —
ChatSession包含messages和revisions,看 Schema 就懂业务 - LLM 输出用的 Schema 要尽量简洁 —
TopicItem只有 4 个字段,不给 LLM 不该生成的字段,输出越简洁越稳定 - Schema + 降级才是完整方案 — Pydantic 校验失败时要有纯文本降级解析,三层防线确保不崩
下篇预告
下一篇讲 流水线编排 — 把"一步生成"拆成"一链流水线",数据在步骤间流转,从 pipeline/runner.py 看人设→选题→生成→质检→输出的完整编排。