# Промпт-инженерия: система управления 50+ промптами в production

> Как писать промпты и управлять ими в production: версионирование, тестирование, A/B-деплой, мониторинг регрессий. Промпт-инженерия от хаоса к системе.
> Author: Roman Belov · Published: 2026-03-26 · Source: https://futurecraft.pro/ru/blog/prompt-engineering-system/

LLM-проект в production быстро обрастает десятками промптов. Классификация, суммаризация, извлечение данных, генерация ответов, оценка качества. Вопрос «как писать промпты» быстро перерастает в «как управлять десятками промптов, не потеряв контроль». Каждый промпт требует итераций, каждая итерация может сломать то, что работало. При 50 промптах хаос неизбежен: кто менял промпт классификатора? Почему упала точность суммаризатора? Какая версия сейчас в production?

Статья о промпт-инженерии как системной дисциплине: как построить систему управления промптами, которая масштабируется от 5 до 500 промптов.

## Почему промпты нельзя хранить в коде

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

Проблемы начинаются при масштабировании:

**Изменение промпта = деплой приложения.** Промпт захардкожен в коде. Чтобы поправить одно слово в системном промпте, нужен PR, review, merge, deploy. Цикл итерации — часы вместо минут.

**Нет версионирования.** Git хранит историю, но diff промпта на 2000 символов нечитаем. Нет способа быстро откатить промпт к предыдущей версии без отката всего приложения.

**Нет связи между версией и метриками.** Промпт изменился, качество упало. Связать конкретную версию промпта с конкретными метриками — ручная работа, если промпт живёт в коде.

**Кросс-командный хаос.** Product-менеджер хочет поправить тон ответов. ML-инженер оптимизирует токены. Разработчик рефакторит шаблон. Все редактируют один файл, результат непредсказуем.

## Анатомия prompt engineering system

Зрелая система управления промптами состоит из четырёх слоёв:

```
┌─────────────────────────────────────────────────┐
│              Prompt Engineering System            │
├────────────┬────────────┬────────────┬──────────┤
│  Registry  │  Testing   │  Deploy    │ Monitor  │
│            │            │            │          │
├────────────┼────────────┼────────────┼──────────┤
│ Хранение   │ Eval перед │ Canary /   │ Метрики  │
│ + версии   │ деплоем    │ A/B rollout│ + алерты │
└────────────┴────────────┴────────────┴──────────┘
```

**Registry** — централизованное хранилище промптов с версионированием, метаданными и контролем доступа.

**Testing** — автоматическая оценка качества промпта на тестовых датасетах перед деплоем в production.

**Deploy** — механизм доставки новой версии промпта в production без деплоя приложения.

**Monitor** — отслеживание метрик качества, привязанных к конкретной версии промпта.

Необязательно строить все четыре слоя сразу. Минимально рабочая система: registry + deploy. Но без testing и monitoring она слепая.

## Registry: централизованное хранилище промптов

Registry решает базовую задачу: один источник правды для всех промптов. Два подхода.

### Подход 1: Langfuse Prompt Management

[Langfuse](/ru/blog/llm-observability-langfuse/) предоставляет prompt management из коробки. Каждый промпт — именованная сущность с версиями, лейблами и переменными.

```python
from langfuse import Langfuse

langfuse = Langfuse()

# Получить production-версию промпта
prompt = langfuse.get_prompt(
    name="ticket-classifier",
    label="production"  # или "staging", "latest"
)

# Промпт с переменными
system_message = prompt.compile(
    categories="billing,technical,general,urgent",
    language="ru"
)
```

Структура промпта в Langfuse:

| Поле | Назначение | Пример |
|------|-----------|--------|
| `name` | Уникальный идентификатор | `ticket-classifier` |
| `version` | Автоинкремент | `14` |
| `label` | Среда / статус | `production`, `staging` |
| `type` | Формат | `text` или `chat` |
| `config` | Параметры модели | `{"model": "gpt-5.4-mini", "temperature": 0}` |

