PydanticAI 实战:给 AI Agent 套上类型系统,少踩 80% 的坑

PydanticAI 实战:给 AI Agent 套上类型系统,少踩 80% 的坑

用 OpenAI SDK 写过 Agent 的人都知道一个感受:tool calling 的参数校验得自己写,返回值格式得自己 parse,多轮对话的 message 列表管理全靠手动拼。写到第三个 Agent 的时候,你会发现一半代码在处理"胶水逻辑",而不是业务本身。

PydanticAI 就是来解决这个问题的。它是 Pydantic 团队官方出品的 Agent 框架,核心思路很直接------把 Python 的类型系统用到 Agent 开发的每个环节:参数校验、返回值约束、依赖注入、流式输出,全部走类型标注。

这篇文章不是框架对比,不做横评。我从一个实际项目中抽取了几个典型场景,用代码说明 PydanticAI 到底解决了哪些问题、怎么用、以及我踩过哪些坑。

先看一个最小 Agent

装好依赖:

bash 复制代码
pip install pydantic-ai

写一个最基本的 Agent:

python 复制代码
from pydantic_ai import Agent

agent = Agent('openai:gpt-4o', system_prompt='你是一个翻译助手,把用户输入翻译成英文。')

result = agent.run_sync('今天天气不错')
print(result.output)
# Today's weather is nice.

到这一步,看起来跟直接调 OpenAI SDK 区别不大。真正的价值在后面。

结构化输出:让返回值有明确类型

用 raw SDK 做结构化输出,一般是这样的:在 prompt 里写"请返回 JSON 格式",然后 json.loads() 解析,再自己校验字段。字段缺了?类型不对?运行时才炸。

PydanticAI 的做法是直接用 Pydantic Model 当输出类型:

python 复制代码
from pydantic import BaseModel
from pydantic_ai import Agent

class MovieReview(BaseModel):
    title: str
    year: int
    rating: float  # 1-10
    summary: str
    tags: list[str]

agent = Agent(
    'openai:gpt-4o',
    output_type=MovieReview,
    system_prompt='从用户的影评文本中提取结构化信息。',
)

result = agent.run_sync('''
昨晚看了《奥本海默》,诺兰拍得真狠。三个小时没觉得长,
cillian murphy演得太好了,那种内心煎熬全在眼神里。
音效炸裂,IMAX必看。我给9分。
''')

review = result.output
print(f'{review.title} ({review.year}) - {review.rating}/10')
print(f'标签: {", ".join(review.tags)}')
# 奥本海默 (2023) - 9.0/10
# 标签: 诺兰, 传记, IMAX, 历史

output_type=MovieReview 这一行干了三件事:

  1. 让 LLM 知道该返回什么结构(PydanticAI 自动把 schema 塞进请求)
  2. 自动 parse 返回值
  3. 用 Pydantic 做校验------year 不是整数?直接报 ValidationError,不用等到下游业务逻辑出 bug 才发现

IDE 里 result.output 的类型推断也是 MovieReview,自动补全能直接点出 .title.year 这些字段。

依赖注入:让 Tool 能拿到外部资源

PydanticAI 里我觉得最值得说的功能是依赖注入(Dependency Injection)。

场景:你写了个 Agent,它需要调工具查数据库。数据库连接从哪来?硬编码在 tool 函数里?那测试的时候换个 mock 连接怎么办?

PydanticAI 的方案是通过 deps_type 声明依赖类型,运行时注入:

python 复制代码
from dataclasses import dataclass
import httpx
from pydantic_ai import Agent, RunContext

@dataclass
class Deps:
    api_client: httpx.AsyncClient
    api_key: str

agent = Agent(
    'openai:gpt-4o',
    deps_type=Deps,
    system_prompt='你是一个天气查询助手。',
)

@agent.tool
async def get_weather(ctx: RunContext[Deps], city: str) -> str:
    """查询指定城市的天气"""
    client = ctx.deps.api_client
    resp = await client.get(
        'https://api.weatherapi.com/v1/current.json',
        params={'key': ctx.deps.api_key, 'q': city}
    )
    data = resp.json()
    current = data['current']
    return f"{city}: {current['temp_c']}°C, {current['condition']['text']}"

