Модель начала отдавать мусор: тихо сменился шаблон промпта
Мониторинг зелёный, HTTP 200, задержки в норме — а в поддержку одно за другим летят сообщения: бот отвечает бредом. Такой инцидент особенно неприятен тем, что все привычные приборы молчат: ни одна метрика не ловит «модель стала тупить», потому что для инфраструктуры это по-прежнему успешный запрос. Расскажу, как мы две смены искали причину и в итоге нашли её не в модели, не в GPU и не в сети, а в одной переименованной переменной шаблона.
Содержание
Что мы увидели: жалобы раньше метрик
Сервис — саппорт-бот на клиентском сайте, ходит к LLM, поднятой на паре GPU-VPS за nginx (обычная схема: upstream llm_backend { server node-a:8000; server node-b:8000; }, round-robin без sticky-сессий). Первый сигнал пришёл не из Grafana, а из тикетов: клиенты жаловались, что бот отвечает не по теме, иногда обрывками фраз, иногда — буквально с фигурными скобками внутри текста, будто разработчик забыл что-то подставить.
Проверили стандартный набор:
- Латентность и код ответа — без отклонений, p95 в обычном коридоре, 5xx не растёт.
- Загрузка GPU —
nvidia-smiпоказывает штатную утилизацию, троттлинга нет, температура в норме. - Версия модели —
/v1/modelsотдаёт тот же идентификатор, что и вчера, чек-сумма файла весов не менялась (сверилиsha256sumс эталоном в бэкапе). - Токены запроса — счётчик контекста не подскакивал, лимит окна не пробивался.
То есть по всем приборам, которые обычно ловят проблемы с инференсом, — тишина. Единственная зацепка: жалобы приходили не на каждый запрос, а через раз, и повторный вопрос от того же пользователя иногда решал проблему «сам собой». Это классический признак того, что сбоит не сам сервис целиком, а часть его инстансов.
Гипотезы, которые отбросили
Прежде чем упереться в реальную причину, перебрали шесть версий, каждая казалась правдоподобной:
- Повреждение весов модели на диске. Проверили чек-суммой — файл не менялся, дата модификации старая.
- Смена параметров сэмплинга (temperature, top_p) кем-то вручную через .env. Посмотрели git-историю конфигов и
docker inspectна обеих нодах — параметры идентичны и совпадают с репозиторием. - Обновление драйвера GPU или рантайма инференса ночным cron-заданием. Проверили версии пакетов на обеих нодах — одинаковые, апдейтов по логам apt/yum не было.
- Рост истории диалога и обрезание контекста не с того конца. Смотрели трейсы конкретных «плохих» запросов — контекст короткий, обрезка не участвовала.
- Баг на стороне клиента, который присылает битый JSON или обрезанный текст. Сверили сырые запросы в access-логе nginx — на входе всё корректно сформировано.
- Сетевые потери, из-за которых модель получает урезанный промпт. Прогнали
tcpdumpна одном воспроизводимом кейсе — на входе в контейнер тело запроса приходит целиком, без обрывов.
Ни одна версия не подтвердилась, и после третьего часа стало ясно: искать нужно не «что сломалось», а «что отличается между нодами» — раз проблема плавающая и наполовину.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверГде нашёлся первый настоящий след
Добавили временный X-Upstream-Node в ответ nginx (add_header X-Upstream-Node $upstream_addr always;), чтобы клиентская сторона могла прислать этот заголовок вместе с жалобой. Через полчаса совпадение стало очевидным: все жалобы приходили с одной и той же ноды — назовём её node-a. Node-b отвечала штатно на те же самые вопросы.
Дальше — сравнение содержимого контейнеров:
ssh node-a 'docker exec llm-app md5sum /app/prompts/system_prompt.txt'
ssh node-b 'docker exec llm-app md5sum /app/prompts/system_prompt.txt'
Хэши разошлись. Файл, который обе ноды должны были получить из одного и того же репозитория при последнем деплое, оказался разным. Это и был первый твёрдый факт: шаблон промпта на двух «одинаковых» серверах — не одинаковый.
Как разный шаблон попал на прод в обход ревью
Шаблон системного промпта живёт в отдельном небольшом репозитории prompts-repo, подключённом как git submodule к основному приложению, привязанный не к тегу, а к ветке main (git submodule update --init --remote в скрипте сборки — классический аналог антипаттерна, о котором мы уже писали в разборе почему latest вместо версии — это мина замедленного действия). Ночью по расписанию отрабатывает deploy.sh, который обновляет ноды по очереди:
#!/bin/bash
for node in node-a node-b; do
ssh $node 'cd /srv/app && git pull && git submodule update --remote && docker compose build && docker compose up -d'
done
Обратите внимание — без set -e и без проверки кода возврата. За день до инцидента коллега из другой команды делал в prompts-repo косметическую правку: переименовал переменную в шаблоне с context на additional_context, посчитав это «просто текстовым файлом», и смёржил прямо в main без ревью — правки промптов в этом репозитории традиционно не проходили тот же процесс, что код приложения.
Ночью деплой дошёл до node-a первой, подтянул новую версию сабмодуля, пересобрал образ. Приложение по-прежнему рендерит промпт через template.format(context=user_context), а в шаблоне теперь ожидается additional_context. Получился KeyError, но перехватывался он широким except Exception с фолбэком:
try:
system_prompt = TEMPLATE.format(context=user_context)
except Exception as e:
logger.warning(f"prompt render failed: {e}")
system_prompt = RAW_TEMPLATE # неотрендеренный текст как есть
То есть вместо честного отказа модель на node-a стала получать сырой шаблон с фигурными скобками, служебными комментариями и незаполненными плейсхолдерами прямо в системном промпте — модель честно пыталась это интерпретировать как инструкцию и отвечала соответствующим бредом. Предупреждение в лог писалось, но уровня WARNING, и алерт на него никто не заводил.
Деплой на node-b в ту же ночь должен был подтянуть тот же коммит — но упал на этапе docker compose build из-за нехватки места на диске (кеш слоёв разросся, свободного места оставалось меньше гигабайта). Скрипт без set -e продолжил выполняться и как бы «завершился успешно», хотя контейнер на node-b просто не обновился и продолжил работать со старым, ещё исправным шаблоном.
В сумме: две ноды за одним балансировщиком, одна с рабочим промптом, вторая — со сломанным, трафик размазан round-robin примерно пополам. Отсюда и «через раз», и «у меня же только что сработало» — просто это был другой апстрим.
Реальная причина одной строкой
Несинхронизированный, непроверяемый деплой шаблона промпта: подключение по плавающей ветке вместо зафиксированной версии, деплой без остановки на ошибке, и молчаливый fallback, который вместо отказа подсунул модели неотрендеренный текст. Ни одна из этих вещей сама по себе не роняла сервис — совпали все три одновременно, и именно поэтому CPU, GPU и HTTP-код ничего не показали: с точки зрения инфраструктуры всё работало штатно, ломался только смысл ответа.
Что изменили после разбора
Правки разбили на три слоя — сборку, деплой и наблюдаемость.
Сборка и версионирование. Submodule зафиксировали на конкретном коммите, а не на ветке; обновление — только через отдельный PR с ревью, как у обычного кода:
git submodule update --init # без --remote, коммит фиксирован в .gitmodules
Правки промптов вернули в общий процесс код-ревью — «это же просто текст» оказалось дорогим заблуждением.
Деплой. В скрипт добавили set -euo pipefail, проверку кода возврата на каждом шаге и явный health-check перед переключением трафика: перед тем как нода получит реальные запросы, на неё гоняется фиксированный набор тестовых промптов и проверяется структура ответа (валиден ли JSON, нет ли в тексте буквальных {/}, не пустой ли ответ). Это, по сути, урезанная версия канареечного релиза — сам подход подробнее разбирали в статье про канареечный деплой. Пока смоук-тест не прошёл, старый контейнер из ротации не убирается.
Отдельно убрали widescreen except Exception: fallback to raw template. Теперь при ошибке рендера шаблона нода не отдаёт сырой текст модели, а отказывает явно и остаётся вне ротации — лучше вернуть пользователю ошибку и держать половину мощности на старой версии, чем молча кормить модель мусором.
Наблюдаемость. Раньше мониторинг проверял только «жив ли процесс», а не «осмысленный ли ответ». Добавили лёгкие эвристические проверки качества ответа (доля повторов, признаки нерендеренных плейсхолдеров, пустой вывод) как отдельную метрику в Prometheus, по аналогии с тем, что описано в статье про мониторинг качества ответов ИИ — она перестала быть «желательной опцией» и стала частью обязательного алертинга наравне с латентностью. Плюс в CI перед мерджем в prompts-repo теперь гоняется набор золотых тест-кейсов — подход и конкретные приёмы описаны в статье про тестирование качества ответов своей модели.
Ещё одна мелочь, которая сэкономила бы часы: в лог каждого ответа стали писать короткий хэш шаблона, из которого он собран, и номер ноды. Если бы это было на месте раньше, локализация проблемы заняла бы минуты, а не половину смены.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Как быстрее всего заметить, что промпт-шаблон разошёлся между репликами, если их несколько?
Добавьте в стартовый лог каждого контейнера короткий хэш файла шаблона и сравнивайте его между нодами — это одна строка кода, а находится расхождение мгновенно, без разбора логов вручную.
Обязательно ли пиннить сам шаблон, если модель и раннер уже зафиксированы по версии?
Да, и это отдельная точка отказа: модель и её рантайм могут быть версионированы идеально, а шаблон промпта, подключённый как отдельный файл или сабмодуль на плавающей ветке, обнуляет весь этот контроль — именно так и произошло в описанном случае.
Как тестировать промпт перед деплоем, если «правильного» ответа модели не существует в точности?
Проверяйте не текст дословно, а структурные свойства ответа: валидность формата (JSON, теги), отсутствие незаполненных плейсхолдеров, разумную длину, отсутствие повторов — этого достаточно, чтобы поймать явно сломанный рендер, не требуя точного совпадения с эталоном.
Что делать, если шаблон промпта хранится не в git, а в базе данных или админке?
Принцип тот же: версионируйте изменение (кто, когда, что именно поменял), проверяйте рендер перед применением и держите откат к предыдущей версии одной кнопкой — источник хранения вторичен, важна дисциплина изменения.
Стоит ли держать такие сервисы на одной ноде, чтобы не ловить расхождение между репликами?
Нет — единственная нода означает единую точку отказа при перезагрузке или обновлении; правильный ответ не «меньше нод», а синхронный деплой с проверкой перед переключением трафика, как описано выше.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →