«Тесты прошли», — сказал агент. Как я перестал верить ИИ на слово

Пост опубликован в блогах iXBT.com, его автор не имеет отношения к редакции iXBT.com
Изображение сгенерировано нейросетью Grok (xAI)

Я не программист по профессии. Пятнадцать с лишним лет я занимаюсь платным трафиком и аналитикой, сейчас работаю Head of Growth. Код в моей жизни появился из практической нужды: выгрузки из рекламных API, n8n-воркфлоу, серверная передача конверсий. Раньше на такие задачи приходилось ждать разработчика неделями. Теперь я пишу их сам вместе с ИИ-агентами в терминале.


Это и есть вайб-кодинг в чистом виде: описываешь задачу словами, агент читает репозиторий, правит файлы, запускает команды и рапортует. Скорость поражает. Но довольно быстро выясняется неприятная вещь: о результате ты узнаёшь из отчёта самого агента. А отчёт пишет языковая модель.

Эта заметка не про конкретный инструмент. Она про привычки, которые я выработал, чтобы агент доказывал, что его код работает, а не просто это сообщал. Всё ниже можно применить с любым агентом.

Отчёт — это не результат

Изображение сгенерировано нейросетью Grok (xAI)

Первое, что приходится принять: фраза «всё работает, тесты зелёные» ничего не значит сама по себе. Типичные варианты того, что за ней стоит:

  • тесты запускались, но на другом коммите;
  • запустилась только часть тестов;
  • тесты не запускались вовсе, а модель уверенно описала ожидаемый результат.

Отсюда правило номер один: тесты я запускаю сам или читаю их вывод целиком, а не пересказ. Если агент пишет «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 и бесконечные циклы

Изображение сгенерировано нейросетью Grok (xAI)

Вот здесь мой маркетинговый опыт пересекается с кодом напрямую. Задача вида «сделай 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, защита ветки и немного дисциплины.

Коротко

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

Мой минимальный набор:

  1. Тесты запускаю сам или читаю полный вывод.
  2. Результат проверки привязан к коммиту и состоянию файлов.
  3. Ревью делает не автор.
  4. Каждая самодельная проверка проходит тест на заведомо плохом случае.
  5. Слабые тесты ловлю мутациями.
  6. Три одинаковых падения подряд — стоп и новая диагностика.
  7. У любого платного цикла есть лимит в коде и тест на него.
  8. Зависимости закреплены и проверяются по базе уязвимостей.
  9. В main — только через PR, откат — тоже через PR.

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

Читайте также

Новости

Публикации