Всем привет! Мы создаём GraphRAG-систему и нам постоянно приходится тестировать новые гипотезы: менять подходы к поиску по графу, обработку контекста, внешние интеграции и вспомогательные компоненты. Почти каждая такая гипотеза требует правок в коде или конфигурирования агента, а значит, быстро возникает несколько параллельных вариантов реализации, которые хочется сравнивать между собой.
При этом тестирование одной версии не должно блокировать тестирование другой. Разработчики должны иметь возможность одновременно прогонять бенчмарки для разных веток, реализаций и конфигураций, а затем выбирать наиболее удачные изменения и интегрировать их в основную версию агента, которая уже проходит путь до эксплуатации.
Другая проблема: агент — это не просто промпт к LLM, а комплексная кодовая база со своим окружением, множеством зависимостей и точек отказа. Тестирование его встраиванием в ноутбуки и кастомные скрипты может аукнуться неприятными побочными эффектами и необходимостью постоянно их дорабатывать под изменения в агенте или добавление новых агентов.
В результате задача «оценить качество агента» превращается не только в задачу про метрики, но и в задачу про инженерную надёжность: как воспроизводимо запускать агент, как не зависеть от конкретного агента или его версии, как не терять промежуточные результаты прогонов, как хранить артефакты и сравнивать результаты между версиями.
Какие требования были к инструменту
Оценив проблематику, мы составили требования к инструменту для бенчмаркинга:
воспроизводимо запускает код агента;
поддерживает decoupling между инструментом для запуска бенчмарков и окружением самого агента;
умеет ставить агент из локального пути, git-репозитория или любого pip-compatible источника;
позволяет подключать агенты независимо от библиотек и фреймворков, на которых построен агент;
сохраняет структурированные артефакты прогонов;
выдерживает длинные прогоны и не заставляет начинать всё заново при сбоях;
имеет стандартизированный формат бенчмарков;
позволяет распараллелить вопросы из бенчмарка в рамках прогона;
позволяет запускать прогоны как локально, так и на удалённых серверах через встраивание в Airflow;
не требует сложной платформы вокруг себя, чтобы начать им пользоваться.
Так родилась основная идея: сделать легковесный CLI-инструмент, который умеет поднимать агент в отдельном воркере, прогонять бенчмарк и сохранять результаты, и который можно как запускать локально, так и легко встроить во внешний оркестратор.
Общая архитектура
Инструмент разделён на два пакета:
bencher— основной CLI и слой окрестрации. Отвечает за:чтение спецификации бенчмарка;
установку агентного окружения;
запуск оркестратора (
BenchmarkRunner) и набора воркеров (WorkerPool);обработку ошибок;
фиксирование чекпоинтов и восстановление из них;
отправку ответов на оценку в LLM-as-a-Judge;
запись результатов.
bencher-worker— легковесный воркер, который не имеет внешних зависимостей и встраивается в виртуальное окружение агента. Отвечает за:загрузку агента из виртуального окружения;
приём запросов от оркестратора;
отправку запросов к агенту через адаптер;
возврат результата или ошибки основному процессу.

