Prompt Engineering System: управление 50+ промптами в production
Что такое система управления промптами?
Система управления промптами — это инфраструктура для хранения, версионирования, тестирования и деплоя LLM-промптов независимо от кода приложения. Она позволяет итерировать промпты без деплоя, запускать A/B-тесты между версиями и отслеживать регрессии качества в production.
TL;DR
- -При 50+ промптах хранение в коде убивает скорость итераций — каждое изменение требует деплоя приложения
- -Система управления промптами: Registry, Testing, Deploy, Monitor — минимальный старт это Registry + Deploy
- -Langfuse даёт версионирование, метки (production/staging) и A/B-роутинг без деплоя приложения
- -Eval-пайплайн: датасет + функция оценки (exact match или LLM-as-Judge) + CI-гейт блокирует регрессии
- -Без метрик на версию невозможно связать падение качества с конкретным изменением промпта
Средний LLM-проект в production использует 20-50 промптов. Классификация, суммаризация, извлечение данных, генерация ответов, оценка качества. Каждый промпт требует итераций, каждая итерация может сломать то, что работало. При 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 предоставляет prompt management из коробки. Каждый промпт — именованная сущность с версиями, лейблами и переменными.
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-4o-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
# prompts/ticket-classifier/prompt.yaml
name: ticket-classifier
type: chat
model: gpt-4o-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"
# 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.
# 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.
{"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
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. Модель-судья оценивает качество ответа по заданным критериям: релевантность, полнота, тональность.
CI/CD интеграция
# .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 на новую версию промпта.
# В 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%.
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, самописная):
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-вызов должен содержать версию промпта в метаданных:
trace = langfuse.trace(
name="ticket-classification",
metadata={
"prompt_name": "ticket-classifier",
"prompt_version": prompt.version, # 14
"prompt_label": "production",
"model": "gpt-4o-mini",
},
)
generation = trace.generation(
name="classify",
model="gpt-4o-mini",
prompt=prompt, # Langfuse автоматически привяжет версию
input=messages,
output=response,
)
Дашборд по версиям
Ключевые метрики для мониторинга:
| Метрика | Что показывает | Алерт при |
|---|---|---|
| Accuracy | Доля правильных ответов | < порога для промпта |
| Latency p95 | Время ответа | > 2x от baseline |
| Token usage | Расход токенов | > 1.5x от предыдущей версии |
| Error rate | Доля невалидных ответов | > 5% |
| Cost per request | Стоимость одного вызова | > бюджета |
# Пример: автоматическое сравнение метрик двух версий
def compare_prompt_versions(
prompt_name: str,
version_a: int,
version_b: int,
metric: str = "accuracy",
) -> dict:
"""Сравнить метрики двух версий промпта из Langfuse."""
traces_a = langfuse.fetch_traces(
name=f"{prompt_name}-eval",
metadata={"prompt_version": version_a},
limit=1000,
)
traces_b = langfuse.fetch_traces(
name=f"{prompt_name}-eval",
metadata={"prompt_version": version_b},
limit=1000,
)
scores_a = [t.scores[metric] for t in traces_a if metric in t.scores]
scores_b = [t.scores[metric] for t in traces_b if metric in t.scores]
return {
"version_a": {"version": version_a, "mean": sum(scores_a) / len(scores_a)},
"version_b": {"version": version_b, "mean": sum(scores_b) / len(scores_b)},
"diff": (sum(scores_b) / len(scores_b)) - (sum(scores_a) / len(scores_a)),
}
Алерты на регрессию
# Проверка метрик каждые 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 токенов сложно тестировать и поддерживать. Разбиение на компоненты:
# 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}}.
Не переводи имена собственные и технические термины.
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
Метаданные промпта
Каждый промпт должен содержать метаданные для аудита:
name: ticket-classifier
metadata:
owner: ml-team
created: 2026-01-15
last_tested: 2026-03-20
model_compatibility:
- gpt-4o-mini
- claude-3-5-sonnet-20241022
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.
Типичные ошибки
Промпты в .env или config-файлах. Без версионирования, без тестирования, без связи с метриками. Работает для прототипа, разваливается в production.
Тестирование на трёх примерах. Промпт проходит три теста и уходит в production. Через неделю выясняется, что он ломается на кириллице, длинных текстах или специфичных категориях.
Отсутствие baseline. Новая версия промпта “работает хорошо”. Но без baseline нечего сравнивать. Вдруг предыдущая работала лучше?
Оптимизация токенов ценой качества. Промпт сократили с 800 до 300 токенов. Стоимость упала на 60%. Accuracy упала с 0.94 до 0.81. Экономия в $50/мес обошлась в десятки ошибочных ответов в день.
Context engineering для промптов
Промпт не существует в вакууме. Качество зависит от того, что подаётся вместе с ним: context engineering определяет, какие данные попадают в контекстное окно и в каком порядке.
Три правила для промптов в production:
-
Переменные вместо хардкода. Всё, что может измениться (категории, языки, форматы), выносится в переменные. Промпт остаётся стабильным.
-
Примеры (few-shot) в конце промпта. Модели лучше “видят” конец контекста. Размещение примеров после инструкций повышает точность.
-
Минимальный контекст. Каждый лишний токен в промпте размывает внимание модели. Если инструкция не влияет на качество — убрать.
С чего начать
Неделя 1. Инвентаризация. Собрать все промпты из кода в одно место. YAML-файлы в Git или Langfuse. Единый формат: name, version, model, messages, variables.
Неделя 2. Датасеты. Для каждого промпта собрать 20-30 тестовых примеров из production-логов. Разметить expected output.
Неделя 3. Eval pipeline. Скрипт, который прогоняет промпт по датасету и выдаёт accuracy. Запуск в CI при изменении промптов.
Неделя 4. Мониторинг. Версия промпта в метаданных каждого трейса. Дашборд с метриками по версиям. Алерт при деградации > 10%.
Через месяц — работающая система, в которой каждое изменение промпта тестируется, версионируется и отслеживается. Без хаоса, без регрессий, без “кто менял этот промпт?”.
Часто задаваемые вопросы
Почему нельзя хранить промпты в коде?
На масштабе промпты в коде создают три проблемы: каждое изменение требует деплоя, нет версионирования и отката, неинженеры не могут итерировать. Система управления промптами (например, Langfuse) отвязывает изменения промптов от деплоя кода.
Как версионировать промпты в продакшене?
Каждый промпт получает имя и номер версии. Приложение запрашивает активную версию из реестра (например, Langfuse) в рантайме. Это позволяет A/B-тесты, мгновенный откат и отслеживание изменений без деплоя.
Как тестировать промпты перед выкаткой?
Eval-пайплайн: датасет тестовых входов с ожидаемыми выходами, функция оценки (exact match, LLM-as-Judge или кастомные метрики), CI-шаг при каждом изменении промпта. Блокировка деплоя при падении точности ниже порога.