«Тесты прошли», — сказал агент. Как я перестал верить ИИ на слово
Я не программист по профессии. Пятнадцать с лишним лет я занимаюсь платным трафиком и аналитикой, сейчас работаю Head of Growth. Код в моей жизни появился из практической нужды: выгрузки из рекламных API, n8n-воркфлоу, серверная передача конверсий. Раньше на такие задачи приходилось ждать разработчика неделями. Теперь я пишу их сам вместе с ИИ-агентами в терминале.
Это и есть вайб-кодинг в чистом виде: описываешь задачу словами, агент читает репозиторий, правит файлы, запускает команды и рапортует. Скорость поражает. Но довольно быстро выясняется неприятная вещь: о результате ты узнаёшь из отчёта самого агента. А отчёт пишет языковая модель.
Эта заметка не про конкретный инструмент. Она про привычки, которые я выработал, чтобы агент доказывал, что его код работает, а не просто это сообщал. Всё ниже можно применить с любым агентом.
Отчёт — это не результат
Первое, что приходится принять: фраза «всё работает, тесты зелёные» ничего не значит сама по себе. Типичные варианты того, что за ней стоит:
- тесты запускались, но на другом коммите;
- запустилась только часть тестов;
- тесты не запускались вовсе, а модель уверенно описала ожидаемый результат.
Отсюда правило номер один: тесты я запускаю сам или читаю их вывод целиком, а не пересказ. Если агент пишет «2 теста упали, но это не связано с задачей», я хочу видеть, какие именно и почему.
Правило номер два: результат проверки привязан к конкретному коду. Не «тесты прошли», а «тесты прошли на коммите такой-то и на таком-то состоянии файлов». Если после проверки поменялся хоть один файл, проверка считается устаревшей. Самая простая версия на коленке:
git rev-parse HEAD > .last-check
git diff --quiet && pytest -q && echo "OK on $(cat .last-check)"
Неидеально, но уже лучше, чем ничего: грязное дерево не даёт выдать проверку за чистую.
Контроль, который ничего не контролирует
Тут есть ловушка, которую я хорошо запомнил по журналу ошибок одного из своих проектов. Агенту нужно было доказать, что серия прогонов шла на одном и том же дереве файлов. Он записывал git rev-parse HEAD плюс git status --porcelain в начале и в конце. Значения совпали. Отлично?
Нет. Независимый ревьюер показал, что значение не меняется и при реальном изменении содержимого: между прогонами в файл с тестами добавился новый тест. git status --porcelain сообщает пути и статус, но не байты. Контроль выглядел как проверка свойства, но проверить его не мог.
Вывод, который записан в проекте: прежде чем доверять самодельной проверке, прогони её на заведомо плохом случае. Сломай ровно то, что она должна ловить, и убедись, что она сработала. Если проверка молчит на явной поломке, это не проверка, а декорация.
Автор не должен сам себя проверять
Следующая проблема тоньше. Одна и та же модель пишет код и потом «делает ревью». У автора и у ревьюера одинаковые слепые пятна, поэтому такое ревью находит опечатки, но пропускает ровно то, что модель не понимает.
Что я делаю:
- ревью делает отдельная сессия, а лучше агент другого семейства моделей;
- ревьюер работает на уже закоммиченном варианте, и в отчёте указан коммит, который он смотрел;
- если код поменялся после ревью, одобрение не переносится на новый коммит.
Честно скажу, что это не панацея. Если ревьюер из того же семейства моделей, что и автор, изоляция уменьшает общие слепые пятна, но не убирает их.
Зато бывает и обратное: ревьюер оказывается прав, а «автор» спорит. В журнале ошибок есть запись, где агент-исполнитель насчитал 352 элемента в рабочей копии, ревьюер в чистом клоне — 346. Исполнитель решил, что ревьюер ошибся. Прав оказался ревьюер, и это подтвердил CI-раннер. Урок: если измерения расходятся, перемеряй в свежем клоне, а не защищай свою цифру.
Тесты, которые всегда зелёные
Агенты обожают писать тесты, которые повторяют реализацию. Покрытие растёт, уверенность — нет. Тест проверяет, что функция делает то, что она делает.
Лекарство, которое реально работает, — мутационное тестирование. Идея простая: специально ломаешь код (меняешь > на >=, удаляешь проверку, инвертируешь условие) и смотришь, упадёт ли хоть один тест. Если не упал — тест слабый. Для Python есть mutmut, для JavaScript — StrykerJS.
У меня в проекте закрепилось правило для исправлений после ревью: фикс принимается, только если в отдельной копии воспроизведён исходный дефект и новый тест на нём покраснел. Исправление, которое ни разу не перевернуло проверку из зелёной в красную, — это утверждение о коде, а не доказательство.
Ещё одно правило про критерии приёмки. Их полезно формулировать через наблюдаемое конечное состояние. Например: «повторный запрос на оплату не должен создать второй платёж». Проверяется это по итоговому состоянию, а не по скриншоту, не по HTTP 200 и не по тесту, который зеркалит реализацию.
Мелочи, которые съедают время и токены
Отдельный класс проблем — не опасный, а просто дорогой.
Неправильные пути. Агенты часто угадывают путь к файлу «по привычке»: пишут lib/Service/X.php, когда реальный файл лежит в lib/X.php. Каждый промах — лишний вызов модели. Помогает простая вещь: в начале задачи подсунуть агенту реальные пути файлов, упомянутых в задаче. Например, вывод git ls-files | grep X.
Один и тот же упавший тест в пятый раз. Картина знакомая: агент перезапускает тот же упавший тест без единого изменения в коде и жжёт токены. Я ввёл для себя жёсткое правило: три одинаковых падения на неизменённом коде — стоп. Дальше либо новая диагностика, либо изменение кода, но не повторный запуск «на удачу».
Побочные эффекты в циклах. В журнале ошибок есть показательная история. Агент искал, почему инструмент считает текстовый файл бинарным, и в каждой итерации поиска создавал gist на GitHub. Набралось шесть gist'ов в аккаунте. Команда удаления не сработала из-за неправильно написанного флага, и агент решил, что удаление просто запрещено. Правило оттуда: любой диагностический цикл с побочными эффектами обязан печатать, что он создал, и убирать за собой. А команду очистки нужно один раз проверить вручную, прежде чем полагаться на неё в цикле.
Деньги: n8n и бесконечные циклы
Вот здесь мой маркетинговый опыт пересекается с кодом напрямую. Задача вида «сделай n8n-флоу, который генерирует видео, пока не получится хорошее» звучит безобидно. На деле это неограниченный цикл платных вызовов. Счёт придёт в конце месяца.
Что я считаю обязательным для любого кода, который ходит в платный API:
- явный лимит вызовов и денег, записанный в коде как константа;
- условие остановки: «стоп после N генераций или после первого принятого результата»
- тест, который проверяет, что лимит действительно работает;
- первый живой платный запуск — только с ручным подтверждением человека.
Пункт про тест важнее, чем кажется. Лимит, написанный в README или в конфиге, ничего не значит, если в коде цикл без счётчика. Отдельно стоит искать @retry без условия остановки: это тот же бесконечный цикл, только вежливый.
MAX_CALLS = 20
def generate_until_ok(prompt):
for attempt in range(MAX_CALLS):
result = call_paid_api(prompt)
if is_acceptable(result):
return result
raise RuntimeError(f"no result after {MAX_CALLS} paid calls")
Для рекламных и SEO-скриптов это, возможно, самая полезная привычка из всех: она защищает бюджет, а не только код.
Зависимости: красный CI — это хорошо
Агент с радостью добавит модную библиотеку «для удобства», а через неделю на неё выйдет уязвимость. Поэтому зависимости у меня закреплены по версиям, а CI проверяет их по базе уязвимостей OSV.
Живой пример из истории репозитория. Закреплённая версия pypdf 6.21.2 начала проваливать живую проверку OSV по семи уязвимостям и заблокировала обязательный Linux-CI. Зависимость подняли до 6.21.4 отдельным точечным исправлением. После этого в PR было зафиксировано 84 закреплённые зависимости без известных уязвимостей, 2800 пройденных тестов и покрытие 80,976%.
Неприятно, когда CI краснеет посреди работы над совсем другой задачей. Но ровно так и должно быть: уязвимость не проскочила, а остановила конвейер.
Main не трогаем, откат — через PR
Самая частая страшилка про агентов — git push --force в main. Или git reset --hard, после которого пропала работа. Или «почищу лишнее» через rm -rf. Или правка CI-воркфлоу, «чтобы прошло».
Мои правила тут скучные:
- агент работает только в отдельной ветке;
- в main попадает только через PR и внешнюю проверку на точном HEAD;
- защита ветки включена на стороне GitHub, а не только локально;
- если плохой код всё-таки попал в main, откат делается тоже через PR. Main не переписывается, теги не двигаются.
Локальные хуки и запреты в агенте полезны, но это не песочница. Процесс, запущенный в другом терминале или другим инструментом, их не увидит. Поэтому решающее слово — за внешней проверкой.
Честная цифра вместо красивой
Один эпизод мне особенно нравится как пример того, как стоит отчитываться. В релизной проверке одного проекта полный набор тестов прогоняли локально на Ubuntu 24.04 в 26 воркеров. Результат: 2219 тестов прошли, один упал. Этот тест чувствителен к нагрузке: в том прогоне он упал, а отдельно проходит.
В релизных заметках так и написано. Тест не спрятали и не перезапускали, пока не позеленеет. Мне кажется, это правильная норма и для людей, и для агентов: лучше «2219 из 2220, один нестабильный, вот какой», чем «всё зелёное».
Что не сработало и чего это стоит
Будет нечестно, если я опишу только плюсы.
Это медленнее. Отдельное ревью, перепроверка в чистом клоне, мутационные тесты — всё это время. На рискованных задачах процесс заметно длиннее. Однострочная правка опечатки не должна проходить тот же путь, что миграция схемы, иначе процесс начинает раздражать и его хочется обойти.
Ревьюеры тоже уходят в бесконечность. В журнале ошибок есть запись, как повторные проверки ревьюеров перед финалом превращали уже работающий этап в бесконечную цепочку новых пограничных случаев. Сдача задерживалась, время на проверки уходило. Есть и другая запись: четыре раунда исправлений закрывали конкретные названные случаи, а ревьюеры каждый раз находили эквивалентные проблемы. Строгость без границ — тоже проблема.
Токены платные. Второй агент-ревьюер, повторные прогоны, мутации — всё это оплачиваешь ты.
Однородность моделей. Даже при разных ролях ревьюер часто из того же семейства, что и автор. Общие слепые пятна никуда не деваются полностью.
Локальная защита не гарантия. Хуки и правила в агенте не заменяют защиту ветки на сервере, и настраивать её приходится самому.
Порог входа. Правила для агентов разрастаются. Чтобы новая сессия не наступала на старые грабли, я веду в репозитории два файла: mistakes. md с повторяющимися уроками и decisions. md с датированными решениями и причинами. Они реально помогают, но их тоже нужно поддерживать, а не только накапливать.
Эти проверки я со временем собрал в свой открытый инструмент, но почти всё описанное можно сделать руками: git, pytest, mutmut, защита ветки и немного дисциплины.
Коротко
Если сжать всё в одну мысль: относитесь к агенту как к подрядчику, которому вы пока не доверяете. Пусть работает быстро, но не объявляет свою работу готовой сам.
Мой минимальный набор:
- Тесты запускаю сам или читаю полный вывод.
- Результат проверки привязан к коммиту и состоянию файлов.
- Ревью делает не автор.
- Каждая самодельная проверка проходит тест на заведомо плохом случае.
- Слабые тесты ловлю мутациями.
- Три одинаковых падения подряд — стоп и новая диагностика.
- У любого платного цикла есть лимит в коде и тест на него.
- Зависимости закреплены и проверяются по базе уязвимостей.
- В main — только через PR, откат — тоже через PR.
Вайб-кодинг никуда не денется, и это хорошо. Просто скорость должна заканчиваться там, где код остаётся в проекте надолго.