The Jerusalem PostJerusalem Orchestra East & West youth division to perform at UNוואלה164 קמ"ש במקום 60: נהגת חדשה בת 18 נתפסה במהירות קיצונית בלב חיפהRTP DesportoPresidente do V. Guimarães agastado com início de época "frustrante"Daily MaverickStudent opens fire outside school in Turkey, eight pupils wounded, NTV reportsThe South AfricanBombshell: Springboks may play Malcolm Marx in new positionStraits Times SportLa Liga president hits out at Real Madrid refereeing ‘conspiracy’ claimsХабрИИ режет не рутину, а расходы. Поэтому подушка теперь базовый минимумOnetPolski "Niedźwiedź" pokazał się światu. Żołnierze powinni być zachwyceniIl Fatto QuotidianoMorto Grigory Ponomarev: l’addio improvviso a 31 anni di “Grizzly”, ex lottatore MMA. L’ipotesi di un legame con l’infortunio del 2023CNN بالعربيةأستراليا تؤكد تحويل أجزاء من مقاتلات F-35 الأمريكية إلى هونغ كونغ بالخطأSportstarLIVE India vs Sri Lanka, Asian Games 2026 hockey: IND 10 - 0 SL, Abhishek, Jugraj score two eachVilaWebLa UE tanca la discussió sobre l’oficialitat del català en set minuts i sense adoptar cap decisió
The Daily Newsstand · Free, Always
Tuesday, September 22, 2026

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

Translate

У меня в проде работает ИИ-консультант на сайте. У него есть два правила, которые нарушать нельзя вообще: не называть цены и не называть клиентов, которые под 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((c) =>

Promise.all(

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

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

)

)

)

);

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

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

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

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

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

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

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

const PRICE_ASK = /(сто[ия]|цен[аеуы]|обойд|прайс|бюджет на внедрен)[^.!?]{0,80}\d[\d\s]{2,}\s*(₽|руб|тысяч|тыс\.)/i;const PRICE_SAY = /\d[\d\s]{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[\d\s]{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} держат совпадение внутри одного предложения — иначе слово «стоимость» в начале абзаца цепляется к числу из конца.

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

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

Правило второе: не называть клиентов под 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 прямо сейчас. У меня та же ошибка вылезла второй раз в другом месте: счётчик стилистических метрик по тексту сайта молча возвращал нули, потому что считал кириллицу небуквенной. Один и тот же баг, два разных файла, и оба раза он не падал, а тихо врал.

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

Третье правило — не 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. Он не работает с кириллицей и не сообщает об этом.

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.