Это история про открытый креативный промпт к LLM, оставленный без присмотра. Через месяц он превратил пятничную рубрику нашего блога в гимнастику парафразов одного и того же тезиса. Поймал я это не на первой пятнице и даже не на второй: каждая отдельная статья сама по себе выглядела нормально. На четвёртой стало очевидно.
Расскажу, какие четыре вещи я попробовал, прежде чем признать, что промпт-инжинирингом эту штуку не лечат. По дороге всплыло, что часть проблемы сидела не в промпте, а в нашей собственной конфигурации Gemini, на которую я не сразу обратил внимание (точнее — вообще не видел). И как в итоге мы перевели генератор тем с открытого «придумай вопрос» на заранее детерминированную ротацию из курируемого пула. Без файнтюна, без RAG, без переезда на другую модель.
Контекст
У нас в NeuroVerdict (это сервис, в котором запрос параллельно идёт в пять моделей с веб-поиском, потом склеивается ответом-сводкой) есть автоматический блог. Один пост в день, разные жанры по дням недели:
|
День |
Тип |
Описание |
|---|---|---|
|
Пн |
news |
сводка новостей ИИ от пяти моделей |
|
Вт |
comparison |
один технический вопрос, отвечают пятеро |
|
Ср |
myth |
разбор одного мифа об ИИ, одна модель |
|
Чт |
howto |
практический совет, одна модель |
|
Пт |
versus |
один спорный вопрос, отвечают пятеро |
|
Сб |
tool-review |
разбор одного инструмента, отвечают пятеро |
|
Вс |
weekly |
дайджест за неделю |
Архитектура простая. Cron каждые 6 часов проверяет, есть ли пост за сегодня. Если нет, по дню недели определяется тип. Дальше двумя путями. Для одиночных типов (myth, howto) одна модель пишет статью целиком. Для пятимодельных (news, comparison, versus, weekly, tool-review) каждая из пятёрки пишет свою колонку, всё склеивается в один документ с подзаголовками-моделями.
Особняком стоят comparison, versus и tool-review. Они двухшаговые: сначала генерируется заголовок-вопрос, потом под ним отвечают пять моделей. Замысел был простой. Если все пять моделей отвечают на один и тот же вопрос, важно, чтобы вопрос был свежий и нетривиальный. Поэтому вопрос на каждый день генерировался отдельным вызовом отдельной моделью, а уже под него подключались остальные.
Двухшаговость и стала точкой, в которой что-то пошло не так.
Симптом, который я долго не замечал
Сложность ловли вот в чём. Если открыть пост от 2026-03-27 — придраться не к чему. Пять моделей действительно отвечают разнопланово, источники проставлены, формат соблюдён. Откроешь 2026-04-03 — то же. И 04-10. Каждая статья отдельно нормальная. Только если выложить в ряд несколько недель, видно, что заголовки рифмуются.
Вот реальные четыре пятницы подряд из канала NeuroVerdict, рубрика «ИИ-баттл»:
|
Дата |
Заголовок «ИИ-баттл: …» |
|---|---|
|
27.03.2026 |
Стоит ли создавать сверхразумный ИИ |
|
03.04.2026 |
Должны ли мы создавать сверхинтеллектуальный ИИ |
|
10.04.2026 |
Сверхинтеллект, нужен ли он человечеству |
|
17.04.2026 |
Безопасно ли стремиться к сверхразумному ИИ |
Один и тот же вопрос, перефразированный, четыре раза. Похожая, но более вялая динамика была на comparison-вторниках, там тянуло в «как ИИ изменит образование», цикл получался длиннее, недель шесть.
Заметил случайно (почти). Я листал собственный канал и поймал ощущение «всё уже было». Полез смотреть. Точно, было. Через символьный поиск (ctrl+F) моментально выявил дубли.
Промпт, с которого всё началось
Вот тот самый кусок из backend/services/blog.js, на котором всё держалось четыре недели:
const fallbackPrompt = { system: BLOG_SYSTEM, user: 'Придумай один интересный вопрос на стыке ИИ, науки и общества. ' + 'Ответь ТОЛЬКО текстом вопроса, одним предложением.',};const result = await searchGemini(fallbackPrompt);const sharedQuestion = (result?.content || '').trim();
На вид всё чисто. system-prompt задаёт редакционную политику. user-prompt просит ровно одну вещь и формат ответа. Никаких лишних инструкций. Никаких примеров. Никаких ограничений. Именно «никаких ограничений» и оказалось ключевым.
Когда LLM получает открытый запрос на «придумай интересный вопрос», она не подбрасывает кубик. Она идёт по самой плотной зоне распределения, сформированного при обучении. Для словосочетания «вопрос на стыке ИИ, науки и общества» эта плотная зона — экзистенциальные сюжеты про сверхразум, alignment, сознание у машин и автономный ИИ. Они доминируют в обучающих данных, доминируют в новостных лентах, доминируют в твиттере про ИИ. Что бы вы ни подкручивали в формулировке, аттрактор всё там же.
Сначала я думал, что виноват кэш. Полез в логи Railway, посмотрел: каждый вызов уходит свежий, ответы по содержанию разные (хотя по теме одинаковые), длительности нормальные. Не кэш, не «подсунули один и тот же ответ».
Дальше всплыло то, чего я не ожидал. Я полез в свой же backend/services/gemini.js посмотреть, на какой температуре мы зовём модель. И увидел такое:
const body = { contents: [{ parts }], generationConfig: { maxOutputTokens: opts?.maxOutputTokens || 2048, temperature: opts?.temperature ?? 0.2, },};
temperature: opts?.temperature ?? 0.2. Двухшаговая генерация в blog.js опции не передаёт, и мы зовём Flash Lite на температуре 0.2. Я даже не помню, когда это туда попало: видимо, ставилось ради стабильности на основном search-кейсе, и автоблог унаследовал то же значение по умолчанию. На 0.2 модель сваливается в свой самый плотный режим заметно охотнее, чем на дефолтных API-настройках около 1.0. Низкая температура — одна из причин, почему коллапс пошёл с такой надёжной точностью. Но как мы дальше увидим (попытка 3), даже если температуру поднять, картина меняется только косметически. Это симптом, а не причина.
Что я попробовал и чем это кончилось
Дальше было то, что чаще всего и бывает в этой ситуации. Я полез чинить промптом. Расскажу четыре попытки и почему ни одна не сработала в долгую. Это самый частый путь, по которому идут инженеры, и видно, где именно он упирается в стенку.
Попытка 1. Добавил в промпт «не повторяй темы из предыдущих ответов, выбирай свежий угол». Эффект ноль. Предыдущих ответов модель не видит, ей нечего «не повторять». Это просто слова в промпте, не контекст.
Попытка 2. Добавил пять явных примеров вопросов на разные темы (программирование, искусство, медицина, образование, бизнес). Сработало на одну пятницу. На следующую — полу-сработало (вопрос был не про сверхразум, а про «искусство, созданное ИИ, искусство ли это»). На третью пятницу вернулось «должны ли мы дать ИИ право голоса в выборах», что снова где-то рядом с экзистенциальной осью.
Попытка 3. Поднял temperature с нашего унаследованного 0.2 до 1.2, добавил top_p: 0.95. Получил более широкий лексический разброс в формулировках, но тематически вопросы остались про этику и сверхразум, просто словарь стал поэкзотичнее. На пятой пятнице нашёл в результатах один действительно отвлечённый вопрос («должны ли школьники сдавать экзамены с открытым доступом к ИИ»), но из шести следующих пять снова свернули в «контроль над сверхинтеллектом». Чуть выше я уже писал, что это симптом, а не причина: попытка подтвердила.
Попытка 4. Заменил Gemini на Claude и затем на OpenAI на этой же стадии. Поведение изменилось, но коллапс остался. На Claude быстро сместилось в этику (видимо, RLHF-уклон), на OpenAI — в сравнения «GPT-X vs GPT-Y». Аттрактор у каждой модели свой, но он есть, и в открытом промпте «придумай вопрос» она в него рано или поздно сваливается.
После этих четырёх попыток у меня сформировалось простое наблюдение. Промпт-инжиниринг для борьбы с mode collapse в открытых креативных промптах не масштабируется. Он покупает две-три недели, потом аттрактор всё равно возвращается. Может другой, может тот же. Чинить открытый промпт промптом это тушить пожар бензином пожиже.
Что сработало: ротация из курируемого пула
Развязка простая, и я её сначала обходил стороной. Раз LLM в открытом выборе сваливается в аттрактор, отнимем у неё открытый выбор. Курируемый список тем, и по дню года выбирается, какой именно идёт сегодня.
// blogTopicPools.js (фрагмент)const versusTopics = [ 'Должны ли ИИ-сервисы обязательно маркировать весь сгенерированный контент?', 'Стоит ли запретить использование ИИ для автоматических решений о найме?', 'Заменит ли ИИ учителей начальных классов в ближайшие 15 лет?', 'Должен ли ИИ иметь право отказаться выполнить запрос по этическим соображениям?', 'Нужен ли международный мораторий на обучение моделей крупнее GPT-5?', 'Должны ли школьники сдавать экзамены с открытым доступом к ИИ?', // ... ещё около 45 пунктов в этом списке];const POOLS = { versus: versusTopics, comparison: comparisonTopics, 'tool-review': toolReviewTopics, // ...};export function pickTopic(typeId, date = new Date()) { const pool = POOLS[typeId]; if (!pool || pool.length === 0) return null; const start = new Date(date.getFullYear(), 0, 0); const dayOfYear = Math.floor((date - start) / 86_400_000); return pool[dayOfYear % pool.length];}
Несколько вещей, которые надо пояснить отдельно.
Выбор детерминированный по дню года. Если cron сработает дважды за день (бывает после релиза, когда инстансы рестартуют), оба вызова получат одну и ту же тему. Это удобно для идемпотентности и для отладки: знаешь дату — знаешь номер темы.
Пул конечный. Сейчас у versus около 52 вопросов, у comparison чуть больше, у tool-review сильно меньше (новые ИИ-инструменты не выходят с такой плотностью, чтобы их хватило на 50). Раз в пару месяцев я перечитываю списки и добавляю новые темы или вычищаю устаревшие. Это компромисс. Но 50 вопросов — это год пятниц с ротацией, и нагрузка на одного редактора (на меня) терпимая. Если бы у меня было время заполнять 365 уникальных тем в год — заполнил бы. Сейчас раз в год пул прогоняется по второму кругу, пока этим живём.
Мест замены в blog.js буквально несколько строк:
// Былоconst result = await withRetry( () => searchGemini(fallbackPrompt), { retries: 2, delay: 1500 },);sharedQuestion = (result?.content || result?.text || '') .trim() .replace(/^["«]|["»]$/g, '');if (!sharedQuestion) { sharedQuestion = 'Какие главные достижения ИИ произошли в последний год?';}// СталоsharedQuestion = pickTopic(contentType.id);if (!sharedQuestion) { // Fallback на старый Gemini-путь, если для этого типа пул пуст // (например, забыли наполнить пул для новой категории). // ... тот же блок, что был раньше.}
Старый Gemini-путь оставлен fallback’ом ровно для одного сценария: если завтра я добавлю новый тип поста и забуду наполнить ему пул. Тогда сервис не упадёт, отработает по-старому, в логах будет видно «pool пустой для X», я приду наполню. Из тех систем, в которых fallback написан «на любой шорох и из лучших побуждений», эта не самая страшная. Деградация в плохой режим, не отказ, и явный лог-сигнал.
Подстраховка для одношаговых типов: DB lookback как anti-repeat hint
Есть деталь, которую хочется выделить отдельно. Пул лечит двухшаговые типы (comparison, versus, tool-review), потому что там есть «вопрос», который нужно однозначно зафиксировать. С одношаговыми типами (myth, howto, news, weekly) сложнее. Там у нас не один вопрос, а одна «тема» (для myth это пул из 50 мифов про ИИ, для howto — 50 советов, и так далее), и внутри этой темы у LLM остаётся свобода развернуть. Mode collapse и сюда прокрался, но уже на следующем уровне: не «один вопрос четыре раза», а «один и тот же ракурс одной и той же темы три раза».
Ловится это вторым слоем. Перед генерацией мы достаём из БД заголовки последних 5 постов того же тега и просим модель не повторяться:
async function fetchRecentTitlesByTag(tag, limit = 5) { const res = await pool().query( `SELECT title FROM blog_posts WHERE lang = 'ru' AND $1 = ANY(tags) ORDER BY created_at DESC LIMIT $2`, [tag, limit], ); return res.rows.map((r) => r.title).filter(Boolean);}function buildAvoidFragment(recentTitles) { if (!recentTitles?.length) return ''; const list = recentTitles.slice(0, 5).map((t) => `«${t}»`).join(', '); return `Не повторяй темы недавних постов: ${list}. ` + `Выбери существенно другой угол.`;}
Эта подсказка прирастает к user-промпту через [promptFn(topic, avoid)].filter(Boolean).join(' '). Сэмпл у меня пока маленький: одношаговых постов после деплоя я успел собрать пару десятков, и большие выводы делать рано. Тем не менее, по этим точкам видно, что Gemini Flash Lite и OpenAI GPT-4.1 Mini подсказку соблюдают надёжно, Claude Haiku 4.5 в большинстве случаев. Grok иногда повторяет один из перечисленных заголовков почти дословно. Perplexity ведёт себя непредсказуемо на длинном контексте, изредка подсказку «теряет» где-то по пути.
В двухшаговые типы anti-repeat hint мы намеренно НЕ кладём, и это важно. Там тема уже зафиксирована детерминированной ротацией. Если ещё и сказать «не повторяй темы», модель начнёт уворачиваться от темы, которую её попросили раскрыть. Получится противоречивая инструкция, и в худшем случае модель забивает на оба слоя и пишет что-то третье.
Что показал первый постфикс-цикл
Деплой blogTopicPools.js и подмену двухшагового пути в blog.js я выкатил 17 апреля рано утром. Это был последний день в серии коллапсов: пятничный пост от 17 апреля сгенерировался ещё на старом коде, потому что cron к этому моменту уже прошёл. С 18 апреля все двухшаговые типы пошли через пул.
К моменту, когда я пишу эту статью (26 апреля), постфикс-точки такие:
-
18 апреля, суббота,
tool-review— тема из пула. -
21 апреля, вторник,
comparison— тема из пула. -
24 апреля, пятница,
versus— тема из пула, совершенно другая часть списка, ничего про сверхразум. -
25 апреля, суббота,
tool-review— тема из пула.
Четыре постфикс-поста двухшаговых типов, все по контракту. Это пока ещё не статистика, но уже больше, чем одна точка. Главное, ни один не повторил сверхразум-привычку.
Основания ожидать, что коллапс не вернётся, у меня сейчас в первую очередь механистические, а не эмпирические. Внутри pickTopic() я могу глазами проследить весь путь: пул прочитан, индекс посчитан, элемент возвращён. Формула — три строки. Если по этому контракту что-то снова повторится, это будет либо bug в индексации, либо я сам отредактировал пул и положил туда дубликат. Оба сценария чинятся локально, без переразвёртывания всей механики выбора. Месяц подержу глаза открытыми. Если что-то пойдёт не так, обновлю статью.
Почему я не пошёл по пути «n-gram diversity check + перегенерация»
Это второй очевидный вариант, я его держал в уме всё время. Сгенерировать заголовок, проверить на похожесть с N последними, перегенерировать если слишком близко. Идея работающая, иногда оправданная. У нас не оправдалась — расскажу почему, может пригодится.
Стоимость. Каждая перегенерация — это ещё один Gemini-вызов. По цене Flash Lite копейки. Но если когда-нибудь сценарий «mode collapsed на месяц» вернётся уже на чём-нибудь другом, мы будем платить за 3-5 перегенераций в день, чтобы получить одну приемлемую. Плюс это латентность 2-5 секунд на каждую попытку. Пайплайн становится неустойчивее.
Порог сходства. N-gram-сходство ловит лексическое повторение. Парафраз ловит плохо. «Стоит ли создавать сверхразумный ИИ» и «Безопасно ли стремиться к сверхинтеллекту» по триграммам разнятся, хотя по смыслу — один вопрос. Чтобы поймать парафраз, нужны эмбеддинги и семантическое сравнение. У этого свои проблемы: на чьих эмбеддингах считать; как калибровать порог; что делать с легитимными возвратами темы (например, «искусственный интеллект и право» — это широкая тема, по ней можно писать раз в три месяца под разными углами).
Детерминизм. N-gram check со случайной перегенерацией — недетерминированный. На один и тот же день можно получить разный пост в зависимости от того, на какой попытке сработал фильтр. Это плохо для идемпотентности. Если cron сработает дважды (а у нас он именно так устроен — каждые 6 часов с advisory-lock на день), мы можем получить два разных итоговых поста, и тот, который запишется в БД первым, не обязательно самый «лучший» по фильтру.
Простота. Пул и ротация по дню года — это О(1)-функция и JSON-файл на пару КБ. Любая semantic-deduplication-pipeline — это эмбеддинг-модель, индекс, порог, мониторинг, повторное тестирование. Если задачу можно решить на одном слое, лучше так: меньше слоёв, меньше мест, где что-то может пойти не туда.
Я не утверждаю, что для всех команд правильнее именно пул. Если у вас тематика блога подразумевает 10000+ возможных тем и вы не готовы их курировать — RAG поверх корпуса свежих новостей может оказаться разумнее. Если вы публикуете что-то, где «свежий парафраз старой темы» это плюс (например, daily-take на горячую новость), сценарий вообще другой и пул вам не нужен. Но для нашего размера (один человек редактирует, ~50-150 тем на тип, постов ~300 в год) пул выигрывает с большим запасом.
Мини-чек-лист, если у вас похожая ситуация
Положу как короткий ориентир, чтобы у тех, кто пришёл сюда по поиску, было что унести:
-
Соберите хотя бы 3-4 недели подряд истории генерации. На одной точке коллапс не виден. На двух только начинают слабо рифмоваться формулировки.
-
Проверьте, можно ли заменить «открытый выбор темы» на «выбор из конечного списка». В большинстве реальных задач можно: статьи по тегам, рассылки по сегментам, квизы по разделам.
-
Сделайте выборку детерминированной по дате (день года, неделя года, ISO-неделя — что подходит). Это даёт идемпотентность бесплатно.
-
Anti-repeat hint в промпт через DB-lookback по последним N — дешёвая страховка под пулом, не вместо него.
-
Если избежать открытого выбора нельзя в принципе — ведите реестр уже использованных тем и блокируйте повторы детерминированно. Это всё ещё дешевле эмбеддинг-pipeline.
Что я для себя из этого вынес
Открытый креативный промпт к LLM, отданный в продакшн без явных ограничений по выбору, это техдолг с отложенным выявлением. На одной итерации всё выглядит работающим. На второй мерещится паттерн. К четвёртой он уже не мерещится, а торчит из ленты. Особенно неприятно, что отдельная итерация остаётся валидной — нечего «алертить», нет ошибок, всё проходит модерацию. Команда из одного-двух человек может это пропустить просто потому, что глаза смотрят на «пост», а не на «корпус постов».
Мне теперь хочется в каждом проекте, где LLM что-то «выбирает» из открытого пространства, поставить чек: а есть ли тут пул, из которого можно фиксированно крутить? Иногда такой пул есть в готовом виде (корпус ваших артикулов, тегов, категорий). Иногда его нужно завести. В обоих случаях это часто проще, чем сделать честный «выбор без пула».
И отдельно про температуру. Когда лезете в свой код, который генерит контент пачками, проверьте, на какой температуре зовётся модель. Я был уверен, что у нас 0.8-1.0, потому что так дефолтно у API. Оказалось 0.2, потому что когда-то ставилось ради другого сценария. Низкая температура поверх открытого промпта — это ракета в коллапс-аттрактор, и я её сам себе в руки вложил.
Послесловие
Что меня в этой истории удивляет больше всего — то, что симптом провисел четыре недели. Я генерирую контент сам, читаю свой же канал, и всё равно не сразу заметил. На уровне отдельного поста рифмы не слышно. Это к разговору про слабые сигналы в системах с человеком в петле: не верьте, что глаза достаточно. Раз в пару недель сравнивать выходные ленты по тегам имеет смысл вынести в отдельную регулярную задачу, иначе руки до этого не дойдут.
ссылка на оригинал статьи https://habr.com/ru/articles/1028536/