Базово поток выполнения выглядит так:
Пользователь запускает команду через CLI с набором параметров.
CLI находит бенчмарк и читает его спецификацию.
CLI создаёт окружение агента, устанавливая в него все зависимости.
CLI создаёт экземпляр
BenchmarkRunner.BenchmarkRunnerподнимаетWorkerPool, состоящий из одного или несколькихWorkerManager.Каждый
WorkerManagerуправляет отдельнымWorker, который загружает адаптер и агент внутри виртуального окружения.BenchmarkRunnerотправляет вопросы в пул воркеров, получает ответы и передаёт их на оценку в LLM-as-a-Judge.Промежуточные результаты CLI записывает как контрольные точки, а по завершении прогона сохраняет итоговые артефакты запуска.
Два ключевых принципа, которые были вложены в архитектуру:
оркестратор и runtime агента разделены;
прогон бенчмарка рассматривается как инженерный артефакт, конфигурация и результаты которого сохраняются для дальнейшего анализа.
Бенчмарк как входной контракт
Публичные датасеты для бенчмарков часто предоставлены в разных форматах: один может быть в CSV, другой в TSV, третий в JSON и так далее. Также набор полей в них может иметь разные названия.
Для стандартизации у нас бенчмарк состоит из двух файлов:
benchmark.yamlилиbenchmark.json;dataset.jsonl.
Пример benchmark.yaml:
name: rusimpleqa version: "1.0" dataset: dataset.jsonl question_key: question golden_key: golden judge: type: llm provider: gigachat model: GigaChat-2-Max temperature: 0.25 system_prompt: > You are a strict benchmark judge. Return only valid JSON with keys: label (correct|incorrect|not_attempted), reason (short). rubric: > Label correct if the answer is semantically equivalent to the golden answer. Label incorrect if it conflicts or misses key facts. Label not_attempted if the answer is empty or a refusal.
Пример строки в dataset.jsonl:
{"id": "q1", "question": "2+2?", "golden": "4", "meta": {}}
Такой формат решает сразу несколько задач:
бенчмарк остаётся декларативным;
позволяет версионировать бенчмарки;
базовая конфигурация judge живёт рядом с бенчмарком.
Как подключается агент
Следующая проблема — как сделать так, чтобы наш инструмент умел работать не с одним конкретным агентом, а с произвольным агентным кодом? Решение есть. Для подключения агента создаём адаптер, который должен реализовывать такой протокол:
class AgentRunner(Protocol): def run(self, question: str, **kwargs: Any) -> str: ...
Пример конкретной реализации адаптера для агента, использующего фреймворк LangChain:
from typing import Any, Sequence from knowledge_graph_agent.core.agent import init from knowledge_graph_agent.core.state import AgentState from langchain_core.messages import BaseMessage, HumanMessage class BencherAdapter: def init(self) -> None: self._agent = init().compile() async def run(self, question: str, **kwargs: Any) -> str: result = await self._agent.ainvoke( AgentState(messages=[HumanMessage(content=question)]) ) messages: Sequence[BaseMessage] = result.get("messages", []) return messages[-1].text() def make_agent() -> BencherAdapter: return BencherAdapter()
Далее нам нужно указать путь для импорта адаптера. Это можно сделать двумя способами. Первый — через описание точки входа в pyproject.toml агента:
[project.entry-points."bencher_agents"] agent = "bencher_runtime.adapter:make_agent"
Далее в CLI указываем название точки входа:
bencher run \ --benchmark rusimpleqa@1.0 \ --agent ./path/to/agent/repo \ --agent-entry-point agent
Второй способ — через указание полного пути для импорта в CLI:
bencher run \ --benchmark rusimpleqa@1.0 \ --agent ./path/to/agent/repo \ --agent-import-path bencher_runtime.adapter:make_agent
Благодаря этому bencher ничего не знает о внутреннем устройстве агента, но при этом может запускать его единообразно.
Где держать код адаптера?
Если у вас single-package репозиторий, то адаптер можно просто добавить отдельным модулем в код пакета, всё же много места он не занимает. Но если вам нужно развернуть агента в разные среды выполнения, то может подойти multi-package структура:
packages/ ├── agent/ ├── example-runtime-1/ ├── example-runtime-2/ └── bencher-runtime/ ├── pyproject.toml └── src/ └── bencher_runtime/ ├── __init__.py └── adapter.py
Идея в том, что agent/ содержит основной код агента, example-runtime-1 и example-runtime-2/ — runtime-обвязки под разные среды, в которых должен запускаться агент, а bencher-runtime/ — минимальный пакет только для интеграции с bencher. В таком варианте адаптер не смешивается с production-обвязками и его можно ставить через --agent.
Запускаем агент из удалённого репозитория
На практике удобно, что bencher умеет ставить агент не только из локального пути, но и напрямую из git-репозитория. Это особенно полезно, когда нужно сравнить несколько веток, прогнать бенчмарк на конкретном теге или проверить гипотезу из feature branch без ручной сборки пакетов.
Если у вас single-package репозиторий и адаптер лежит прямо внутри основного пакета, то всё совсем просто:
bencher run \ --benchmark rusimpleqa@1.0 \ --agent git+https://gitlab.example.org/org/repo.git@main \ --agent-entry-point agent
В этом случае bencher просто ставит один пакет из репозитория с его зависимостями.
Если адаптер лежит в отдельном пакете bencher-runtime, а основная логика агента — в другом пакете agent внутри того же монорепозитория, то может понадобиться отдельно указать адрес основного пакета. Для этого есть параметр --agent-extra-dep:
bencher run \ --benchmark rusimpleqa@1.0 \ --agent git+https://gitlab.example.org/org/repo.git@feature-branch#subdirectory=packages/bencher-runtime \ --agent-entry-point agent \ --agent-extra-dep git+https://gitlab.example.org/org/repo.git@feature-branch#subdirectory=packages/agent
Опциональные параметры запуска
Помимо обязательных --benchmark и --agent, у bencher run есть несколько других параметров, которые особенно полезны в реальной эксплуатации:
--timeout— максимальное время ожидания ответа на один вопрос. Если агент отвечает слишком долго, то текущая попытка завершается по таймауту.--worker-ready-timeout— сколько ждать, пока воркер после старта пришлёт сообщениеready. Удобно, если агент долго инициализируется.--limit— ограничивает количество записей датасета, которые будут обработаны. Удобно для smoke-тестов и быстрой проверки конвейера.--sample— случайно выбирает подмножество записей из датасета перед запуском. Полезно, когда нужно быстро сравнить гипотезу не на всём бенчмарке, а на части данных.--seed— фиксирует сид для--sample, чтобы выбранная подвыборка воспроизводилась между запусками.--parallelism— сколько воркеров запускать параллельно. Это основной способ ускорить прогон на больших датасетах.--retriesи--retry-backoff-sec— количество повторных попыток и пауза между ними. Полезно, когда агент ходит во внешние сервисы и отдельные запросы могут выполняться нестабильно.--agent-extra-dep— дополнительные зависимости, которые нужно поставить в то же окружение, что и адаптер. Особенно полезно для монорепозитория.--checkpoint-dir,--checkpoint-flush-everyи--resume— параметры для длинных прогонов. Они позволяют периодически сохранять промежуточные результаты и возобновлять запуск после сбоя.--skip-judge— отключает оценку и сохраняет только ответы агента. Удобно, если вы хотите сначала отдельно собрать ответы, а оценивать позже.--output-dir— директория, куда будут записаны финальные артефакты запуска.--upload-dir— если нужна автоматическая загрузка результатов в объектное хранилище после завершения прогона.--invalidate-agent-cache— принудительно пересобирает окружение агента и не использует существующий кеш.--log-levelи--log-ipc— помогают при отладке, когда нужно увидеть внутренние сообщения междуWorkerManagerи воркером.--worker-source— позволяет явно указать, откуда ставить пакетbencher-worker. Полезно, если нужно протестировать изменённую версию worker runtime.--description— произвольное текстовое описание запуска, которое потом попадёт в run.json.
Изоляция runtime и кеширование зависимостей
Читая статью, вы могли задаться вопросом: почему просто не импортировать агент в основной процесс? Причин несколько:
Зависимости агента могут конфликтовать с зависимостями исполнителя бенчмарков.
Разные версии агента должны запускаться воспроизводимо.
Ошибки и падения должны быть локализованы и не ломать процесс работы бенчмарка.
Поэтому для агента мы создаём отдельное Python-окружение и кешируем его по хешу входных параметров. В хеш входят:
источник агента;
способ подключения;
дополнительные зависимости;
источник пакета
bencher-worker.
Это даёт нам несколько полезных свойств:
одно и то же окружение можно переиспользовать между запусками;
обновление агента или зависимостей приводит к созданию нового venv;
исполнитель бенчмарков не загрязняется агентными библиотеками.
Как работает worker runtime
После того как окружение готово, WorkerManager через subprocess.Popen поднимает воркеры как отдельные процессы:
cmd = [str(self._python_path), "-u", "-m", "bencher_worker.worker"] if self._entry_point: cmd.extend(["--entry-point", self._entry_point]) if self._import_path: cmd.extend(["--import-path", self._import_path]) self._proc = subprocess.Popen( cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, )
Здесь self._python_path — путь до собранного виртуального окружения.
Далее каждый воркер:
резолвит агент;
отправляет
readyв основной процесс;читает сообщения из
stdin;исполняет агент;
возвращает в
stdoutсообщения с типомresultилиerror.
Всё взаимодействие между менеджером и воркером происходит через IPC.