Промпт отделён от кода. Product-менеджер правит промпт в UI, назначает лейбл `staging`, тестирует, переключает на `production`. Код приложения не меняется.

### Подход 2: Промпты как код (Prompts-as-Code)

Для команд, которые предпочитают Git как единый источник правды:

```
prompts/
├── ticket-classifier/
│   ├── prompt.yaml
│   ├── config.yaml
│   └── tests/
│       ├── dataset.jsonl
│       └── eval.py
├── summarizer/
│   ├── prompt.yaml
│   ├── config.yaml
│   └── tests/
└── prompt_registry.py
```

```yaml
# prompts/ticket-classifier/prompt.yaml
name: ticket-classifier
type: chat
model: gpt-5.4-mini
temperature: 0
messages:
  - role: system
    content: |
      Ты классификатор тикетов поддержки.
      Категории: {{categories}}.
      Верни JSON: {"category": "...", "confidence": 0.0-1.0, "reasoning": "..."}
      Язык ответа: {{language}}.
  - role: user
    content: "{{ticket_text}}"
variables:
  categories: "billing,technical,general,urgent"
  language: "ru"
```

```python
# prompt_registry.py
import yaml
from pathlib import Path

class PromptRegistry:
    def __init__(self, prompts_dir: str = "prompts"):
        self.prompts_dir = Path(prompts_dir)
        self._cache = {}

    def get(self, name: str) -> dict:
        if name not in self._cache:
            prompt_path = self.prompts_dir / name / "prompt.yaml"
            with open(prompt_path) as f:
                self._cache[name] = yaml.safe_load(f)
        return self._cache[name]

    def compile(self, name: str, **variables) -> list[dict]:
        prompt = self.get(name)
        messages = []
        for msg in prompt["messages"]:
            content = msg["content"]
            for key, value in {**prompt.get("variables", {}), **variables}.items():
                content = content.replace(f"{{{{{key}}}}}", str(value))
            messages.append({"role": msg["role"], "content": content})
        return messages
```

Оба подхода допускают гибридный вариант: промпты хранятся в Git, а CI/CD синхронизирует их в Langfuse при каждом merge в main.

```python
# ci/sync_prompts.py — вызывается в CI pipeline
from langfuse import Langfuse
from prompt_registry import PromptRegistry

langfuse = Langfuse()
registry = PromptRegistry()

for prompt_name in ["ticket-classifier", "summarizer", "response-generator"]:
    prompt_data = registry.get(prompt_name)
    langfuse.create_prompt(
        name=prompt_name,
        prompt=prompt_data["messages"],
        config={"model": prompt_data["model"], "temperature": prompt_data["temperature"]},
        labels=["production"],
    )
```

## Testing: eval перед деплоем промпта

Промпт без тестов — рулетка. Каждое изменение потенциально ломает edge cases. Автоматическая оценка перед деплоем ловит регрессии до того, как они попадут к пользователям.

### Датасеты: золотой стандарт

Каждый промпт нуждается в тестовом датасете. Минимальный размер: 20-30 примеров, покрывающих основные сценарии и edge cases.

```jsonl
{"input": "Не могу оплатить подписку, карта отклоняется", "expected": {"category": "billing", "confidence_min": 0.8}}
{"input": "Приложение вылетает при открытии чата", "expected": {"category": "technical", "confidence_min": 0.8}}
{"input": "Хочу удалить аккаунт и все данные", "expected": {"category": "general", "confidence_min": 0.7}}
{"input": "СРОЧНО! Сервер лежит, клиенты не могут войти", "expected": {"category": "urgent", "confidence_min": 0.9}}
```

Источники датасетов:
- **Production-логи.** Реальные запросы с размеченными ответами. Самый ценный источник.
- **Ручная разметка.** Для новых промптов без production-данных.
- **Синтетические данные.** LLM генерирует вариации существующих примеров. Полезно для расширения покрытия edge cases.

