Как тестировать промпты: простой eval harness на Python
«У меня работает» — не проверка. Собираем небольшой скрипт, который прогоняет промпт на наборе примеров и нескольких моделях и показывает, стало ли лучше.
4 мин · · разработка · модели
Новый промпт кажется лучше, пока не прогонишь его на двадцати примерах и не увидишь, где он ошибается. Eval harness — небольшой скрипт для тестирования промптов: он прогоняет набор примеров через промпт и модель, оценивает ответы и считает итог. Ниже — рабочая версия на Python, которую можно запускать на любых моделях Zukko через один ключ.
Шаг 1. Набор примеров
Создайте файл test_cases.jsonl — по одному JSON-объекту на строку:
{"input": "Обожаю этот товар, всем советую", "expected": "позитив"}
{"input": "Ужасное качество, вернул на следующий день", "expected": "негатив"}
{"input": "Нормально, ничего особенного", "expected": "нейтрал"}
{"input": "Лучшая покупка за год!", "expected": "позитив"}
{"input": "Деньги на ветер", "expected": "негатив"}
{"input": "Пришло в срок, упаковка целая", "expected": "нейтрал"}Как собрать хороший набор:
- берите реальные входные данные, а не придуманные — придуманные обычно слишком простые;
- добавьте трудные и пограничные случаи: сарказм, смешанные оценки, опечатки;
- начните с 20–50 примеров и пополняйте набор каждый раз, когда находите ошибку в продакшене.
Шаг 2. Harness
Используем OpenAI SDK: через адрес https://api.zukko.pro/v1 им можно вызывать любую модель каталога, так что один код подойдёт и для Claude, и для GPT, и для GLM.
import json
import time
from dataclasses import dataclass, asdict
from openai import OpenAI
client = OpenAI(base_url="https://api.zukko.pro/v1", api_key="fetch_ваш_ключ")
PRICES = { # $ за 1 млн токенов: (вход, выход)
"claude-sonnet-5": (0.30, 0.30),
"claude-opus-5-5": (0.30, 0.30),
"gpt-5.6-sol": (0.30, 0.30),
"gpt-5.6-luna": (0.10, 0.10),
"glm-5.3-flash": (0.04, 0.04),Стоимость считается по формуле (входные × цена_входа + выходные × цена_выхода) / 1 000 000 с ценами из словаря. Набор моделей меняется: сверяйте словарь с GET https://api.zukko.pro/v1/models или страницей «Модели» в кабинете.
max_tokens здесь с запасом: у части моделей в лимит могут входить и токены рассуждения, и слишком маленькое значение обрежет ответ.
Шаг 3. Две версии промпта
PROMPT_V1 = """Определи тональность отзыва. Ответь одним словом: позитив, негатив или нейтрал.
Отзыв: {input}"""
PROMPT_V2 = """Ты классифицируешь отзывы покупателей.
- позитив: покупатель доволен или рекомендует товар;
- негатив: покупатель недоволен, жалуется или вернул товар;
- нейтрал: факты без явной оценки.
Ответь строго одним словом из списка, без точки и пояснений.
Отзыв: {input}"""Шаг 4. Сравнение
def summary(results: list[EvalResult]) -> str:
passed = sum(r.passed for r in results)
cost = sum(r.cost_usd for r in results)
return f"{passed}/{len(results)} ({passed / len(results):.0%}), ${cost:.4f}"
cases = load_cases("test_cases.jsonl")
all_results = []
for model in ["claude-sonnet-5", "gpt-5.6-luna"]:
for version, prompt in [("v1", PROMPT_V1), ("v2", PROMPT_V2)]:
res = run_eval(prompt, version, cases, model)
all_results += res
print(f"== {model} {version}: {summary(res)}")Скрипт печатает строку на каждый пример и итог по каждой паре «модель + версия промпта»: долю прошедших и стоимость прогона. Обязательно смотрите и на ошибки по отдельности: часто это не провал промпта, а неточная разметка в наборе.
Как читать результат
- Учитывайте размер набора. На 20 примерах одна ошибка — это 5 процентных пунктов. Разница в один-два примера между версиями может оказаться случайной. Чем больше набор, тем увереннее вывод.
- Прогоните дважды. Ответы моделей не полностью детерминированы, а у новых моделей Claude параметр
temperatureне настраивается. Запустите набор два-три раза: если результат «прыгает», расширьте набор или сделайте формат ответа строже. - Сравнивайте стоимость. Более длинный промпт стоит дороже на каждом вызове. Если v2 даёт тот же результат, что v1, но длиннее, выбирайте v1.
Оценка без точного совпадения: LLM-судья
Для генерации текста точного совпадения не бывает. Тогда ответ оценивает другая модель по вашим критериям:
JUDGE_PROMPT = """Оцени ответ по критериям:
1) отвечает на вопрос; 2) нет выдуманных фактов; 3) не длиннее 3 предложений.
Верни JSON: {{"score": число от 0 до 3, "reason": "кратко"}}
Вопрос: {question}
Эталон: {expected}
Ответ: {answer}"""
def judge(question: str, expected: str, answer: str, model: str = "claude-sonnet-5") -> dict:
r = client.chat.completions.create(
model=model,
max_tokens=300,
messages=[{"role": "user", "content": JUDGE_PROMPT.format(
question=question, expected=expected, answer=answer)}],
)
text = r.choices[0].message.content or ""
try:
start, end = text.index("{"), text.rindex("}") + 1
return json.loads(text[start:end])
except ValueError:
return {"score": None, "reason": "не удалось разобрать ответ судьи"}json.JSONDecodeError — подкласс ValueError, поэтому except ValueError ловит и отсутствие скобок, и невалидный JSON.
Прежде чем доверять судье, проверьте его: оцените 20 ответов вручную и сравните с его оценками. Если он часто расходится с вами, уточните критерии. Критерии формулируйте проверяемыми («не длиннее 3 предложений»), а не общими («хороший ответ»).
Сохранение результатов
import csv
from datetime import date
def save_csv(results: list[EvalResult], path: str):
with open(path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=list(asdict(results[0]).keys()))
writer.writeheader()
for r in results:
writer.writerow(asdict(r))
save_csv(all_results, f"eval_{date.today()}.csv")Файл с датой в имени позволяет сравнивать прогоны во времени: открыли два CSV — и видно, какие примеры начали проходить, а какие сломались.
Процесс в команде
- Промпты хранятся в репозитории рядом с кодом, у каждого есть версия.
- Любое изменение промпта сопровождается прогоном eval.
- Новая версия выкатывается, только если не хуже текущей на всём наборе.
- Ошибки из продакшена добавляются в набор как новые примеры.
Для прогонов удобно завести отдельный ключ с дневным лимитом — его можно создать в разделе API-ключи. Подробнее о вызовах — в справке по Chat Completions.
Частые вопросы
Сколько примеров нужно?
Для первой проверки — 20–50. Для решения о выкате важного промпта лучше 100 и больше: на маленьком наборе случайные колебания сопоставимы с эффектом.
Почему ответы на одинаковый пример отличаются от прогона к прогону?
Генерация не полностью детерминирована. Сделайте формат ответа строже (одно слово, JSON по схеме) и смотрите на итог по нескольким прогонам, а не на единичный ответ.
Нужно ли тестировать на нескольких моделях?
Если вы выбираете модель или можете её сменить — да. Один и тот же промпт может вести себя по-разному на разных моделях; harness выше проверяет это одной строкой в списке моделей.
Сколько стоит прогон?
Считается по формуле. Например, 50 примеров × 2 версии промпта на claude-sonnet-5, в среднем 400 входных и 20 выходных токенов на запрос: 100 запросов × 420 токенов = 42 000 токенов; 42 000 × $0.30 / 1 000 000 = $0.0126. Тот же прогон на gpt-5.6-luna: 42 000 × $0.10 / 1 000 000 = $0.0042.
zukko
Платите в 25×
меньше
за Claude Code & ChatGPT
Один ключ fetch_* и base URL api.zukko.pro — в Cursor, Claude Code, Codex и любом SDK.
Похожие статьи
- Промпт-инжиниринг: примеры и 12 готовых шаблонов для работыДвенадцать шаблонов промптов для рабочих задач: что в них написано, почему именно так и сколько стоит один вызов по API.
- База данных для RAG: где хранить знания — векторная БД, поиск или контекстДля RAG не обязательно ставить векторную базу. Разбираем четыре способа хранить знания, считаем стоимость запроса и показываем рабочий пример на SQLite.
- Лучшая нейросеть для программирования на C#: выбор и настройкаРейтингов «лучшей модели для C#» мы не даём, зато показываем, как сравнить варианты по цене и индексу, настроить агента для .NET и проверить результат сборкой и тестами.
- Claude Opus 4.6: что это и как перейти на Opus 5.5 без сюрпризовOpus 4.6, 4.7 и 4.8 — прошлое поколение, а в каталоге уже Opus 5 и 5.5. Даём план миграции из четырёх шагов: id модели, прогон своих промптов, сравнение стоимости, откат.