- BrainTools - https://www.braintools.ru -
У типового проекта с 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 [1], полный список — здесь [2]. Если возьмёте реферальную ссылку [3], получите 300.000 CAPS на тестирование ИИ-агентов — на все примеры из статьи хватит с запасом. Токен создаётся в два клика: регистрируетесь на BotHub, заходите на страницу для разработчиков [4] и создаёте ключ, который вставляется прямо в код.
Хотите обычный OpenAI без провайдерных классов — агент объявляется одной строкой:
agent = Agent(
'openai:gpt-6.1-sol',
instructions='Отвечай коротко, одним предложением.',
)
result = agent.run_sync('Откуда взялась фраза hello world?')
print(result.output)
Затем задаёте OPENAI_API_KEY как переменную окружения.
Классический способ описать инструмент для LLM — словарь, JSON-схема аргументов в одном месте, текстовое описание в другом, сама функция где-то третьим (документация [5]). Такие схемы пишутся руками, живут отдельно от кода и расползаются с каждым релизом. В 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 здесь адресован модели: от формулировки зависит, выберет ли она инструмент и с какими аргументами вызовет. «Вернуть статус заказа по номеру» она понимает сразу, «хелпер для работы с заказами» — уже с натяжкой. Писать его стоит так, будто объясняете новому коллеге. Тем, кому декораторы не по вкусу [6], функции можно передать списком в конструктор — Agent(..., tools=[get_order]), эффект тот же.
raise ModelRetry внутри normalize_phone — отдельная история. Обычное исключение из инструмента улетает наверх и роняет запуск. ModelRetry устроен иначе: текст ошибки [7] уходит самой модели, она пробует поправить аргументы и вызвать инструмент ещё раз. Та же механика включается, когда модель передаёт аргументы мимо схемы: ValidationError обрабатывается как ещё один повод для ретрая. По умолчанию у каждого инструмента одна повторная попытка, лимит поднимается агентом (retries={'tools': N}) или точечно — @agent.tool(retries=N). В v2 появился ToolFailed на случай, когда повторять [8] бессмысленно: модель узнаёт о провале, но бюджет ретраев не тратится.
Как мы выяснили в начале, ломается всё на стыке между ответом модели и кодом приложения. output_type закрывает ровно это место. Описываете pydantic-модель, передаёте в конструктор — и дальше агент обязан вернуть данные по конкретной схеме. Невалидный ответ не пройдёт валидацию и уйдёт модели на переделку, кривой JSON больше не доедет до бизнес-логики, а result.output в вашем коде окажется уже готовым типизированным объектом (документация [9]). 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
Схема, однако, ловит только формат. Модель способна честно заполнить все поля и выдать с точки зрения [10] бизнеса бессмыслицу — скажем, одобрить возврат на миллион. Для таких случаев есть @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) # снапшот: часть полей может ещё не доехать
Для чат-интерфейсов разница ощутимая: пользователь видит карточку ответа, пока модель ещё дописывает последние поля.
Обычно тест агента выглядит так: подняли мок HTTP, застабили ответ и молитесь, чтобы формат совпал с реальностью. Или, что чаще, агента вообще не тестируют, потому что «там же модель, что тут тестировать», — я и сам год жил по этой логике [11]. В Pydantic AI модель подменяется целиком, и связка «агент + инструменты + зависимости + вывод» гоняется как обычный Python-код в pytest (документация [12]).
Простейший вариант — 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. Логика же агента, ради которой всё это писалось, тестируется без единого токена.
Вторая версия вышла 23 июня 2026 года, примерно через десять месяцев после 1.0 (анонс [13]). Центральная новинка называется capability. Это блок поведения [14], в котором инструкции, инструменты, хуки жизненного цикла и настройки модели собраны вместе и подключаются к агенту одним движением. Раньше всё это раскладывалось по конструктору отдельными аргументами и в сложных агентах превращалось в портянку из полусотни строк. Теперь поведение [15] собирается из блоков, а блок сериализуется в 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(), и в трассах видны промпты, ответы, токены и стоимость каждого запуска (документация [16]). Под капотом OpenTelemetry, так что трейсы уходят в любую совместимую систему — Grafana, Jaeger, что у вас там заведено. AI Gateway, платный продукт той же экосистемы, заменяет двадцать ключей одним, умеет фейловер между провайдерами и сводит расходы в одну квоту.
Каталог провайдеров перевалил за тридцать семь позиций — от OpenAI и Anthropic до Kimi, Qwen, Together и OpenRouter (обзор [17]). Но проверка гибкости — локальный запуск. Причины у всех свои: где-то врачебные данные и документы клиентов нельзя отправлять наружу в принципе, где-то изолированный контур предприятия без интернета, а где-то надоело считать токены на агенте, который молотит круглосуточно, — своя видеокарта окупается за пару месяцев.
Подключение локальной модели выглядит скромно:
from pydantic_ai import Agent
agent = Agent('ollama:qwen3') # сервер на localhost:11434
Нужен нестандартный адрес сервера — через явный провайдер (документация [18]):
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 [1], а реферальная ссылка [3] даст 300.000 CAPS на тесты.
Мелкие локальные модели — обычно 7–14 миллиардов параметров — на строгих контрактах сыпятся: tool-calling уходит мимо схемы, структурный вывод собирается с пропущенными полями. Лечится снижением амбиций: схемы аргументов попроще, задачи дробнее, output_type поскромнее. Бюджет ретраев для таких моделей имеет смысл поднять заранее, а тесты на TestModel покажут, где именно модель спотыкается, ещё до продакшена. Ну и выбирать модель с приличной поддержкой tool-calling — в лёгкой весовой категории из открытых сейчас самые вменяемые как раз qwen’ы.
Сравнивать фреймворки по звёздам на GitHub — занятие малополезное; предметнее по задачам. Для нового Python-проекта, в котором агент дёргает инструменты и обязан возвращать структуры, я бы по умолчанию брал Pydantic AI. Типизация там — несущая стена: структурированный вывод, ретраи, валидаторы и тесты идут из коробки одним механизмом, без аддонов.
У LangChain экосистема шире — больше семисот интеграций, LangGraph для сложной оркестрации состояний и LangSmith за $39–399 в месяц. Если проект держится на коннекторах к десятку SaaS, это весомые аргументы, и LangGraph годами тащит сложные stateful-пайплайны — хоронить его рано. Но типизация там прикручена сбоку, структурированный вывод собирается аддон-парсерами, и на типовом агенте разница заметна невооружённым глазом: замер из обзора betterclaw [19] дал порядка шестидесяти строк на 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 для долгих процессов. Ставлю на то, что через пару лет спорить о типизации агентов будет так же странно, как спорить о типизации веб-фреймворков.
Автор: DrArgentum
Источник [20]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/37544
URLs in this post:
[1] BotHub: https://bothub.chat/
[2] здесь: https://bothub.chat/models
[3] реферальную ссылку: https://bothub.chat/?invitedBy=zx7xIePYIOcWHhTfgg6-O
[4] на страницу для разработчиков: https://bothub.chat/ru/profile/for-developers
[5] документация: https://ai.pydantic.dev/tools/
[6] вкусу: http://www.braintools.ru/article/6291
[7] ошибки: http://www.braintools.ru/article/4192
[8] повторять: http://www.braintools.ru/article/4012
[9] документация: https://ai.pydantic.dev/output/
[10] зрения: http://www.braintools.ru/article/6238
[11] логике: http://www.braintools.ru/article/7640
[12] документация: https://ai.pydantic.dev/testing/
[13] анонс: https://pydantic.dev/articles/pydantic-ai-v2
[14] поведения: http://www.braintools.ru/article/9372
[15] поведение: http://www.braintools.ru/article/5593
[16] документация: https://ai.pydantic.dev/logfire/
[17] обзор: https://ai.pydantic.dev/models/overview/
[18] документация: https://ai.pydantic.dev/models/ollama/
[19] обзора betterclaw: https://www.betterclaw.io/blog/pydantic-ai-vs-langchain
[20] Источник: https://habr.com/ru/companies/bothub/articles/1099642/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1099642
Нажмите здесь для печати.