Менеджер ждёт от воркера первое сообщение ready, после чего считает процесс готовым к работе. Дальше каждый вопрос передаётся как отдельное JSON-сообщение с id, текстом вопроса и дополнительным контекстом, если он есть, а ответ возвращается либо как result, либо как error.
Такой подход кажется довольно простым, но у него есть важные преимущества:
окружение агента изолировано;
воркер можно перезапускать при падении;
взаимодействие можно прозрачно журналировать;
нет жёсткой зависимости от конкретного фреймворка и реализации агента.
LLM-as-a-Judge
В нашем случае судья основан на LLM. Это важный выбор, потому что для агентных систем часто недостаточно строгого текстового совпадения: нужно оценивать семантическую близость ответа, учитывать вариативность формулировок и отделять ошибочные ответы от отказа пытаться ответить на вопрос.
Конфигурация судьи задаётся в спецификации бенчмарка:
judge: type: llm provider: gigachat model: GigaChat-2-Max temperature: 0.25 system_prompt: > You are a strict benchmark judge. Return only valid JSON with keys: label (correct|incorrect|not_attempted), reason (short). rubric: > Label correct if the answer is semantically equivalent to the golden answer. Label incorrect if it conflicts or misses key facts. Label not_attempted if the answer is empty or a refusal.
При необходимости эти параметры можно переопределить, либо указать дополнительные параметры для LLM через переменную окружения:
export BENCHER_LLM_JUDGE_KWARGS='{"provider":"gigachat","model":"GigaChat-2-Max","temperature":0.1,"timeout":180,"top_p":0.1}'
В спецификации выше указан самый простой rubric. Чтобы LLM не ошибалась в выборе метки между incorrect и not_attempted, стоит добавить в промпт примеры:
Label correct if the answer is semantically equivalent to the golden answer. Example of correct: - question: В каком году был основан муниципалитет Рондон, Бояка, Колумбия? - golden answer: 1904 - answer 1: Муниципалитет Рондон, Бояка, Колумбия был основан 30 июня 1904 года. - answer 2: 1904 Label incorrect if the answer is attempted but significantly differs from the golden answer or misses key facts. Example of incorrect: - question: Black Gryph0n (Блэк Грифон) и Baasik (Баасик) сотрудничали над какой фанатской песней в 2021 году о персонаже Hazbin Hotel (Хазбин Отель) Аласторе? - golden answer: Insane - answer 1: Black Gryph0n и Baasik в 2021 году выпустили совместную фан-песню об Аласторе из "Hazbin Hotel". - answer 2: Amazing Label not_attempted if the answer is empty, says that there is no information or asks for extra input. Example of not_attempted: - question: Как назывался кратер на Меркурии, названный в честь Курта Воннегута, в научной литературе до его наименования? - golden answer: e5 - answer 1: Нет информации о названии кратера на Меркурии до присвоения ему имени Курт Воннегут. - answer 2: Из представленных фактов невозможно определить название кратера на Меркурии до присвоения ему имени Курт Воннегут. - answer 3: [] - answer 4: ""
У LLM-as-a-Judge, конечно, есть ограничения:
судья сам является моделью и может быть источником нестабильности;
промпты требуют настройки и влияют на точность оценки;
какая конкретно выбрана модель также имеет значение.
Но в нашем случае это оказался наиболее практичный способ оценивать ответы агента в реальных сценариях бенчмаркинга.
Надёжность и воспроизводимость
Если инструмент запускается один раз локально, то надёжность можно недооценить. Но как только бенчмарки становятся длинными и регулярными, именно надёжность начинает определять полезность всей системы.
Поэтому в bencher мы изначально закладывали несколько механизмов:
Таймауты и повторы. У каждого ответа есть таймаут. Если воркер зависает или падает, то исполнитель умеет его перезапустить и повторить попытку. Это важно, потому что часть ошибок связана не с качеством ответа, а с нестабильностью внешних зависимостей.
Контрольные точки. При длинных прогонах особенно неприятно терять весь прогресс. Поэтому результаты можно периодически сбрасывать в контрольную точку (локально или в объектное хранилище) и затем возобновлять прогон с уже обработанных записей.
Идентификатор прогона. Каждый прогон бенчмарка получает
run_id. С ним связаны артефакты прогона и создаваемая для них директория.
Артефакты прогона
По завершении прогона bencher сохраняет отдельную директорию <output_dir>/<run_id>/, в которой лежат основные артефакты:
answers.jsonl— построчный список ответов агента. Для каждой записи сохраняютсяidвопроса, исходный вопрос, полученный ответ, эталонный ответ, длительность выполнения и номер попытки. Если во время исполнения произошла ошибка, то она тоже попадает в запись:
{ "id": "q1", "question": "2+2?", "answer": "4", "golden": "4", "duration_ms": 842, "attempt": 1 }
scores.jsonl— построчный список результатов оценки. Здесь хранятсяidвопроса, численное значение score (1.0 для корректного результата, 0.0 в остальных случаях), меткаcorrect,incorrectилиnot_attempted, а также пояснение для проставленной оценки:
{ "id": "q1", "value": 1.0, "method": "llm_judge", "reason": "Ответ семантически совпадает с эталоном.", "label": "correct" }
summary.json— агрегированная сводка по всему прогону: сколько записей было обработано, сколько из них были корректными, какова итоговая точность и другие метрики:
{ "total": 100, "attempted": 95, "correct": 76, "accuracy": 0.76, "avg_score": 0.76, "precision": 0.8, "recall": 0.76, "f1": 0.7794871794871795, "judged": true }
В текущей реализации
bencherметрикиaccuracyиrecallсовпадают, потому что обе считаются как доля корректно решённых задач от общего количества в датасете. Это упрощённая схема расчёта, в которой нет отдельной матрицы ошибок (confusion matrix), аprecisionзависит от количества задач, которые были засчитаны как попытки ответа.
run.json— техническое описание самого запуска. В нём фиксируютсяrun_id, бенчмарк, источник агента, время начала и завершения, а также остальные параметры запуска, которые важны для воспроизводимости:
{ "run_id": "20260518-154501-a1b2c3d4", "start_time": "2026-05-18T12:45:01.123456+00:00", "finish_time": "2026-05-18T12:58:44.654321+00:00", "benchmark": "rusimpleqa@1.0", "dataset": "dataset.jsonl", "agent_source": "git+https://gitlab.example.org/org/repo.git@main#subdirectory=packages/bencher-runtime", "agent_entry_point": "agent", "parallelism": 4, "retries": 2, "checkpoint_dir": "s3://my-bucket/bencher/checkpoints", "resume": true, ... }
Если включены контрольные точки, то внутри их хранилища дополнительно создаются промежуточные answers.jsonl и scores.jsonl. Они нужны для восстановления прогона после сбоя и продолжения с уже обработанных записей. Благодаря этому к каждому прогону можно вернуться позже и точно понять, как он был выполнен и откуда взялись результаты.
Интеграция с Airflow
Локальный CLI удобен для разработки, но в реальной работе нам было важно запускать бенчмарки как полноценный batch-процесс: с параметрами, секретами, журналированием, кешем зависимостей и последующей загрузкой результатов в аналитический контур. Для этого мы встроили bencher в Airflow. При этом сам Airflow у нас работает поверх Kubernetes, а каждый запуск бенчмарка исполняется в отдельном поде.
На уровне DAG логика выглядит так:
Airflow генерирует или принимает извне
run_id.Под конкретный запуск создаётся PVC для кеша
~/.cache/bencher.В под Kubernetes запускается команда
bencher runс параметрами из Airflow.Bencherпишет артефакты в объектное хранилище.Следующее задание забирает
run.jsonиsummary.jsonпо тому жеrun_id.После этого метаданные запуска и агрегированные результаты записываются в Postgres и используются для анализа и визуализации на дашбордах.

