Self-hosted evals для AI-агентів: як вимірювати якість
Створіть власний eval-цикл: test cases із реальних trace, спочатку дешеві deterministic checks, потім LLM judge і pass rate для кожного commit.
Що таке self-hosted evals для AI-агентів
Self-hosted evals для AI-агентів — це чотири компоненти, які зберігаються у вашому власному репозиторії: файл зі збереженими тестовими випадками, скрипт для запуску агента на них, набір перевірок для оцінювання кожної відповіді та таблиця результатів, яку можна опитувати. Жоден компонент із цього переліку не потребує vendor. Увесь цикл можна реалізувати за кілька сотень рядків Python і в одному файлі SQLite.
Агент працював у демонстрації, тому що ви самі вибрали п’ять вхідних даних. На другому тижні він почав помилятися, оскільки змінився рядок prompt, модель або опис tool, а вимірювання не охоплювало жодної з цих змін. Eval-цикл перетворює «тепер працює гірше» на «частка успішних перевірок зменшилася з 58 із 60 до 51 із 60 у commit 4f1c9ab».
Цикл складається з чотирьох кроків, і цьому присвячено окремий розділ посібника для кожного кроку: збирати реальні trace, перетворювати цікаві з них на test cases, оцінювати кожен case після кожної зміни та зберігати частку успішних перевірок поруч із commit, який її спричинив. Той самий цикл працює незалежно від того, на чому ви запускаєте агента, а self-hosted фреймворки для AI-агентів, які варто запускати здебільшого відрізняються обсягом trace, який вони надають без додаткових налаштувань.
Чому агент ламається на другому тижні
Агент складається з prompt, моделі, набору описів інструментів і контексту, отриманого під час виконання. Усі чотири компоненти можуть змінюватися без змін у коді застосунку, тому під час звичайного code review немає очевидної причини для зауважень.
Найпоширеніша причина — зміна prompt. Ви додаєте одне речення, щоб припинити грубу відповідь. Це речення змінює поведінку для вхідних даних, які ніхто повторно не перевірив. У trace це чітко видно: у trace за минулий тиждень для того самого запитання є виклик інструмента create_refund, а в trace за цей тиждень його немає, і натомість агент повертає ввічливе вибачення. Помилки не виникло, тому alert не спрацював.
Друга причина — модель. Записуйте точний рядок моделі, який ви надсилали з кожним запуском, claude-haiku-4-5-20251001, а не скорочену назву, яку тримаєте в пам’яті. Якщо pass rate знизився в день переходу на іншу модель, встановити причину можна лише тоді, коли модель зазначена в цьому рядку.
Третя причина — інструменти. Переформулювання опису інструмента змінює момент, коли модель вирішує його викликати. Якщо інструменти надходять через MCP-сервери, що працюють на VPS, схема зберігається в іншому процесі, тому вона може змінитися без жодної різниці у вашому repository. Четверта причина — retrieval: те саме запитання надходить до індексу, який перебудували вночі, і відповідь формується на основі нового документа.
Зберіть golden set із trace, які ви вже накопичуєте
Не вигадуйте eval-кейси. Беріть їх із traffic. Якщо ви вже використовуєте self-hosted Langfuse tracing для свого агента, кожен запит зберігається разом із його input, викликами tools і output. Це саме ті raw data, які потрібні для case.
Експортуйте діапазон root observations через public API. Для автентифікації використовується basic authentication: public key — як username, а secret key — як password.
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]'Спочатку прочитайте один record, а вже потім пишіть парсер. Рядки повертаються в data, але назви полів із питанням і відповіддю залежать від того, як ваш агент інструментує свої spans. Тому зіставте поля з фактичними даними, а не з очікуваною структурою. Потім вручну запишіть cases: один JSON object у кожному рядку 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 cases. Якщо їх менше 20, один нестабільний case змінює pass rate на 5 пунктів, і число, яке стрибає без причини, перестають сприймати серйозно.
- Кожен production bug, який ви виправили, у день виправлення стає окремим case. Саме ця звичка забезпечує правильне зростання набору.
- Один case — одна поведінка. Case, який одночасно перевіряє суму повернення коштів і тон відповіді, нічого не пояснює, якщо перевірка завершується помилкою.
idніколи не змінюється, оскільки id дає змогу порівнювати сьогоднішній запуск із запуском минулого місяця.- Редагуйте дані перед commit. Цей файл потрапить у git, тому видаліть імена клієнтів і номери замовлень, які вам не належать.
Спочатку оцінюйте за допомогою детермінованих перевірок, оскільки вони не потребують витрат
Для всього, що має однозначно правильну відповідь, використовуйте звичайне твердження. Виклик моделі не потрібен, витрат немає, неоднозначність відсутня. Детерміновані перевірки виявляють структурні регресії. Саме вони порушують роботу систем навколо вашого агента: JSON не розбирається, інструмент не було викликано, заборонена фраза знову з’явилася або відповідь не містить жодного джерела.
Лише одна функція має знати про вашого агента. Усе інше в harness є універсальним.
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 model
Оцінювати кожен випадок найдорожчою моделлю під час кожного коміту — це спосіб витратити на eval більше, ніж коштує агент, який ви тестуєте. Розташуйте graders за ціною та зупиняйтеся, щойно відповідь стане однозначною.
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 runs не є інтерактивними, тому Batch API удвічі зменшує ціни на вхідні та вихідні дані в обмін на асинхронне виконання. Це перший рядок на графіку. Rubric та інструкції в кожному виклику повністю ідентичні на рівні байтів, тому підходить prompt caching: читання з кешу коштує десяту частину базової ціни вхідних даних, а запис у кеш на п’ять хвилин коштує 1.25 базової ціни вхідних даних. Отже, кеш окупається після одного повторного використання. Це list prices Anthropic станом на August 2026. Для Sonnet 5 діє introductory pricing до 31 August 2026, тому після цієї дати третій стовпчик стане вищим.
Послідовність така:
- Deterministic checks для кожного випадку. Витрати на API відсутні.
- Small model judge для випадків, які пройшли ці перевірки.
- Frontier judge лише там, де small judge повернув fail або pass із низькою впевненістю.
- Human review для невеликої вибірки раз на тиждень.
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"]Це зменшує витрати ціною певної точності оцінювання, тому вимірюйте цей компроміс, а не робіть припущення. Раз на місяць оцінюйте весь набір також за допомогою strict judge і порівнюйте два стовпці. Якщо результати відрізняються більш ніж у кількох випадках, ваш rubric недостатньо точний для small model. Саме rubric потрібно виправити. Контроль витрат самого агента — окреме завдання. Воно описане в розділі контроль витрат AI-агента на VPS.
Відстежуйте частку успішних проходжень у часі у власній системі
Частка успішних проходжень, яку неможливо пов’язати з commit, є лише суб’єктивним враженням. Зберігайте один рядок для кожного кейсу в кожному запуску. Додавайте commit і модель у цей рядок.
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, зміни моделі та інструментів, а не про кожен commit у будь-якій частині repository. Hook 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. Це дає змогу виявити зміни, що надходять з-поза меж вашого repository, наприклад зміни поведінки hosted tool.
Перевірка людиною за вибіркою, а не вичерпна
Оцінювач відкалібрований за людськими оцінками, тому хтось має їх виставляти. Щотижня перевіряйте вибірку: кожен випадок, у якому оцінювач помилився, а також десять випадково вибраних випадків із позитивним результатом. Випадково вибрані позитивні результати є важливою половиною перевірки, оскільки оцінювач, який непомітно почав схвалювати неправильні відповіді, матиме бездоганний вигляд на будь-якій інформаційній панелі, побудованій на основі його власних висновків.
П’ятнадцять випадків по три хвилини — це 45 хвилин на тиждень. Така перевірка дає змогу виправляти rubric у місцях, де ваші висновки не збігаються з висновками оцінювача, а також додавати нові випадки для типів помилок, про які ніхто не припускав. Записуйте людський висновок у ту саму таблицю, установлюючи graded_by у значення human. Тоді узгодженість між оцінювачем і людиною можна буде отримати запитом, а не тримати в пам’яті.
Що ламається безпосередньо в eval harness
anthropic.RateLimitError під час першого повного запуску. Одночасний запуск шістдесяти випадків перевищує ліміт запитів або токенів для вашого tier. Обмежте concurrency чотирма workers і перенесіть нічний запуск до Batch API.
json.JSONDecodeError: Expecting value: line 1 column 1 (char 0) від judge. Модель відповіла прозою або обгорнула свій JSON у code fence. Повторіть спробу один раз, а потім запишіть цей випадок як помилку. Ніколи не зараховуйте помилку парсингу як успішний результат, оскільки suite, який перетворює помилки на успішні результати, наближається до 100%, тоді як agent працює дедалі гірше.
Нестабільні випадки. Один і той самий input проходить під час одного запуску й не проходить під час наступного, оскільки agent семплує свій output. Запустіть нестабільний випадок тричі й запишіть частку успішних результатів, а не видаляйте цей випадок. Випадок, який проходить у двох із трьох запусків, є реальною проблемою надійності, і клієнт її виявить.
Деградація golden set. Хтось редагує очікувану відповідь, щоб suite став green. Перевіряйте diff до evals/cases.jsonl так само ретельно, як і diff agent, оскільки цей файл є вашим письмовим визначенням правильного результату.
Suite, який ніколи не завершується помилкою. Показник успішності, що протягом місяця тримається на рівні 100%, означає, що набір перестав відстежувати стан продукту. Виберіть десять недавніх trace, знайдіть серед них випадки, які agent обробив неправильно, і додайте їх. Потім навмисно зламайте щось і переконайтеся, що запуск завершується зі статусом red. Це перевірка застосування mutation testing до test suite і єдиний спосіб переконатися, що ваш набір досі виявляє проблеми.
FAQ
Скільки кейсів потрібно для eval set AI-агента?
Почніть із 40–80 і розширюйте набір на основі реальних збоїв. Якщо кейсів менше ніж приблизно 20, один нестабільний результат змінює pass rate на 5 пунктів, тому цей показник втрачає інформативність. Якщо кейсів уже кілька сотень, кожен запуск потребує реальних коштів і часу, а додавання нового кейсу майже не збільшує покриття. Важлива не кількість, а частка відомих типів збоїв у production, які представлені в наборі хоча б один раз.
Чи можна довіряти LLM judge для оцінювання мого агента?
Лише після перевірки на власних мітках. Залиште 30 кейсів, які ви оцінили вручну, і порівнюйте з ними результати judge щоразу, коли змінюєте модель або prompt для judge. Для judge характерні упередження щодо довжини: розширені відповіді частіше проходять перевірку; а також самоперевага: output від власного сімейства моделей оцінюється поблажливіше. Обидва ефекти можна перевірити: доповніть невдалу відповідь і оцініть її повторно або оцініть ті самі відповіді за допомогою judge з іншого сімейства моделей. Якщо judge не погоджується з вашими мітками більш ніж в одному випадку з десяти, rubric надто нечіткий для використання.
Яка модель має оцінювати evals?
Спочатку використовуйте дешеві перевірки, а потім підвищуйте рівень. Deterministic assertions нічого не коштують, тому вони запускаються першими для кожного кейсу. Невелика модель обробляє очевидні позитивні результати. Лише невдалі результати та вердикти з низькою впевненістю передаються frontier model. За list prices у August 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 визначає, чи справді ваше виправлення спрацювало.