Промпт изменили на пару строк. Убрали лишнее «пожалуйста», добавили требование отвечать короче, переставили абзац с форматом ответа. Через несколько дней клиент присылает письмо: ассистент ответил не так, как обычно, и ответ ему не нравится. Вы открываете историю и обнаруживаете, что нужной формулировки нет. В редакторе лежит текущий текст, в чате команды три варианта «последней версии», а в репозитории файл правили дважды за день. Какая именно редакция обслужила этот запрос, не знает никто.

Беда не в том, что правку сделали. Беда в том, что промпт хранился как документ, который переписывают на месте, а использовался как часть программы, которая определяет поведение системы. Ниже разберём, как устроить каталог промптов так, чтобы любое изменение можно было объяснить, проверить до включения и найти по одному проблемному ответу.

Две сущности под одним именем

Первое, что стоит сделать, это разделить два понятия, которые обычно смешивают. Первое понятие: версия промпта. Это неизменяемый снимок текста. Раз создана, она не редактируется: любая правка рождает новую версию. Второе понятие: метка окружения, например «рабочая» или «эксперимент». Это подвижный указатель, который говорит, какая версия сейчас обслуживает определённую среду.

Такая схема описана, в частности, в документации Langfuse по контролю версий промптов: версии получают идентификаторы, а метки могут указывать окружение или эксперимент и переноситься между версиями (Langfuse: Prompt Version Control). Нам важна сама идея, а не конкретный продукт: каталог можно вести и в этом инструменте, и в обычном репозитории с файлами, и в собственной таблице базы данных. Главное, чтобы текст не менялся, а менялся только указатель на него.

Почему это важно на практике? Если приложение запрашивает «промпт для ответа поддержки, версия 14», оно получит одно и то же сколько угодно раз. Если оно запрашивает «промпт для ответа поддержки, как сейчас», результат зависит от того, когда вы спросили. Оба режима нужны, но для разных задач. Метка удобна, когда вы хотите включить правку без повторного выпуска кода. Фиксированный номер удобен, когда нужна повторяемость: в тестах, в разборе жалоб, в сравнении кандидатов.

Хорошее правило: код приложения читает по метке, но в момент чтения запоминает, какой номер за ней стоял. Дальше мы вернёмся к этому, потому что именно запомненный номер потом спасает при разборе жалобы.

Разберём на цифрах, чем это отличается от «просто читать по метке». Допустим, в понедельник за рабочей меткой стоит версия 14, а во вторник в 11:00 метку переносят на 15. Жалоба приходит в среду и касается ответа, выданного во вторник в 10:40. Если приложение записало только метку «рабочая», то вы увидите слово, которое сегодня означает версию 15, и придёте к неверному выводу: будете разбирать текст, который в тот момент ещё не обслуживал никого. Если записан номер 14, вы открываете именно ту редакцию, и правка во вторник вообще ни при чём. Такая путаница случается чаще, чем кажется, потому что метка по замыслу меняется, а запомненный номер нет.

Из этого следует и требование к самому каталогу: у версии должно быть что-то, что нельзя перепутать при чтении журнала, то есть стабильный идентификатор. Документация Langfuse как раз описывает, что версии получают идентификаторы, а метки можно переносить между ними (Langfuse: Prompt Version Control). Если вы ведёте каталог в репозитории, роль идентификатора может играть номер в имени файла или отпечаток содержимого; главное, чтобы два разных текста никогда не получили один и тот же номер.

Промпт сам по себе ничего не определяет

Если версия хранит только текст, каталог решает половину задачи. Поведение ответа складывается из нескольких слагаемых, и текст инструкции лишь одно из них. Рядом с ним стоят:

  • модель и её настройки, например температура и лимит длины ответа;
  • переменные, которые подставляются в шаблон: имя клиента, фрагменты базы знаний, история диалога;
  • контрольный набор примеров, по которому проверяют, что версия делает то, что от неё ждут.

Если поменять модель, не трогая текст, ответы изменятся. Если изменить состав переменных, например начать подставлять не три фрагмента базы знаний, а пять, ответы тоже изменятся. Поэтому версия промпта в каталоге должна ссылаться на всё это, а не только на текст.

Здесь нужен небольшой паспорт именно промпта. Не паспорт всей инфраструктуры, а короткая карточка, которая лежит рядом с текстом и меняется вместе с ним. Вот пример для вымышленного ассистента, который отвечает на вопросы о сроках доставки:

