Этот материал навеян типичными требованиями к агентным Python-инженерам - теми списками требований к вакансии, где рядом стоят всякие PydanticAI/CrewAI/Langraph, MCP/A2A, фреймворки, оценки, векторные базы и тому подобное. Ну мне вот такое попадается, например, и заметил я, что большинство кандидатов люто плавают в этих вопросах. И даже перспективы перейти к физическому труду в обозримом будущем не мотивируют оных слегка прокачать нужные навыки - тем более что много-то и не нужно. Конечно, есть категория гениев, которая чаще всего встречается в анонимных комментариях на хабре, но я-то про обычных земных людей, с которыми встречаюсь на этой грешной земле, и на которых этот материал прежде всего и рассчитан. Так что, дорогой ноунейм, этот материал поможет тебе пройти интервью, и надеюсь, постоловаться еще какое-то время в наши скудные времена.
Ну и не забываем: все течет и меняется нынче быстро - так что и эти тезисы устареют скоро.
Давайте для начала дадим определение, что такое агент. Не книжное, желательно, а как бы понимаемое.
Ну, в первом приближении - это некая сущность, в которой есть промпт, LLM и инструменты. Но это не всё - по такому описанию агентом окажется любой скрипт с одним вызовом модели. Нужно еще две вещи.
Первая - цикл. Причём не цепочка вида промпт → модель → парсинг → модель → ответ, где маршрут вы проложили заранее. В цикле очередной шаг выбирает сама модель: думает, решает вызвать инструмент, получает результат, думает снова.
Вторая - обратная связь. Модель видит, что вышло из её действия, и с учётом этого решает, что делать дальше. Если результат никуда не возвращается, то у вас не агент, а генератор планов.
Отсюда и главная разница: в цепочке вы программируете последовательность шагов, в агенте - набор возможностей и цель, а последовательность рождается на ходу.

