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

> Методология A/B тестирования промптов: метрики качества, статистическая значимость, инструменты (Langfuse, DeepEval). Пошаговое руководство от гипотезы до production-решения.
> Author: Roman Belov · Published: 2026-04-05 · Source: https://futurecraft.pro/ru/blog/prompt-ab-testing/

Большинство промптов в 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-запросов — вопросы на русском, а датасет только на английском, результаты теста нерелевантны.

```python
# Структура датасета в 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: запуск двух версий промпта

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

```python
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-5.4",
                input=prompt.compile(**item.input),
                metadata={"prompt_version": prompt_version}
            )

            response = call_llm(
                model="gpt-5.4",
                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-5.4-2026-03-05, не просто gpt-5.4).
- **Temperature.** Одинаковая для обоих вариантов. Для воспроизводимости — 0 или 0.1–0.3.
- **Seed.** Если провайдер поддерживает (OpenAI), фиксировать seed для детерминизма.
- **Max tokens.** Одинаковый лимит, чтобы один вариант не выигрывал за счёт длины.

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

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

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

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

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

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

Оценивают семантическое качество. Каждая оценка — вызов модели, но они покрывают аспекты, недоступные детерминированным метрикам. Подробнее о паттерне LLM-as-Judge — в [отдельном руководстве](/ru/blog/llm-as-judge-automated-quality-gate/).

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

```python
from deepeval.metrics import (
    AnswerRelevancyMetric,
    FaithfulnessMetric,
    GEval
)
from deepeval.test_case import SingleTurnParams

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

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

# 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=[SingleTurnParams.ACTUAL_OUTPUT],
    model="gpt-5.4",
    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.

```python
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-value | Cohen's d | Решение |
|---|---|---|
| < 0.05 | > 0.5 | Принять Prompt B: значимое и существенное улучшение |
| < 0.05 | 0.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 — вот [руководство по установке и настройке](/ru/blog/llm-observability-langfuse/).

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

```python
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: Запуск эксперимента

```python
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-5.4-2026-03-05",
                messages=messages,
                temperature=0.2,
                seed=42
            )

            output = response.choices[0].message.content
            trace.generation(
                name="support-response",
                model="gpt-5.4-2026-03-05",
                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

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

metrics = [
    AnswerRelevancyMetric(threshold=0.7, model="gpt-5.4"),
    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=[SingleTurnParams.INPUT, SingleTurnParams.ACTUAL_OUTPUT],
        model="gpt-5.4",
        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: Статистический анализ

```python
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 тест не должен быть ручным процессом. Он запускается автоматически при изменении промпта.

```yaml
# .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-полям из датасета.

```python
# Анализ по сегментам
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](/ru/blog/llm-as-judge-automated-quality-gate/) для автоматической оценки качества и [Langfuse](/ru/blog/llm-observability-langfuse/) для трейсинга и prompt management.

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

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

---

*Нужна помощь с настройкой eval-пайплайнов и A/B тестирования промптов? Я помогаю стартапам внедрять AI-решения и строить продукты — [belov.works](https://belov.works).*
