Туториалы AI Ops

Prompt A/B Testing: как научно улучшать качество ответов AI

Что такое Prompt A/B testing?

Prompt A/B testing — структурированная методология сравнения двух версий LLM-промпта на фиксированном датасете с использованием количественных метрик и проверки статистической значимости. В отличие от ручной оценки на нескольких примерах, требует минимум 100-500 репрезентативных входных данных, автоматической оценки через детерминированные метрики или LLM-as-Judge, и парного статистического теста (Wilcoxon или t-test) для подтверждения реальности наблюдаемых различий. Это стандартный способ принимать обоснованные решения по оптимизации промптов в production AI-системах.

TL;DR

  • -Ручная оценка на 5-10 примерах с вероятностью 33% пропустит деградацию в 20% случаев — статистически значимые тесты требуют от 50 до 500 примеров в зависимости от ожидаемого размера эффекта
  • -Парный тест Вилкоксона на одних и тех же входных данных устраняет внешние факторы и является корректным статистическим тестом для сравнения LLM-метрик
  • -Cohen's d < 0.2 означает статистически значимое, но практически пренебрежимое различие — перед деплоем нового промпта должны быть выполнены оба порога: p-value и effect size
  • -Поправка Бонферрони обязательна при одновременном тестировании 3+ метрик: делите alpha на количество тестов, чтобы избежать уровня ложных срабатываний 14%+
  • -Интеграция в CI/CD с логикой --fail-on-regression позволяет автоматически деплоить промпт, если candidate не хуже baseline больше, чем на заданный порог

Большинство промптов в production никогда не проходят формальное сравнительное тестирование. Команды меняют формулировки по интуиции, оценивают результат на трёх примерах и деплоят. Через неделю обнаруживают деградацию на edge cases, откатывают, пробуют снова. Prompt A/B testing устраняет угадывание и превращает оптимизацию промптов в измеримый процесс.

Почему интуитивная оценка промптов не работает

Промпт-инженеры совершают три системные ошибки при ручной оценке.

Ошибка малых выборок. Проверка на 5–10 примерах не выявляет проблемы. Промпт A может выигрывать на простых запросах и проигрывать на сложных. При выборке в 5 запросов вероятность пропустить деградацию на 20% случаев составляет 33%.

Confirmation bias. Автор промпта подсознательно выбирает примеры, на которых новая версия выглядит лучше. Это не злой умысел, а когнитивное искажение. Единственный способ его устранить — слепая оценка на случайной выборке.

Отсутствие baseline. Без зафиксированного baseline невозможно понять, стала ли новая версия лучше. «Кажется, ответы стали точнее» — не метрика. 0.82 → 0.87 по faithfulness на 200 примерах — метрика.

A/B тестирование промптов решает все три проблемы: фиксированный датасет, автоматическая оценка, статистическая проверка разницы.

Архитектура prompt A/B тестирования

A/B тест промптов отличается от A/B теста в продукте. В продукте измеряют поведение пользователей (CTR, конверсия). В промптах измеряют качество выходов модели. Пользователь может быть вообще не задействован.

┌─────────────────────────────────────────────────────┐
│              Prompt A/B Test Pipeline                │
├─────────────┬─────────────┬─────────────────────────┤
│  Dataset    │  Execution  │  Evaluation             │
│             │             │                         │
│ Входы       │ Prompt A    │ LLM-as-Judge            │
│ (inputs)    │ Prompt B    │ Детерминированные       │
│ Expected    │ → outputs   │ метрики                 │
│ outputs     │             │ → scores                │
│ (optional)  │             │                         │
├─────────────┼─────────────┼─────────────────────────┤
│  Statistical Analysis → Winner / No Difference      │
└─────────────┴─────────────┴─────────────────────────┘

Три компонента: датасет, execution, evaluation. Каждый требует отдельного внимания.

Датасет: основа эксперимента