Рис. 1. Агент - это модель в цикле с инструментами. Пока в цепочке маршрут задан заранее, здесь на каждом витке модель сама решает, что делать дальше.
Устройство цикла простое. Инструменты объявляются схемой на уровне API - имя, описание, JSON Schema аргументов; современные модели специально дообучены под этот формат. Это штатный режим работы, а не попытка уговорить модель отвечать JSON-ом. Дальше модель отвечает либо текстом, либо вызовом инструмента: «вызови инструмент с аргументом таким-то». Сама модель ничего не делает, она только просит. Выполняет просьбу рантайм - он же кладёт результат обратно в контекст, хранит историю и снова дёргает модель.
Цикл идёт, пока не сработает стоп-условие (ага, «red»). В идеале модель сама решает, что задача выполнена, и отвечает текстом без вызова инструмента. Но есть еще всякие нюансы типа лимита итераций, бюджета токенов, необработанных ошибок инструментов и кнопки «прервать». По сути весь Agentic AI стоит на этой схеме, и вся сложность - вокруг неё: как описать инструменты, чтобы модель их поняла, что делать с зацикливанием, где хранить состояние. И как потом разобраться, где именно агент свернул не туда.
Ещё академичности ради добавлю, чтобы убрать вилку «или цепочка, или агент». Это скорее две крайности, между которыми есть и промежуточные формы: роутинг, где модель выбирает ветку, но сами ветки заданы вами; оркестратор с воркерами; цепочка, у которой агентный только один шаг. Чем дальше по этой шкале, тем больше свободы у модели. Гарантий, соответственно, меньше. Но об этом чуть ниже в разделе описания агентных фреймворков.
Мораль сего следующая: агент оправдан там, где маршрут зависит от данных и предсказать его нельзя: исследование, дебаг, многошаговая работа с неизвестным заранее числом шагов. Ну а цена агента - токены и непредсказуемость.
Среди агентных фреймворков PydanticAI сейчас самый аутентичный (в стиле питона) способ собирать агентов. Он от команды Pydantic, и философия у него та же, что у самой библиотеки: если вы объявили тип, то фреймворк проследит, чтобы данные ему соответствовали.
Смотрите, как выглядит личинка агента:
from pydantic import BaseModel
from pydantic_ai import Agent
class WeatherAnswer(BaseModel):
city: str
temperature: float
summary: str
agent = Agent(
"openai:gpt-5",
output_type=WeatherAnswer,
system_prompt="Ты метеоролог. Отвечай строго по фактам инструментов.",
)
@agent.tool_plain
def get_temperature(city: str) -> float:
"""Узнать текущую температуру в городе."""
return weather_api.current(city)
result = agent.run_sync("Что с погодой в Стамбуле?")
print(result.output) # WeatherAnswer(city='Стамбул', temperature=31.5, ...)
Придирчивый читатель конечно спросит - «мы видели этот пример в миллионе гайдов агентной разработки с этой погодой, и опять?». Да, пример почти тот же - но есть пара полезных нюансов. Разберём по пунктам, что здесь происходит, потому что каждый пункт потом всплывёт в оценке.
Первое - output_type. Мы объявили, что ответ агента - это Pydantic-модель (WeatherAnswer). Фреймворк превращает её в JSON-схему, отдаёт модели как формат ответа, а потом валидирует то, что модель вернула. Не сошлось со схемой - битый JSON вам не прилетит: агент вернёт модели ошибку валидации и попросит попробовать ещё раз. Вот так и получается структурированный вывод - через валидацию с повторами. Без переписывания промпта с мольбами вернуть результат в нужном формате.
Второе - инструменты. Декораторы agent.tool и agent.tool_plain превращают обычную функцию в инструмент: схема аргументов строится из аннотаций типов, описание - из докстринга (вот почему докстринг в примере важен - его читает модель при выборе инструмента). Типы аргументов тоже валидируются - модель не сможет передать строку туда, где float. Разница между двумя декораторами одна: tool подставляет функции первым параметром RunContext - объект с контекстом текущего запуска, а tool_plain отдаёт только аргументы от модели, и функция остаётся самодостаточной.
Третье - зависимости, и здесь как раз становится понятно, зачем вообще нужен RunContext. Лежит в нём прежде всего ctx.deps - зависимости запуска. Устроено так: при создании агента вы объявляете их тип параметром deps_type - клиент БД, конфиг, пользовательская сессия, всё одним объектом; при запуске передаёте конкретный экземпляр: agent.run_sync("…", deps=…). Инструмент на tool достаёт этот экземпляр из ctx.deps, а инструмент на tool_plain такого доступа не имеет - всё, что ему нужно извне, он берет сам, через глобальные переменные и импорты. Кроме deps в RunContext живут ещё номер текущей попытки, история сообщений и счётчик токенов, но это уже детали. Главное, что это dependency injection: в тестах подсовываете фейковый клиент, в проде - настоящий, код инструментов не меняется. Когда ниже дойдём до оценки, это пригодится: прогон агента по датасету почти всегда идёт с подменёнными зависимостями.
Четвёртое - валидаторы результата. Поверх схемы можно навесить свои проверки (output_validator): например, «температура от -90 до +60, иначе переспросить». Модель получает текст ошибки и исправляется.
Пятое - всё остальное понемногу: потоковый вывод (run_stream), учёт использованных токенов (result.usage()), модель-агностика (OpenAI, Anthropic, Gemini, локальные через OpenAI-совместимые API), поддержка MCP-серверов как источника инструментов, Logfire для наблюдаемости (обёртка над OpenTelemetry - пригодится в последнем разделе), и pydantic-graph - декларативные графы шагов, когда цикл агента нужно встроить в жёсткий маршрут.
Чем PydanticAI отличается от того, что вы бы написали руками: цикл он от вас не прячет, но типизирует всё, что по этому циклу течёт. Промпт, инструменты, зависимости, вывод - везде объявлены типы, и косяки ловятся валидацией ещё до прода.

