Self-hosted evals для AI-агентів: власний цикл тестів
Зберігайте golden cases із реальних trace, спершу запускайте детерміновані перевірки, потім LLM judge і відстежуйте pass rate для кожного коміту.
Що таке self-hosted evals для AI-агентів
Self-hosted evals для AI-агентів — це чотири компоненти, які зберігаються у вашому власному репозиторії: файл зі збереженими кейсами, скрипт для запуску агента на цих кейсах, набір перевірок для оцінювання кожної відповіді та таблиця результатів, яку можна запитувати. Для жодного з цих компонентів не потрібен vendor. Увесь цикл можна реалізувати за кілька сотень рядків Python і в одному файлі SQLite.
Агент успішно працював у демо, тому що ви самі вибрали п’ять вхідних даних. На другому тижні він почав помилятися, оскільки змінився рядок prompt, модель або опис інструмента, а жоден показник цього не враховував. Цикл eval перетворює «зараз результат здається гіршим» на «частка успішних перевірок зменшилася з 58 з 60 до 51 з 60 у коміті 4f1c9ab».
Цикл має чотири кроки, і цьому присвячено по одному розділу посібника: збирати реальні trace, перетворювати цікаві з них на кейси, оцінювати кожен кейс після кожної зміни та зберігати частку успішних перевірок поруч із комітом, який її сформував. Цей цикл працює незалежно від того, на чому ви запускаєте агента, а self-hosted фреймворки для AI-агентів, які варто запускати переважно відрізняються обсягом trace, який вони надають без додаткового налаштування.
Чому агент ламається на другому тижні
Агент — це prompt, model, набір визначень інструментів і контекст, який отримується під час виконання. Усі чотири компоненти можуть змінитися без змін у коді застосунку, тому під час звичайного code review немає очевидних причин для зауважень.
Найпоширеніша причина — редагування prompt. Ви додаєте одне речення, щоб зупинити грубу відповідь. Це речення змінює поведінку для вхідних даних, які ніхто повторно не перевірив. У trace це видно безпосередньо: trace за минулий тиждень для того самого запитання містить create_refund виклик інструмента, trace за цей тиждень — ні, а відповідь натомість є ввічливим вибаченням. Помилка не виникла, тому alert не спрацював.
Друга причина — model. Записуйте точний рядок model, який ви надсилали з кожним запуском, claude-haiku-4-5-20251001 а не скорочену назву, яку тримаєте в пам’яті. Якщо частка успішних результатів знизилася в день переходу на іншу model, визначити причину можна лише тоді, коли model зазначено в записі.
Третя причина — інструменти. Переформулювання опису інструмента змінює момент, коли model вирішує його викликати. Якщо інструменти надходять через MCP-сервери, що працюють на VPS, схема зберігається в іншому процесі. Тому вона може змінитися без будь-яких відмінностей у вашому repository. Четверта причина — retrieval: те саме запитання звертається до індексу, який перебудували вночі, і відповідь формується на основі нового документа.
Створіть еталонний набір із трас, які ви вже збираєте
Не вигадуйте eval-кейси. Беріть їх із трафіку. Якщо ви вже використовуєте self-hosted трасування Langfuse для свого агента, кожен запит зберігається разом із вхідними даними, викликами інструментів і вихідними даними. Це саме той необроблений матеріал, який потрібен для кейсу.
Експортуйте період root-спостережень через публічний API. Для автентифікації використовується basic authentication: публічний ключ є іменем користувача, а секретний ключ — паролем.
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, але назви полів із питанням і відповіддю залежать від того, як ваш агент інструментує свої spans. Тому зіставляйте фактичні поля, а не ті, які ви очікували побачити. Потім створіть кейси вручну: один 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, один нестабільний кейс змінює pass rate на 5 пунктів, і числу, яке без причини стрибає, перестають довіряти.
- Кожна помилка у production, яку ви виправили, того самого дня стає окремим кейсом. Саме ця звичка забезпечує правильне зростання набору.
- Один кейс — одна поведінка. Кейс, який одночасно перевіряє суму повернення коштів і тон відповіді, нічого не пояснює в разі помилки.
idніколи не змінюється, оскільки за id поточний запуск порівнюють із запуском минулого місяця.- Виконуйте редагування перед комітом. Цей файл потрапляє до git, тому видаляйте імена клієнтів і всі номери замовлень, які вам не належать.
Спочатку перевіряйте детерміновані умови, оскільки це безкоштовно
Для всього, що має однозначно правильну відповідь, використовуйте просте твердження. Виклики моделі не потрібні, витрат немає, неоднозначність відсутня. Детерміновані перевірки виявляють структурні регресії. Саме вони порушують роботу систем навколо вашого агента: 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 випадків, які ви оцінили вручну, і щоразу порівнюйте результати судді з вашими оцінками, коли змінюєте модель судді або його prompt. Якщо суддя не погоджується з вами більше ніж в одному випадку з десяти, виправте рубрику, перш ніж довіряти будь-якому показнику проходження, який він формує. Суддя є кодом, тому його потрібно версіонувати та переглядати так само, як код.
Починайте з дешевої моделі, а за потреби переходьте до frontier-моделі
Оцінювання кожного випадку найдорожчою моделлю під час кожного коміту призводить до того, що рахунок за eval перевищує вартість агента, який тестується. Розташуйте засоби оцінювання за ціною та зупиняйтеся, щойно відповідь стане однозначною.
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"
}
]Ці показники розраховано приблизно для 1,200 вхідних токенів і 120 вихідних токенів на один виклик judge. Це реалістичний обсяг для одного запитання, однієї відповіді та одного критерію. Оцінювання 1,000 випадків коштує 1.80 доларів США на Claude Haiku 4.5 і 9.00 на Claude Opus 5. Різниця здається незначною, доки не помножити її на кількість викликів. Набір із 60 випадків, який оцінюється під час кожного коміту за 40 комітів на тиждень, дає 2,400 викликів judge на тиждень, ще до запуску нічного завдання.
Для eval добре підходять дві знижки, і їх можна комбінувати. Запуски eval не є інтерактивними, тому Batch API удвічі зменшує ціни на вхідні та вихідні дані в обмін на асинхронне виконання. Саме це показано в першому рядку діаграми. Rubric та інструкції в кожному виклику повністю ідентичні на рівні байтів, тому підходить prompt caching: читання кешу коштує десяту частину базової ціни вхідних даних, а запис у кеш на п’ять хвилин коштує 1.25 базової ціни вхідних даних. Тому кеш окупається вже після одного повторного використання. Це list prices Anthropic станом на August 2026. Sonnet 5 має introductory pricing до 31 August 2026, тому після цієї дати третій стовпчик зросте.
Послідовність така:
- Детерміновані перевірки для кожного випадку. Витрати на API відсутні.
- Judge на малій моделі для випадків, які пройшли ці перевірки.
- Judge на frontier-моделі лише тоді, коли мала модель визначила результат fail або визначила pass із низькою впевненістю.
- Перевірка людиною невеликої вибірки раз на тиждень.
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"]Такий підхід зменшує точність оцінювання в обмін на нижчу вартість. Тому вимірюйте цей компроміс, а не робіть припущення. Раз на місяць оцінюйте весь набір також за допомогою суворого judge і порівнюйте два стовпці. Якщо результати відрізняються більш ніж у кількох випадках, ваш rubric є недостатньо чітким для малої моделі. Саме rubric потрібно виправити. Контроль витрат, які безпосередньо створює агент, є окремим завданням. Його описано в контроль витрат для AI-агента на VPS.
Відстежуйте pass rate у часі у власній системі
Показник pass rate, який не можна пов’язати з комітом, є лише суб’єктивним враженням. Зберігайте один рядок для кожного кейсу в кожному запуску. Додавайте коміт і модель безпосередньо в цей рядок.
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 у production на 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Запускайте набір тестів для змін, які можуть порушити роботу агента. Це зміни prompt, моделі та інструментів, а не кожен коміт у будь-якій частині репозиторію. Хук 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 і timer на VPS запускають увесь набір для розгорнутого prompt. Це дає змогу виявити зміни, які надходять з-поза вашого репозиторію, наприклад зміну поведінки hosted tool.
Людська перевірка: вибіркова, а не вичерпна
Оцінювач калібрується за людськими оцінками, тому їх потрібно отримувати. Щотижня перевіряйте вибірку: усі випадки, у яких оцінювач помилився, а також десять випадково вибраних випадків, які він оцінив правильно. Випадково вибрані правильні оцінки — важлива половина перевірки, оскільки оцінювач, який непомітно почав пропускати погані відповіді, виглядатиме бездоганно на будь-якій інформаційній панелі, побудованій на основі його власних вердиктів.
П’ятнадцять випадків по три хвилини — це 45 хвилин на тиждень. Така перевірка дає змогу виправляти критерії там, де ваші оцінки не збігаються з оцінками оцінювача, а також додавати нові випадки для типів помилок, про які раніше ніхто не здогадувався. Записуйте людський вердикт у ту саму таблицю, установлюючи graded_by у значення human. Тоді відповідність між оцінками оцінювача й людини можна буде отримувати запитом, а не зберігати в пам’яті.
Що ламається безпосередньо в eval harness
anthropic.RateLimitError під час першого повного запуску. Одночасний запуск шістдесяти тестів перевищує ліміт запитів або токенів для вашого тарифного рівня. Обмежте паралельність чотирма workers, а нічний запуск перенесіть до Batch API.
json.JSONDecodeError: Expecting value: line 1 column 1 (char 0) від judge. Модель відповіла прозою або обгорнула свій JSON у code fence. Повторіть спробу один раз, а потім запишіть цей тестовий випадок як помилку. Помилка розбору ніколи не повинна вважатися успішним проходженням, оскільки набір, який перетворює помилки на успішні результати, наближається до 100%, тоді як якість agent погіршується.
Нестабільні тестові випадки. Один і той самий вхід проходить в одному запуску й завершується помилкою в наступному, оскільки agent семплує свій результат. Запустіть нестабільний тестовий випадок тричі та зафіксуйте частку успішних проходжень, а не видаляйте його. Тестовий випадок, який проходить у двох із трьох запусків, свідчить про реальну проблему зі стійкістю, і клієнт її виявить.
Псування golden set. Хтось редагує очікувану відповідь, щоб набір знову став успішним. Перевіряйте diff до evals/cases.jsonl так само ретельно, як і diff agent, оскільки цей файл є вашим письмовим визначенням правильного результату.
Набір, у якому ніколи не виникає помилок. Частка успішних проходжень, яка протягом місяця тримається на рівні 100%, означає, що набір більше не відстежує зміни в продукті. Виберіть десять нещодавніх трасувань, знайдіть серед них випадки, які agent обробив неправильно, і додайте їх.
FAQ
Скільки випадків має містити набір для оцінювання AI-агента?
Почніть із 40–80 випадків і розширюйте набір на основі реальних збоїв. Якщо випадків менше приблизно 20, один нестабільний результат змінює частку успішних проходжень на 5 пунктів, тому цей показник перестає бути інформативним. Якщо випадків уже кілька сотень, кожен запуск потребує реальних коштів і часу, а додатковий випадок майже не розширює покриття. Важлива не кількість, а частка відомих типів збоїв у production, які представлені в наборі хоча б один раз.
Чи можна довіряти LLM-судді під час оцінювання мого агента?
Лише після перевірки на власних мітках. Залиште 30 випадків, які ви оцінили вручну, і порівнюйте з ними результати судді щоразу, коли змінюєте модель судді або prompt судді. Судді виявляють упередженість щодо довжини: доповнені відповіді частіше проходять оцінювання. Також вони виявляють самоперевагу: результати моделей із тієї самої родини оцінюються поблажливіше. Обидва ефекти можна перевірити: доповніть невдалу відповідь і повторіть оцінювання або оцініть ті самі відповіді за допомогою судді з іншої родини моделей. Якщо суддя не погоджується з вашими мітками більш ніж в одному випадку з десяти, rubric надто нечіткий для використання.
Яка модель має оцінювати evals?
Починайте з дешевої моделі та підвищуйте рівень за потреби. Deterministic assertions нічого не коштують, тому їх спочатку запускають для кожного випадку. Невелика модель обробляє очевидні успішні результати. До frontier model передаються лише невдалі результати та вердикти з низькою впевненістю. За list prices у серпні 2026 року оцінювання 1,000 випадків коштує приблизно 1.80 US dollars із Claude Haiku 4.5 і приблизно 9.00 із Claude Opus 5. Оскільки eval runs виконуються асинхронно, Batch API удвічі зменшує обидві суми.
Чи замінюють evals production monitoring?
Ні, оскільки вони відповідають на різні запитання. Eval suite показує, чи робить зміна, яку ви збираєтеся випустити, фіксований набір випадків кращим або гіршим. Tracing і monitoring показують, із чим реально стикаються користувачі зараз, зокрема з input, якого немає в жодному випадку. Вони доповнюють одне одного: traces постачають нові випадки, а eval suite визначає, чи справді ваше виправлення спрацювало.