Pydantic 数据建模:给Agent装上结构化思维 — Schema驱动开发实战

0 阅读17分钟

先说结论

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 做了什么

  1. 告诉 LLM 该输出什么schema.model_json_schema() 自动生成 JSON Schema 注入 Prompt
  2. 校验 LLM 的输出 — 类型不对、字段缺失,立刻报错而不是静默污染
  3. 给字段兜底 — 缺少可选字段时用默认值,不崩
  4. 类型转换"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用在哪限定值
NichePersonaConfig.niche10 个赛道
PersonaTypePersonaConfig.persona_type5 种人设类型
WritingStylePersonaConfig.writing_style5 种文案风格
ContentFormatPersonaConfig.content_format4 种内容形式
EmojiFrequencyPersonaConfig.emoji_frequency4 档频率

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="创建时间")

嵌套模型的好处

  1. 结构清晰content.quality_report.scorecontent["quality_report"]["score"] 有自动补全
  2. 校验传递 — 创建 GeneratedContent 时,quality_report 字段会自动用 QualityReport 校验
  3. 独立复用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 个模型都用了这个模式PersonaConfigTopicGeneratedContentChatMessageRevisionRecordChatSession

为什么用 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.pyTopicList 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 不需要知道 idpersona_idcreated_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_ctrge=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.dumpjson.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.pyfield_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_validatormodel_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 返回的不是合法 JSONLLM 不稳定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 不支持 datetimemodel_dump(mode="json") 自动转 ISO 字符串
旧数据恢复失败版本升级字段变更每条 try/except,跳过坏的加载好的
配置错误运行时才暴露没有启动时校验AppConfig.model_validate() 启动时校验

经验总结

  1. Schema 驱动开发是 Agent 的地基 — 先定义数据结构再写逻辑,LLM 的输出经过校验才进入下游,不给"胡说八道"留机会
  2. Enum 管离散取值,Field 约束管连续取值 — 两者配合让数据"不可能出错",错误在构造时就暴露
  3. 嵌套模型让数据结构和业务语义对齐ChatSession 包含 messagesrevisions,看 Schema 就懂业务
  4. LLM 输出用的 Schema 要尽量简洁TopicItem 只有 4 个字段,不给 LLM 不该生成的字段,输出越简洁越稳定
  5. Schema + 降级才是完整方案 — Pydantic 校验失败时要有纯文本降级解析,三层防线确保不崩

下篇预告

下一篇讲 流水线编排 — 把"一步生成"拆成"一链流水线",数据在步骤间流转,从 pipeline/runner.py 看人设→选题→生成→质检→输出的完整编排。