prompt: support-delivery-answer
version: 14
label: production
model: указанная в конфигурации модель и её параметры
variables:
  - customer_name (строка, обязательная)
  - order_status (строка из справочника, обязательная)
  - kb_snippets (список фрагментов, до пяти)
checks: delivery-answer-suite, 40 примеров
enabled_at: дата и время включения метки
previous_version: 13
author: кто предложил правку
approver: кто согласовал выпуск

Число сорок здесь условное, оно нужно только для примера. Каждое поле отвечает на конкретный вопрос, который неизбежно возникнет. «Какие данные ждёт шаблон?» Переменные. «Чем проверяли?» Контрольный набор. «С какого момента это работало у пользователей?» Дата включения. «К чему возвращаться?» Предыдущая версия. Если какое-то поле вы не можете осмысленно заполнить, значит, в процессе есть дыра, и лучше обнаружить её в карточке, чем в разборе инцидента.

Отдельно про переменные. Шаблон с обязательной переменной, которая иногда приходит пустой, ведёт себя как два разных промпта. Описывайте, что происходит при отсутствии значения: подставляется заглушка, блок убирается или запрос отклоняется. Это тоже часть версии, потому что меняет итоговый текст, который видит модель.

Покажем, как пустая переменная раздваивает промпт. Пусть в шаблоне есть блок «Фрагменты базы знаний: {kb_snippets}». Когда поиск находит пять фрагментов, модель получает насыщенный контекст и отвечает опираясь на него. Когда поиск ничего не нашёл и подстановка оставила пустое место, модель видит заголовок блока без содержимого и может либо сообщить, что данных нет, либо ответить по общим представлениям. Это два разных поведения при одном номере версии. Если же при пустом списке блок целиком убирается, то модель вообще не знает, что поиск был, и ответ снова другой.

Поэтому в карточке полезно записать правило для каждой обязательной переменной в одну строку: что подставляется при пустом значении и как это отражено в журнале. Тогда при разборе жалобы вы сразу отличите ответ, данный с контекстом, от ответа, данного вслепую, и не станете винить формулировку инструкции в том, что на самом деле было сбоем поиска.

Есть и обратная сторона: чем больше вариантов обработки пропусков, тем больше комбинаций нужно покрыть контрольным набором. Разумно включить в него хотя бы по одному примеру с пустой и с неполной переменной, иначе проверка пройдёт только на «красивых» входах.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Путь правки: от черновика до смены метки

Пройдём одну правку целиком. Допустим, поддержка жалуется, что ассистент слишком многословен, когда клиент спрашивает всего лишь о дате доставки. Автор правки хочет добавить в инструкцию ограничение: сначала дать прямой ответ одной фразой, затем при необходимости пояснение.

Шаг первый: кандидат получает номер, но не метку

Автор создаёт версию 15. Рабочая метка по-прежнему стоит на 14. Новая версия существует, её можно вызвать по номеру, но никто из пользователей её не получает. Это важный момент: создание версии и включение версии разведены. Правка уже записана и не потеряется, но ещё ничего не меняет в боевой системе.

Шаг второй: проверка на контрольном наборе

Дальше кандидата прогоняют по контрольному набору и сравнивают с текущей версией. Для автоматических проверок подойдут инструменты вроде Promptfoo: в его документации результаты проверяются через assertions, в том числе группами условий, а среди проверок предусмотрены стоимость и задержка, при этом критерии задаются конфигурацией теста (Promptfoo: Assertions and Metrics). Для нашей задачи это значит, что «ответ стал короче» можно превратить из ощущения в условие, а заодно отследить побочные эффекты.

Какие условия имеет смысл записать для правки про краткость? Например:

  1. Ответ на вопрос о дате содержит саму дату в первом предложении.
  2. Ответ не обещает сроков, которых нет в переданных данных заказа.
  3. Для вопросов о возврате денег структура ответа не пострадала: правка не должна была их затрагивать.
  4. Задержка и стоимость не выросли выше порога, который вы заранее приняли.

Третий пункт особенно важен. Правки промптов редко бывают локальными: инструкция «отвечай короче» может сократить и тот ответ, где полнота была обязательной. Контрольный набор должен содержать примеры именно из соседних областей, а не только из той, которую вы правите. Иначе проверка подтвердит ровно то, что вы и так ожидали.