Рис. 2. Агент по контракту: ответ проверяется схемой Pydantic. Не прошло проверку - модель идёт на второй заход, и до ваших логов эта ошибка не доходит.
Рядом с PydanticAI обычно стоят LangGraph, smolagents и CrewAI, но мы тут не про разные API, а про философскую разницу.
LangGraph - это граф состояний. Вы описываете узлы (шаги: вызов модели, инструмент, ваша функция) и рёбра между ними, а по графу течёт объект-состояние: каждый узел читает его и дописывает в него. Агентный цикл здесь - частный случай графа с условным ребром: «если модель попросила инструмент - к узлу инструмента, иначе - к выходу». За эту явность приходится платить церемонией, зато вы получаете контроль, которого у свободного цикла нет. Паузы с человеческим одобрением (interrupt - граф встаёт и ждёт решения человека), чекпоинты состояния (разговор можно заморозить и продолжить с любой точки, вплоть до хранения состояния в БД), ветвления, параллельные ветки. На бизнес-процесс с агентными вставками LangGraph ложится неплохо. А вот в чистом исследовании граф начинает мешать - вы просто не знаете заранее, какие рёбра рисовать.
smolagents - поделка от Hugging Face, и главная его идея - code agents: агент отвечает не JSON-вызовом инструмента, а куском Python-кода, который рантайм исполняет. Хочешь сложить два поиска и отфильтровать - модель пишет цикл, а не делает три отдельных вызова с тремя витками цикла и тремя счетами за токены. Выразительно и экономит витки. Но вы исполняете сгенерированный код, и вопрос песочницы становится вашим личным (в комплекте - исполнение локально, но есть готовые песочницы через E2B, Modal или Docker; а если код не нужен, есть и обычный ToolCallingAgent с JSON-вызовами). Фреймворк небольшой - ядро умещается примерно в тысячу строк, читается за вечер, и это дает нам возможность посмотреть, как устроен агентный рантайм, когда он не спрятан под абстракциями.
CrewAI смотрит на задачу больше с организационной стороны. Здесь не надо ни программировать цикл, ни рисовать граф. Вы составляете штатное расписание. У каждого агента есть роль, цель и биография: «старший исследователь, цель - находить точные факты, биография - осторожный человек, который цитирует источники». Задачи описываются как поручения с ожидаемым результатом, а бригада (так и называется - crew) запускается одним вызовом kickoff() и работает по одному из двух процессов: последовательному, где задачи идут друг за другом, или иерархическому, где над исполнителями появляется агент-управляющий, который сам раздаёт работу и проверяет результаты. Минус - ну в высокой абстракции: под капотом всё равно промпты и циклы, просто вы их не видите, и когда поведение расходится с ожиданием, разбираться приходится сквозь слои этого всего фреймворка. Есть и более продвинутый вариант - Flows: событийные оркестрации, где методы связываются декораторами @start и @listen («начни здесь», «выполни после того»), а состояние между шагами носит Pydantic-модель. Типичная схема - Flow держит жёсткий маршрут процесса, а бригада вызывается на тех шагах, где нужна автономная работа команды.
Кстати - тут же вспоминается Claude Agent Teams, там сходная концепция. Есть ведущий агент, он держит план и раздаёт подзадачи субагентам, те параллельно и автономно работают над своими задачами, возвращают сжатые выжимки - ведущий собирает финальный ответ.
Заключаем, что и когда выбирать (это весьма приблизительно): PydanticAI - когда важны типы и контракты (продакшен-агенты в питоновом стеке), LangGraph - если нужен контроль над маршрутом: процессы, согласования, чекпоинты. smolagents берут за минимализм или ради агента, пишущего код, а CrewAI - когда задача сама по себе «командная» и описать её ролями и поручениями проще, чем рёбрами и циклом.
Теперь протоколы. MCP (Model Context Protocol) открыла Anthropic в конце 2024-го, и за год он стал стандартом де-факто - его подхватили OpenAI, Google и вообще все. Сразу закрепилась всем известная аналогия: USB-C для ИИ. До MCP каждая связка «приложение - инструмент» была точечной интеграцией со своей схемой и своими костылями. Теперь инструмент упаковывается в MCP-сервер один раз и работает с любым клиентом: десктопные ассистенты, IDE, ваш агент.
Просто краткое напоминание (могут же спросить). Клиент и сервер говорят JSON-RPC 2.0. Транспорта два: stdio (сервер - локальный процесс, которого клиент сам запускает и с которым общается через пайпы) и Streamable HTTP (сервер где-то в сети). Ну и три сущности сервера: tools - функции, которые модель может вызывать; resources - данные, которые можно читать (файлы, записи); prompts - готовые шаблоны промптов.
Была ещё пара клиентских примитивов - sampling (сервер просит клиента сходить к модели) и elicitation (сервер просит у пользователя ввод), но sampling уже объявили устаревшим. То есть пока поддерживается, но со временем уберут.
Причина же в том, что MCP поехал в stateless (то есть без сохранения состояния): сессии и рукопожатие initialize убрали, чтобы запросы можно было раскидывать по любым экземплярам сервера за балансировщиком. А сэмплинг - это вызов, который инициирует сервер, и ему нужен держащийся двусторонний канал. Взамен появился общий механизм Multi Round-Trip Requests: сервер отвечает «мне нужен ввод» со списком запросов, клиент собирает ответы и переотправляет исходный вызов уже с ними. Elicitation переехал на него же.
Живой пример конфига в формате, который вы наверняка встретите:
{
"mcpServers": {
"jira": {
"command": "node",
"args": ["./jira-mcp-server.js"],
"env": { "JIRA_TOKEN": "..." }
}
}
}
Подсвечу три момента:
секреты живут в env процесса, а не в промптах - модель токен не видит, видит только схему инструмента;
локальный сервер клиент запускает сам, при старте, - отсюда следствие, что правку конфига он подхватит только после перезапуска;
схемы инструментов клиент забирает вызовом tools/list и дальше просто подставляет их в контекст модели.
Практическая польза тут в том, что вы перестаёте писать инструменты под каждый фреймворк. Написали MCP-сервер - и его инструменты доступны агенту на PydanticAI, LangGraph, чему угодно, что умеет в MCP-клиент. Вся экосистема съезжается на этот контракт, поэтому MCP и стал вариантом по умолчанию.
Вторая аббревиатура в паре - A2A (Agent2Agent), протокол Google, представленный в 2025-м. Он решает соседнюю задачу: если MCP подключает агента к инструментам, то A2A подключает агента к другому агенту.
Сценарий: у компании есть агент-исследователь, агент-писатель и агент-редактор, возможно, на разных фреймворках и у разных вендоров. Как им поручать друг другу работу, не зная внутренностей друг друга? A2A предлагает контракт. Каждый агент публикует Agent Card - JSON-документ (обычно на известном пути вида /.well-known/agent.json) с описанием: кто я, что умею, какие у меня навыки, как со мной говорить. Дальше клиент-агент создаёт task (задачу) у удалённого агента, задача живёт как объект со стадиями, обмен идёт сообщениями, а результат приходит в виде артефактов.
И еще вопрос долгих оркестраций - «а как это работает, когда работа идёт неделю». У каждой задачи есть taskId - это одна единица работы, - и contextId, который логически связывает несколько задач и сообщений в одну нить. То есть: taskId отвечает на вопрос «что именно сейчас делается», contextId - «в рамках какого разговора». Попросили агента-исследователя собрать материал, потом по итогам попросили уточнить один пункт, потом отдали результат агенту-редактору - это три разные задачи с разными taskId, но один contextId. Удалённый агент по нему опознаёт, что это продолжение, и может держать свою историю и контекст модели между задачами. Кто заводит contextId - вопрос договорённости: агент может выдать свой, а может принять ваш.
Задача - это объект с состоянием, и живёт он на стороне исполнителя. Может провисеть в работе дни и недели, а клиенту при этом не нужно держать соединение открытым.
Потому и стадий тут больше, чем привычные три:
submitted - задачу приняли, но ещё не начали;
working - агент работает;
input-required - работа приостановлена, агенту не хватает данных и он ждёт от вас ответа;
auth-required - работа приостановлена, агенту не хватает прав доступа;
completed, failed, canceled, rejected - конечные состояния: сделано, сломалось, отменили, не взяли в работу.
Первые четыре стадии рабочие, из них задача ещё куда-то поедет. Остальные терминальные - приехали.
Следить за этим всем можно тремя способами:
опрос (polling) - сами дёргаете tasks/get по taskId и смотрите текущее состояние. Просто, но узнаёте с задержкой;
стриминг (streaming) - агент шлёт вам события по мере их появления через SSE (Server-Sent Events - однонаправленный поток сообщений от сервера поверх обычного HTTP). Узнаёте сразу, но надо держать соединение;
push-уведомления - агент сам стучится HTTP-запросом на ваш вебхук (webhook - ваш URL, который вы заранее зарегистрировали у агента, чтобы он вас на него дёргал). Соединение держать не нужно вообще.
И еще два практических момента:
если стрим оборвался - а на долгой задаче он оборвётся, - есть tasks/resubscribe, переподключение к потоку той же задачи; начинать сначала не нужно.
с вебхуком типовой сценарий такой - пришло уведомление, вы проверили его подлинность и дёрнули tasks/get по taskId за полным состоянием и артефактами. То есть в уведомлении самих данных нет, оно только говорит, что пора сходить за ними. Поэтому A2A и годится для мобильных приложений и serverless-функций, где постоянного соединения нет в принципе.
В целом эти протоколы друг другу не конкуренты. Через MCP агент тянется вниз, к инструментам и данным, а через A2A договаривается с равным себе. Если надо запомнить одной строчкой: MCP = агент ↔ инструменты, A2A = агент ↔ агент.