Датасет для A/B теста содержит входы (inputs), опционально — эталонные выходы (expected outputs) и метаданные (категория запроса, сложность).

Минимальный размер выборки зависит от ожидаемого размера эффекта:

Ожидаемый эффектМинимум примеровПояснение
Большой (>0.15)50-100Полная переработка промпта
Средний (0.05-0.15)200-500Существенное изменение инструкций
Малый (<0.05)500-1000+Тонкая настройка формулировок

Датасет должен покрывать distribution реальных запросов. Если 40% production-запросов — вопросы на русском, а датасет только на английском, результаты теста нерелевантны.

# Структура датасета в Langfuse
dataset_items = [
    {
        "input": {"query": "Объясни разницу между Docker и Kubernetes"},
        "expected_output": "Docker — контейнеризация, Kubernetes — оркестрация...",
        "metadata": {"category": "technical", "complexity": "medium", "locale": "ru"}
    },
    {
        "input": {"query": "Write a product description for wireless headphones"},
        "expected_output": None,  # Judge-based evaluation, no reference needed
        "metadata": {"category": "creative", "complexity": "low", "locale": "en"}
    }
]

Стратегии формирования датасета:

  1. Production sampling. Случайная выборка из реальных запросов. Самый релевантный подход. Langfuse позволяет создавать dataset items прямо из трейсов.
  2. Stratified sampling. Выборка с сохранением пропорций по категориям. Если 30% запросов — summarization, 30% — Q&A, 40% — generation, датасет сохраняет те же пропорции.
  3. Adversarial augmentation. Добавление сложных и граничных случаев, которые редко встречаются в production, но критичны для качества.

Execution: запуск двух версий промпта

Каждый элемент датасета проходит через оба промпта. Важно контролировать переменные:

from langfuse import Langfuse

langfuse = Langfuse()

dataset = langfuse.get_dataset("eval-dataset-v2")

def run_experiment(prompt_name: str, prompt_version: int, run_name: str):
    prompt = langfuse.get_prompt(prompt_name, version=prompt_version)

    for item in dataset.items:
        # Каждый run привязан к dataset item
        with item.observe(run_name=run_name) as trace:
            generation = trace.generation(
                name="main-llm-call",
                model="gpt-4o",
                input=prompt.compile(**item.input),
                metadata={"prompt_version": prompt_version}
            )

            response = call_llm(
                model="gpt-4o",
                messages=prompt.compile(**item.input),
                temperature=0.3  # Фиксированная temperature
            )

            generation.end(output=response)

# Запуск двух версий
run_experiment("assistant-prompt", version=3, run_name="prompt-v3-baseline")
run_experiment("assistant-prompt", version=4, run_name="prompt-v4-candidate")

Критические параметры, которые фиксируют между вариантами:

  • Модель. Один и тот же model ID (gpt-4o-2024-08-06, не просто gpt-4o).
  • Temperature. Одинаковая для обоих вариантов. Для воспроизводимости — 0 или 0.1–0.3.
  • Seed. Если провайдер поддерживает (OpenAI), фиксировать seed для детерминизма.
  • Max tokens. Одинаковый лимит, чтобы один вариант не выигрывал за счёт длины.

Метрики оценки качества промптов

Метрики делятся на две категории: детерминированные (вычисляются алгоритмически) и LLM-based (оцениваются моделью-судьёй).

Детерминированные метрики

Работают быстро, стоят ноль, полностью воспроизводимы. Покрывают ограниченный набор аспектов.

МетрикаЧто измеряетКогда применять
ROUGE-LСовпадение с эталоном (longest common subsequence)Summarization, extraction
BLEUN-gram overlap с эталономПеревод, перефразирование
Exact matchТочное совпадениеClassification, entity extraction
JSON validityВалидность структурыStructured output
LatencyВремя ответаЛюбой use case
Token countДлина ответаCost optimization

LLM-as-Judge метрики