Автоматические условия не отменяют чтения глазами. Условие «в первом предложении есть дата» не поймает, что фраза стала грубой. Разумно просмотреть выборку ответов вручную и записать вывод в карточку версии. Заметьте также, что модель может отвечать по-разному на один и тот же вход, поэтому один прогон не доказательство. Проверка показывает склонность версии, а не гарантию для каждого ответа.

Чтобы условия не превратились в формальность, полезно заранее решить, что считать провалом версии, а что допустимым шумом. Пример для нашего контрольного набора из сорока примеров (число условное): правило «дата в первом предложении» должно выполняться на всех примерах о доставке, потому что это прямая цель правки, а для соседних тем, например возврата, допускается ноль ухудшений относительно версии 14. Такие пороги разные по смыслу: первый проверяет, что правка работает, второй, что она ничего не сломала.

Promptfoo позволяет собирать проверки в группы условий, а стоимость и задержку проверять наравне с содержимым ответа; сами критерии описываются в конфигурации теста (Promptfoo: Assertions and Metrics). Практическая польза в том, что конфигурация теста лежит в репозитории рядом с карточкой, а значит, меняется вместе с версией и сама попадает в историю. Если порог пересмотрели, это видно в разнице, и никто потом не удивляется, почему кандидат, который раньше не прошёл бы, сегодня проходит.

Отдельно записывайте, какие примеры добавили в набор после реальных жалоб. Каждая разобранная жалоба, превращённая в пример, делает следующую правку чуть безопаснее: набор растёт из настоящих ошибок, а не из того, что автору пришло в голову в момент написания.

Шаг третий: согласование

Согласующий смотрит не на красоту текста, а на три вещи: что изменилось относительно версии 14, какие проверки прошли и что будет, если включение окажется ошибкой. Для этого карточка кандидата должна содержать разницу между версиями, результат прогона и указание на версию, к которой вернёмся. Если согласующему приходится выяснять это в переписке, процесс уже работает против вас.

Кто согласует, зависит от команды. В небольшой команде это может быть второй человек, который не писал правку. В большой может понадобиться владелец продукта для правок, меняющих тон общения с клиентом, и юрист для правок, затрагивающих обещания и условия. Не нужно придумывать единую иерархию: достаточно, чтобы для каждого промпта было записано, кто имеет право сказать «включаем».

Шаг четвёртый: перенос метки

Только теперь метка «рабочая» переезжает с версии 14 на версию 15. В карточку записывается время включения. Версия 14 никуда не исчезает: она осталась в каталоге такой же, какой была, и ею можно пользоваться как точкой возврата. Сам перенос метки операция маленькая и обратимая, и в этом смысл всей схемы: рискованное решение превращено в движение указателя.

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

Почему откат текста не возвращает прежнего поведения

Теперь неприятная часть. Допустим, версия 15 оказалась неудачной, и вы переносите метку обратно на версию 14. Вы вернули тот же текст. Вернули ли вы то же поведение? Не обязательно.

Причина в том, что текст промпта лишь один из входов. Предположим, между включением версии 14 и откатом провайдер обновил модель под тем же именем, либо вы сами переключили приложение на другую модель, либо изменился состав фрагментов базы знаний, которые подставляются в шаблон. Тогда старый текст работает в новом окружении, а такой комбинации никто никогда не проверял.

Это прямое следствие того, что версионирование промптов не заменяет версионирование модели и правил. Если у вас есть версия 14 и отметка «работала хорошо», на самом деле эта отметка относилась к тройке: текст 14, конкретная модель, конкретный набор переменных. Поэтому в паспорте версии мы записываем модель и параметры, а при откате не ограничиваемся переносом метки. Правильная последовательность такая:

  1. Перенести метку на предыдущую версию, чтобы остановить ущерб.
  2. Прогнать эту версию по контрольному набору в текущем окружении, а не доверять старому результату.
  3. Сравнить с результатом, записанным при её первом включении, и отметить расхождения в карточке.

Если расхождения есть, откат не завершён: вы вернулись к знакомому тексту, но не к знакомому поведению. Тогда придётся создавать новую версию, которая учитывает изменившееся окружение, и проходить путь согласования заново. Это не поражение процесса, а честное признание того, что «вернуть как было» после смены модели невозможно в принципе.

Отсюда практический совет: изменения модели и изменения промпта лучше не включать одновременно. Если сменили сразу и то и другое, а качество упало, вы не узнаете, что виновато. Разведите их по времени, каждое со своим прогоном контрольного набора. Дольше, зато причина всегда видна.