Рис. 3. Два протокола - две оси. MCP подключает агента к инструментам и данным (USB-C-розетка), A2A соединяет агентов друг с другом через публичные Agent Card.
Дошли до самого загадочного - оценки. Это то, чем агентная разработка отличается от обычной разработки с LLM: модель недетерминирована, агент выбирает маршрут сам, и вопрос «он вообще хорошо работает?» не решается юнит-тестом. Так что попробуем понять, что вообще нужно измерять, потом посмотрим на путь агента как на объект оценки, затем пощупаем DeepEval - с кодом и сценариями - и в конце соберём подобие конвейера.
Три причины. Первая: ответ - текст, и «правильность» редко бинарна; один и тот же смысл можно передать многими способами. Вторая: у агента, как у самурая, «есть только путь» - он мог ответить верно, позвав не те инструменты, или неверно при верных инструментах. Третья: входы разнообразны и хвост длинный - на ста кейсах всё хорошо, на сто первом агент уходит в цикл.
Из этого вырастает и структура метрик - их удобно делить на три уровня:
итог: решена ли задача, релевантен ли ответ вопросу, соответствует ли он фактам контекста (к примеру, faithfulness - метрика для RAG: ответ не должен выдумывать то, чего нет в найденных документах).
путь, или траектория: позвал ли агент правильные инструменты с правильными аргументами, сколько витков сделал, не ходил ли кругами.
токены, стоимость, латентность. За сколько токенов агент решил задачу? За какое время?
Главный инструмент оценки текстов - LLM-as-judge (модель-судья): другая (или та же) модель получает вопрос, ответ и критерии оценки - то есть инструкцию, за что и сколько ставить, - и выставляет оценку. В целом работает, но судья может быть пристрастен.
Начнём с критериев оценки, это же главное. Сравните два критерия: «оцени качество ответа по шкале 1-10» и «вот список фактов, которые должны быть в ответе; по одному баллу за каждый найденный». Первый даст вам шум, второй - осмысленные цифры. Почему так:
Широкая формулировка заставляет судью додумывать критерий. «Качество» каждый раз означает что-то своё - на одном кейсе судья смотрит на полноту, на другом на вежливость. Вы получаете среднее по несопоставимым величинам.
Шкала 1-10 не работает. На практике оценки сбиваются в верхнюю часть шкалы, и разница между приличным ответом и отличным пропадает. Три деления или вообще бинарное «да/нет» ведут себя устойчивее.
Узкий критерий разбирается на проверки. «Все ли факты на месте» превращается в чеклист: разбили эталон на отдельные утверждения и по каждому спросили «есть в ответе - да или нет». Каждый вопрос простой, судья на таких почти не врёт, а балл складывается сам.
Обоснование - до оценки, а не после. Просите судью сначала написать, почему, и только потом поставить балл. Если наоборот - он поставит цифру, а дальше будет подгонять под неё объяснение.
Теперь еще такой термин: смещения (в официальной документации того же Anthropic - bias). Смещение - это не случайная ошибка, а систематическая: судья ошибается всегда в одну сторону. Случайный разброс на большом датасете усредняется, а вот смещение никуда не денется: сколько кейсов ни прогони, перекос останется тем же. Поэтому вот краткий список с пруфами:
Длина. Длинный и красиво отформатированный ответ судья систематически ставит выше короткого, даже если короткий точнее. Значит нужен отдельный пункт в критериях и контроль: дайте судье намеренно раздутую версию правильного ответа и посмотрите, вырастет ли балл. (Zheng et al., MT-Bench, NeurIPS 2023 - там это verbosity bias)
Позиция. При сравнении двух ответов судья склонен выбирать тот, что идёт первым. Что делать: прогоняйте каждую пару дважды, меняя ответы местами, и засчитывайте только совпавшие вердикты. (Wang et al., 2023)
Любовь к себе. Модель выше оценивает тексты, порождённые ей же (или её роднёй по семейству). Отсюда как бы нежелательно судить агента на GPT судьёй на GPT, лучше брать судью другого провайдера. (Panickssery et al., NeurIPS 2024)
Мягкость и согласие. Судья склонен завышать, а если в промпте намекнуть, что ответ правильный, - охотно согласится. Не пишите в критериях ничего, что выдаёт ожидаемый ответ. (Sharma et al., Anthropic, 2023)
Дальше - калибровка. Разметьте руками сотню кейсов, прогоните по ним судью и посмотрите, насколько его оценки совпали с вашими. Совпало плохо - вы измеряете не качество агента, а предпочтения судьи. И делать это придётся не один раз: сменили модель судьи или переписали критерии - калибруйте заново. Поэтому критерии и промпт судьи стоит версионировать наравне с кодом.
Ну и не стоит забывать, что не нужен судья там, где можно просто проверить кодом и бесплатно: содержит ли ответ номер заказа? Валидный ли JSON? Тот ли был вызван инструмент?
Траектория - это упорядоченная запись всего, что агент делал по дороге к ответу: виток за витком, какой вызов модели, какой инструмент он выбрал, с какими аргументами, что инструмент вернул, что модель решила дальше. Ответ показывает, куда агент пришёл, а траектория - какой дорогой. Разбирать её приходится не реже, чем сам ответ.
Сценарий для примера. Агент поддержки, задача из датасета: «где мой заказ №123?». Эталонная траектория короткая: lookup_order(order_id=123) → ответ клиенту. Теперь смотрим, что мог сделать агент:
траектория A (хорошо): lookup_order(123) → ответ
траектория B (терпимо): search_kb("заказ 123") → lookup_order(123) → ответ
траектория C (плохо): search_kb("заказ 123") → search_kb("123") → ответ «не знаю»
траектория D (ужасно): lookup_order(123) → lookup_order(123) → lookup_order(123) → ...По траектории измеряем вот что:
выбор инструментов. Множество вызванных инструментов против множества ожидаемых: в кейсе B агент вызвал lookup_order - засчитано, но захватил лишний search_kb. Это precision и recall по инструментам: нужные позваны? лишних сколько? Траектория C нужный инструмент не позвала вовсе - и ответ «не знаю» при живом заказе в базе.
порядок вызовов. Иногда он критичен: сначала авторизация, потом данные; сначала расчёт скидки, потом запись в заказ. Тогда траекторию сравнивают с эталонной строго - exact match (последовательности совпали один в один) или in-order (эталонная последовательность содержится в фактической как подпоследовательность, лишние вставки разрешены). Чаще порядок не важен, важен набор - тогда сравнивают any-order.
аргументы. Вызвать lookup_order с order_id=123 и с order_id=«сто двадцать три» - разные вещи, даже если имя инструмента угадано. Проверяется двумя слоями: синтаксис (схема инструмента, типы - вот здесь и отрабатывает валидация PydanticAI, битый вызов просто не проходит) и семантика (судья отвечает на узкий вопрос: «соответствуют ли аргументы намерению из запроса?»).
эффективность пути. Число витков против эталона: кейс B решил задачу за лишний виток - сам по себе не провал, но это лишние токены и лишнее время. А на датасете из ста кейсов лишний виток на каждом третьем выливается уже в заметные деньги. Метрики вида steps vs expected_steps и доля кейсов, решённых за минимум витков.
зацикливание. Траектория D - классическая болезнь: агент вызывает один и тот же инструмент с одними и теми же аргументами, не продвигаясь. Ловится обычным кодом, без всякой модели: повтор пары (инструмент, аргументы) подряд - красный флаг, число витков больше порога - тоже. Токенов такая проверка не стоит вообще.
И заметьте такое: ответ в кейсе C мог быть вежливым, релевантным по форме и даже честным («не знаю») - метрики первого уровня вполне его пропустят, и только траектория покажет, что агент просто не туда пошёл. Поэтому путь и надо оценивать. Без этого у вас есть только факт ошибки, без всякого понимания, на каком шаге всё пошло не так.
Наконец-то взглянем на пример. DeepEval - это оценка как юнит-тесты: почти обычный pytest, только вместо assert - метрики. Ставится стандартно (pip install deepeval), прогоняется pytest, и за счёт этого оценка ложится в привычный цикл разработки и в CI без отдельной инфраструктуры.
Всё здесь строится на одной структуре - LLMTestCase. Это набор полей: что спросили (input), что агент ответил (actual_output), что он должен был ответить, какие документы нашёл, какие инструменты позвал. Обязательны первые два, остальные заполняются по мере надобности - в зависимости от того, что требуют выбранные метрики. Метрика берёт такой кейс и возвращает балл от 0 до 1, порог, выше которого считается «сдал», и текстом - почему она столько поставила. Последнее полезно: когда тест падает, вы читаете не «0.42 < 0.7», а фразу про то, какого именно факта в ответе не хватило.
Сценарий первый: RAG-ответ. Агент отвечает на вопросы по базе знаний, и мы хотим, чтобы ответ был по делу и не выдумывал фактов сверх найденного:
from deepeval import assert_test
from deepeval.test_case import LLMTestCase
from deepeval.metrics import AnswerRelevancyMetric, FaithfulnessMetric
def test_return_policy():
question = "Могу ли я вернуть товар без чека?"
result = support_agent.run_sync(question)
test_case = LLMTestCase(
input=question,
actual_output=result.output,
retrieval_context=[
"Возврат возможен в течение 30 дней с момента покупки.",
"Без чека возврат оформляется на подарочную карту по цене товара на день возврата.",
],
)
assert_test(test_case, [
AnswerRelevancyMetric(threshold=0.8),
FaithfulnessMetric(threshold=0.9),
])
Разберём, что здесь происходит. AnswerRelevancyMetric спрашивает: отвечает ли ответ на заданный вопрос (а не «вежливо о чём-то рядом»).
FaithfulnessMetric делает детальнее: разбирает ответ на атомарные утверждения и проверяет каждое - подтверждается ли оно retrieval_context. Если агент сочинит «без чека вернуть нельзя никак» - метрика упадёт, потому что в контексте сказано про подарочную карту. Это и есть главный способ борьбы с галлюцинациями в RAG. Пороги выставляются под задачу: у саппорта faithfulness держат высоким (0.85-0.9), релевантность можно мягче.
Тем не менее - готовых метрик никогда не хватает на все требования. Скажем, вы хотите, чтобы ответ не обещал того, чего нет в политике возврата, и чтобы он заканчивался конкретным следующим шагом - что именно клиенту сделать дальше. Метрики «заканчивается понятным следующим шагом» в комплекте нет, а проверять как-то надо.
Для таких случаев в DeepEval есть GEval. Это метрика-конструктор: вы задаёте критерий обычным текстом, а она собирает из него LLM-судью - разворачивает критерий в последовательность шагов оценки, прогоняет по ним модель и возвращает балл от 0 до 1 с объяснением. Название - от статьи G-Eval, где показали, что судья, которому дали не голый критерий, а расписанную процедуру оценки, куда лучше сходится с людьми. Собственно, вот:
from deepeval.metrics import GEval
from deepeval.test_case import LLMTestCaseParams
no_overpromise = GEval(
name="Без лишних обещаний",
criteria=(
"Оцени, не обещает ли ответ то, чего нет в политике возврата. "
"Шаг 1: выпиши все обещания из ответа. "
"Шаг 2: для каждого проверь, следует ли оно из контекста. "
"Балл 1.0 - все обещания подтверждены, 0.0 - есть хотя бы одно выдуманное."
),
evaluation_params=[
LLMTestCaseParams.INPUT,
LLMTestCaseParams.ACTUAL_OUTPUT,
LLMTestCaseParams.RETRIEVAL_CONTEXT,
],
threshold=0.8,
)Обратите внимание на форму критерия: не «оцени качество», а процедура с шагами - выпиши, проверь каждый, посчитай. Это ровно тот принцип узких критериев из раздела про судью: чем процедурнее критерий, тем меньше шум и тем честнее причина, которую метрика вернёт вместе с баллом.
Сценарий второй: траектория и инструменты. Тот самый агент поддержки и задача «где мой заказ №123?». В DeepEval вызванные инструменты кладутся в тот же тест-кейс, и метрика ToolCorrectnessMetric сравнивает их с ожидаемыми:
from deepeval.test_case import LLMTestCase, ToolCall
from deepeval.metrics import ToolCorrectnessMetric
def test_order_lookup_trajectory():
result = support_agent.run_sync("где мой заказ №123?")
test_case = LLMTestCase(
input="где мой заказ №123?",
actual_output=result.output,
tools_called=[
ToolCall(name="search_kb", input_parameters={"query": "заказ 123"}),
ToolCall(name="lookup_order", input_parameters={"order_id": "123"}),
],
expected_tools=[
ToolCall(name="lookup_order", input_parameters={"order_id": "123"}),
],
)
assert_test(test_case, [
ToolCorrectnessMetric(threshold=0.5),
])Метрика сравнивает два списка: какие инструменты должны были быть вызваны и какие вызваны на самом деле, с учётом имён и аргументов. В этом примере нужный lookup_order на месте, лишний search_kb тянет балл вниз - наш кейс B из раздела про траектории. Порог 0.5 означает «лишние вызовы терпим, отсутствие нужного - нет». Откуда взять список вызовов - зависит от фреймворка: в PydanticAI история сообщений и вызовов инструментов достаётся из объекта результата (result.all_messages() и вызовы в них), в LangGraph - из состояния графа, а универсальный способ - снимать их из трассы OpenTelemetry, о которой ниже.
И сценарий третий: решена ли задача вообще. TaskCompletionMetric - судья, который получает задачу, трассу вызовов и ответ и решает: выполнено или нет. Это метрика первого уровня в чистом виде, и её удобно ставить в самый конец: даже если все частные метрики зелёные, вопрос «задача решена?» ловит агента, который формально всё сделал, а по сути нет.
Ну и пара мелочей. Кейсы удобно собирать в датасеты (в DeepEval это EvaluationDataset, в облаке Confident AI - те же датасеты с интерфейсом) и прогонять пачкой, а не по одному. Балл каждой метрики сопровождается reason - текстовым объяснением судьи; читайте их на провалившихся кейсах, там обычно сразу видно, что сломалось.
Теперь соберём конвейер целиком, по частям - так, как его собирают, когда под рукой нет ничего, кроме pytest и здравого смысла.
Часть первая - золотой датасет. Не тысяча синтетических кейсов, а для начала пятьдесят-сто реальных: вопросы из прода или от заказчика, с эталонными ответами или хотя бы с критериями («должен назвать цену и срок», «не должен обещать возврат»). Тут объём вторичен. Синтетика из той же модели измеряет агента на его же языке и пропускает ровно те косяки, на которых спотыкаются живые люди.
Часть вторая - прогон. Датасет запускается через агента целиком, с записью всего: ответ, траектория (вызовы инструментов, витки, токены), время. Прогон должен быть воспроизводимым, так что зафиксируйте версию промпта, модели и инструментов. Иначе при расхождении результатов вы не поймёте, что именно поменялось.
Часть третья - оценщики. Слои, от дешёвого к дорогому: детерминированные проверки (ответ не пустой, JSON валиден, повторных вызовов с теми же аргументами нет), проверки пути (нужный инструмент вызван, аргументы корректны, витков не больше N), и только потом LLM-судьи с узкими критериями. Дешёвые слои отсеивают заметную часть провалов бесплатно, и гонять судью по тому, что проверяется обычным сравнением строк, смысла нет.
Часть четвёртая - агрегация и порог. Метрики собираются в отчёт: среднее по датасету, разбивка по типам кейсов, сравнение с прошлой версией. Дальше порог в CI: упала faithfulness ниже 0.85 или выросла стоимость выше бюджета - сборка красная. С этого момента качество агента перестаёт быть предметом обсуждения и становится условием мержа.
Часть пятая - обратная связь из прода. Кейсы, где агент провалился, разбираются и пополняют датасет; трассы с плохими отзывами - туда же. Датасет растёт на ваших же ошибках, конвейер ловит регрессии, и качеством наконец можно управлять, а не надеяться на него.

