Как настроить self-hosted evals для AI-агентов
Создайте собственную систему оценки AI-агентов без сторонних сервисов. Руководство по внедрению цикла тестирования: от сбора трассировок до отслеживания pass rate в каждом коммите.
Что такое 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 пунктов, и на такие скачки показателей перестанут обращать внимание.
- Любая исправленная ошибка из production должна превращаться в сценарий в день исправления. Эта привычка позволяет набору развиваться в нужном направлении.
- Один сценарий — одно проверяемое поведение. Сценарий, который одновременно проверяет сумму возврата и тон общения, не даст полезной информации при сбое.
- Поле
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 случаев, которые вы разметили вручную, и оценивайте работу судьи по вашим меткам каждый раз, когда меняете модель судьи или его промпт. Если судья не согласен с вами более чем в одном случае из десяти, исправьте критерии, прежде чем доверять любому уровню прохождения, который он выдает. Судья — это код, поэтому он должен версионироваться и проходить проверку так же, как и код.
Оценка дешевыми моделями с переходом на передовые
Использование самой дорогой модели для оценки каждого коммита приводит к тому, что стоимость тестирования превышает стоимость работы самого агента. Выстройте модели оценки по цене и останавливайтесь, как только результат становится очевиден.
Данные, на которых построен график
[
{
"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 раза больше базового ввода, а чтение из кэша — десятую часть от него), но кэшируемая часть промпта должна достигать минимальной длины: 4096 токенов для Claude Haiku 4.5, 1024 для Claude Sonnet 5 и 512 для Claude Opus 5. Вызов оценщика на 1200 токенов значительно ниже порога Haiku, и API обрабатывает короткий промпт без кэширования и без возврата ошибки, поэтому запись в кэш не происходит, попаданий в кэш нет, и экономии тоже нет. В Opus 5 подошел бы только общий префикс длиной не менее 512 токенов, но фиксированные инструкции в judge_prompt намного короче, так как рубрика меняется с каждым случаем. Это прейскурантные цены Anthropic по состоянию на октябрь 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"]По состоянию на октябрь 2026 года Anthropic указывает обе эти модели как устаревшие, но все еще доступные. На странице прекращения поддержки указана дата вывода из эксплуатации Claude Haiku 4.5 не ранее 15 октября 2026 года, поэтому проверяйте эту страницу, прежде чем полагаться на дешевый оценщик в течение долгого времени. Относитесь к замене модели оценщика как к любому другому изменению: сначала повторно запустите 30 случаев, размеченных вручную.
Это решение меняет точность оценки на экономию, поэтому измеряйте этот компромисс, а не предполагайте его. Раз в месяц оценивайте весь набор строгой моделью и сравнивайте два столбца. Если они расходятся более чем в нескольких случаях, ваша рубрика слишком мягкая для малой модели, и исправлять нужно именно рубрику. Контроль расходов самого агента — это отдельная задача, описанная в контроле расходов для 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 описаны настройки, которые становятся важными, если этот файл используется совместно на нескольких машинах.
Программа запуска тестов выводит ту же информацию для человека:
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 cases). Один и тот же входной набор проходит в одном запуске и проваливается в другом, так как агент использует вероятностную выборку при генерации ответа. Запустите нестабильный тест 3 раза и запишите долю успешных попыток, вместо того чтобы удалять тест. Если тест проходит 2 раза из 3, это реальная проблема надежности, с которой столкнется пользователь.
Устаревание эталонных данных (golden set rot). Кто-то редактирует ожидаемый ответ, чтобы «зеленый» статус теста вернулся. Проверяйте diff-файлы для evals/cases.jsonl так же тщательно, как и изменения в коде агента, поскольку этот файл является вашим формальным определением корректности.
Набор тестов, который никогда не выдает ошибок. Если уровень успешных прохождений держится на отметке 100% в течение месяца, значит, набор перестал соответствовать продукту. Выгрузите 10 последних трассировок, найдите те, с которыми агент справился плохо, и добавьте их в набор. Затем намеренно сломайте что-нибудь и убедитесь, что результат запуска стал «красным» — это проверка, которую мутационное тестирование применяет к набору тестов, и единственный способ убедиться, что ваш набор тестов все еще эффективен.
FAQ
Сколько случаев нужно для набора оценки AI-агента?
Начните с 40–80 и расширяйте набор на основе реальных сбоев. При количестве менее 20 случаев один случайный результат меняет показатель успешности на 5 пунктов, поэтому данные теряют информативность. После нескольких сотен случаев каждый запуск требует реальных денег и времени, в то время как маргинальный случай дает мало покрытия. Важен не счет, а доля известных вам типов сбоев в продакшене, которые появляются в наборе хотя бы раз.
Можно ли доверять LLM-оценщику при проверке агента?
Только после того, как вы измерили его точность относительно ваших собственных разметок. Сохраните 30 случаев, оцененных вручную, и проверяйте оценщик на них всякий раз, когда меняете модель оценщика или промпт оценщика. Оценщики склонны к предвзятости по длине (более длинные ответы проходят чаще) и самопредпочтению (вывод от их собственного семейства моделей оценивается более благосклонно). Оба фактора проверяемы: дополните неудачный ответ и оцените его снова или оцените те же ответы оценщиком из другого семейства. Если оценщик не согласен с вашими разметками более чем в одном случае из десяти, рубрика слишком расплывчата для использования.
Какая модель должна оценивать результаты?
Оценивайте дешево и переходите к более мощным моделям при необходимости. Детерминированные утверждения ничего не стоят, поэтому они выполняются первыми для каждого случая. Малая модель справляется с очевидными прохождениями. Только сбои и вердикты с низкой уверенностью передаются передовой модели. По прейскурантным ценам октября 2026 года оценка 1000 случаев стоит около 1.80 долларов США с Claude Haiku 4.5 и около 9.00 с Claude Opus 5, а поскольку запуски оценки асинхронны, Batch API сокращает любую из этих цифр вдвое.
Заменяют ли оценки мониторинг продакшена?
Нет, потому что они отвечают на разные вопросы. Набор оценок говорит вам, делает ли изменение, которое вы собираетесь выпустить, фиксированный набор случаев лучше или хуже. Трассировка и мониторинг показывают, с чем сталкиваются реальные пользователи прямо сейчас, включая входные данные, которые не покрывает ни один случай. Они дополняют друг друга: трассировки поставляют новые случаи, а набор оценок определяет, действительно ли ваше исправление сработало.