### Eval pipeline

```python
import json
from openai import OpenAI
from prompt_registry import PromptRegistry

client = OpenAI()
registry = PromptRegistry()

def evaluate_prompt(prompt_name: str, dataset_path: str, threshold: float = 0.85):
    """Оценить промпт на датасете. Вернуть pass/fail."""
    with open(dataset_path) as f:
        examples = [json.loads(line) for line in f]

    correct = 0
    total = len(examples)
    failures = []

    for example in examples:
        messages = registry.compile(prompt_name, ticket_text=example["input"])
        response = client.chat.completions.create(
            model=registry.get(prompt_name)["model"],
            messages=messages,
            temperature=0,
        )

        result = json.loads(response.choices[0].message.content)

        if result["category"] == example["expected"]["category"]:
            if result["confidence"] >= example["expected"]["confidence_min"]:
                correct += 1
            else:
                failures.append({
                    "input": example["input"],
                    "reason": f"low confidence: {result['confidence']}",
                })
        else:
            failures.append({
                "input": example["input"],
                "reason": f"wrong category: {result['category']}",
            })

    accuracy = correct / total
    passed = accuracy >= threshold

    return {
        "accuracy": accuracy,
        "threshold": threshold,
        "passed": passed,
        "failures": failures,
    }
```

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

### CI/CD интеграция

```yaml
# .github/workflows/prompt-eval.yml
name: Prompt Evaluation
on:
  pull_request:
    paths:
      - 'prompts/**'

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

      - name: Install dependencies
        run: pip install openai langfuse pyyaml

      - name: Run prompt evaluations
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
        run: python ci/eval_prompts.py --changed-only

      - name: Comment PR with results
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const results = JSON.parse(fs.readFileSync('eval_results.json'));
            let body = '## Prompt Eval Results\n\n';
            for (const [name, result] of Object.entries(results)) {
              const status = result.passed ? '✅' : '❌';
              body += `| ${name} | ${status} | ${result.accuracy.toFixed(2)} | ${result.threshold} |\n`;
            }
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body
            });
```

Каждый PR, затрагивающий промпты, автоматически прогоняет eval pipeline и публикует результаты в комментарии.

## Deploy: доставка промптов без деплоя кода

Три стратегии доставки новой версии промпта в production.

### Мгновенное переключение

Самый простой вариант. Переключение лейбла `production` на новую версию промпта.

```python
# В Langfuse UI: промпт v14 → назначить label "production"
# Приложение автоматически подхватит при следующем запросе

prompt = langfuse.get_prompt(
    name="ticket-classifier",
    label="production",
    cache_ttl_seconds=300,  # кэш 5 минут
)
```

Подходит для некритичных промптов и быстрых фиксов. Риск: 100% трафика сразу на новой версии.

### Canary deploy

Постепенное переключение трафика: 5% → 25% → 50% → 100%.

```python
import random

def get_prompt_with_canary(
    name: str,
    canary_percentage: int = 10,
) -> tuple[dict, str]:
    """Вернуть промпт и версию (production или canary)."""
    if random.randint(1, 100) <= canary_percentage:
        prompt = langfuse.get_prompt(name=name, label="canary")
        return prompt, "canary"
    else:
        prompt = langfuse.get_prompt(name=name, label="production")
        return prompt, "production"
```

Метрики canary и production сравниваются в реальном времени. Если canary деградирует — автоматический откат.

### Feature flags

Для команд с существующей системой feature flags (LaunchDarkly, Unleash, самописная):

```python
def get_prompt_version(name: str, user_id: str) -> str:
    """Определить версию промпта через feature flag."""
    flag = feature_flags.get(f"prompt_{name}_version")

    if flag.is_enabled(user_id):
        return flag.get_variant(user_id)  # "v14", "v15"
    return "production"
```