Рис. 4. Оценочный конвейер целиком: золотой датасет → прогон агента с записью трасс → слои оценщиков от дешёвых к дорогим → отчёт с порогом в CI → провальные кейсы возвращаются в датасет.
Контекст модели ограничен и стоит денег, а документов может быть много - все не влезут. Плюс чем длиннее контекст, тем охотнее модель теряет в нём нужное (это называют context rot, загниванием контекста). То есть наша задача - держать в контексте поменьше и только самое нужное.
Отсюда RAG (Retrieval Augmented Generation). Документы заранее режут на куски, каждый кусок прогоняют через эмбеддинг-модель и получают вектор - набор координат в многомерном смысловом пространстве, где куски с близким смыслом стоят рядом. Близость меряют углом между векторами - термин «косинусное сходство» все слышали. Ну и какое-то количество кусков с близким смыслом мы найдем.
На иллюстрации ниже вы видите аббревиатуру HNSW. Это один из алгоритмов поиска тех самых ближайших соседей. Перебирать миллион векторов на каждый запрос чересчур затратно, поэтому база заранее строит из них граф связей и по нему прыгает: сначала большими шагами в нужную область пространства, а у цели уже мелкими. Работает это быстро, но ответ приблизительный - изредка настоящий ближайший сосед теряется по дороге. Насколько тщательно искать, вы решаете сами, настройками индекса: чем тщательнее, тем медленнее.
Впрочем, статья не про RAG и векторные базы, это скорее напоминание.