Оценивают семантическое качество. Каждая оценка — вызов модели, но они покрывают аспекты, недоступные детерминированным метрикам. Подробнее о паттерне LLM-as-Judge — в отдельном руководстве.

Ключевые метрики, реализованные в DeepEval:

from deepeval.metrics import (
    AnswerRelevancyMetric,
    FaithfulnessMetric,
    GEval
)

# Relevancy: насколько ответ соответствует вопросу
relevancy = AnswerRelevancyMetric(threshold=0.7, model="gpt-4o")

# Faithfulness: насколько ответ основан на предоставленном контексте (для RAG)
faithfulness = FaithfulnessMetric(threshold=0.8, model="gpt-4o")

# Custom metric через G-Eval
tone_consistency = GEval(
    name="Tone Consistency",
    criteria="Evaluate whether the response maintains a professional, "
             "helpful tone throughout. Score 0-1.",
    evaluation_params=["actual_output"],
    model="gpt-4o",
    threshold=0.7
)

G-Eval заслуживает отдельного внимания. Это фреймворк для создания кастомных LLM-as-Judge метрик. Описываете критерий на естественном языке, G-Eval генерирует chain-of-thought evaluation steps и оценивает выход. Это позволяет тестировать специфичные для бизнеса аспекты: соответствие бренд-гайдлайнам, корректность юридических формулировок, наличие обязательных дисклеймеров.

Какие метрики выбрать

Не тестируйте всё подряд. Каждый A/B тест проверяет конкретную гипотезу, и метрики выбираются под неё.

ГипотезаМетрики
«Новый промпт точнее отвечает на вопросы»Answer Relevancy, Faithfulness
«Structured output стал надёжнее»JSON validity rate, schema compliance
«Ответы стали лаконичнее без потери качества»Token count + Answer Relevancy
«Тон стал профессиональнее»G-Eval с custom criteria

Статистическая значимость: когда разница реальна

Промпт A набрал 0.83 по relevancy, промпт B — 0.86. Это реальное улучшение или случайность? Статистический тест даёт ответ.

Выбор теста

Для LLM-метрик (непрерывные значения 0–1) применяют paired t-test или Wilcoxon signed-rank test. Paired — потому что оба промпта оцениваются на одних и тех же inputs.

import numpy as np
from scipy import stats

# Scores для каждого input из датасета
scores_a = np.array([0.82, 0.91, 0.78, 0.85, 0.79, ...])  # Prompt A
scores_b = np.array([0.88, 0.89, 0.84, 0.90, 0.83, ...])  # Prompt B

# Paired t-test (если распределение ≈ нормальное)
t_stat, p_value = stats.ttest_rel(scores_a, scores_b)

# Wilcoxon signed-rank (непараметрический, без допущений о распределении)
w_stat, p_value_w = stats.wilcoxon(scores_a, scores_b)

# Effect size (Cohen's d для paired samples)
diff = scores_b - scores_a
cohens_d = np.mean(diff) / np.std(diff, ddof=1)

print(f"Mean A: {scores_a.mean():.3f}, Mean B: {scores_b.mean():.3f}")
print(f"P-value (t-test): {p_value:.4f}")
print(f"P-value (Wilcoxon): {p_value_w:.4f}")
print(f"Cohen's d: {cohens_d:.3f}")

Интерпретация результатов

P-valueCohen’s dРешение
< 0.05> 0.5Принять Prompt B: значимое и существенное улучшение
< 0.050.2-0.5Рассмотреть Prompt B: значимо, но эффект умеренный
< 0.05< 0.2Осторожность: статистически значимо, но практически незначительно
> 0.05любойНет оснований предпочесть одну версию другой

p-value < 0.05 при Cohen’s d < 0.1 означает, что разница существует, но она настолько мала, что не стоит усилий на деплой и связанных рисков.

Поправка на множественные сравнения