Дополнительный плюс: можно таргетировать конкретных пользователей, сегменты, регионы.

## Monitor: привязка метрик к версиям промптов

Мониторинг без привязки к версии промпта — слепой. Качество упало, но что именно сломалось: промпт, модель, данные?

### Трейсинг с версией промпта

Каждый LLM-вызов должен содержать версию промпта в метаданных:

```python
from langfuse import get_client

langfuse = get_client()

prompt = langfuse.get_prompt(name="ticket-classifier", label="production")

with langfuse.start_as_current_observation(
    as_type="generation",
    name="ticket-classification",
    model="gpt-5.4-mini",
    input=messages,
    prompt=prompt,  # Langfuse автоматически привяжет версию
    metadata={
        "prompt_name": "ticket-classifier",
        "prompt_version": prompt.version,      # 14
        "prompt_label": "production",
    },
) as generation:
    response = call_llm(messages)
    generation.update(output=response)
```

### Дашборд по версиям

Ключевые метрики для мониторинга:

| Метрика | Что показывает | Алерт при |
|---------|---------------|-----------|
| Accuracy | Доля правильных ответов | < порога для промпта |
| Latency p95 | Время ответа | > 2x от baseline |
| Token usage | Расход токенов | > 1.5x от предыдущей версии |
| Error rate | Доля невалидных ответов | > 5% |
| Cost per request | Стоимость одного вызова | > бюджета |

```python
# Пример: автоматическое сравнение метрик двух версий
import json
from datetime import datetime, timedelta, timezone

def compare_prompt_versions(
    prompt_name: str,
    version_a: int,
    version_b: int,
    metric: str = "accuracy",
) -> dict:
    """Сравнить метрики двух версий промпта из Langfuse."""
    now = datetime.now(timezone.utc)
    # Metrics API агрегирует скоры по версии промпта на стороне Langfuse
    result = langfuse.api.legacy.metrics_v1.metrics(query=json.dumps({
        "view": "scores-numeric",
        "metrics": [{"measure": "value", "aggregation": "avg"}],
        "dimensions": [{"field": "observationPromptVersion"}],
        "filters": [
            {"column": "name", "operator": "=", "value": metric, "type": "string"},
            {"column": "observationPromptName", "operator": "=",
             "value": prompt_name, "type": "string"},
        ],
        "fromTimestamp": (now - timedelta(days=30)).isoformat(),
        "toTimestamp": now.isoformat(),
    }))

    means = {str(row["observationPromptVersion"]): float(row["avg_value"])
             for row in result.data}
    mean_a = means[str(version_a)]
    mean_b = means[str(version_b)]

    return {
        "version_a": {"version": version_a, "mean": mean_a},
        "version_b": {"version": version_b, "mean": mean_b},
        "diff": mean_b - mean_a,
    }
```

`langfuse.api.legacy.metrics_v1.metrics` работает и в self-hosted инсталляциях. На Langfuse Cloud есть более быстрый v2-эндпоинт — `langfuse.api.metrics.metrics` — с тем же форматом запроса.

### Алерты на регрессию

```python
# Проверка метрик каждые 15 минут (cron job или Langfuse webhook)
def check_prompt_regression(prompt_name: str):
    current_version = langfuse.get_prompt(name=prompt_name, label="production").version
    recent_scores = get_recent_scores(prompt_name, current_version, hours=1)

    baseline = get_baseline_scores(prompt_name, current_version)

    if recent_scores["accuracy"] < baseline["accuracy"] * 0.9:  # деградация > 10%
        alert(
            channel="slack",
            message=f"Regression detected: {prompt_name} v{current_version}. "
                    f"Accuracy: {recent_scores['accuracy']:.2f} "
                    f"(baseline: {baseline['accuracy']:.2f})",
        )
        # Автоматический откат к предыдущей версии
        rollback_prompt(prompt_name, to_version=current_version - 1)
```