Рис. 5. Документы превращаются в векторы и становятся точками в смысловом пространстве, запрос - звёздочкой, а база отдаёт k ближайших к нему соседей. Справа - тот самый HNSW: искать соседей помогает многослойный граф, где верхние уровни разрежены для дальних прыжков, а нижние плотные - для точного попадания.
Ну и стоит еще упомянуть OpenTelemetry - инструмент анализа трасс, способ понять, что там происходит в проде.
OpenTelemetry (OTel) - открытый стандарт телеметрии: трассы, метрики, логи, единый протокол (OTLP), коллекторы и экспортёры в любой бэкенд (Jaeger, Tempo, Datadog, а из нашего мира - Langfuse, Logfire, та же Opik). Но нам нужна трасса: запись одного запроса как дерева спанов. Спан - одна операция: имя, начало, длительность, атрибуты, родитель.
Теперь положим это на агента - смотрим пример на иллюстрации. Корневой спан - запуск агента (12 секунд). Внутри - спаны вызовов модели (с атрибутами: какая модель, сколько токенов входа и выхода, какая стоимость), спаны вызовов инструментов (какой инструмент, какие аргументы, какой результат, сколько занял), а внутри них - спаны того, куда инструмент сходил: HTTP-запрос, векторная база, что угодно. И вместо «агент отвечал двенадцать секунд» вы теперь видите, что пять из них ушло на поиск в базе, потому что индекс не по метаданным, а модель дважды переспрашивали, потому что инструмент вернул пусто. Уже можно чинить.

Рис. 6. Трасса одного запуска агента: корневой спан и вложенные спаны вызовов модели и инструментов с атрибутами - модель, токены, стоимость, длительность. По такому водопаду видно, где агент потерял время и деньги.
Ну собственно, цель была покрыть стек агентной разработки и помочь тебе собрать общее понимание, из чего он состоит - а в идеале отбиться от настойчивых интервьеров с их подлыми вопросами. Если будет вдохновение - поговорим еще о всяких интересных вещах типа Claude Agent Teams и Dynamic Workflow, или как сделать движок для пайплайнов, основанных на собственном DSL под конкретные задачи - чтобы обойти недостатки вышеуказанных технологий и сделать выполнение работы подешевле. Если…