Параллельный эксперимент без хаоса

Иногда нужно не заменить версию, а сравнить две. Один автор считает, что ответы должны быть формальнее, другой хочет разговорный тон. Спорить можно долго, а можно запустить обе версии и посмотреть.

Здесь пригождается то, что метки могут обозначать не только окружение, но и эксперимент. Вы создаёте версии 16 и 17, помечаете их, скажем, «эксперимент-А» и «эксперимент-Б», а рабочая метка по-прежнему стоит на проверенной версии. Приложение направляет небольшую долю запросов на экспериментальные версии, остальные идут как раньше.

Чтобы эксперимент не превратился в кашу, нужны несколько договорённостей:

  • У эксперимента есть вопрос и срок. «Какая из двух формулировок чаще даёт ответ без уточняющих вопросов от клиента» годится, «посмотрим, что получится» нет.
  • Различие между вариантами одно. Если в версии 17 изменены и тон, и формат, и порядок инструкций, вывод не к чему привязать.
  • Критерий оценки записан до запуска. Иначе после получения результатов найдётся метрика, по которой победит любимый вариант.
  • По окончании эксперимента метка снимается. Победитель идёт обычным путём согласования и получает рабочую метку, проигравший остаётся в каталоге как история.

Проигравшие версии удалять не стоит. Через полгода кто-нибудь предложит ту же идею, и запись о том, что она уже проверялась и чем закончилась, сэкономит неделю.

Запись версии в техническом журнале

Каталог отвечает на вопрос, что существует. Журнал запросов отвечает на вопрос, что реально использовалось. Без второго первое бесполезно при разборе жалоб. Поэтому при каждом обращении к модели приложение записывает в технический журнал минимум такого набора:

request_id: идентификатор запроса
prompt: support-delivery-answer
prompt_version: 15
label_at_call: production
model: имя и параметры вызова
variables_hash: отпечаток подставленных переменных
timestamp: время вызова

Обратите внимание на поле «версия при вызове» рядом с меткой. Метка может переехать через минуту после вызова, а номер версии, записанный в момент обращения, останется истиной. Именно он позволяет через месяц ответить на вопрос «что тогда было».

Про переменные стоит сказать отдельно. Полный текст подставленных данных в журнале может содержать персональную информацию клиентов, и хранить его без необходимости рискованно. Часто достаточно отпечатка либо ссылки на запись, которую можно восстановить по правилам доступа вашей компании. Что именно хранить и как долго, решайте исходя из ваших обязательств и политики, но сам факт, что версия записана, должен быть неотъемлемой частью каждой строки журнала.

Где всё это физически живёт? Каталог в простом варианте это репозиторий с файлами и карточками, а журнал это обычная база или файл логов вашего приложения. Если вы развёртываете такие компоненты на собственном сервере, подойдёт любой VPS с достаточным запасом диска под журнал: нагрузка здесь невелика, а нужен прежде всего root-доступ для установки того, что вы выберете. У MAATRIX в публичных тарифах от Ba с 1 vCPU, 2 ГБ RAM и 40 ГБ NVMe до Djed с 16 vCPU и 32 ГБ; ресурсы это не обещание ёмкости, поэтому объём журнала и нагрузку на каталог лучше прикинуть по своему трафику. Подробности и актуальные образы видны в форме заказа VPS. Для самой дисциплины версий сервер вторичен: она работает и на ноутбуке, и на облачном сервисе.

Что сделать с жалобой на один ответ

Вернёмся к клиенту из начала. Теперь у вас есть всё нужное. По идентификатору запроса вы находите строку журнала и видите версию 15, модель и время. Открываете версию 15 в каталоге и читаете точный текст, который получила модель. Рядом лежит карточка: что менялось по сравнению с 14, какие проверки прошли, кто согласовал. Вы запускаете эту версию с теми же переменными по контрольному набору и по самому проблемному примеру и смотрите, склонна ли она к такому ответу.

Возможно, ответ не воспроизведётся дословно: модели не обещают одинакового вывода на один вход, и версия промпта этого не исправит. Но вы увидите, воспроизводится ли тенденция, и сможете принять решение: оставить, поправить или откатить. Всё это без единого поиска в переписке команды. Один проблемный ответ ведёт к одной строке журнала, к одной версии и к одной карточке, и вопрос «какая формулировка это сделала» перестаёт быть загадкой.