## Паттерны организации промптов

### Композиция вместо монолитов

Большой промпт на 3000 токенов сложно тестировать и поддерживать. Разбиение на компоненты:

```yaml
# prompts/components/output-format.yaml
name: output-format-json
content: |
  Ответ СТРОГО в JSON. Никакого текста до или после JSON.
  Если не можешь определить — верни {"error": "unable to classify"}.

# prompts/components/language-rules.yaml
name: language-rules
content: |
  Язык ответа: {{language}}.
  Не переводи имена собственные и технические термины.
```

```python
def compose_prompt(*component_names: str, **variables) -> str:
    """Собрать промпт из компонентов."""
    parts = []
    for name in component_names:
        component = registry.get(f"components/{name}")
        content = component["content"]
        for key, value in variables.items():
            content = content.replace(f"{{{{{key}}}}}", str(value))
        parts.append(content)
    return "\n\n".join(parts)

# Использование
system_prompt = compose_prompt(
    "ticket-classifier-core",
    "output-format-json",
    "language-rules",
    categories="billing,technical,general",
    language="ru",
)
```

### Naming convention

Консистентное именование критично при 50+ промптах:

```
{domain}-{task}-{variant}

ticket-classifier-v2
ticket-classifier-multilingual
order-summarizer-short
order-summarizer-detailed
response-generator-formal
response-generator-casual
quality-judge-relevance
quality-judge-toxicity
```

### Метаданные промпта

Каждый промпт должен содержать метаданные для аудита:

```yaml
name: ticket-classifier
metadata:
  owner: ml-team
  created: 2026-01-15
  last_tested: 2026-03-20
  model_compatibility:
    - gpt-5.4-mini
    - claude-sonnet-4-6
  avg_tokens: 450
  cost_per_call_usd: 0.002
  test_accuracy: 0.92
  dataset_size: 150
```

## Масштабирование: от 5 до 500 промптов

Как система меняется при росте количества промптов.

| Масштаб | Registry | Testing | Deploy | Monitor |
|---------|----------|---------|--------|---------|
| 5-10 промптов | YAML в Git | Ручной eval | Мгновенное переключение | Логи |
| 10-50 промптов | Langfuse + Git sync | CI eval pipeline | Canary | Дашборд по версиям |
| 50-200 промптов | Langfuse + RBAC | CI + LLM-as-Judge | Feature flags | Алерты + автооткат |
| 200+ промптов | Кастомный registry | Eval platform | Progressive rollout | ML-мониторинг |

Ключевые пороги:

**10 промптов** — нужен registry. Промпты в коде перестают быть управляемыми.

**30 промптов** — нужен CI eval. Ручное тестирование не масштабируется, регрессии проскальзывают.

**50 промптов** — нужен RBAC. Разные команды владеют разными промптами, нужен контроль доступа.

**100 промптов** — нужен автооткат. Человек не успевает реагировать на регрессии в реальном времени.

## Инструменты для управления промптами

| Инструмент | Тип | Сильные стороны |
|-----------|-----|----------------|
| **Langfuse** | Open-source | Prompt management + tracing + evals в одном. Self-hosted |
| **PromptLayer** | SaaS | Специализирован на prompt management. Хороший UI |
| **Humanloop** | SaaS | Prompt management + eval + annotation. Enterprise |
| **Pezzo** | Open-source | Prompt management. Легковесный |
| **Самописный** | Custom | Git + YAML + CI scripts. Максимальный контроль |

Langfuse покрывает большинство сценариев: registry с версионированием, привязка промптов к трейсам, eval через датасеты, MCP-сервер для управления из IDE. Подробный разбор в [руководстве по Langfuse](/ru/blog/llm-observability-langfuse/).

## Типичные ошибки

**Промпты в .env или config-файлах.** Без версионирования, без тестирования, без связи с метриками. Работает для прототипа, разваливается в production.