# 运行
async def main():
    async with httpx.AsyncClient() as client:
        deps = Deps(api_client=client, api_key='your-key-here')
        result = await agent.run('北京今天多少度?', deps=deps)
        print(result.output)

几个要点:

  1. RunContext[Deps] 的类型参数 Deps 必须跟 Agent 的 deps_type 一致,不匹配的话 mypy/pyright 会报错------在写代码阶段就能发现,不是运行时
  2. 测试时只要传一个 mock 的 Deps 就行,不用 monkey patch 任何东西
  3. 一个 Agent 的所有 tool 共享同一个 deps 实例,天然避免了资源重复创建

跟 FastAPI 的 Depends() 是一脉相承的思路。如果你用过 FastAPI,上手零成本。

Tool 注册:声明式定义,自动校验参数

PydanticAI 的 tool 定义方式很干净。用装饰器注册,参数自动从函数签名提取 schema:

python 复制代码
from pydantic_ai import Agent, RunContext

agent = Agent('openai:gpt-4o', deps_type=str)

@agent.tool
async def search_docs(ctx: RunContext[str], query: str, max_results: int = 5) -> str:
    """在文档库中搜索相关内容
    
    Args:
        query: 搜索关键词
        max_results: 最大返回结果数,默认5
    """
    # ctx.deps 是项目名称
    project = ctx.deps
    # 实际搜索逻辑
    return f"在 {project} 中搜索 '{query}',找到 {max_results} 条结果..."

函数签名里的类型标注直接变成 tool 的参数 schema。query: str 就是必填字符串参数,max_results: int = 5 就是可选整型参数。函数的 docstring 变成 tool 的描述。

不用再手写一大坨 JSON schema 了。

多种输出类型:Union type 的妙用

一个实际场景:用户问问题,Agent 可能给出文本回答,也可能判断需要升级到人工客服。两种情况的返回结构完全不同。

python 复制代码
from pydantic import BaseModel
from pydantic_ai import Agent

class TextAnswer(BaseModel):
    answer: str
    confidence: float

class Escalation(BaseModel):
    reason: str
    department: str
    priority: int  # 1-5

agent = Agent(
    'openai:gpt-4o',
    output_type=TextAnswer | Escalation,  # Union type
    system_prompt='''你是客服助手。能回答的直接回答,
    遇到投诉、退款、法律问题等敏感话题,升级到人工。''',
)

result = agent.run_sync('我买的手机屏幕碎了,想退货')
output = result.output

if isinstance(output, Escalation):
    print(f'转人工: {output.department} (优先级{output.priority})')
    print(f'原因: {output.reason}')
elif isinstance(output, TextAnswer):
    print(f'回答: {output.answer} (置信度{output.confidence})')

output_type=TextAnswer | Escalation 告诉 LLM:你返回的数据必须是这两种之一。PydanticAI 会自动尝试校验,匹配上哪个就返回哪个。下游代码用 isinstance 做分支,类型完全安全。

流式输出:边生成边校验

长文本生成场景下,等 LLM 全部生成完再返回太慢了。PydanticAI 支持流式输出:

python 复制代码
from pydantic_ai import Agent

agent = Agent('openai:gpt-4o')

async def stream_demo():
    async with agent.run_stream('写一首关于 Python 的打油诗') as response:
        async for chunk in response.stream_text(delta=True):
            print(chunk, end='', flush=True)
    
    # 流结束后,还能拿到完整的结构化结果
    print(f'\n\n用量: {response.usage()}')

结构化输出也能流式:

python 复制代码
from pydantic import BaseModel
from pydantic_ai import Agent

class StoryOutline(BaseModel):
    title: str
    chapters: list[str]
    estimated_words: int

agent = Agent('openai:gpt-4o', output_type=StoryOutline)

async def stream_structured():
    async with agent.run_stream('帮我规划一本关于 Python 设计模式的技术书') as response:
        async for partial in response.stream_output(debounce_by=0.1):
            # partial 是部分解析的 StoryOutline
            # 字段逐步填充,可以做进度展示
            print(f'已解析: title={partial.title}')

stream_output() 会在数据到达时尝试部分解析。标题先出来了就能先展示,不用等整个结构生成完。debounce_by=0.1 控制更新频率,避免过于频繁触发 UI 重渲染。

