Семь классов
инцидентов
Что на самом деле ломается в production с AI-агентами — и как перестать наступать на одно и то же.
Зачем я это сделал
За полгода ежедневной работы с AI-агентами на пяти проектах у меня накопилось больше восьмидесяти задокументированных производственных инцидентов. Не «ой, забыл точку с запятой» — а настоящие: молчаливая потеря данных, двойные списания денег с пользователей, сервисы, которые считались живыми, а на самом деле были мертвы неделями.
В какой-то момент я перестал видеть отдельные баги и начал видеть систему. Одни и те же классы инцидентов повторялись в разных проектах, с разными моделями, в разное время. Агент на базе Claude попадал в ту же структурную ловушку, что и агент на базе Gemini. Это не вопрос «кто виноват» — это вопрос «какие условия гарантированно приводят к инциденту».
Я разложил всё по семи категориям. Не для того чтобы обвинить агентов, а чтобы понять: что именно в архитектуре, документации или пайплайне позволило одному и тому же классу проблем случиться в трёх проектах подряд. Это производственный постмортем условий, а не ошибок.
Разрыв между средой разработки и продакшеном
Суть простая: агент тестирует в одном окружении, а код поедет в другом. В эту яму падают и люди — но человек хотя бы спросит «а на проде точно такой же линукс?». Агент не спросит, пока вы ему не скажете.
1.1 «Working tree = production»
На одном из проектов система запускает Python-код прямо из рабочей директории. Никакого GitHub, никакого CI — файлы с диска сразу становятся production-сервисом. Один агент оставил в файле синтаксическую ошибку, ушёл, следующий перезапуск сервиса (автоматический, Restart=always) уронил бота.
Здесь агент виноват: не проверил компиляцию перед тем, как считать работу законченной. Решение: правило «py_compile после каждого edit». Пока компиляция не прошла — работа не сделана.
1.2 tmpfs на 2 ГБ — три параллельных npm install переполнили RAM
Агенты использовали git worktree для изоляции параллельных задач. Worktree размещались в /tmp, который на этом сервере — tmpfs в оперативной памяти (2 ГБ). Три параллельных npm install плюс сборка production-бандла переполнили RAM. Bash-инструмент агента начал молча падать с exit 1 на любую команду.
Здесь агент мог проверить df -h /tmp перед тем как разворачивать три тяжёлых процесса. Не проверил. Решение: worktree — только в /var/tmp (диск), правило записано в документацию каждого проекта.
Молчаливые отказы — самый опасный класс
Ошибка, которая видна сразу, раздражает. Ошибка, которая не видна месяцами — стоит денег, данных и нервов. Причём код с точки зрения синтаксиса и тестов часто абсолютно корректен. Он просто не делает то, что должен.
2.1 Sentry не работал 70 дней
После миграции на Next.js 15+ конфигурационные файлы Sentry (sentry.client.config.ts, sentry.server.config.ts) перестали подхватываться фреймворком. Next.js 15+ использует instrumentation.ts, а не legacy config files. Агент, переносивший проект, не знал об этом изменении. Sentry SDK не загружался — 70 дней производственный фронтенд работал без единого события об ошибках.
Решение: проверка после деплоя — grep хеша Sentry DSN в клиентских чанках. Если нет — SDK не зашёлся в сборку, деплой откатываем.
2.2 Google Gemini чаты были бесплатными
Биллинг-прокси определял, нужно ли списывать деньги, по подстрокам chat/completions или messages в URL. Google Gemini шлёт запросы по путям :generateContent и :streamGenerateContent — ни одна не матчилась. Дополнительно: streamGenerateContent содержит заглавную G, проверка была case-sensitive.
Счёт: сотни запросов в день, каждый — от 0,06 до 0,83 рублей. Недели бесплатного трафика.
Решение: явные проверки на провайдер-специфичные пути. Не полагаться на «похоже на chat/completions».
2.3 print() вместо logger и kwargs, которые не работают
Массовое использование print() в бэкенд-сервисах — 1388 вхождений по данным аудита, накопленных исторически разными авторами (не только агентами). print() не пишет в structured logs, не виден в Sentry, не несёт контекст.
Но есть специфично-агентский вклад в эту же зону. Стандартный питоновский logging не принимает kwargs: logger.info("msg", user_id=123) падает с TypeError в проде. Тесты не ловят — при уровне WARNING строка не исполняется. Агент, привыкший к structlog-стилю с kwargs, написал именно так — и тихо уронил продакшен.
Решение: только f-строки со стандартным логгером. structlog.get_logger() — можно kwargs, но это другой объект.
2.4 Навык не работал в обычном чате — два контура исполнения, один реализован
Функция публикации пользовательских сайтов через чат. В обычном чате навык молча возвращал пустоту — модель не «видела» сайтов пользователя.
Корень: два независимых контура исполнения builtin-инструментов — клиентский (браузерный) для обычного чата и серверный (Node.js) для групповых агентов. Навык имел только server runtime. Клиентский реестр не находил исполнителя — «No executor found» — и молча возвращал пустой результат. Ни ошибки, ни лога.
Решение: любой новый инструмент с серверными данными требует парной регистрации — client executor и server runtime. Правило в чеклисте для новых интеграций.
Числа и конфигурация — почему агенты ошибаются в цифрах
Агент не проверяет число — он берёт его из конфига или из памяти модели. Если в конфиге опечатка, а модель не перепроверила — число уходит в продакшен.
3.1 GLM-5 Turbo в 24 раза дешевле реальной цены
В реестре моделей для GLM-5 Turbo стояла цена $0,05/$0,20 за миллион токенов. Реальная — $1,20/$4,00. На каждом миллионе output-токенов терялось 418 рублей. Цена была скопирована из соседней модели и не сверена с официальным прайсом.
3.2 Gemini считался в 40 раз дороже реальной цены
Обратная ситуация: для Gemini считали стоимость по длине текста, а модель возвращала отдельное поле thoughtsTokenCount, которое накладывалось на candidatesTokenCount. Двойной счёт — завышение в 40 раз. Пользователь платил рубль за две копейки. Это обратная сторона инцидента 2.2: там биллинг не распознавал путь Gemini и пропускал запросы бесплатно — здесь распознал, но насчитал в десятки раз больше нужного.
3.3 Qwen reasoning: 300+ токенов на «да, это баг»
Qwen 3.6 — reasoning-модель. Без enable_thinking: false генерирует цепочку рассуждений на 400-1500 токенов даже для тривиального ответа. Каждый такой ответ — деньги и лишние секунды.
enable_thinking, responseMimeType) — обязательная часть чеклиста при регистрации новой модели.Невидимые контракты фреймворков
У каждого фреймворка есть правила, которые нигде не написаны крупно, но нарушение которых ломает всё. Опытный разработчик знает их. Агент — нет, пока ему не покажут.
4.1 Zod 4: null ≠ undefined
Клиент шлёт null для неустановленного поля. Zod-схема ждёт undefined. .optional() допускает отсутствие ключа, но не null. Результат: 400 Bad Request.
Хуже: .default('someValue') в PATCH-схеме. Клиент отправляет только изменившиеся поля, Zod подставляет дефолт в опущенное → существующие данные в БД затираются.
Решение: .nullable().optional() для всех полей, .default() в PATCH запрещён. Contract-тесты с реальными payload клиента.
4.2 Четверги «прилипали» к средам
new Date() + setDate() в браузере — локальная таймзона (МСК, UTC+3). .toISOString() конвертит в UTC. Локальная полночь 16 апреля → UTC 15 апреля 21:00. Ключ в Map — «среда» вместо «четверга».
Решение: toLocalDateString(date) через getFullYear/getMonth/getDate. ESLint-правило блокирует .toISOString().split('T')[0].
4.3 timeAgo() в render → вся гидратация отвалилась
Client-компоненты в Next.js рендерятся дважды: сервер (SSR) и браузер (hydration). Date.now() или Math.random() в render дают разный результат → React видит mismatch → все обработчики событий в поддереве не привязываются.
Инцидент: timeAgo() в шести компонентах на странице. Сториз не открывались по клику. На главной те же сториз работали — там не было компонентов с timeAgo().
Решение: компонент <TimeAgo> с mount-guard — абсолютная дата при SSR, относительная через useEffect на клиенте.
4.4 [1,2,3,4].slice(0, max) режет до 4 при max=8
Кнопки выбора количества изображений. Агент написал [1,2,3,4].slice(0, max). При max=8 — четыре кнопки. Месяц в проде.
Решение: Array.from({length: max}, (_, i) => i + 1).
4.5 Цена без знака валюты
«Создать за 7» вместо «Создать за 7 ₽». Старый шаблон имел `${cost} ₽`, новый — `${cost}`. Агент не проверил визуальный вывод.
Решение: правило «всегда выводить знак валюты» в стандартах проекта.
Инфраструктура: деплой и миграции
Тут код обычно правильный — ломается обвязка вокруг него: порядок шагов деплоя, симлинк, забытая команда. Самый обидный класс, потому что ревью кода его не ловит — в самом коде дефекта нет, он в том, как код доезжает до прода.
5.1 npm run build напрямую ломает blue/green
Blue/green деплой: два слота .dist-blue / .dist-green, симлинк dist на активный. Агент запустил npm run build напрямую — создалась реальная директория dist/. Следующий deploy.sh упал.
Решение: никто кроме deploy.sh не трогает dist/. Правило зашито в документацию.
5.2 Миграция с будущей датой применилась досрочно
Миграция на удаление таблиц с именем 20260511d001_drop_.... deploy.sh делает alembic upgrade head при каждом деплое. Миграция применилась 22 апреля вместо 11 мая.
Решение: датированные миграции не коммитятся в main до дня применения. Держатся в feature-ветке или оборачиваются в условный op.execute.
5.3 Build до миграций — дефект пайплайна
Next.js с cacheComponents: true prerender'ит страницы при next build. Если новый код читает колонку, добавляемую миграцией, а миграция в деплой-скрипте идёт после сборки — билд падает с «column does not exist».
Это не столько ошибка агента, сколько архитектурный дефект: порядок шагов в пайплайне таков, что любая правка «новая колонка + новый код» гарантированно валится при первом деплое. Решение на стороне пайплайна: либо миграции до сборки, либо код обязан быть толерантен к отсутствию колонки.
5.4 Забытый rsync сломал SPA
Сборка требует двух rsync: .next/ и public/_spa/. Агент скопировал .next/, пропустил public/_spa/. SPA не мог загрузить чанки — 404 на всё.
Решение: build-скрипт с проверкой BUILD_ID, не полагаться на память.
Изоляция и безопасность
Агент постоянно запускает дочерние процессы — тесты, миграции, сборки. Каждый наследует окружение родителя, и если там лежит доступ к боевой базе, агент об этом не подозревает. Цена ошибки здесь максимальная: не кривая вёрстка, а стёртые данные или открытый к деньгам доступ.
6.1 DATABASE_URL утекал в тесты соседнего проекта
Сервис автоматического исправления ошибок: получает алерт, создаёт изолированный git-клон, запускает агента. При запуске pytest в клоне процесс унаследовал DATABASE_URL родителя. Тестовая фикстура сделала DROP TABLE ... CASCADE в боевой базе данных.
Решение: принудительная очистка DATABASE_URL и DATABASE_URL_TEST из окружения subprocess. Не полагаться на .env клонируемого проекта.
6.2 Каскадный отказ: бан-система забанила живого пользователя
Здесь нет одного виноватого — это классический каскад из четырёх независимых причин, каждая из которых по отдельности не была ошибкой:
Пользователь с iPhone пытался сгенерировать видео. Первый запрос — ошибка. Но UI показал пустой toast: HTTP/2 не передаёт reason phrase, фронтовый обработчик ошибок не подставил человеческий текст. Пользователь нажал «Сгенерировать» ещё пять раз — 6 запросов за 134 миллисекунды. Nginx, настроенный с burst=5 на генерационных эндпоинтах, засчитал превышение лимита. Защитный фильтр сработал — бан на 4 часа. Пользователь с балансом 1481 ₽ потерял доступ.
Каждый элемент цепочки был разумен: низкий burst — защита от ботов, HTTP/2 без reason phrase — спецификация, пустой toast — баг в парсинге ошибок (вот здесь агентская вина реальна). Но вместе они дали катастрофу.
Idempotency-Key; человеческий текст ошибки вместо пустого toast; whitelist авторизованных пользователей в бан-системе.6.3 Забаненный пользователь продолжал списывать деньги
JWT с TTL 30 дней. Пользователя забанили, но все живые токены продолжали работать — handler'ы проверяли только подпись и срок, не сверяясь с бан-листом.
Это архитектурная ошибка: бан применялся на уровне auth middleware, но money-path эндпоинты его не проверяли. Решение: is_user_banned(user_id) в каждом money-path handler'е, а не только в middleware.
LLM-специфика: когда модель не «сама разберётся»
У каждого AI-провайдера свой контракт. Модель не знает его, пока вы явно не скажете. И агент, программирующий взаимодействие с моделью, тоже.
7.1 Gemini thoughtSignature терялся в tool-use loop
Gemini 3.x возвращает thoughtSignature в каждом functionCall — зашифрованное представление reasoning. Это поле обязательно для возврата в следующем запросе. У Anthropic такого требования нет. Общий промежуточный формат, спроектированный по Anthropic-модели, терял поле при конверсии. Gemini API: «Function call is missing a thought_signature».
Решение: провайдер-специфичные поля живут в промежуточном формате, а не отбрасываются «потому что другой провайдер их не использует».
7.2 Gemini отдаёт JS-объекты вместо JSON
По умолчанию Gemini может вернуть {key: "val"} вместо {"key": "val"} — без кавычек на ключах. JSON.parse падает. Промпт «верни JSON» не гарантирует.
Решение: responseMimeType: 'application/json' — нативный валидатор на стороне модели.
7.3 LLM не умеют считать символы
Проверка длины рекламного текста через LLM-критика. Тот подтвердил «длина в порядке». API ответил «Превышена допустимая длина» — ошибка на 5 символов из 81.
Решение: character-count — только кодом (string.length). LLM — для семантики, не для чисел.
7.4 Claude refusal text стал заголовком кнопки
Пакетный перевод UI-строк через Claude Haiku. Короткие ключи спровоцировали отказ: модель вернула «I'm sorry, I can't assist with that request» вместо перевода. Текст записался в JSON-файл как валидный перевод. На сайте кнопка показала «I'm sorry, I can't assist with that request» как label.
Решение: пост-обработка LLM-переводов с детекцией refusal-паттернов. CI-сканер падает при обнаружении таких текстов в файлах переводов.
Что из этого вышло
Эти семь классов — не теоретическая классификация. Каждый вырос из реальных инцидентов на живых проектах. Не все они — ошибки агентов. Часть — дефекты пайплайна, часть — каскадные отказы, где нет одного виноватого. Но у них есть общее свойство: каждый инцидент можно было предотвратить, если бы перед агентом (или перед человеком, запускающим агента) была явно сформулирована граница, которую нельзя переходить.
Вот что я вынес:
.git? Эту информацию нужно держать в проектной документации, которую агент читает до начала работы. И главное — не ждать, что агент «сам догадается».За полгода я превратил этот список в работающую систему: каждый проект имеет AGENTS.md с инвариантами, KNOWN_ISSUES.md с историей инцидентов, и gotchas.md с неочевидными правилами, которые агент читает до правки. Новая проблема → запись в документацию → та же проблема не повторяется.
Агенты не стали идеальными. Они по-прежнему не знают того, чего нет в контексте. Но у них теперь есть карта уже обнаруженных проблем — и они перестали открывать одни и те же люки во второй раз.
Что скопировать агенту
Ниже — конкретные инструкции, которые можно дать своему агенту. Каждый пункт привязан к инциденту из статьи: не абстрактный совет, а прямое противоядие. Файл с правилами называется AGENTS.md — это универсальное имя, его читают все AI-агенты (Claude, Gemini, Copilot), а не кто-то один. Вайбкодеру достаточно скопировать текст и отправить агенту — дальше он сам.
Базовый AGENTS.md
Скажи агенту: «Создай в корне проекта файл AGENTS.md с этими правилами. Адаптируй под наш стек — если у нас Python, а не JavaScript, поправь команды сборки. Если у нас нет тестов — добавь их создание в план».
# Правила для агента ## Перед тем как сказать «готово» 1. Код компилируется. Какая бы ни была команда сборки в этом проекте, она должна проходить. 2. Продакшен трогает ровно один способ. Если в проекте уже есть скрипт деплоя, только он. Если нет, ты должен его создать, а не жать кнопки вручную. 3. Каждый новый код-путь оставляет след. Если что-то пошло не так, это должно быть видно в логах, а не молча исчезнуть. ## Что тебе запрещено - Трогать работающий код, не связанный с задачей, даже «для красоты» - Удалять импорты, переменные, функции «за ненадобностью»: они могут быть нужны соседу - Писать в логи голые сообщения без контекста (какой пользователь, какой запрос) - Оставлять в коде «TODO» или «доделаю потом» без явной задачи в трекере ## Числа и конфигурация - Любая цена, лимит, версия модели: из живой документации провайдера (WebSearch), не из памяти - Если не уверен в числе: «нужна проверка», не придумывай ## Зоны максимального риска - Код, который меняет деньги или данные пользователя: проверяй дважды - Миграции базы данных: отдельная осторожность, не коммитить с будущей датой, тестировать на копии - Удаление файлов, таблиц, данных: только с явного разрешения
Чеклист «перед деплоем»
Скажи агенту: «Перед каждым деплоем проходи этот список и показывай мне результат».
## Pre-deploy checklist 1. Сборка проходит без ошибок -> без этого: инцидент 5.1 (прямой npm run build сломал blue/green) 2. Тесты проходят. Если тестов нет, создать хотя бы smoke-тест 3. Все новые цены и лимиты сверены с живой документацией провайдера -> без этого: инциденты 3.1 (цена в 24x ниже) и 3.2 (цена в 40x выше) 4. Все новые переменные окружения задокументированы 5. Все новые эндпоинты и фоновые задачи пишут в логи при ошибках -> без этого: инциденты 2.3 (print вместо logger) и 2.1 (Sentry не работал 70 дней) 6. Миграции базы данных протестированы на копии -> без этого: инциденты 5.2 (Alembic досрочно) и 5.3 (build до миграций) 7. Деплой, только через проектный скрипт или CI, не руками -> без этого: инциденты 5.1 и 5.4 (забытый rsync сломал SPA)
Чеклист «новая AI-модель»
Скажи агенту: «Когда добавляешь новую LLM-модель, проходи этот список».
## New model registration checklist 1. Название модели, точное, как в API провайдера (не из памяти модели) -> модель уверенно называет несуществующие версии, сверяй имя по живому списку API 2. Цена, с официальной страницы pricing, для всех типов токенов (input, output, thinking, cache) -> без этого: инциденты 3.1 (24x) и 3.2 (40x) 3. Формат usage-ответа, отдельный парсер под провайдера. OpenAI, Google и Anthropic отдают usage в разных полях -> без этого: инциденты 3.2 (thoughtsTokenCount не учтён) и 2.2 (Gemini-чат был бесплатным) 4. Провайдер-специфичные параметры: - Gemini: responseMimeType application/json для structured output -> инцидент 7.2 - Qwen: enable_thinking false для обычных ответов -> инцидент 3.3 - Gemini 3+: thoughtSignature обязан выживать через всю цепочку вызовов -> инцидент 7.1 - Проверить, что наружу к провайдеру уходит его API-ключ, а не внутренний токен авторизации 5. Streaming: на каких URL модель отдаёт поток, как помечает конец стрима -> без этого: инцидент 2.2 (streamGenerateContent не матчился) 6. 2-3 теста с реальным вызовом API (не mock)
Чеклист «новый проект»
Скажи агенту: «Создай проект. Затем, до первого коммита, заложи эти семь вещей».
## New project foundation (до первого коммита) 1. Файл AGENTS.md с правилами, по шаблону выше -> закрывает классы 1, 2, 3 и 5 2. Файл KNOWN_ISSUES.md, пока пустой, сюда будем записывать каждый инцидент -> чтобы один и тот же инцидент не повторялся 3. Файл docs/gotchas.md, пока пустой, сюда неочевидные правила фреймворка -> закрывает класс 4 (Zod, timezone, hydration, всё сюда) 4. Нормальное логирование: структурированное, с контекстом, не print() / console.log() -> без этого: инцидент 2.3 5. Мониторинг ошибок, подключён и проверен до первого деплоя -> без этого: инцидент 2.1 (70 дней без Sentry) 6. Один способ деплоя (скрипт или CI), который никто не обходит -> без этого: инциденты 5.1 и 5.4 7. CI, который падает при ошибках сборки или тестов, до первого коммита
Что покрывает каждый чеклист
| Класс инцидента | Закрывается |
|---|---|
| 1. Разрыв dev/prod | AGENTS.md + чеклист «новый проект» |
| 2. Молчаливые отказы | «перед деплоем» п.5 + «новый проект» п.4-5 |
| 3. Числа и конфигурация | AGENTS.md + «новая модель» п.2-3 + «перед деплоем» п.3 |
| 4. Контракты фреймворков | docs/gotchas.md — каждый новый контракт → запись |
| 5. Инфраструктура | «перед деплоем» п.1,6,7 + «новый проект» п.6-7 |
| 6. Изоляция | AGENTS.md (зоны риска) + опыт в KNOWN_ISSUES.md |
| 7. LLM-специфика | чеклист «новая модель» целиком |
Это не сделает проект неуязвимым. Но это закроет шесть из семи классов инцидентов — не потому, что агент стал умнее, а потому что ему явно сказали, где наступать нельзя.
// Обсуждение
Можно писать анонимно. Укажите email, чтобы получать уведомления об ответах.