Если тестируете три метрики одновременно (relevancy, faithfulness, tone), вероятность ложноположительного результата растёт. При трёх независимых тестах с alpha=0.05 вероятность хотя бы одного ложного срабатывания — 14%.

Поправка Бонферрони: делите alpha на количество тестов. Для трёх метрик: 0.05 / 3 = 0.017. Результат значим только при p < 0.017.

Полный pipeline с Langfuse и DeepEval

Langfuse управляет промптами, датасетами и трейсингом. DeepEval проводит evaluation. Вместе они покрывают весь цикл A/B теста. Если вы ещё не настроили Langfuse — вот руководство по установке и настройке.

Шаг 1: Подготовка датасета в Langfuse

from langfuse import Langfuse

langfuse = Langfuse()

# Создание датасета
dataset = langfuse.create_dataset(
    name="support-bot-eval-v3",
    description="200 реальных запросов в поддержку, stratified по категориям",
    metadata={"source": "production-sampling", "period": "2026-03-01 to 2026-03-15"}
)

# Добавление items из production traces
for trace in sampled_traces:
    langfuse.create_dataset_item(
        dataset_name="support-bot-eval-v3",
        input=trace.input,
        expected_output=trace.verified_output,  # Проверенный человеком
        metadata={"category": trace.metadata.get("category")}
    )

Шаг 2: Запуск эксперимента

import openai

client = openai.OpenAI()

def run_prompt_variant(dataset_name: str, prompt_version: int, run_name: str):
    dataset = langfuse.get_dataset(dataset_name)
    prompt = langfuse.get_prompt("support-bot", version=prompt_version)

    results = []

    for item in dataset.items:
        with item.observe(run_name=run_name) as trace:
            messages = prompt.compile(**item.input)

            response = client.chat.completions.create(
                model="gpt-4o-2024-08-06",
                messages=messages,
                temperature=0.2,
                seed=42
            )

            output = response.choices[0].message.content
            trace.generation(
                name="support-response",
                model="gpt-4o-2024-08-06",
                input=messages,
                output=output,
                usage={
                    "input": response.usage.prompt_tokens,
                    "output": response.usage.completion_tokens
                }
            )

            results.append({
                "item_id": item.id,
                "input": item.input,
                "output": output,
                "expected": item.expected_output,
                "metadata": item.metadata
            })

    return results

baseline_results = run_prompt_variant("support-bot-eval-v3", version=3, run_name="v3-baseline")
candidate_results = run_prompt_variant("support-bot-eval-v3", version=4, run_name="v4-candidate")

Шаг 3: Evaluation с DeepEval

from deepeval import evaluate
from deepeval.test_case import LLMTestCase
from deepeval.metrics import AnswerRelevancyMetric, FaithfulnessMetric, GEval

metrics = [
    AnswerRelevancyMetric(threshold=0.7, model="gpt-4o"),
    GEval(
        name="Helpfulness",
        criteria="Rate how helpful and actionable the support response is. "
                 "Consider: does it solve the user's problem? Does it provide "
                 "clear next steps? Score 0-1.",
        evaluation_params=["input", "actual_output"],
        model="gpt-4o",
        threshold=0.7
    )
]

def evaluate_results(results: list, run_label: str):
    test_cases = []
    for r in results:
        test_cases.append(LLMTestCase(
            input=r["input"]["query"],
            actual_output=r["output"],
            expected_output=r.get("expected"),
            context=[r["input"].get("context", "")]
        ))

    evaluation = evaluate(test_cases=test_cases, metrics=metrics)
    return evaluation

Шаг 4: Статистический анализ

import numpy as np
from scipy import stats