对话历史管理

多轮对话的 message 管理,用 raw SDK 得自己维护一个 list,每轮追加 user/assistant/tool message。PydanticAI 把这个流程做到了 API 层面:

python 复制代码
from pydantic_ai import Agent

agent = Agent('openai:gpt-4o', system_prompt='你是编程助手。')

# 第一轮
result1 = agent.run_sync('Python 的 GIL 是什么?')
print(result1.output)

# 第二轮,传入上一轮的 message_history
result2 = agent.run_sync(
    '那它对多线程有什么影响?',
    message_history=result1.all_messages(),
)
print(result2.output)

# 第三轮
result3 = agent.run_sync(
    '有什么绕过方案?',
    message_history=result2.all_messages(),
)
print(result3.output)

result.all_messages() 返回整个对话历史,包括 system prompt、tool calls、tool results。传给下一轮就能保持上下文。

也可以做持久化------all_messages() 返回的是可序列化的 dataclass 列表,直接 json dump 存数据库:

python 复制代码
import json
from pydantic_ai.messages import ModelMessagesTypeAdapter

# 序列化
messages = result.all_messages()
json_bytes = ModelMessagesTypeAdapter.dump_json(messages)

# 反序列化
loaded = ModelMessagesTypeAdapter.validate_json(json_bytes)
result_next = agent.run_sync('继续', message_history=loaded)

多模型切换

同一个 Agent 逻辑,换个模型试试效果?创建 Agent 时不指定模型,运行时传入:

python 复制代码
from pydantic_ai import Agent

agent = Agent(system_prompt='你是代码审查专家。')

# 用 GPT-4o 跑一遍
result_gpt = agent.run_sync(
    '审查这段代码: def add(a, b): return a + b',
    model='openai:gpt-4o'
)

# 换 Claude 跑一遍
result_claude = agent.run_sync(
    '审查这段代码: def add(a, b): return a + b',
    model='anthropic:claude-sonnet-4-20250514'
)

# 用 DeepSeek 跑一遍
result_ds = agent.run_sync(
    '审查这段代码: def add(a, b): return a + b',
    model='deepseek:deepseek-chat'
)

Agent 定义、tool 定义、输出类型全不用改。模型只是一个运行参数。做 A/B 测试、模型效果对比的时候特别方便。

踩坑记录

用了一个月,踩过的几个坑记下来。

坑 1:output_type 用复杂嵌套模型时,小模型经常校验失败

我用 gpt-4o-mini 搭配一个三层嵌套的 Pydantic Model 做输出,失败率大概 30%。原因是小模型生成的 JSON 结构容易出错------漏字段、类型不对、数组里混了 null。

解法:对小模型用扁平的输出结构,嵌套不超过两层。或者用 output_typestrict=True 模式(需要模型支持 structured outputs)。

坑 2:tool 函数的 docstring 格式影响调用准确率

Google 风格的 docstring(带 Args: 段落)对 tool 参数描述的效果比较好。我一开始偷懒没写 docstring,LLM 对参数的理解全靠参数名猜测,结果经常传错值。

加上详细的 docstring 后,调用准确率从大概 70% 提升到 95% 以上。

坑 3:deps 对象不要放不可序列化的东西

deps 对象会被 PydanticAI 内部传递,如果里面放了数据库连接池、文件句柄这类不可序列化的资源,在某些场景(比如 Logfire 追踪)下会出问题。

好的做法是 deps 里放"配置"(URL、key、config),需要的时候在 tool 内部创建连接。或者用 @dataclass 包装,只存轻量级的引用。

坑 4:流式输出 + 结构化输出的兼容性

不是所有模型都支持 structured output streaming。我用 deepseek-chat 试过 stream_output(),拿到的 partial 对象很多字段是 None,直到整个响应结束才能拿到完整数据。gpt-4oclaude-sonnet 的流式结构化表现好得多。

用之前先确认你的模型是否支持这个特性。

和 raw SDK 对比

用一个具体例子说明差异。需求:让 LLM 查询数据库,返回结构化分析结果。

raw OpenAI SDK 写法(大约 60 行):

