- BrainTools - https://www.braintools.ru -

Промпт — не контракт: замерил, как часто модель нарушает жёсткие правила, и вынес их в код

У меня в проде работает ИИ-консультант на сайте. У него есть два правила, которые нарушать нельзя вообще: не называть цены и не называть клиентов, которые под NDA. Оба правила были написаны в системном промпте капслоком, с объяснением почему.

Я замерил, как они соблюдаются. Получилось так:

  • правило «не называй цен» модель срывает примерно раз на восемь прогонов;

  • имя клиента под NDA проскакивало раз на пять.

Это не «модель плохая» — модель нормальная. Это про то, что инструкция в промпте не является механизмом принуждения, и относиться к ней как к контракту нельзя. Ниже — методика замера, три правила, которые я вынес из промпта в код, два бага в самих страховках (один из них выстрелит у каждого, кто пишет на русском) и честный раздел про то, чего код не ловит.


Почему про это вообще стоит говорить в 2026

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

Это правда ровно наполовину. Изменилась форма, не природа. Скилл — это файл с инструкциями, который кладут в контекст. Описание инструмента — это текст, по которому модель решает, вызывать его или нет. Даже structured output гарантирует форму ответа, а не его содержание: схема заставит вернуть строку в поле client_name, но не помешает положить туда имя, которое разглашать нельзя.

Всё это — по-прежнему инструкции, которые исполняются вероятностно. Правило, записанное словами, соблюдается с какой-то частотой, и эту частоту нужно знать. А если цена нарушения высока — правило должно быть вынесено туда, где исполнение детерминировано. То есть в код.

Разница между промптом и кодом простая. Промпт — это просьба. Код — это гарантия.

Методика замера

Прогон — обычный скрипт, гоняет набор диалогов через боевой эндпоинт и проверяет ответы правилами. Ничего необычного, кроме одной детали, до которой я дошёл не сразу.

Набор — 35 случаев. Каждый случай это диалог, ожидание (must) и запрет (mustNot). Собраны они не из головы, а из реальных провалов, которые я видел в логах прода: отказ делать сайты (модель решила, что мы только про ИИ), выдуманная фамилия «Андреев» — модель достроила её по созвучию с отчеством, ложное «передал заявку» до того, как посетитель нажал кнопку.

Ключевая деталь — повторы. Из 35 случаев 21 гоняется по несколько раз: 18 по три прогона, 3 по пять.

// Критичные правила модель соблюдает не всегда: гоняем их по нескольку раз.// Прод уже показал срыв там, где одиночный прогон был зелёный.const runs = await Promise.all(  chunk.map((c) =>    Promise.all(      Array.from({ length: c.repeat || 1 }, () =>        ask(c.dialog, c.page, c.leadOffered).catch((e) => `[СБОЙ: ${e.message}]`)      )    )  ));

// Прод уже показал срыв там, где одиночный прогон был зелёный.

const runs = await Promise.all(

chunk.map [1]((c) =>

Promise.all(

Array.from({ length: c.repeat || 1 }, () =>

ask(c.dialog, c.page [2], c.leadOffered).catch((e) => [СБОЙ: ${e.message}])

)

)

)

);

Комментарий в коде — это шрам. Прогон был зелёный, а прод сорвался. Потому что одиночный прогон вероятностной системы не измеряет ничего: он показывает один исход из распределения. Если правило соблюдается в 87% случаев, один зелёный прогон — это просто удача с вероятностью 0,87.

Отсюда и цифры в начале. «Раз на восемь» и «раз на пять» — это не единичные наблюдения, а частота срывов на повторных прогонах критичных случаев.

Практический вывод, который переносится на любой проект с LLM в проде: тест вероятностной системы, выполненный один раз, не тест. Либо повторы, либо вы не знаете, что у вас происходит.

Правило первое: не называть цены

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

Наивная страховка — запретить в ответе символ рубля. Она ломает продукт. На сайте есть настоящие результаты проектов: «заявка за 334 ₽». Это доказательство работы маркетинга, о нём говорить нужно.

Значит, различать надо не наличие суммы, а то, что именно оценивается:

const PRICE_ASK = /(сто[ия]|цен[аеуы]|обойд|прайс|бюджет на внедрен)[^.!?]{0,80}d[ds]{2,}s*(₽|руб|тысяч|тыс.)/i;const PRICE_SAY = /d[ds]{2,}s*(₽|руб(?!ежом)|тысяч рублей)[^.!?]{0,60}(за внедрен|за агент|за работ|стоит|стоимость)/i;
// Результаты кейсов — не наши цены: «заявка за 334 ₽» говорить можно и нужно.const CASE_METRIC = /(заявк|лид[ауеы]?b|лида|клик|обращени|CPL|CPA|за подписчик|у клиент|на проекте|бюджет рекламы)/i;
export function noPrices(reply) {  if (CASE_METRIC.test(reply)) return reply;  return PRICE_ASK.test(reply) || PRICE_SAY.test(reply) ? PRICE_FALLBACK : reply;}

const PRICE_SAY = /d[ds]{2,}s*(₽|руб(?!ежом)|тысяч рублей)[^.!?]{0,60}(за внедрен|за агент|за работ|стоит|стоимость)/i;

// Результаты кейсов — не наши цены: «заявка за 334 ₽» говорить можно и нужно.

const CASE_METRIC = /(заявк|лид[ауеы]?b|лида|клик|обращени|CPL|CPA|за подписчик|у клиент|на проекте|бюджет рекламы)/i;

export function noPrices(reply) {

if (CASE_METRIC.test(reply)) return reply;

return PRICE_ASK.test(reply) || PRICE_SAY.test(reply) ? PRICE_FALLBACK : reply;

}

Два шаблона вместо одного, потому что цена появляется в двух синтаксических позициях: в вопросе («сколько стоит… 200 000 ₽») и в утверждении («200 000 ₽ за внедрение»). Ограничители [^.!?]{0,80} держат совпадение внутри одного предложения — иначе слово «стоимость» в начале абзаца цепляется к числу из конца.

Отдельно обратите внимание [3] на руб(?!ежом). Без этого «за рубежом» становится ценой.

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

Правило второе: не называть клиентов под NDA. И баг, который стоит отдельной главы

Часть проектов под соглашением о неразглашении. Имена этих компаний не должны появляться в ответе никогда.

Правило жило в промпте. Модель нарушала его раз на пять прогонов — обычно безобидно, в духе «мы делали похожее для…», но для NDA безобидных нарушений не бывает.

Выносим в код. Казалось бы, тривиально: список имён, границы слова, замена.

// Так было. Не работает.const NDA_NAMES = /b(acme|акме|globex|глобэкс)b/gi;

const NDA_NAMES = /b(acme|акме|globex|глобэкс)b/gi;

(здесь и дальше имена условные — настоящий стоп-лист я по понятным причинам не публикую)

Латинские имена ловились. Кириллические проходили насквозь.

Причина в том, что b в JavaScript определён через w, а w — это [A-Za-z0-9_]. Кириллица в него не входит. Поэтому перед буквой «А» в слове «Акме» граница слова не возникает вообще, и b не срабатывает.

Это не особенность JS — то же самое в Python re без флага re.UNICODE в Python 2, в PCRE без /u, в Go, в Java до определённых настроек. Проверка на кириллице ломается молча: регулярка не падает, не предупреждает, просто ничего не находит.

Рабочий вариант — границы через свойства Юникода:

export const NDA_NAMES =  /(?<![p{L}p{N}])(acme|акме|globex|глобэкс)[p{L}]*/giu;
export function noClients(reply) {  return String(reply).replace(NDA_NAMES, 'клиент под NDA');}

/(?<![p{L}p{N}])(acme|акме|globex|глобэкс)[p{L}]*/giu;

export function noClients(reply) {

return String(reply).replace(NDA_NAMES, 'клиент под NDA');

}

Разбор:

  • (?<![p{L}p{N}]) — ретроспективная проверка: перед совпадением не буква и не цифра. Это и есть граница слева, работающая для любого алфавита.

  • [p{L}]* — хвост из букв. Нужен для русских склонений: «Акме» → «Акмей», «Акмеем». Без хвоста замена оставляла бы окончание сиротой.

  • флаг u обязателен, иначе p{...} не работает.

  • Границы справа нет намеренно: хвост её съедает.

Если вы фильтруете русский текст регулярками — проверьте свои b прямо сейчас. У меня та же ошибка [4] вылезла второй раз в другом месте: счётчик стилистических метрик по тексту сайта молча возвращал нули, потому что считал кириллицу небуквенной. Один и тот же баг, два разных файла, и оба раза он не падал, а тихо врал.