def compare_variants(eval_a, eval_b, metric_name: str, alpha: float = 0.05):
    scores_a = np.array([tc.metrics_data[metric_name] for tc in eval_a.test_cases])
    scores_b = np.array([tc.metrics_data[metric_name] for tc in eval_b.test_cases])

    # Paired test
    _, p_value = stats.wilcoxon(scores_a, scores_b)

    # Effect size
    diff = scores_b - scores_a
    cohens_d = np.mean(diff) / np.std(diff, ddof=1) if np.std(diff) > 0 else 0

    result = {
        "metric": metric_name,
        "mean_a": float(scores_a.mean()),
        "mean_b": float(scores_b.mean()),
        "delta": float(scores_b.mean() - scores_a.mean()),
        "p_value": float(p_value),
        "cohens_d": float(cohens_d),
        "significant": p_value < alpha,
        "recommendation": "adopt" if (p_value < alpha and cohens_d > 0.2) else "keep_baseline"
    }

    return result

# Сравнение по каждой метрике
for metric_name in ["Answer Relevancy", "Helpfulness"]:
    result = compare_variants(eval_baseline, eval_candidate, metric_name)
    print(f"\n{result['metric']}:")
    print(f"  Baseline: {result['mean_a']:.3f} → Candidate: {result['mean_b']:.3f}")
    print(f"  Delta: {result['delta']:+.3f}, p={result['p_value']:.4f}, d={result['cohens_d']:.3f}")
    print(f"  → {result['recommendation']}")

Интеграция в CI/CD: автоматические prompt тесты

A/B тест не должен быть ручным процессом. Он запускается автоматически при изменении промпта.

# .github/workflows/prompt-test.yml
name: Prompt A/B Test

on:
  push:
    paths:
      - 'prompts/**'

jobs:
  prompt-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Detect changed prompts
        id: changes
        run: |
          changed=$(git diff --name-only HEAD~1 -- prompts/)
          echo "prompts=$changed" >> $GITHUB_OUTPUT

      - name: Run A/B test
        env:
          LANGFUSE_PUBLIC_KEY: ${{ secrets.LANGFUSE_PUBLIC_KEY }}
          LANGFUSE_SECRET_KEY: ${{ secrets.LANGFUSE_SECRET_KEY }}
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
        run: |
          python scripts/run_prompt_ab_test.py \
            --prompt-name support-bot \
            --baseline-version current \
            --candidate-version staged \
            --dataset support-bot-eval-v3 \
            --min-effect-size 0.1 \
            --alpha 0.05

      - name: Check results
        run: |
          python scripts/check_ab_results.py \
            --fail-on-regression \
            --min-delta -0.02  # Допустимая деградация не более 2%

Логика --fail-on-regression: pipeline проходит, если candidate не хуже baseline больше, чем на заданный порог. Это позволяет деплоить промпты, которые улучшают одну метрику и не деградируют остальные.

Паттерны и антипаттерны prompt A/B тестирования

Паттерны, которые работают

Один промпт — одна переменная. Меняйте одну вещь за раз. Если одновременно изменили system prompt и few-shot примеры, невозможно понять, что повлияло на результат.

Версионирование в Langfuse. Каждая версия промпта хранится в Langfuse Prompt Management с метаданными: кто изменил, зачем, какую гипотезу проверяет. Это создаёт audit trail оптимизации.

Сегментированный анализ. Общий score может быть одинаковым, но один промпт лучше для коротких запросов, другой — для длинных. Разбивайте результаты по metadata-полям из датасета.

# Анализ по сегментам
for category in ["technical", "billing", "general"]:
    segment_a = [s for s, m in zip(scores_a, metadata) if m["category"] == category]
    segment_b = [s for s, m in zip(scores_b, metadata) if m["category"] == category]

    if len(segment_a) >= 20:  # Достаточный размер сегмента
        _, p = stats.wilcoxon(segment_a, segment_b)
        print(f"{category}: A={np.mean(segment_a):.3f}, B={np.mean(segment_b):.3f}, p={p:.4f}")

Cost-aware evaluation. Промпт B может быть на 3% лучше по качеству, но на 40% дороже из-за увеличенного контекста. Считайте cost per quality point.

Антипаттерны