python 复制代码
# 手动定义 tool schema
tools = [{
    "type": "function",
    "function": {
        "name": "query_db",
        "description": "查询数据库",
        "parameters": {
            "type": "object",
            "properties": {
                "sql": {"type": "string", "description": "SQL语句"}
            },
            "required": ["sql"]
        }
    }
}]

# 手动处理 tool calling 循环
messages = [{"role": "system", "content": "你是数据分析助手"}]
messages.append({"role": "user", "content": "查一下上月销售额"})

while True:
    response = client.chat.completions.create(
        model="gpt-4o", messages=messages, tools=tools
    )
    msg = response.choices[0].message
    if msg.tool_calls:
        for call in msg.tool_calls:
            # 手动解析参数
            args = json.loads(call.function.arguments)
            result = execute_sql(args['sql'])
            messages.append(msg)
            messages.append({
                "role": "tool",
                "tool_call_id": call.id,
                "content": str(result)
            })
    else:
        # 手动 parse 返回的 JSON
        try:
            analysis = json.loads(msg.content)
        except json.JSONDecodeError:
            analysis = {"error": "解析失败"}
        break

PydanticAI 写法(大约 25 行):

python 复制代码
from pydantic import BaseModel
from pydantic_ai import Agent, RunContext

class SalesAnalysis(BaseModel):
    total_revenue: float
    top_product: str
    growth_rate: float

agent = Agent('openai:gpt-4o', deps_type=str, output_type=SalesAnalysis,
              system_prompt='你是数据分析助手。')

@agent.tool
async def query_db(ctx: RunContext[str], sql: str) -> str:
    """执行SQL查询数据库
    Args:
        sql: 要执行的SQL语句
    """
    return execute_sql(sql)

result = agent.run_sync('查一下上月销售额', deps='sales_db')
analysis = result.output  # 类型是 SalesAnalysis,IDE 自动补全可用
print(f'总营收: {analysis.total_revenue}')

代码量少了一半多,tool calling 循环、参数解析、结果校验全由框架处理。改字段?改 SalesAnalysis 的定义就行,上下游自动同步。

适用场景和不适用场景

适合用 PydanticAI 的场景:

  • 需要结构化输出的数据提取/分析任务
  • 需要 tool calling 的 Agent
  • 需要类型安全和 IDE 支持的生产项目
  • 需要依赖注入做测试的场景
  • 多模型切换和效果对比

不太适合的场景:

  • 纯聊天机器人(没结构化输出需求,用 raw SDK 更轻量)
  • 已经深度集成 LangChain 的项目(迁移成本高)
  • 需要复杂的多 Agent 编排(LangGraph 在图编排上更成熟)

小结

PydanticAI 做的事情不复杂------把 Python 类型系统和 Pydantic 验证引入 Agent 开发。但这个看起来简单的决定,在实际项目中省了大量胶水代码和运行时 bug。

跟 FastAPI 改变 Web 开发体验的思路一样:不是发明新概念,而是用好 Python 已有的东西,让开发体验回到"写代码"而不是"拼 JSON"。

安装:pip install pydantic-ai,文档:ai.pydantic.dev。建议从一个小的数据提取任务开始试,体验一下类型驱动的 Agent 开发是什么感觉。

相关推荐
imbackneverdie4 小时前
读研有哪些常用的科研工具
人工智能·ai·aigc·科研绘图·研究生·ai工具·科研工具
小手智联老徐4 小时前
OpenClaw 5 月技术演进:从语音桥接到 Control UI 重构
ai·aigc·openclaw
小谢取证5 小时前
Claude Code桌面版启动!!!
aigc
sunneo7 小时前
专栏E-产品品牌与叙事-05-产品发布学
人工智能·产品运营·aigc·产品经理·ai-native
Aision_17 小时前
Agent 为什么需要 Checkpoint?
人工智能·python·gpt·langchain·prompt·aigc·agi
爱吃的小肥羊21 小时前
Codex 居然能剪视频了!我实测了两个案例,结果出乎意料
aigc·openai
少年白马醉春风丶1 天前
从零构建 AIGC 无限画布:AIGCCanvasFlow 技术全解析
前端·后端·aigc
Awu12271 天前
🍎Vue官方Skills深度解读:那些被悄悄藏起来的宝藏
前端·aigc·claude
Awu12271 天前
⚡精通Claude第7课-Plugins实战指南
人工智能·aigc·claude