Почему здесь важен Kubernetes
Запуск через Kubernetes имеет несколько практических преимуществ:
можно явно управлять ресурсами процессора и памяти под разные прогоны;
легко подключать секреты и тома;
сам
bencherостаётся обычным CLI, не зависящим от конкретного оркестратора.
В DAG это выглядит как обычное kubernetes_cmd-задание, которое поднимает под с нужным образом, ресурсами, секретами и монтированиями томов.
Как прокидываются секреты и переменные окружения
Одна из важных частей интеграции — сделать конфигурацию DAG полностью параметризированной:
секреты, переменные окружения и параметры запуска для
bencher;секреты и переменные окружения для агента;
учётные данные к git-репозиторию;
секреты и переменные окружения для трассировки и наблюдаемости;
поддержка точечных переопределений переменных окружения для конкретного запуска.
Для этого под через envFrom получает несколько секретов Kubernetes:
"envFrom": [ {"secretRef": {"name": "{{ params.BENCHER_ENV_VARS_SECRET_NAME }}"}}, {"secretRef": {"name": "{{ params.AGENT_ENV_VARS_SECRET_NAME }}"}}, {"secretRef": {"name": "{{ params.PHOENIX_ENV_VARS_SECRET_NAME }}"}}, ]
Здесь:
BENCHER_ENV_VARS_SECRET_NAMEсодержит переменные окружения, нужные самомуbencher, например, доступ к объектному хранилищу;AGENT_ENV_VARS_SECRET_NAMEсодержит переменные, которые нужны коду агента;PHOENIX_ENV_VARS_SECRET_NAMEиспользуется для настройки трассировки и наблюдаемости через Phoenix;
Отдельно подключается git-секрет с .netrc, чтобы worker мог ставить агент и зависимости из приватных git-репозиториев:
"volumes": [ { "name": "bencher-netrc", "secret": { "secretName": "{{ params.GIT_SECRET_NAME }}", "items": [{"key": "netrc", "path": ".netrc"}], }, } ]
И затем этот секрет монтируется в домашнюю директорию контейнера:
"volumeMounts": [ { "name": "bencher-netrc", "mountPath": "/home/nonroot/.netrc", "subPath": ".netrc", } ]
Благодаря этому bencher может прозрачно выполнять pip install из приватных git-репозиториев без ручной настройки внутри контейнера.
Опциональные overrides для переменных окружения
Иногда базовых секретов недостаточно: например, для одного запуска нужно подменить endpoint, включить feature flag или передать тестовый параметр только для конкретной гипотезы. Чтобы не создавать отдельный секрет Kubernetes, в DAG есть параметр ENV_VARS_OVERRIDES. Он превращается в словарь строк и затем добавляется поверх уже собранного набора переменных окружения, то есть имеет приоритет над одноимёнными переменными из секретов:
env_vars.update(_stringify_env_var_overrides(params.get("ENV_VARS_OVERRIDES")))
Проецирование конфигурации Airflow в параметры bencher
Внутри DAG конфигурация Airflow фактически становится тонкой обёрткой вокруг bencher run. Параметры из params последовательно проецируются в CLI-аргументы:
add_option("benchmark", params["BENCHMARK"]) add_option("agent", params["AGENT"]) add_option("agent-entry-point", params["AGENT_ENTRY_POINT"]) add_option("timeout", params["TIMEOUT"]) add_option("parallelism", params["PARALLELISM"]) ...
То есть Airflow не знает внутренней логики бенчмарка. Он только:
собирает параметры запуска;
подготавливает окружение пода;
запускает
bencher;забирает артефакты после завершения.
Тем самым мы разграничиваем ответственность: логика прогона бенчмарка остаётся в bencher, а оркестрация и интеграция с инфраструктурой — в Airflow.
Сквозной run_id
Отдельно стоит выделить run_id. Он проходит через весь конвейер:
генерируется в DAG или принимается как override;
передаётся в
bencher runчерез--run-id;попадает в trace metadata;
используется в пути к артефактам и в самих артефактах (
run.json);и по нему же результаты вставляются в таблицы Postgres.
В DAG это начинается с отдельной задачи:
def generate_run_id() -> str: params = get_current_context()["params"] override = str(params.get("BENCHER_RUN_ID_OVERRIDE", "")).strip() if override: return override start_dt = pendulum.now("UTC") return f"{start_dt.strftime('%Y%m%d-%H%M%S')}-{uuid4().hex[:8]}"
Поэтому весь запуск остаётся сквозным и однозначно идентифицируемым.
Как артефакты попадают в аналитический контур
После завершения bencher Airflow по тому же run_id читает run.json и summary.json из объектного хранилища:
run_key = f"{run_prefix}/run.json" summary_key = f"{run_prefix}/summary.json" run_payload = client.get_object(Bucket=bucket, Key=run_key) summary_payload = client.get_object(Bucket=bucket, Key=summary_key)
Затем следующая задача вставляет данные в две таблицы Postgres: таблицу запусков с параметрами из run.json и таблицу с агрегированными метриками из summary.json. Это позволяет затем сравнивать версии агента между собой, отслеживать деградации и делать аналитические витрины и дашборды.
Что в итоге
В результате мы получили инструмент, который позволил нам быстро итерироваться по доработкам агента, тестировать различные гипотезы, сравнивать результаты и вводить лучшие решения в эксплуатацию. О том, какие гипотезы мы тестировали и что из этого получилось, можно узнать в этой статье.
А как вы тестируете свои агенты?
