Pydantic AI: типизированные агенты на своих моделях

У типового проекта с LLM-агентом судьба примерно одна. Сначала это милые сорок строк: промпт, вызов openai-совместимого API, json.loads — и всё работает. Через пару недель модель начинает вставлять перед JSON пояснительный текст в духе «Конечно! Вот ваш ответ:», одно из обязательных полей то появляется, то исчезает, а вложенный массив иногда приходит строкой. В код въезжают регулярки, ручные проверки и повторные запросы. Ещё через месяц вы владеете комом связующего кода на несколько сотен строк, который никто не хочет трогать — потому что непонятно, что именно отвалится.
Увы, модель тут чаще ни при чём. Ломается стык между её выводом и приложением — там, где нет ни типов, ни валидации формата.
Pydantic AI предлагает выйти из этого круга через модели типов. Фреймворк делает команда Pydantic — той самой библиотеки, на которой держится валидация в FastAPI, OpenAI SDK и ещё доброй дюжине проектов. Инструмент агента здесь описывается обычной функцией с аннотациями, структурированный ответ — pydantic-моделью, а замена облачной модели на локальную через Ollama сводится к правке одной строки.
Так написано в README. Под катом — как это выглядит на практике.
Ставится всё привычно: uv add pydantic-ai или pip install pydantic-ai — кто как привык. В v2 базовая установка стала компактнее, специфичные провайдеры доустанавливаются отдельными extras, но для первого знакомства хватает базовой.
from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider
# 1. Создаем провайдер с настройками вашего шлюза
provider = OpenAIProvider(
base_url='https://openai.bothub.chat/v1', # URL вашего шлюза
api_key='your-api-token-here'
)
# 2. Создаем модель, явно указывая имя модели на шлюзе
model = OpenAIChatModel(
'gpt-6-luna',
provider=provider
)
# 3. Передаем модель в Agent
agent = Agent(
model,
instructions='Отвечай коротко, одним предложением.',
)
result = agent.run_sync('Откуда взялась фраза hello world?')
print(result.output)
У меня ответ выглядел так:
Фраза «hello, world» впервые появилась в книге Брайана Кернигана и Денниса Ритчи «Язык программирования C» (1978) как пример простейшей программы.
Вся программа — девять строк, из которых добрая половина — обвязка. run_sync — синхронная надстройка над асинхронным ядром: внутри всё построено на asyncio, но для скриптов и блокнотов есть готовые sync-варианты, а для сервиса на FastAPI — обычный await agent.run(...). Асинхронность тут основная, синхронность — удобное дополнение. Для Python-экосистемы редкая расстановка, обычно наоборот.
Код выше ходит в наш OpenAI-совместимый шлюз. GPT-6-Luna из примера и ещё несколько десятков моделей открыты на BotHub, полный список — здесь. Если возьмёте реферальную ссылку, получите 300.000 CAPS на тестирование ИИ-агентов — на все примеры из статьи хватит с запасом. Токен создаётся в два клика: регистрируетесь на BotHub, заходите на страницу для разработчиков и создаёте ключ, который вставляется прямо в код.
Хотите обычный OpenAI без провайдерных классов — агент объявляется одной строкой:
agent = Agent(
'openai:gpt-6.1-sol',
instructions='Отвечай коротко, одним предложением.',
)
result = agent.run_sync('Откуда взялась фраза hello world?')
print(result.output)
Затем задаёте OPENAI_API_KEY как переменную окружения.
Инструменты и зависимости
Классический способ описать инструмент для LLM — словарь, JSON-схема аргументов в одном месте, текстовое описание в другом, сама функция где-то третьим (документация). Такие схемы пишутся руками, живут отдельно от кода и расползаются с каждым релизом. В Pydantic AI от этого оставили только функцию: навешиваете декоратор, всё остальное фреймворк собирает сам. Сигнатура превращается в JSON-схему, docstring уходит модели как описание инструмента.
Вот агент поддержки с двумя инструментами и зависимостями:
from dataclasses import dataclass
import httpx
from pydantic_ai import Agent, ModelRetry, RunContext
@dataclass
class SupportDeps:
http: httpx.AsyncClient
api_base: str
support = Agent(
'openai:gpt-6.1-sol',
deps_type=SupportDeps,
instructions='Помогай клиенту разбираться с заказами.',
)
@support.tool
async def get_order(ctx: RunContext[SupportDeps], order_id: int) -> dict:
"""Вернуть статус заказа по номеру."""
r = await ctx.deps.http.get(f'{ctx.deps.api_base}/orders/{order_id}')
r.raise_for_status()
return r.json()
@support.tool_plain
def normalize_phone(raw: str) -> str:
"""Привести телефон к формату +7XXXXXXXXXX."""
digits = ''.join(ch for ch in raw if ch.isdigit())
if len(digits) != 11:
raise ModelRetry('В номере должно быть 11 цифр. Попробуй ещё раз.')
return f'+7{digits[1:]}'
Модель в итоге видит примерно то же, что и вы: имя функции, схему аргументов, описание. Возвращаемое значение уходит обратно в диалог как результат вызова, модель читает его и решает, что говорить дальше.
Декораторы отличаются одним: @agent.tool ждёт первым аргументом RunContext, @agent.tool_plain — нет. Через контекст инструмент добирается до зависимостей. Зависимости описываются обычным типом (здесь dataclass с HTTP-клиентом), объявляются в конструкторе агента через deps_type, а при запуске передаются значением: support.run(..., deps=SupportDeps(...)). Никаких глобальных переменных и синглтонов: что нужно инструменту — то и в типе, и статический чекер подскажет, если забыли поле. Тестам это тоже на руку: подсовываете заглушку вместо HTTP-клиента и гоняете агента без сети. Писавшим на FastAPI механизм покажется знакомым до неприличия: аннотации описывают контракт, фреймворк валидирует вход и ругается, пока не исправите. Только потребителем схемы оказывается модель.
Docstring здесь адресован модели: от формулировки зависит, выберет ли она инструмент и с какими аргументами вызовет. «Вернуть статус заказа по номеру» она понимает сразу, «хелпер для работы с заказами» — уже с натяжкой. Писать его стоит так, будто объясняете новому коллеге. Тем, кому декораторы не по вкусу, функции можно передать списком в конструктор — Agent(..., tools=[get_order]), эффект тот же.
raise ModelRetry внутри normalize_phone — отдельная история. Обычное исключение из инструмента улетает наверх и роняет запуск. ModelRetry устроен иначе: текст ошибки уходит самой модели, она пробует поправить аргументы и вызвать инструмент ещё раз. Та же механика включается, когда модель передаёт аргументы мимо схемы: ValidationError обрабатывается как ещё один повод для ретрая. По умолчанию у каждого инструмента одна повторная попытка, лимит поднимается агентом (retries={'tools': N}) или точечно — @agent.tool(retries=N). В v2 появился ToolFailed на случай, когда повторять бессмысленно: модель узнаёт о провале, но бюджет ретраев не тратится.
Структурированный вывод
Как мы выяснили в начале, ломается всё на стыке между ответом модели и кодом приложения. output_type закрывает ровно это место. Описываете pydantic-модель, передаёте в конструктор — и дальше агент обязан вернуть данные по конкретной схеме. Невалидный ответ не пройдёт валидацию и уйдёт модели на переделку, кривой JSON больше не доедет до бизнес-логики, а result.output в вашем коде окажется уже готовым типизированным объектом (документация). IDE подсказывает поля, mypy ловит опечатки, сериализация достаётся бесплатно.
from pydantic import BaseModel
from pydantic_ai import Agent, ModelRetry, RunContext
class RefundDecision(BaseModel):
approved: bool
amount: int
reason: str
refund = Agent(
'openai:gpt-6.1-sol',
output_type=RefundDecision,
instructions='Решай, одобрять ли возврат. Безопасные суммы — до 50 000.',
)
@refund.output_validator
def check_limit(ctx: RunContext, decision: RefundDecision) -> RefundDecision:
if decision.approved and decision.amount > 50_000:
raise ModelRetry('Свыше 50 000 возврат согласовывает человек. Верни approved=False.')
return decision
Схема, однако, ловит только формат. Модель способна честно заполнить все поля и выдать с точки зрения бизнеса бессмыслицу — скажем, одобрить возврат на миллион. Для таких случаев есть @agent.output_validator — доменная проверка поверх схемы. Функция может быть асинхронной, лезть в базу, ходить во внешние сервисы — что угодно. Провал проверки уходит модели текстом ошибки, и она перегенерирует ответ уже с учётом замечания. Бюджет на переделки вывода по умолчанию — опять одна попытка, расширяется через retries={'output': N}; когда бюджет кончится, запуск упадёт с UnexpectedModelBehavior — тихо проглотить проблему не выйдет.
В output_type можно передать и объединение. Агент выше с output_type=[RefundDecision, str] вернёт либо структуру, либо обычный текст — уточняющий вопрос, если данных не хватает. Каждый вариант Pydantic AI регистрирует отдельным output-инструментом, чтобы схема не разрасталась и модели было проще попадать. Результат потом разбирается через isinstance, а диалог с уточнениями работает вообще без вашей оркестрации. Подходят не только модели BaseModel — dataclass, TypedDict и всё остальное, что Pydantic умеет валидировать.
Добрались и до стриминга. Структурированный ответ можно отдавать в интерфейс по мере генерации: Pydantic умеет частично валидировать недописанный JSON, и stream_output() выдаёт промежуточные снапшоты, каждый провалидирован настолько, насколько успел.
async with refund.run_stream('Клиент купил тостер и вернул его в тот же день') as result:
async for decision in result.stream_output():
render(decision) # снапшот: часть полей может ещё не доехать
Для чат-интерфейсов разница ощутимая: пользователь видит карточку ответа, пока модель ещё дописывает последние поля.
Тесты без единого вызова API
Обычно тест агента выглядит так: подняли мок HTTP, застабили ответ и молитесь, чтобы формат совпал с реальностью. Или, что чаще, агента вообще не тестируют, потому что «там же модель, что тут тестировать», — я и сам год жил по этой логике. В Pydantic AI модель подменяется целиком, и связка «агент + инструменты + зависимости + вывод» гоняется как обычный Python-код в pytest (документация).
Простейший вариант — TestModel. Он смотрит на JSON-схемы ваших инструментов, сам сочиняет валидные аргументы, вызывает каждый инструмент и возвращает сводку. Никакого ИИ внутри, чистая процедурщина, поэтому прогоны детерминированы. Подменить модель можно прямо в тесте:
import pytest
from pydantic_ai import models
from pydantic_ai.models.test import TestModel
from support import FakeHttp, SupportDeps, support # агент из прошлого раздела
pytestmark = pytest.mark.anyio
models.ALLOW_MODEL_REQUESTS = False # случайный реальный вызов уронит тест
async def test_support_flow():
deps = SupportDeps(http=FakeHttp(), api_base='http://test')
with support.override(model=TestModel()):
result = await support.run('Где мой заказ 42?', deps=deps)
assert 'get_order' in result.output # в output — сводка вызванных инструментов
ALLOW_MODEL_REQUESTS = False страхует на случай, если где-то в тестах забыли подмену: реальный вызов упадёт с ошибкой, вместо того чтобы тихо списать деньги с вашего ключа. А Agent('test') в конструкторе даёт тот же TestModel — удобно для экспериментов.
Проверяет он то, что чаще всего и отказывает: инструменты зарегистрированы и вызываются, зависимости доходят до них, output валидируется и возвращается. Большинство поломок агентов — обвязка, и именно она теперь под тестом. Для сценариев, где нужны конкретные ответы, есть FunctionModel: вместо модели подставляется ваша функция, которая получает историю сообщений и возвращает ModelResponse.
# from pydantic_ai import ModelResponse, ToolCallPart
def scripted_model(messages, info):
return ModelResponse(parts=[ToolCallPart('get_order', {'order_id': 42})])
with support.override(model=FunctionModel(scripted_model)):
... # траектория предсказуема до запуска
Такой тест стабилен в любой день и в любом CI, попутно документируя ожидаемую траекторию: какие инструменты и в каком порядке срабатывают. Если тестов много, подмена выносится в pytest-фикстуру с support.override(...). Живую модель это не проверяет — за качеством ответов идите в evals, отдельный инструмент экосистемы Pydantic. Логика же агента, ради которой всё это писалось, тестируется без единого токена.
Что принесла v2
Вторая версия вышла 23 июня 2026 года, примерно через десять месяцев после 1.0 (анонс). Центральная новинка называется capability. Это блок поведения, в котором инструкции, инструменты, хуки жизненного цикла и настройки модели собраны вместе и подключаются к агенту одним движением. Раньше всё это раскладывалось по конструктору отдельными аргументами и в сложных агентах превращалось в портянку из полусотни строк. Теперь поведение собирается из блоков, а блок сериализуется в spec-файл — можно закоммитить, отдать коллеге, подключить другому агенту.
CodeMode решает другую проблему — цену агентного цикла. Классическая схема: модель вызывает инструмент, ждёт результат, думает, вызывает следующий, и каждый виток заново прогоняет через модель всю историю диалога. В CodeMode модель вместо череды вызовов пишет код, который дёргает инструменты как обычные функции, и этот код исполняется один раз в песочнице Monty, собственном мини-интерпретаторе Python от Pydantic. На задачах с десятком шагов экономия токенов выходит заметной, и паузы между вызовами уходят.
ToolSearch нужен на противоположном конце — когда инструментов сотни. Схемы всех инструментов по умолчанию живут в промпте, и на большом наборе контекст кончается раньше, чем агент начнёт работать. Инструменты с пометкой defer_loading=True в промпт не попадают: модель находит их по запросу через ToolSearch, где на Anthropic и OpenAI Responses поиск нативный, а в остальных случаях работает локальная функция с выбором стратегии — от ключевых слов до BM25.
С миграцией большинство проектов обойдётся заменой пары импортов, но три поведенческих изменения стоит проверить руками. Префикс openai: теперь ведёт на Responses API, а за прежним Chat Completions закреплён префикс openai-chat: — провайдерный класс из первого раздела как раз про него. Стратегия завершения цикла end_strategy по умолчанию сменилась на graceful, из-за чего отмены посреди вызова инструмента ведут себя немного иначе. Инструментация обновилась до v5 со своей статистикой токенов — владельцы собственных дашбордов, это к вам. В документации лежит карта миграции V1 → V2, перед апдейтом продовых агентов её правда стоит открыть.
Что понадобится в продакшене
run и run_sync покрывают девять случаев из десяти. Когда нужно заглянуть внутрь цикла — рисовать в интерфейсе каждый шаг, инспектировать вызовы инструментов, ветвить логику вручную, — есть agent.iter(). Он отдаёт цикл по узлам, где вы сами решаете, что делать с запросом к модели и с исполнением инструментов, и туда же приходят стриминговые события. Для чата с живым статусом инструментов штука дельная.
Лимиты задаются на уровне запуска через UsageLimits — число запросов к модели, токены, вызовы инструментов. Счёт ведётся за весь запуск вместе со всеми витками цикла, так что агент не сможет молча потратить втрое больше, чем вы закладывали. Превышение кидает обычное исключение и ловится рядом с запуском — предохранитель, который окупается с первого же потерянного цикла.
Агент согласования документов может ждать подписи человека часами, ночной батч по тысяче документов обязан пережить рестарт сервиса. Для таких сценариев Pydantic AI работает поверх durable execution: каждый шаг сохраняется, и после падения процесс продолжается с места остановки, без повторных вызовов модели. Интеграции готовые — Temporal, DBOS, Prefect, Restate, AWS Lambda, Apache Airflow, плюс лёгкие варианты вроде Absurd и pgtask; скорее всего, что-то из списка уже стоит в вашей инфраструктуре.
Внешние инструменты подключаются через MCP: агенту передаётся toolsets=[MCPToolset(url)], и сервер — локальный через stdio или удалённый по HTTP — становится источником инструментов. Пометив набор defer_loading=True, вы подключаете хоть сотню серверов и не переплачиваете контекстом.
Наблюдаемость — Logfire от той же команды. Одна строка logfire.instrument_pydantic_ai(), и в трассах видны промпты, ответы, токены и стоимость каждого запуска (документация). Под капотом OpenTelemetry, так что трейсы уходят в любую совместимую систему — Grafana, Jaeger, что у вас там заведено. AI Gateway, платный продукт той же экосистемы, заменяет двадцать ключей одним, умеет фейловер между провайдерами и сводит расходы в одну квоту.
Модели на своём железе
Каталог провайдеров перевалил за тридцать семь позиций — от OpenAI и Anthropic до Kimi, Qwen, Together и OpenRouter (обзор). Но проверка гибкости — локальный запуск. Причины у всех свои: где-то врачебные данные и документы клиентов нельзя отправлять наружу в принципе, где-то изолированный контур предприятия без интернета, а где-то надоело считать токены на агенте, который молотит круглосуточно, — своя видеокарта окупается за пару месяцев.
Подключение локальной модели выглядит скромно:
from pydantic_ai import Agent
agent = Agent('ollama:qwen3') # сервер на localhost:11434
Нужен нестандартный адрес сервера — через явный провайдер (документация):
from pydantic_ai.models.ollama import OllamaModel
from pydantic_ai.providers.ollama import OllamaProvider
model = OllamaModel(
'qwen3',
provider=OllamaProvider(base_url='http://gpu-box.local:11434/v1'),
)
Самохостинг через vLLM даёт отдельный префикс vllm:, а любой прочий OpenAI-совместимый эндпоинт подключается тем же OpenAIChatModel с Provider(base_url=...), как в первом разделе. Свой класс модели писать не понадобится.
Наш шлюз, к слову, — тоже OpenAI-совместимый эндпоинт, так что гибрид «чувствительное крутится локально, остальное ходит в облако» собирается тем же кодом. Модели из примеров есть на BotHub, а реферальная ссылка даст 300.000 CAPS на тесты.
Мелкие локальные модели — обычно 7–14 миллиардов параметров — на строгих контрактах сыпятся: tool-calling уходит мимо схемы, структурный вывод собирается с пропущенными полями. Лечится снижением амбиций: схемы аргументов попроще, задачи дробнее, output_type поскромнее. Бюджет ретраев для таких моделей имеет смысл поднять заранее, а тесты на TestModel покажут, где именно модель спотыкается, ещё до продакшена. Ну и выбирать модель с приличной поддержкой tool-calling — в лёгкой весовой категории из открытых сейчас самые вменяемые как раз qwen’ы.
LangChain и остальные
Сравнивать фреймворки по звёздам на GitHub — занятие малополезное; предметнее по задачам. Для нового Python-проекта, в котором агент дёргает инструменты и обязан возвращать структуры, я бы по умолчанию брал Pydantic AI. Типизация там — несущая стена: структурированный вывод, ретраи, валидаторы и тесты идут из коробки одним механизмом, без аддонов.
У LangChain экосистема шире — больше семисот интеграций, LangGraph для сложной оркестрации состояний и LangSmith за $39–399 в месяц. Если проект держится на коннекторах к десятку SaaS, это весомые аргументы, и LangGraph годами тащит сложные stateful-пайплайны — хоронить его рано. Но типизация там прикручена сбоку, структурированный вывод собирается аддон-парсерами, и на типовом агенте разница заметна невооружённым глазом: замер из обзора betterclaw дал порядка шестидесяти строк на Pydantic AI против двухсот на LangChain. Втрое меньше клея, который придётся читать, чинить и покрывать тестами. Начинать на LangChain новый проект в 2026-м я бы не стал; старый, где уже вложились в граф состояний и коннекторы, переезжать ради переезда не стоит.
CrewAI живёт в другой плоскости: там ролевые команды агентов — вы описываете «исследователя» и «писателя», а фреймворк разруливает передачу работы между ними. Для мультиагентных симуляций ниша своя, и лезть туда с Pydantic AI смысла мало. Работаете в TypeScript — смотрите на Mastra и Vercel AI SDK, у AWS есть свой Strands.
Иногда не нужен вообще никто. Один промпт и один вызов модели в скрипте — берите SDK провайдера напрямую, церемоний меньше; фреймворк ради одного вызова — как ехать в магазин на бульдозере. Граница проходит там, где появляются инструменты, ретраи и структуры ответов: вот это руками писать уже больно.
Есть у фреймворка и недостатки, куда без них. Релизный ритм плотный: за время жизни v1 вышло больше сотни релизов, changelog читается как сериал, и уследить за всеми изменениями не выйдет даже самому преданному фанату. На сложных сценариях абстракции протекают — приходится спускаться в историю сообщений и низкоуровневые типы, где вся красивость API заканчивается. И идеологию Pydantic придётся принять целиком: всё во фреймворке через pydantic-модели, валидировать «как-нибудь по-другому» тут не получится. Лично меня третье не задевает — валидация через модели типов ровно за тем я фреймворк и взял, — но в команде с другим стилем на этом почёсываются головы.
Заключение
За полгода на Pydantic AI у меня переехали три агента, и ни один с тех пор не потребовал ни одной регулярки для разбора ответа модели. Показатель для меня весомее любого бенчмарка.
Направление темы угадывается легко: структурированные выводы давно живут в SDK крупных провайдеров, а Pydantic AI доводит идею до полного цикла — от инструмента до теста. Следующий слой зрелости лежит в экосистеме: evals для оценки качества ответов, Logfire для наблюдаемости, durable execution для долгих процессов. Ставлю на то, что через пару лет спорить о типизации агентов будет так же странно, как спорить о типизации веб-фреймворков.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.