Тестирование на обучающих примерах. Если few-shot примеры в промпте взяты из датасета — результаты невалидны. Датасет должен содержать примеры, которые модель видит впервые.

Игнорирование дисперсии. Средний score 0.85 при стандартном отклонении 0.25 хуже, чем 0.82 при стандартном отклонении 0.05. Высокая дисперсия означает непредсказуемое качество. Проверяйте не только среднее, но и std, min, percentile 5.

Преждевременная остановка. Первые 50 примеров показали улучшение — соблазн остановиться и задеплоить. Но первые 50 могут быть из одной категории. Дождитесь полного прогона.

Cherry-picking результатов. Протестировали пять метрик, одна показала p < 0.05 — не значит, что промпт лучше. Это проблема множественных сравнений (описано выше). Определяйте primary metric заранее.

Checklist: запуск prompt A/B теста

  1. Гипотеза. Сформулируйте, что именно улучшает новый промпт и почему.
  2. Primary metric. Выберите одну главную метрику. Остальные — вспомогательные.
  3. Датасет. Минимум 100 примеров, репрезентативная выборка из production.
  4. Контрольные переменные. Модель, temperature, seed, max_tokens — зафиксированы.
  5. Execution. Оба промпта прогнаны на полном датасете, результаты записаны в Langfuse.
  6. Evaluation. Метрики посчитаны для обоих вариантов.
  7. Статистика. Paired test, p-value, effect size. Поправка на множественные сравнения при необходимости.
  8. Сегментация. Проверка результатов по категориям запросов.
  9. Решение. Adopt, reject или iterate на основе данных.
  10. Документация. Результат зафиксирован: какой промпт, какой эффект, какие ограничения.

Что дальше

Prompt A/B testing — один из элементов зрелого LLM ops pipeline. Он работает в связке с LLM-as-Judge для автоматической оценки качества и Langfuse для трейсинга и prompt management.

Следующий уровень — online A/B testing, когда два промпта одновременно работают в production, а трафик распределяется между ними. Это требует feature flags, routing logic и real-time мониторинга. Offline A/B testing (описанный в этой статье) проще, дешевле и закрывает 90% потребностей в оптимизации промптов.

Начните с малого: один датасет из 100 production-запросов, одна метрика, один статистический тест. Это уже даст больше уверенности в решениях, чем любое количество ручной проверки.

FAQ

Как часто нужно обновлять датасет для A/B тестирования промптов?

Обновляйте датасет, когда распределение production-запросов существенно меняется — запуск новой фичи, изменение демографии пользователей, языковая экспансия. Практическое правило: пересэмплировать 30-50% датасета каждый квартал из последнего production-трафика. Устаревший датасет полугодовой давности даст корректную статистику, которая уже не отражает текущих паттернов использования, — и уверенные выводы о качестве промптов окажутся вводящими в заблуждение.

Можно ли использовать A/B тестирование для сравнения разных LLM, а не разных промптов?

Да, тот же pipeline применяется к сравнению моделей — запустить обе модели на идентичном датасете с идентичными промптами и оценить по тем же метрикам. Ключевое ограничение — стоимость: прогнать GPT-4o и Claude Sonnet на 500 примерах каждую обходится дорого. Для выбора модели начните с 50-100 стратифицированных примеров для выявления явных победителей, и проводите полный статистический тест только когда первичные результаты неоднозначны.

Каков реалистичный цикл улучшения при оптимизации промптов?

Полный цикл — гипотеза, прогон датасета, статистический анализ, решение — занимает 2-4 часа реального времени для датасета из 200 примеров с облачным LLM-evaluation. Узкое место почти всегда конструирование датасета (поиск и разметка репрезентативных примеров), а не сам evaluation. Команды, работающие с еженедельными циклами оптимизации, поддерживают постоянно обновляемый eval-датасет из production, сокращая время цикла до менее часа для каждой новой гипотезы.