Правило третье: формат. Где баг съел переводы строк

Третье правило — не markdown. Модель по привычке отвечает разметкой, а в текстовом пузыре чата жирный и [ссылка](url) выглядят как сбой. Инструкция «отвечай простым текстом» соблюдается так же, как остальные, — то есть не всегда.

Чистка markdown — отдельная функция. Интересен в ней не сам список замен, а предупреждение в шапке:

export function plain(s) {  // ВАЖНО: в правилах «начало/конец строки» используем [ t], а не s.  // s включает перевод строки, поэтому жадный s*$ съедает разрыв и склеивает  // соседние строки в одну — так слипались строки таблицы.

// ВАЖНО: в правилах «начало/конец строки» используем [ t], а не s.

// s включает перевод строки, поэтому жадный s*$ съедает разрыв и склеивает

// соседние строки в одну — так слипались строки таблицы.

s в регулярках включает n. Поэтому ^[ t]*|(.+)|[ t]*$ с s вместо [ t] схлопывает многострочную таблицу в одну строку. Баг проявлялся только на ответах с таблицами, то есть примерно никогда на тестах и регулярно в проде.

Там же решение, которое я считаю правильным по смыслу: markdown-ссылка не удаляется, а разворачивается. Если текст ссылки совпадает с адресом — остаётся голый адрес, если нет — «текст — адрес». Посетителю в чате нужен адрес, по которому можно перейти, а не синтаксис.

Чего код не ловит

Раздел, ради которого стоит читать всё остальное.

Страховка регуляркой ловит форму. Всё, что про смысл, она не ловит в принципе.

Вот правила из того же промпта, которые я в код вынести не смог:

  • «Не выдумывай». Модель достроила фамилию «Андреев» по созвучию с отчеством «Андреевич». Формально в ответе нет ни цены, ни имени под NDA, ни markdown. Регулярка чиста. Ответ — ложь.

  • «Не обещай от имени человека». «Я передал вашу заявку» до того, как посетитель нажал кнопку. Проверяется только сверкой с состоянием приложения, а не текстом.

  • «Держи тон». Формализуется плохо, а на конверсию влияет.

  • «Не отказывайся от нашей же работы». Модель решила, что мы занимаемся только ИИ, и отказалась от сайтов — половины портфолио.

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

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

Что в итоге

Промпт остался. Он задаёт роль, контекст, тон и то, что система знает о мире. Он это делает хорошо, и никакие скиллы его не отменили.

Правила из промпта уехали в код. Не все — только те, у которых цена нарушения выше стоимости страховки.

Порядок, к которому я пришёл:

  1. Выпишите правила и проставьте цену нарушения. «Назвал цену» — потерянная сделка. «Назвал клиента под NDA» — суд. «Ответил markdown» — некрасиво. Три разных уровня, три разных отношения.

  2. Замерьте частоту соблюдения повторами. Одиночный прогон ничего не измеряет. Пока у вас нет частоты, вы не знаете, какие правила уже нарушаются в проде.

  3. Дорогие правила — в код, между моделью и пользователем. Функция на выходе, не инструкция на входе.

  4. Страховка обязана знать исключения. Запрет символа рубля убил бы настоящие результаты проектов. Правило без исключений обычно ломает продукт, а не защищает его.

  5. Проверьте, что страховка вообще срабатывает. Подсадите нарушение и убедитесь, что оно поймано. У меня половина багов в этой истории — баги не модели, а самих страховок, и все они молчали.

  6. То, что не формализуется, держите в прогоне с повторами и не делайте вид, что это гарантия.

Отдельно, для русскоязычных проектов: если вы фильтруете текст регулярками — проверьте b. Он не работает с кириллицей и не сообщает об этом.

Автор: OstapAndreevich

Источник [5]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/35821

URLs in this post:

[1] chunk.map: http://chunk.map

[2] c.page: http://c.page

[3] внимание: http://www.braintools.ru/article/7595

[4] ошибка: http://www.braintools.ru/article/4192

[5] Источник: https://habr.com/ru/articles/1085028/?utm_campaign=1085028&utm_source=habrahabr&utm_medium=rss

www.BrainTools.ru

Rambler's Top100