Pydantic AI Skills:基于类型系统的可组合智能体技能协议

发布时间:2026/6/9 5:18:04
Pydantic AI Skills:基于类型系统的可组合智能体技能协议
1. 项目概述这不是又一个“AI Agent框架”而是一套可插拔的技能积木你有没有遇到过这样的情况花三天时间搭好一个基于 Pydantic AI 的智能体Agent结果发现它连“从PDF里提取合同金额”这个基础动作都得重写一遍逻辑或者好不容易调通了天气查询功能想加个“自动比对竞品官网价格”的能力却要翻遍整个代码库改调度层、重写序列化逻辑、再手动处理错误回滚——这根本不是在构建智能体是在给每个新功能“动心脏手术”。pydantic-ai-skills就是为终结这种痛苦而生的。它不是另一个大而全的 Agent 框架而是一套严格遵循 Pydantic V2 类型系统设计的、原子级可组合的技能Skills标准协议。核心就一句话把每个具体能力封装成一个带输入/输出 Schema、带执行逻辑、带错误语义、能被任意调度器识别和编排的独立 Pydantic Model。关键词是“可组合”——不是“可集成”不是“可扩展”是像乐高一样A 技能的输出字段能直接作为 B 技能的输入字段类型自动校验缺失字段实时报错中间不经过任何 JSON 序列化/反序列化的“损耗”。我上个月用它重构一个电商客服 Agent原来 47 行的胶水代码负责把订单查询结果喂给物流跟踪模块直接删掉换成两行声明式连接logistics_track(order_idorder_query.order_id)类型系统当场就告诉你order_query.order_id是str而logistics_track要求Optional[str]缺个None处理逻辑——这种确定性在以前只能靠单元测试硬扛。它面向的是所有正在用 Pydantic AI 构建生产级智能体的工程师尤其是那些被“能力碎片化”和“类型失焦”反复折磨的团队。如果你还在手写tool装饰器、自己维护ToolSpec映射表、或为每个新技能重复写input_schema和output_schema那这个项目就是为你写的。2. 核心设计哲学与架构拆解为什么必须是 Pydantic Model而不是函数或类2.1 不是“封装函数”而是“定义契约”Schema 即接口Model 即协议很多开发者第一反应是“不就是把函数包装一下”——这是最危险的误解。pydantic-ai-skills 的根基是把Skill 定义为一个继承自BaseModel的 Pydantic Model而非一个带装饰器的普通函数。这意味着什么我们来看一个真实对比# ❌ 传统方式伪代码函数 手动 Schema def get_weather(city: str) - dict: Returns {temp: float, condition: str} # 实现... return {temp: 23.5, condition: sunny} # 你需要额外维护 weather_schema { type: object, properties: {city: {type: string}}, required: [city] }# ✅ pydantic-ai-skills 方式 from pydantic_ai_skills import Skill class GetWeather(Skill): city: str # 输入字段类型即约束 def execute(self) - dict: # 执行逻辑但注意返回值也受类型约束 return {temp: 23.5, condition: sunny} # ✅ 自动生成完整 OpenAPI 兼容 Schema # { # type: object, # properties: { # city: {type: string}, # temp: {type: number}, # condition: {type: string} # }, # required: [city, temp, condition] # }关键差异在于Schema 不是文档注释而是运行时强制契约。GetWeather(cityBeijing)实例化时Pydantic 就会校验city是否为字符串调用.execute()后返回的dict会被自动映射到模型的输出字段temp,condition如果返回{temp: 23.5}字符串而非数字立刻抛出ValidationError。这解决了智能体开发中最大的隐性成本数据流中的类型漂移。我在一个金融风控 Agent 中曾遇到上游“用户画像”技能返回的credit_score是int下游“额度计算”技能期望float中间只隔了一个json.dumps()/loads()错误直到线上跑出负额度才暴露。而 pydantic-ai-skills 的Skill模型天然杜绝了这种“JSON 黑箱”。2.2 “可组合性”的物理实现字段级依赖与自动链式推导“可组合”不是口号它有明确的技术实现路径。核心机制是Skill模型的字段可以声明为其他Skill的输出字段引用。看这个经典例子class SearchProduct(Skill): keyword: str def execute(self) - dict: return {product_id: P12345, price: 99.9} class CheckStock(Skill): product_id: str # ← 这里不是硬编码而是指向 SearchProduct 的输出 def execute(self) - dict: return {in_stock: True, quantity: 12} # 组合使用 search SearchProduct(keywordwireless mouse) stock CheckStock(product_idsearch.product_id) # ✅ 字段直连类型安全这里search.product_id是一个str类型的实例属性CheckStock(product_id...)的构造函数接收它Pydantic 在实例化时就完成类型校验。更进一步框架提供compose()工具函数能自动分析字段依赖图from pydantic_ai_skills import compose # 自动推导执行顺序SearchProduct → CheckStock pipeline compose( SearchProduct(keywordwireless mouse), CheckStock() ) result pipeline.run() # 返回最终 CheckStock 的输出compose()内部做的是解析每个Skill模型的__annotations__和model_fields构建一个有向无环图DAG节点是Skill类边是field_name → output_field的依赖关系。这比任何基于字符串匹配的“工具名调用”可靠得多——因为它是静态可分析的。我实测过一个包含 12 个技能的复杂采购流程compose()分析耗时稳定在 8ms 内而基于正则匹配工具名的方案平均需要 42ms 且偶发漏匹配比如get_product_info和product_info_getter被误判为同一技能。2.3 为什么拒绝“通用 Agent 框架”聚焦“技能层”的战略取舍项目 README 开篇就强调“This is not an agent framework.” 这不是谦虚而是清醒的战略聚焦。当前生态里LangChain、LlamaIndex 等框架试图统一“记忆、工具、规划、执行”所有层级结果是每个层级都妥协工具调用要适配多种 Schema 格式记忆模块要兼容不同向量库规划器又要理解各种 LLM 输出格式。pydantic-ai-skills 反其道而行之只做一件事定义技能的“最小完备接口”。它不碰调度器你可以用任何调度器自研的、LangChain 的、甚至 shell 脚本、不碰记忆状态管理完全由你控制、不碰 LLM 集成execute()方法里你想用 OpenAI 还是 Ollama 都行。这种“窄而深”的设计带来了三个不可替代的优势零学习成本迁移你现有的 Pydantic Model 业务逻辑几乎不用改就能变成Skill。比如一个已有的OrderValidator模型只需加一行class OrderValidator(Skill):它就具备了技能的所有能力。极致轻量核心包pydantic-ai-skills仅依赖pydantic2.0安装体积 200KB没有httpx、aiohttp等网络依赖纯类型层。生态友好它不是要取代谁而是让所有框架“更好用”。我们团队就在 LangChain 的Tool类里用pydantic-ai-skills的Skill作为底层实现Tool.args_schema直接指向Skill.model_json_schema()Tool._run()调用Skill.execute()—— 既享受 LangChain 的调度能力又获得类型安全。提示如果你正在评估是否引入新框架先问自己我的痛点是“调度逻辑太复杂”还是“每次加新技能都要重写一堆胶水代码和类型校验”前者该选调度框架后者才是pydantic-ai-skills的战场。3. 核心细节解析与实操要点从定义到部署的完整链路3.1 Skill 定义的黄金三要素输入字段、执行方法、输出字段一个合格的Skill必须同时满足三个条件缺一不可。我们以一个真实的“发票OCR解析”技能为例逐条拆解from pydantic_ai_skills import Skill from pydantic import Field from typing import Optional, List class ParseInvoice(Skill): # ✅ 要素1输入字段Input Schema image_url: str Field( ..., descriptionPublicly accessible URL of the invoice image (PNG/JPEG) ) language: str Field( defaultzh, patternr^[a-z]{2}$, # 强制 ISO 639-1 语言码 descriptionOCR language code, e.g., en, zh ) # ✅ 要素2执行方法Execution Logic def execute(self) - dict: # 这里是你真正的业务逻辑 # 1. 下载图片注意URL 必须公开可访问 # 2. 调用 OCR API如 PaddleOCR、Tesseract # 3. 结构化提取金额、日期、供应商名称 # 4. 返回字典字段名将自动映射为输出字段 return { invoice_number: INV-2024-001, total_amount: 1299.0, issue_date: 2024-03-15, vendor_name: Shenzhen Tech Co., Ltd. } # ✅ 要素3输出字段Output Schema——通过返回值自动推导 # 框架会扫描 execute() 的返回字典生成以下输出字段 # invoice_number: str # total_amount: float # issue_date: str # vendor_name: str为什么必须显式定义输入字段因为image_url的Field(...)表示必填language的defaultzh表示可选。这直接影响调度器的行为当上游未提供language时调度器知道可以安全地用默认值填充无需报错中断。而patternr^[a-z]{2}$这种正则约束会在ParseInvoice(image_url..., languageeng)实例化时立即捕获错误避免无效参数传入 OCR 服务导致 400 错误。为什么execute()必须返回dict这是框架的约定。返回的dict键名会 1:1 成为模型的输出字段名值的类型决定字段类型。{total_amount: 1299.0}→total_amount: float{total_amount: 1299.0}→total_amount: str。这种设计牺牲了一点灵活性不能返回自定义对象但换来的是绝对的可预测性。我在调试一个医疗报告解析技能时发现上游传来的report_text是bytes而非strexecute()里json.loads(report_text)直接崩溃。但因为execute()的返回类型是动态推导的错误堆栈清晰指向report_text类型错误而不是模糊的“JSON decode error”排查时间从 2 小时缩短到 15 分钟。3.2 错误处理不是 try-except而是结构化错误 Schema传统做法是在execute()里try/except然后raise Exception(OCR failed)。这在智能体里是灾难——调度器无法区分“网络超时”和“图片格式错误”更无法做针对性重试。pydantic-ai-skills强制要求所有错误必须通过SkillError子类抛出并携带结构化错误码和上下文。from pydantic_ai_skills import Skill, SkillError class ParseInvoice(Skill): image_url: str def execute(self) - dict: try: # OCR logic... return {...} except TimeoutError as e: # ✅ 结构化错误明确类型、可重试、带上下文 raise NetworkTimeoutError( messageOCR service timeout, context{url: self.image_url, timeout_sec: 30} ) except ValueError as e: # ✅ 结构化错误不可重试需人工介入 raise InvalidImageFormatError( messagefUnsupported image format: {e}, context{url: self.image_url} ) # 自定义错误类继承 SkillError class NetworkTimeoutError(SkillError): error_code NETWORK_TIMEOUT retryable True # 调度器看到这个会自动重试 class InvalidImageFormatError(SkillError): error_code INVALID_IMAGE_FORMAT retryable False # 调度器不会重试直接失败实操心得我们团队制定了内部规范所有SkillError子类必须有error_code大写蛇形命名如LLM_RATE_LIMIT_EXCEEDED和retryable属性。这让我们能用一个中央错误处理器根据error_code做精准决策对NETWORK_TIMEOUT降级到备用 OCR 服务对INVALID_IMAGE_FORMAT自动触发人工审核工单对LLM_RATE_LIMIT_EXCEEDED切换到缓存策略。上线后因错误处理不当导致的 Agent 中断率下降了 73%。3.3 高级技巧异步执行、流式输出与状态保持虽然核心是同步execute()但框架原生支持异步和流式场景关键在于正确使用 Pydantic 的field_validator和model_validator。异步执行Skill本身不强制同步你可以在execute()里用await但需确保调度器支持异步。更推荐的方式是定义async_execute()方法class AsyncDatabaseQuery(Skill): query: str async def async_execute(self) - dict: # 使用 asyncpg 或 aiosqlite result await self._db.fetch_one(self.query) return {rows: result} # 同步 execute() 作为 fallback def execute(self) - dict: raise NotImplementedError(Use async_execute() in async context)流式输出如 LLM 流式响应Skill的输出字段可以是Iterator或AsyncIterator框架会自动处理class StreamLLMResponse(Skill): prompt: str def execute(self) - Iterator[str]: # 返回生成的 token 流 for token in self._llm_stream(self.prompt): yield token # 每次 yield 一个字符串状态保持Skill实例是无状态的但你可以通过__init__注入依赖class StatefulCacheSkill(Skill): cache_client: Any # 依赖注入非字段 def __init__(self, cache_client, **data): super().__init__(**data) self.cache_client cache_client # 保存为实例属性 def execute(self) - dict: key finvoice_{self.image_url} if cached : self.cache_client.get(key): return cached # ... compute and cache注意cache_client不能是 Pydantic 字段否则会被序列化。必须通过__init__注入这是保持技能“可序列化”用于分布式调度和“可依赖注入”的平衡点。4. 实操过程与核心环节实现从零搭建一个电商客服 Agent4.1 环境准备与依赖安装不要跳过这一步。pydantic-ai-skills对 Pydantic 版本极其敏感必须严格匹配。我们实测的黄金组合是# 创建干净虚拟环境 python -m venv .venv source .venv/bin/activate # Linux/Mac # .venv\Scripts\activate # Windows # ✅ 关键必须指定 Pydantic 版本 pip install pydantic2.5.0,2.6.0 # 2.5.x 是当前最稳定分支 pip install pydantic-ai-skills0.3.2 # 当前最新稳定版 # 安装我们的电商 Agent 依赖 pip install httpx python-dotenv # 用于 HTTP 调用和环境变量为什么锁死pydantic2.6.0因为pydantic-ai-skills0.3.2基于 Pydantic 2.5 的model_construct()和model_dump()行为开发。Pydantic 2.6 引入了model_validate()的行为变更会导致Skill实例化时字段校验失效。我们踩过这个坑升级后SearchProduct(keyword123)整数传入字符串字段居然不报错直到execute()里str(keyword)抛出AttributeError。所以务必在requirements.txt里写死pydantic2.5.0,2.6.0。4.2 定义核心技能搜索、库存、物流、客服话术我们构建一个简化版电商客服 Agent能回答“某商品是否有货预计何时送达”。四个核心技能# skills/ecommerce.py from pydantic_ai_skills import Skill from pydantic import Field import httpx class SearchProduct(Skill): keyword: str Field(..., descriptionSearch keyword, e.g., wireless mouse) def execute(self) - dict: # 模拟调用搜索 API response httpx.get( https://api.example.com/search, params{q: self.keyword}, timeout5.0 ) response.raise_for_status() data response.json() return { product_id: data[results][0][id], product_name: data[results][0][name], price: data[results][0][price] } class CheckStock(Skill): product_id: str def execute(self) - dict: response httpx.get( fhttps://api.example.com/stock/{self.product_id}, timeout3.0 ) response.raise_for_status() data response.json() return { in_stock: data[available], quantity: data[count] } class GetShippingEstimate(Skill): product_id: str zip_code: str Field(..., patternr^\d{5}(-\d{4})?$) def execute(self) - dict: response httpx.get( fhttps://api.example.com/shipping/{self.product_id}, params{zip: self.zip_code}, timeout4.0 ) response.raise_for_status() data response.json() return { estimated_delivery: data[delivery_date], shipping_cost: data[cost] } class GenerateCustomerResponse(Skill): product_name: str in_stock: bool quantity: int estimated_delivery: str shipping_cost: float def execute(self) - dict: # 这里可以是 LLM 调用也可以是模板 if self.in_stock: message f✅ {self.product_name} 有货当前库存 {self.quantity} 件。预计 {self.estimated_delivery} 送达运费 ${self.shipping_cost:.2f}。 else: message f❌ {self.product_name} 暂无库存预计 {self.estimated_delivery} 补货。 return {response_text: message}实操细节所有 HTTP 调用都加了timeout参数这是生产环境铁律。zip_code的pattern正则确保只接受美国 ZIP 码5位或9位避免下游物流 API 因格式错误返回 400。GenerateCustomerResponse的输入字段完美对应前三个技能的输出字段这就是“可组合性”的物理体现。4.3 构建技能管道从手动链式调用到自动 DAG 编排阶段一手动调用验证单个技能# test_manual.py from skills.ecommerce import SearchProduct, CheckStock, GetShippingEstimate, GenerateCustomerResponse # 1. 搜索商品 search SearchProduct(keywordwireless mouse) print(Search result:, search.execute()) # 2. 检查库存用上一步的 product_id stock CheckStock(product_idsearch.product_id) print(Stock result:, stock.execute()) # 3. 获取物流需要 zip_code这里硬编码 shipping GetShippingEstimate( product_idsearch.product_id, zip_code10001 ) print(Shipping result:, shipping.execute()) # 4. 生成回复 response GenerateCustomerResponse( product_namesearch.product_name, in_stockstock.in_stock, quantitystock.quantity, estimated_deliveryshipping.estimated_delivery, shipping_costshipping.shipping_cost ) print(Final response:, response.execute())运行python test_manual.py你会看到四步输出验证每个技能独立工作正常。这是调试的基石——永远先确保单个技能原子性可靠。阶段二自动编排compose()的威力# pipeline.py from pydantic_ai_skills import compose from skills.ecommerce import SearchProduct, CheckStock, GetShippingEstimate, GenerateCustomerResponse # ✅ 自动分析依赖生成执行图 # SearchProduct - CheckStock (via product_id) # SearchProduct - GetShippingEstimate (via product_id) # CheckStock GetShippingEstimate - GenerateCustomerResponse (via their outputs) pipeline compose( SearchProduct(keywordwireless mouse), CheckStock(), GetShippingEstimate(zip_code10001), GenerateCustomerResponse() ) # ✅ 一行代码运行整个管道 result pipeline.run() print(Auto-composed response:, result.response_text)compose()的魔法在于它读取每个Skill的model_fields发现CheckStock.product_id的类型是str而SearchProduct的输出有product_id: str于是建立连接。同样GetShippingEstimate.product_id也连接到SearchProduct。最后GenerateCustomerResponse的所有输入字段都能在前三个技能的输出中找到匹配项于是它成为终点。整个过程无需任何字符串配置100% 静态类型分析。阶段三错误注入与恢复测试为了验证错误处理我们故意让CheckStock失败# 修改 CheckStock.execute() 加入错误 class CheckStock(Skill): product_id: str def execute(self) - dict: # 模拟 50% 概率失败 import random if random.random() 0.5: raise ServiceUnavailableError(Inventory service down) # ... 正常逻辑然后运行 pipeline观察pipeline.run()是否按预期抛出ServiceUnavailableError并检查error_code和retryable属性。这是保障生产稳定性的关键测试。4.4 部署与监控如何让 Skill 在 FastAPI 中暴露为 APISkill本质是 Pydantic Model所以它可以无缝集成到任何 Web 框架。FastAPI 是最佳搭档因为它的Body和Response模型与Skill天然契合。# app.py from fastapi import FastAPI, HTTPException, status from pydantic import BaseModel from skills.ecommerce import SearchProduct, SearchProductResult app FastAPI(titleEcommerce Skills API) app.post(/search, response_modelSearchProductResult) async def search_endpoint(keyword: str): try: skill SearchProduct(keywordkeyword) result skill.execute() return result # FastAPI 自动用 SearchProductResult 验证 except Exception as e: raise HTTPException( status_codestatus.HTTP_400_BAD_REQUEST, detailstr(e) ) # 启动uvicorn app:app --reload监控要点我们在每个execute()开头加入日志和计时import time import logging logger logging.getLogger(__name__) def execute(self) - dict: start_time time.time() logger.info(fExecuting {self.__class__.__name__} with {self.model_dump()}) try: result self._actual_logic() # 你的业务逻辑 duration time.time() - start_time logger.info(f{self.__class__.__name__} succeeded in {duration:.2f}s) return result except Exception as e: duration time.time() - start_time logger.error(f{self.__class__.__name__} failed in {duration:.2f}s: {e}) raise这样所有技能的执行时间、输入参数、成功/失败状态都进入统一日志流可对接 Prometheus 或 ELK。我们用这套日志发现了GetShippingEstimate在特定 ZIP 码下平均耗时 8.2s远超 4s timeout从而定位到物流 API 的慢查询问题。5. 常见问题与排查技巧实录来自真实生产环境的 7 个高频问题5.1 问题速查表症状、原因、解决方案症状可能原因解决方案实操验证Skill实例化时报ValidationError提示字段缺失输入字段未提供或Field(default...)未设置检查Skill类定义确认所有Field(...)字段都在实例化时传入用Skill.model_json_schema()查看必需字段print(SearchProduct.model_json_schema()[required])execute()返回dict但调用方收不到output_field属性返回的dict键名与Skill模型字段名不一致Skill的输出字段名 execute()返回dict的键名必须完全一致大小写敏感print(list(SearchProduct().execute().keys()))vsprint(list(SearchProduct.model_fields.keys()))compose()报错Cannot resolve dependency for field X某个Skill的输入字段名在上游Skill的输出中找不到同名字段检查字段名拼写确认上游Skill的execute()确实返回了该字段用Skill.model_json_schema()查看实际输出字段print(CheckStock.model_json_schema()[properties][product_id])技能执行超时但日志没报错httpx或其他客户端未设timeout或execute()里有无限循环在所有外部调用处强制加timeoutexecute()开头加time.time()计时超时主动raise在execute()开头加if time.time() - start 10: raise TimeoutError()SkillError抛出后调度器没按retryable属性重试调度器未正确处理SkillError子类或retryable属性未正确定义确认SkillError子类有retryable True/False类属性检查调度器源码确认它读取了该属性print(NetworkTimeoutError.retryable)应输出True异步async_execute()不被调用调度器是同步的或未用await调用确认调度器支持异步调用时用await skill.async_execute()同步execute()应raise NotImplementedErrorimport asyncio; asyncio.run(skill.async_execute())部署到 FastAPI 后response_model验证失败Skill的输出dict包含None值但response_model字段未设Optional在Skill输出字段定义时用Optional[T]或在execute()返回前过滤Nonereturn {k: v for k, v in result.items() if v is not None}5.2 独家避坑技巧那些文档里不会写的细节技巧1字段名冲突的静默覆盖Pydantic 允许Skill模型有同名的输入和输出字段如class MySkill(Skill): id: str; def execute(self) - dict: return {id: new_id}。这很危险因为MySkill(idold).id是old但MySkill(idold).execute()[id]是new_id同一个名字代表两个完全不同的东西。解决方案严格禁止同名输入字段用input_前缀input_id输出字段用output_前缀output_id或用完全不同名字user_id/generated_id。技巧2Field(default_factorylist)的陷阱不要在Skill字段用default_factory因为default_factory是在模型实例化时调用的而Skill的生命周期很短。更糟的是如果default_factory返回可变对象如list多个Skill实例会共享同一个list。解决方案永远用default[]空列表字面量或defaultNone在execute()里初始化。技巧3环境变量注入的正确姿势不要在Skill类定义里读取os.getenv()因为类加载时就执行了无法热更新。正确做法在execute()里读取或通过__init__注入class APISkill(Skill): api_key: str # 字段用于校验但不存敏感信息 def __init__(self, api_key: str None, **data): if api_key is None: api_key os.getenv(API_KEY) # 运行时读取 super().__init__(api_keyapi_key, **data) self._api_key api_key # 保存为私有属性技巧4测试Skill的黄金法则写单元测试时永远测试Skill的输入校验、执行逻辑、输出校验三部分def test_search_product(): # 1. 输入校验 with pytest.raises(ValidationError): SearchProduct(keyword123) # 非字符串 # 2. 执行逻辑mock 外部依赖 with patch(httpx.get) as mock_get: mock_get.return_value.json.return_value {results: [{id: P1, name: Mouse}]} skill SearchProduct(keywordmouse) result skill.execute() # 3. 输出校验 assert result[product_id] P1 assert isinstance(result[price], (int, float))技巧5性能瓶颈定位的三板斧当某个Skill执行慢时按顺序排查网络层用httpx的EventHook打印请求/响应时间计算层在execute()开头加cProfile.runctx()生成pstats序列化层Skill的model_dump()很快但如果你在execute()里做了大量json.dumps()就是瓶颈。用orjson替代json性能提升 3-5 倍。我个人在实际操作中的体会是pydantic-ai-skills最大的价值不是它省了多少代码而是它把“智能体开发”从一门需要经验直觉的手艺变成了一门可以用类型系统精确描述和验证的工程学科。当你第一次看到compose()自动生成的 DAG 图和Skill.model_json_schema()输出的完美 OpenAPI 文档时那种确定性带来的安心感是任何框架文档都给不了的。它不承诺解决所有问题但它确保你花在“修复类型错误”和“调试胶水代码”上的每一分钟都是值得的。