Как тестировать промпты: простой eval harness на Python

«У меня работает» — не проверка. Собираем небольшой скрипт, который прогоняет промпт на наборе примеров и нескольких моделях и показывает, стало ли лучше.

4 мин · · разработка · модели

Новый промпт кажется лучше, пока не прогонишь его на двадцати примерах и не увидишь, где он ошибается. Eval harness — небольшой скрипт для тестирования промптов: он прогоняет набор примеров через промпт и модель, оценивает ответы и считает итог. Ниже — рабочая версия на Python, которую можно запускать на любых моделях Zukko через один ключ.

Шаг 1. Набор примеров

Создайте файл test_cases.jsonl — по одному JSON-объекту на строку:

bash
{"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.

bash
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. Две версии промпта

bash
PROMPT_V1 = """Определи тональность отзыва. Ответь одним словом: позитив, негатив или нейтрал.

Отзыв: {input}"""

PROMPT_V2 = """Ты классифицируешь отзывы покупателей.
- позитив: покупатель доволен или рекомендует товар;
- негатив: покупатель недоволен, жалуется или вернул товар;
- нейтрал: факты без явной оценки.
Ответь строго одним словом из списка, без точки и пояснений.

Отзыв: {input}"""

Шаг 4. Сравнение

bash
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-судья

Для генерации текста точного совпадения не бывает. Тогда ответ оценивает другая модель по вашим критериям:

bash
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 предложений»), а не общими («хороший ответ»).

Сохранение результатов

bash
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 — и видно, какие примеры начали проходить, а какие сломались.

Процесс в команде

  1. Промпты хранятся в репозитории рядом с кодом, у каждого есть версия.
  2. Любое изменение промпта сопровождается прогоном eval.
  3. Новая версия выкатывается, только если не хуже текущей на всём наборе.
  4. Ошибки из продакшена добавляются в набор как новые примеры.

Для прогонов удобно завести отдельный ключ с дневным лимитом — его можно создать в разделе 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.