**Тестирование на трёх примерах.** Промпт проходит три теста и уходит в production. Через неделю выясняется, что он ломается на кириллице, длинных текстах или специфичных категориях.

**Отсутствие baseline.** Новая версия промпта "работает хорошо". Но без baseline нечего сравнивать. Вдруг предыдущая работала лучше?

**Оптимизация токенов ценой качества.** Промпт сократили с 800 до 300 токенов. Стоимость упала на 60%. Accuracy упала с 0.94 до 0.81. Экономия в $50/мес обошлась в десятки ошибочных ответов в день.

## Context engineering для промптов

Промпт не существует в вакууме. Качество зависит от того, что подаётся вместе с ним: [context engineering](/ru/blog/context-engineering-guide/) определяет, какие данные попадают в контекстное окно и в каком порядке.

Три правила для промптов в production:

1. **Переменные вместо хардкода.** Всё, что может измениться (категории, языки, форматы), выносится в переменные. Промпт остаётся стабильным.

2. **Примеры (few-shot) в конце промпта.** Модели лучше "видят" конец контекста. Размещение примеров после инструкций повышает точность.

3. **Минимальный контекст.** Каждый лишний токен в промпте размывает внимание модели. Если инструкция не влияет на качество — убрать.

## С чего начать

**Неделя 1.** Инвентаризация. Собрать все промпты из кода в одно место. YAML-файлы в Git или Langfuse. Единый формат: name, version, model, messages, variables.

**Неделя 2.** Датасеты. Для каждого промпта собрать 20-30 тестовых примеров из production-логов. Разметить expected output.

**Неделя 3.** Eval pipeline. Скрипт, который прогоняет промпт по датасету и выдаёт accuracy. Запуск в CI при изменении промптов.

**Неделя 4.** Мониторинг. Версия промпта в метаданных каждого трейса. Дашборд с метриками по версиям. Алерт при деградации > 10%.

Через месяц — работающая система, в которой каждое изменение промпта тестируется, версионируется и отслеживается. Без хаоса, без регрессий, без "кто менял этот промпт?".

## FAQ

### Какого размера должен быть тестовый датасет, чтобы результаты eval стали статистически достоверными?

На 20–30 примерах можно поймать очевидные регрессии — промпт, упавший с 92% до 70%. Чтобы обнаруживать изменения на 3–5 процентных пунктов (которые важны на масштабе), нужно 150–200 размеченных примеров. Практический ориентир: для промптов классификации 100 примеров даёт погрешность ±5% при доверительном интервале 95%; для задач генерации с оценкой через LLM-as-Judge достаточно 50 примеров — оценки судьи сильнее коррелируют между собой, и измеряется среднее, а не бинарный pass/fail.

### Может ли `cache_ttl_seconds` в Langfuse при загрузке промптов привести к неравномерному распределению трафика при canary-деплое?

Да. При TTL кэша 300 секунд и 20 инстансах приложения каждый кэширует свою версию независимо в случайной точке своего TTL-цикла. Во время canary-деплоя часть инстансов будет отдавать старую `production`-версию до 5 минут после смены метки. Для значимого разделения 5%/95% установите `cache_ttl_seconds=0` или короткий TTL (30–60 с) в окно развёртывания, а после стабилизации canary верните длинный TTL.

### Каким должен быть порог для авто-отката и как избежать ложных срабатываний в периоды низкого трафика?

Плоский процентный порог (например, откат при падении accuracy на 10%) даёт ложные срабатывания в ночные часы с низким трафиком, когда 5 запросов определяют исход. Рабочий паттерн — комбинировать минимальный размер выборки с процентным порогом: инициировать откат только при условии, что accuracy упала на 10% И в окне наблюдения минимум 50 запросов. Для промптов с менее 50 запросов в час — переходить на ежедневное сравнение со скользящим 7-дневным базовым уровнем вместо 1-часового окна.

---

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