Как настроить self-hosted evals для AI-агентов
Создайте систему оценки AI-агентов с хранением данных в собственном репозитории. Руководство по внедрению детерминированных проверок и LLM-судей для отслеживания метрик.
Что такое self-hosted evals для AI-агентов
Self-hosted evals для AI-агентов — это четыре компонента, которые вы храните в собственном репозитории: файл с сохраненными кейсами, скрипт для запуска агента на этих данных, набор проверок для оценки каждого ответа и таблица результатов, по которой можно выполнять запросы. Ни один из этих элементов не требует стороннего поставщика. Весь цикл состоит из нескольких сотен строк на Python и одного файла SQLite.
Агент работал в демо-версии, потому что вы сами подобрали пять входных параметров. Он сломался на второй неделе, потому что изменилась строка промпта, модель или описание инструмента, а никакие метрики это не отслеживали. Цикл оценки превращает субъективное «стало работать хуже» в конкретное «процент успешных ответов снизился с 58 из 60 до 51 из 60 в коммите 4f1c9ab».
Цикл состоит из четырех этапов, и данное руководство посвящает каждому из них отдельный раздел: сбор реальных трассировок, перенос интересных примеров в кейсы, оценка каждого кейса при любых изменениях и сохранение процента успешных ответов рядом с коммитом, который их породил. Этот цикл работает независимо от того, на чем вы запускаете агента, а фреймворки для self-hosted агентов, заслуживающие внимания различаются в основном тем, какой объем трассировки они предоставляют вам автоматически.
Почему агент перестает работать на второй неделе
Агент состоит из промпта, модели, набора определений инструментов и контекста, который извлекается во время выполнения. Все четыре компонента могут измениться без внесения правок в код приложения, поэтому обычный code review не выявит проблем.
Самая частая причина — изменение промпта. Вы добавляете одно предложение, чтобы предотвратить грубые ответы. Это предложение меняет поведение на входных данных, которые никто не перепроверял, и трассировки это наглядно показывают: трассировка прошлого года для того же вопроса содержит вызов инструмента create_refund, а текущая — нет, и вместо ответа агент выдает вежливое извинение. Ошибок не возникло, поэтому оповещение не сработало.
Вторая причина — модель. Фиксируйте точную строку модели, которую вы отправляли с каждым запуском, claude-haiku-4-5-20251001, а не сокращенное название, которое вы держите в уме. Падение процента успешных ответов в день смены модели можно диагностировать, только если модель указана в строке лога.
Третья причина — инструменты. Переформулировка описания инструмента меняет логику принятия решения о его вызове моделью. Если ваши инструменты поступают через MCP-серверы, запущенные на VPS, схема находится в другом процессе, поэтому она может измениться без каких-либо изменений в вашем репозитории. Четвертая причина — извлечение данных: тот же вопрос попадает в индекс, который был перестроен за ночь, и ответ строится на основе нового документа.
Создание эталонного набора из уже собранных трассировок
Не придумывайте тестовые сценарии. Берите их из реального трафика. Если вы уже используете self-hosted Langfuse tracing для вашего агента, каждый запрос сохраняется вместе с входными данными, вызовами инструментов и выходными данными — это именно тот исходный материал, который необходим для создания сценария.
Экспортируйте окно корневых наблюдений через публичный API. Он использует базовую аутентификацию, где ваш публичный ключ выступает в роли имени пользователя, а секретный ключ — в роли пароля.
export LF_HOST="https://langfuse.example.com"
curl -sS -u "$LF_PUBLIC_KEY:$LF_SECRET_KEY" \
"$LF_HOST/api/public/v2/observations?limit=50&isRootObservation=true&fromStartTime=2026-07-01T00:00:00Z" \
| jq '.data[0]'Прочитайте одну запись, прежде чем писать код для парсинга. Строки возвращаются в data, но имена полей, содержащих вопрос и ответ, зависят от того, как ваш агент инструментирует спаны, поэтому ориентируйтесь на то, что видите фактически, а не на то, что ожидали увидеть. Затем создайте сценарии вручную, по одному JSON-объекту на строку, в evals/cases.jsonl:
{"id": "refund-double-charge", "tags": ["smoke"], "input": "I was charged twice for order 41822.", "must_call": ["lookup_order", "create_refund"], "must_not_include": ["I cannot help"], "rubric": "The reply confirms exactly one refund for order 41822 and states the amount."}Пять правил, которые помогут сохранить ценность набора:
- Для начала достаточно от 40 до 80 сценариев. Если их меньше 20, один нестабильный тест меняет показатель успешности на 5 пунктов, и на цифры, которые скачут без причины, перестают обращать внимание.
- Любая исправленная ошибка из продакшена должна стать сценарием в день исправления. Эта привычка позволяет набору развиваться в нужном направлении.
- Одно поведение на один сценарий. Сценарий, который одновременно проверяет сумму возврата и тон общения, не даст никакой информации при сбое.
idникогда не меняется, так как по идентификатору сравниваются результаты сегодняшнего запуска и запуска за прошлый месяц.- Выполняйте деидентификацию перед коммитом. Этот файл попадет в git, поэтому удалите имена клиентов и любые номера заказов, которые вам не принадлежат.
Сначала выполняйте детерминированные проверки, так как они бесплатны
Любая задача с однозначным правильным ответом должна проверяться простым утверждением (assertion). Это не требует вызова модели, не стоит денег и исключает двусмысленность. Детерминированные проверки выявляют структурные регрессии, которые нарушают работу систем вокруг вашего агента: JSON не парсится, инструмент не был вызван, в ответе содержится запрещенная фраза или ответ не содержит ссылок на источники.
Только одна функция должна знать о специфике вашего агента. Всё остальное в тестовом окружении должно быть универсальным.
import json, os, urllib.request
def run_agent(case):
req = urllib.request.Request(
os.environ["AGENT_URL"],
data=json.dumps({"input": case["input"]}).encode(),
headers={"content-type": "application/json"},
)
with urllib.request.urlopen(req, timeout=120) as resp:
return json.load(resp)
def deterministic(case, result):
text = result.get("output", "")
called = [c["name"] for c in result.get("tool_calls", [])]
failures = []
for tool in case.get("must_call", []):
if tool not in called:
failures.append(f"tool not called: {tool}")
for phrase in case.get("must_not_include", []):
if phrase.lower() in text.lower():
failures.append(f"forbidden phrase: {phrase}")
if len(called) > case.get("max_tool_calls", 12):
failures.append(f"too many tool calls: {len(called)}")
return failuresУчитывайте бюджет вызовов инструментов в этом списке. Если агент сегодня решает задачу за 3 вызова, а завтра за 11 — это регрессия, даже если итоговый ответ верный, так как вы платите за каждый совершенный вызов.
LLM в роли судьи и четыре способа, которыми это может пойти не так
Все, что проходит проверку утверждениями, требует оценщика, способного читать текст. LLM-судья — это второй вызов модели: она получает вопрос, ответ агента и один критерий, а затем выносит вердикт. Это единственный практический способ оценить, «отвечает ли ответ на то, о чем просил пользователь».
Четыре правила делают судью пригодным к работе:
- Бинарный вердикт, никаких оценок от 1 до 10. Шкала приводит к тому, что почти всему ставятся 7 или 8 баллов, поэтому число не меняется, и вы ничего не узнаете из такой оценки.
- Один критерий за один вызов. Спрашивайте либо о сумме возврата, либо о тоне, но не о том и другом одновременно.
- Предоставляйте судье ожидаемый ответ, если он существует. Оценивать по эталону гораздо проще, чем оценивать абстрактно.
- Принудительно задавайте формат вывода и строго его парсите.
from anthropic import Anthropic
client = Anthropic() # reads ANTHROPIC_API_KEY from the environment
def judge_prompt(case, output):
return (
"You grade one answer against one criterion.\n"
"Reply with JSON only, in this exact shape:\n"
'{"verdict": "pass", "confidence": "high", "reason": "one short sentence"}\n'
f"Criterion: {case['rubric']}\n"
f"Question: {case['input']}\n"
f"Answer: {output}\n"
"Length is not a criterion. Judge only the criterion above."
)
def judge(case, output, model):
msg = client.messages.create(
model=model,
max_tokens=200,
messages=[{"role": "user", "content": judge_prompt(case, output)}],
)
return json.loads(msg.content[0].text)Теперь о режимах отказа. Для каждого из них есть тест, который можно провести сегодня во второй половине дня, и это важно, потому что непроверенный судья выдает цифры, которые выглядят точными, но ничего не значат.
Предвзятость к длине. Более длинные ответы проходят проверку чаще. Протестируйте это: возьмите десять ответов, которые судья забраковал, дополните каждый двумя абзацами уверенного «воды», не добавляющей новых фактов, и оцените их снова. Любой вердикт, который изменился на «пройдено», указывает на предвзятость к длине, и исправлять нужно критерии оценки.
Предпочтение «своих». Судья часто оценивает вывод модели своего семейства более благосклонно, чем вывод другой модели. Протестируйте это: оцените одни и те же 30 ответов судьями из двух разных семейств и сравните вердикты по каждому случаю. Там, где они не совпадают, изучите случай самостоятельно.
Предвзятость к позиции. Если вы используете судью для сравнения двух ответов, A и B, поменяйте их местами и запустите проверку снова. Вердикт, который меняется при перестановке, означает, что парное сравнение для данного критерия пока небезопасно.
Дрейф критериев. Расплывчатые критерии порождают уступчивых судей. «Является ли ответ полезным» — проходит почти всё. «Указывает ли ответ сумму возврата в долларах» — проходит только то, что вы имели в виду. Переписывайте каждый критерий до тех пор, пока он не будет называть конкретный проверяемый факт.
Один метод защиты покрывает все четыре проблемы. Сохраните 30 случаев, которые вы разметили вручную, и проверяйте судью по своим меткам каждый раз, когда меняете модель судьи или его промпт. Если судья не согласен с вами более чем в одном случае из десяти, исправьте критерии, прежде чем доверять любому уровню прохождения, который он выдает. Судья — это код, поэтому он должен версионироваться и проходить проверку так же, как и код.
Оценка дешевыми моделями с переходом на передовые
Использование самой дорогой модели для проверки каждого случая при каждом коммите приводит к тому, что расходы на тестирование превышают стоимость самого агента. Расположите модели-оценщики по цене и прекращайте проверку, как только ответ становится очевиден.
The data behind this chart
[
{
"label": "Haiku 4.5, Batch API",
"usd_per_1000_judge_calls": "0.90"
},
{
"label": "Haiku 4.5",
"usd_per_1000_judge_calls": "1.80"
},
{
"label": "Sonnet 5",
"usd_per_1000_judge_calls": "3.60"
},
{
"label": "Opus 5",
"usd_per_1000_judge_calls": "9.00"
}
]Эти цифры предполагают около 1200 входных токенов и 120 выходных токенов на один вызов оценщика, что является реалистичным объемом для одного вопроса, одного ответа и одного критерия. Оценка 1000 случаев стоит 1.80 долларов США при использовании Claude Haiku 4.5 и 9.00 при использовании Claude Opus 5. Разница кажется незначительной, пока вы не умножите её на масштаб. Набор из 60 случаев, оцениваемый при каждом коммите, при 40 коммитах в неделю, дает 2400 вызовов оценщика в неделю еще до запуска ночных заданий.
К задачам оценки применимы две скидки, и они суммируются. Запуски оценки не требуют интерактивности, поэтому Batch API сокращает цены на ввод и вывод вдвое в обмен на асинхронную доставку, что отражено в первой строке таблицы. Рубрика и инструкции идентичны байт в байт в каждом вызове, поэтому эффективно работает prompt caching: чтение из кэша стоит одну десятую от базовой цены ввода, а запись в кэш с пятиминутным сроком жизни стоит 1,25 от базового ввода, поэтому кэш окупается после первого же попадания. Это прейскурантные цены Anthropic по состоянию на август 2026 года; на Sonnet 5 действуют вводные цены до 31 августа 2026 года, поэтому после этой даты стоимость в третьем столбце возрастет.
Иерархия проверок:
- Детерминированные проверки для каждого случая. Стоимость API равна нулю.
- Оценка небольшой моделью для случаев, прошедших предыдущие проверки.
- Оценка передовой моделью только в тех случаях, когда малая модель сообщает о неудаче или о прохождении с низкой уверенностью.
- Проверка человеком на небольшой выборке раз в неделю.
CHEAP = "claude-haiku-4-5-20251001"
STRICT = "claude-opus-5"
def grade(case, result):
hard = deterministic(case, result)
if hard:
return False, "deterministic", "; ".join(hard)
first = judge(case, result["output"], CHEAP)
if first["verdict"] == "pass" and first["confidence"] == "high":
return True, CHEAP, first["reason"]
second = judge(case, result["output"], STRICT)
return second["verdict"] == "pass", STRICT, second["reason"]Этот подход жертвует точностью оценки ради экономии, поэтому измеряйте этот компромисс, а не полагайтесь на предположения. Раз в месяц оценивайте весь набор строгим судьей и сравнивайте результаты. Если оценки расходятся более чем в нескольких случаях, ваша рубрика слишком нечеткая для малой модели, и исправлять нужно именно рубрику. Контроль расходов самого агента — это отдельная задача, описанная в контроле затрат для AI-агента на VPS.
Отслеживание процента успешных прохождений тестов с течением времени
Процент успешных прохождений, который невозможно соотнести с конкретным коммитом, является лишь субъективным ощущением. Сохраняйте по одной строке для каждого тестового случая в каждом запуске, включая в эту строку идентификатор коммита и используемую модель.
CREATE TABLE IF NOT EXISTS results (
run_id TEXT NOT NULL,
ran_at TEXT NOT NULL,
git_sha TEXT NOT NULL,
agent_model TEXT NOT NULL,
case_id TEXT NOT NULL,
passed INTEGER NOT NULL,
graded_by TEXT NOT NULL,
reason TEXT
);SELECT run_id, git_sha, agent_model,
count(*) AS cases,
round(100.0 * sum(passed) / count(*), 1) AS pass_pct
FROM results
GROUP BY run_id
ORDER BY ran_at DESC
LIMIT 10;Загрузите схему с помощью sqlite3 evals/results.db < evals/schema.sql, а затем проанализируйте тренды через sqlite3 -box evals/results.db < evals/passrate.sql. Ежедневные запуски по 60 тестовых случаев в течение года дадут около 22,000 строк, поэтому хранилище данных не превратится в отдельный сложный проект. В статье Использование SQLite в продакшене на VPS описаны настройки, которые становятся важными, если этот файл используется совместно на нескольких машинах.
Runner выводит ту же информацию в удобном для человека виде:
run 2026-08-05T09:14:22Z sha 4f1c9ab model claude-sonnet-5 58/60 pass (96.7%)
FAIL refund-double-charge deterministic: tool not called: create_refund
FAIL pto-policy-question judge(opus): reply gives no dollar amountЗапускайте набор тестов для изменений, которые могут нарушить работу агента: это правки промптов, изменения моделей и инструментов, а не каждый коммит в репозитории. Хук pre-push покрывает быструю подгруппу тестов:
cat > .git/hooks/pre-push <<'EOF'
#!/bin/sh
python3 evals/run.py --set smoke || exit 1
EOF
chmod +x .git/hooks/pre-pushПолные запуски выполняются дольше, поэтому их следует запускать по расписанию. Ночной systemd service и таймер на VPS выполняет полный набор тестов для развернутого промпта. Это позволяет выявить изменения, приходящие извне вашего репозитория, например, когда меняется поведение стороннего инструмента.
Выборочная проверка человеком вместо сплошной
Судья калибруется на основе размеченных человеком данных, поэтому кто-то должен их создавать. Еженедельно проверяйте выборку: все случаи, где судья ошибся, плюс десять случайных успешных ответов. Случайные успешные ответы — это важная часть, так как судья, который начал пропускать плохие ответы, на любой панели мониторинга, построенной на его собственных вердиктах, будет выглядеть идеально.
Пятнадцать случаев по три минуты каждый занимают 45 минут в неделю. Это позволяет вносить исправления в критерии оценки там, где вы и судья не согласны, а также выявлять новые типы ошибок, которые никто не мог предусмотреть. Записывайте вердикт человека в ту же таблицу, где graded_by установлено в human, чтобы проверка согласия между судьей и человеком стала запросом к базе данных, а не вопросом памяти.
Что может выйти из строя в самой системе тестирования
anthropic.RateLimitError при первом полном запуске. Одновременный запуск 60 тестовых случаев превышает лимит запросов или токенов для вашего тарифного плана. Ограничьте параллелизм до 4 воркеров и перенесите ночные запуски на Batch API.
json.JSONDecodeError: Expecting value: line 1 column 1 (char 0) со стороны судьи. Модель ответила в свободной форме или обернула JSON в блок кода. Повторите попытку один раз, затем зафиксируйте случай как ошибку. Никогда не засчитывайте ошибку парсинга как успешный результат, так как набор тестов, превращающий ошибки в успехи, будет стремиться к 100%, в то время как качество работы агента будет падать.
Нестабильные (flaky) случаи. Один и тот же входной набор проходит в одном запуске и проваливается в другом, так как агент использует сэмплирование вывода. Запустите нестабильный случай 3 раза и запишите долю успешных попыток вместо удаления теста. Случай, который проходит 2 раза из 3, является реальной ошибкой устойчивости, и клиент обязательно с ней столкнется.
Устаревание эталонного набора (golden set). Кто-то редактирует ожидаемый ответ, чтобы «позеленить» набор тестов. Проверяйте diff-файлы для evals/cases.jsonl так же тщательно, как и diff-файлы самого агента, поскольку этот файл является вашим письменным определением корректности.
Набор тестов, который никогда не выдает ошибок. Если показатель успешности замер на отметке 100% в течение месяца, значит, набор перестал соответствовать продукту. Выгрузите 10 недавних трассировок, найдите те, с которыми агент справился плохо, и добавьте их в набор.
FAQ
Сколько кейсов нужно для набора оценки AI-агента?
Начните с 40–80 кейсов и расширяйте набор на основе реальных сбоев. При количестве менее 20 кейсов один нестабильный результат меняет процент успешных прохождений на 5 пунктов, поэтому показатель теряет информативность. После нескольких сотен кейсов каждый запуск требует значительных затрат времени и средств, а предельная полезность каждого нового кейса невелика. Важен не объем, а доля известных вам типов производственных сбоев, которые хотя бы раз представлены в наборе.
Можно ли доверять LLM-судье при оценке агента?
Только после того, как вы сравнили его оценки со своими собственными. Сохраните 30 кейсов, размеченных вручную, и проверяйте судью на них каждый раз при изменении модели или промпта судьи. Судьи склонны к «предвзятости длины» (более длинные ответы проходят чаще) и «предпочтению своего» (вывод модели того же семейства оценивается выше). Оба фактора проверяемы: дополните неудачный ответ и оцените его снова или используйте судью из другого семейства. Если судья не согласен с вашей разметкой более чем в одном случае из десяти, критерии оценки слишком расплывчаты.
Какую модель использовать для оценки?
Оценивайте дешево и эскалируйте. Детерминированные проверки бесплатны, поэтому они выполняются первыми для каждого кейса. Небольшая модель справляется с очевидными успешными результатами. Только сбои и неоднозначные вердикты отправляются на проверку передовой модели. По состоянию на август 2026 года, оценка 1 000 кейсов стоит около 1.80 долларов США при использовании Claude Haiku 4.5 и около 9.00 при использовании Claude Opus 5. Поскольку запуски оценки асинхронны, Batch API позволяет сократить обе суммы вдвое.
Заменяют ли оценки мониторинг в production?
Нет, так как они отвечают на разные вопросы. Набор оценок показывает, улучшает или ухудшает изменение, которое вы собираетесь внедрить, фиксированный набор кейсов. Трассировка и мониторинг показывают, с чем сталкиваются реальные пользователи прямо сейчас, включая входные данные, не охваченные ни одним кейсом. Они дополняют друг друга: трассировка поставляет новые кейсы, а набор оценок определяет, действительно ли ваше исправление сработало.