Когда мы делегировали генерацию кода AI-агентам, на программистов легла дополнительная нагрузка. Разработчикам приходилось вычитывать код, в котором даже оператор агента разбирается поверхностно. Из-за этого 50% времени, сэкономленного на создании кода, сжигалось на его ревью. 

Я Александр Кальницкий, архитектор Mindbox. Для меня было пыткой вычитывать диффы на 10k строк, которые агент создал за час. В статье расскажу, как перестроил работу с AI-агентами, чтобы не тратить время на ревью нейросгенерированного кода.

Материал будет полезен разработчикам, которые используют AI-агентов для крупных задач и хотят тратить меньше времени на код-ревью.

Навайбкодил, но не ревьюил: подход spec-review

С декабря 2025 года в бэклоге нашей команды висела задача разработать микросервис для брендирования ссылок на клиентских доменах. Когда она появилась, ее оценили в три месяца работы для команды из трех человек. Свободных рук у нас не было, поэтому я решил заделиверить задачу с помощью AI-агента. В результате за два месяца факультативной работы я получил от него микросервис из 11k строк продакшен-кода и 18k строк тестов. В процессе разработки я не потратил ни минуты на код-ревью.

Еще по горячим следам взялся переделать легаси-фичу для отправки рассылок по часовым поясам. Мы разработали ее пять лет назад и все это время улучшали, дорабатывали и фиксили баги. Архитектура фичи с самого начала была небезупречной, так что за пять лет там накопился большой техдолг. Мы с агентом перенесли ее на новую архитектуру, полностью переписав код. Фича ушла в продакшен также без классического код-ревью, а работа над ней заняла неделю.

Чтобы сгенерировать AI-код, я использую собственный подход. Его суть в том, что команда проектирует, обсуждает и утверждает дизайн-документ до того, как поставить агенту задачу на генерацию кода. На этом же этапе мы принимаем ключевые архитектурные решения. Агент работает по утвержденной спецификации, а за качество кода отвечает оператор AI-агента. Все это позволяет перенести ревью на спеку: такой подход можно условно назвать spec-review.

Чтобы убедиться, что агент написал код по утвержденной спецификации, я использую набор методов и инструментов:

— приемочные тесты на языке Gherkin,

— интеграционные тесты,

— мутационное тестирование,

— DoD при составлении плана и субагенты-ревьюеры.

Дополнительно с помощью автоматического чек-листа проверяю:
— соответствует ли схема БД модели данных,

— актуальна ли документация API приложения,

— соблюдаются ли зависимости между компонентами.

Работоспособность подхода я подтвердил эмпирически: микросервис для брендирования ссылок и фича отправки по часовым поясам уже месяц в проде без единого инцидента. При этом фича работает под нагрузкой с ежечасными пиками до 30k запросов в секунду.

Сначала сделал спеку: формат и три архитектурных слоя

AI не способен придумать архитектуру под нетривиальную фичу. Он может предложить нечто правдоподобное, но, скорее всего, неправильное. На кривой архитектуре фича не эволюционирует: агент переписывает ее целиком каждый раз, создавая огромные MR на тысячи строк. При этом все, что делает код понятным человеку, делает его понятным и агенту. Только в случае работы с агентом требования к коду еще важнее, потому что у AI нет ни интуиции старожила проекта, ни возможности спросить автора или посоветоваться с коллегой.

Я никогда не пускаю агента писать код по описанию задачи. Сначала создаю спецификацию — короткий дизайн‑документ, который можно прочитать за 5–10 минут. Внутри выделяю три архитектурных слоя и описываю правила для каждого:

1. Функциональное ядро, логика которого описана алгебраическими типами и чистыми функциями. Агенту легко с ними работать, потому что они целиком помещаются в контекст и тестируются без моков. Человеку чистые функции тоже понятны, потому что их поведение читается по сигнатуре. Принципы, которые агент соблюдает при проектировании ядра:

Make illegal states unrepresentable

Каждый инвариант обеспечивается системой типов. Если невалидное состояние невыразимо, то проверка не нужна

Value objects instead of primitives

Значимые данные оформляются как Value objects. Конструктор проверяет инварианты и не позволяет создать объект с некорректным значением

Parse, don’t validate

Входные данные сразу преобразуются в корректные типы, поэтому невалидные значения в ядро не попадают

Total functions

Для любых допустимых входных данных функция возвращает предусмотренный результат и не завершается внезапным исключением

2. Оболочка, которая предоставляет тонкие интерфейсы к внешнему миру: БД, Kafka, HTTP. В ней же реализуется обвязка для надежности: транзакции, ретраи, таймауты, кеши. По возможности обеспечивается идемпотентность, а для доставки сообщений по умолчанию используется гарантия at‑least‑once. Иными словами, оболочка делает то, что ядро не может выразить типами.

3. Оркестрация, представленная, например, контроллерами или консьюмерами. Она дергает оболочку, передает результат в ядро и применяет решение ядра.

Помимо архитектурных правил, я использую единый шаблон спеки. В нем предусмотрено восемь разделов:

Раздел спецификации

Описание раздела

Описание проблемы

Пара предложений о том, что сейчас работает плохо и какую пользу должна принести задача

DoD задачи

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

Короткая сводка изменений

Нумерованный список файлов, слоев и компонентов, которых коснется задача. Для каждого пункта по одному предложению с описанием того, что делаем и что изменится

Функциональное ядро

Описание бизнес‑логики, типов и сигнатур функций. Для сложной логики добавляем алгоритм, а для возможных ошибок — способы их представить в коде

Оболочка

Описание интерфейсов для работы с внешними системами, гарантии этих интерфейсов, хранимые данные и инварианты. Здесь же перечисляем интеграционные сценарии, метрики и формат логов

Workflows

Описание для каждого изменяемого пользовательского или системного сценария. Что запускает сценарий, какую задачу он решает, через какие состояния проходит, какие правила соблюдает и чем может закончить работу. Здесь же описываются порядок шагов, приемочные тесты, health‑метрики и поведение при сбоях

Удаляемый код

Список файлов, классов и методов, которые больше не нужны. Отдельно отмечаем код, который пока нужно сохранить

Что запишем в write-ahead log, он же WAL

Список архитектурных изменений, которые нужно сохранить в журнале проекта: что решили изменить, почему выбрали конкретный вариант и какие риски остались

Перечень разделов не высечен в граните — они зависят от задачи, так как не всегда нужно удалять код или править модель.

Структура спеки по шаблону позволяет мне задавать нужное направление генерации кода, понимать все ключевые изменения и видеть его общую архитектуру.

Когда спека эволюционирует, в первую очередь я проверяю:

— типы и сигнатуры функций в функциональном ядре,

— интерфейсы адаптеров и их гарантии в оболочке.

В этих местах ловлю 90% потенциальных проблем. Например, null там, где поле должно быть обязательным, неверные паттерны, лишние компоненты или расплывчатые наименования.

Если тут все чисто, то код почти наверняка будет правильным.

Делюсь вырезками из настоящего дизайн-документа фичи для отправки рассылок по часовым поясам:

Часть спеки про ядро

Core/ не имеет using на Kafka, Microsoft.Extensions.*, Confluent.Kafka — ноль I/O.

Решение продюсера — ADT, не bool + nullable:

    public abstract record ProducerDecision {

        public sealed record SendNow                            : ProducerDecision;

        public sealed record DeferUntilWindow(UtcWindow Target) : ProducerDecision;

        public sealed record DeadlineExceeded(DeadlinePolicy Policy) : ProducerDecision;

    }

    public abstract record DeadlinePolicy {

        public sealed record Discard    : DeadlinePolicy;   // протух — выбросить

        public sealed record SendAnyway : DeadlinePolicy;   // протух — слать все равно

    }

Окно — value object со smart constructor; ошибка через Result, не throw:

    SendingWindow.Parse(start, end)

        → StartNotBeforeEnd | NotAlignedTo15Min | OutOfRange | Ok(SendingWindow)

Инвариант «выровнено на 15 минут» и «Start < End» нарушить невозможно — нет

публичного конструктора, значение рождается только через Parse.

Время инжектится, не берется из DateTime.Now — Core остается pure:

    public interface IClock { DateTimeOffset UtcNow { get; } }

    // Decide/Evaluate принимают DateTimeOffset now параметром

Логика выбора окна, пересчета на завтра, перехода через полночь, конвертация в UTC с учетом перевода часов — все это чистые функции. Их тестирует unit-проект без моков.

Часть спеки про оболочку

Публичный контракт продюсера — результат тоже ADT:

    public interface ISendingWindowsProducer<T> {

        Task<SubmitResult> SubmitAsync(DeliveryEnvelope<T> envelope, CancellationToken ct);

    }

    public abstract record SubmitResult {

        public sealed record SentToOutbound                  : SubmitResult;

        public sealed record DeferredToPending(string Topic) : SubmitResult;

        public sealed record Rejected(SubmitError Error)     : SubmitResult;

    }

Алгоритм SubmitAsync — оркестрация, не логика:

  1. decision = SendingWindowDecisions.Decide(window, tz, clock.Now)   // ← вызов ядра

  2. SendNow            → outbound.SendAsync(envelope, cts(5s)) → SentToOutbound

  3. DeferUntilWindow t → ProduceAsync(topicFor(t), envelope.Key, json) → DeferredToPending

  4. DST-дыра в расчете → Rejected(DstNonexistentLocalHour)

Гарантия downstream-интерфейса (это и есть «контракт оболочки» из спеки):

    public interface IOutboundProducer<T> {

        Task SendAsync(DeliveryEnvelope<T> envelope, CancellationToken ct);  // SLA: ≤ 5с или throw

    }

Тут описываются тонкий интерфейс и явные гарантии. Никакой бизнес-логики: она осталась в ядре, а оболочка только дергает ядро и применяет результат к Kafka.

Гарантия надежности описана правилом:

Ни одно сообщение не уходит вне своего окна. Сбой SendAsync за 5 секунд → нет commit оффсета → at-least-once: сообщение перечитается на следующем круге. Нарушение инварианта (now < window.Start) → throw → рестарт хоста → новый ассайн партиций.

Это и есть то «невыразимое типами», ради чего оболочка существует.

Принимаю код тестами: ядро под мутациями, оболочка на реальной инфре, поведение в BDD

Поскольку мы исключили ревью кода, его работоспособность подтверждаем тестами. В обоих проектах, которые я заделиверил с AI‑агентом, строк тестов больше, чем строк продакшен‑кода:

— в микросервисе для брендирования ссылок 18k против 11k,

— в фиче по часовым поясам 5k против 1,5k.

Типы тестов распределяю по слоям архитектуры:

  1. Ядро покрыто unit‑тестами без моков. Чтобы убедиться, что unit‑тесты вообще что‑то проверяют, я добавляю мутационное тестирование. Для этого использую Stryker — опенсорсный инструмент, который ломает продакшен‑код. Например, меняет < на >, + на – или вообще удаляет кусок кода. Unit‑тесты на таких «мутантах» должны упасть. Для агента я устанавливаю цель в 80% «убитых мутантов». Он не сдаст задачу, пока не достигнет этого показателя. На отдельные сложные алгоритмы добавляю property‑based тесты, которые обговариваю с агентом заранее.

  2. Оболочку покрываю интеграционными тестами и запускаю их на реальной инфраструктуре через Testcontainers, без моков для БД и брокеров. Так сразу видно, работает ли адаптер с настоящим Postgres, а не только с его упрощенной имитацией.

  3. На этапе планирования для всего сервиса описываю приемочные тесты в формате Gherkin. У агента есть несколько блоков с инструкциями по «антирационализации», чтобы он не оправдывал упрощения, когда пишет тесты. К приемочным тестам применяю BDD‑подход. Пока не реализованы все шаги сценария, тесты не соберутся. Задача считается готовой, когда все сценарии прошли. Для микросервиса получилось 78, а для фичи — 17 сценариев основной логики и еще 3 интеграционных. Такие тесты гарантируют работоспособность продукта.

Агент пишет код по TDD, соблюдая принцип Test‑First, Watch It Red: любой unit‑ или интеграционный тест пишется и выполняется до изменения основного кода. Сначала тест фиксируется как красный и зеленеет только после внесения изменений. Зеленому тесту с первого запуска доверяться не стоит: он либо проверяет уже существующее поведение, либо у него слишком слабый assert. Принцип Watch It Red ловит оба случая.

Набил шишки: что стоит учесть при внедрении spec-review

Чтобы не показаться восторженным апологетом вайбкодинга, расскажу об ограничениях и сложностях, с которыми столкнулся, когда осваивал spec‑review.

Вероятность критически ошибиться никуда не делась. Только раньше она была выше в коде, а теперь — в дизайн‑документе. Например, в микросервисе для брендирования ссылок я опирался на продуктовое решение, которое к моменту разработки устарело и не учитывало всех нюансов DNS‑спецификации. Из‑за этого я дважды адаптировал спецификацию и архитектуру в ней под новые условия, а агент переписывал код. Перед проектированием стоило бы на практике проверить самые рискованные предположения о работе с DNS в виде небольших PoC. Это сэкономило бы мне время и 30% токенов.

Нужна модель с большим контекстным окном — от 1 млн токенов. Подход spec‑review не работает на слабой модели с контекстом 100k токенов.

Нефункциональные требования нужно указывать явно. Если не обозначить в спеке ожидаемый RPS и прочие NFR, агент начинает гадать: добавляет ненужный кеш, выбирает неподходящие таймауты, закладывает слишком мало параллельных обработчиков или не учитывает пиковую нагрузку.

Контекст лучше хранить прямо в репозитории. Созданный в корне репозитория файл AGENTS.md может содержать краткие инструкции, как вести разработку именно в этом репозитории: где находится source of truth поведения или какие шаги пайплайна дергать. Этот контекст агент будет подхватывать в начале каждой сессии. Здесь же можно попросить его вести write‑ahead log решений. WAL — это последовательный лог ADR, принятых в репозитории. Агент фиксирует в логе новые архитектурные решения по мере разработки. WAL легко читать и через полгода, в отличие от git blame. 

Оператор агента должен быть опытным. Для большой фичи нужна продуманная строгая архитектура, которую агент будет поддерживать и точечно править код. Если архитектура не продумана, агент каждый раз переписывает код с нуля. Чтобы применять метод spec‑review для разработки больших проектов, нужен опытный разработчик со знанием предметной области и опытом системного проектирования. Задача оператора — утвердить дизайн фичи и проверить, что агент реализовал его в коде.

Популяризировал spec-review в команде: бот в мессенджере и редактор для спек

Когда работоспособность spec‑review подтвердилась, я решил внедрить его для всей команды и добавил два инструмента:

  1. Бот в корпоративном мессенджере. С агентом теперь можно работать прямо из чата: ставить задачи и получать ссылки на MR. К слову, бота я тоже написал по spec‑review. Сейчас им пользуются в среднем 30 человек в день.

  2. Plannotator — сервис для составления дизайн‑документа, по которому агент будет генерировать код. С его помощью удобно комментировать спеку и вносить в нее изменения. Я развернул Plannotator в режиме сервера в нашей сети, и теперь любой человек в компании может поделиться планом с командой.

Например, сейчас мы делаем быстрые фиксы по такому воркфлоу:

Шаг 1. Поддержка приносит задачу на улучшение.

Шаг 2. Я добавляю бота в беседу. 

Шаг 3. Тегаю его, описываю задачу и прошу набросать спеку реализации в Plannotator. 

Шаг 4. Агент составляет спеку, я ее корректирую и при необходимости отправляю на ревью коллеге.

Шаг 5. Когда спецификацию утвердили, агент вносит изменения в код и тут же присылает ссылку на MR.

Шаг 6. MR автоматически мерджится.

В репозитории, который развивается по spec‑review, со временем накапливаются коммиты с качественными изменениями. Агент ориентируется на эти стандарты и пишет по ним код, поэтому совсем уж мелкие правки не требуют спеки. Иногда достаточно просто позвать бота в чат и сказать «поправь».

Разбираем баг в треде. Прошу бота выяснить, учитывается ли признак Intent при определении статуса домена
Разбираем баг в треде. Прошу бота выяснить, учитывается ли признак Intent при определении статуса домена
Прошу бота сделать так, чтобы для доменов с Intent = Revoked статус определялся как NOT_CONFIGURED независимо от текущего состояния. Бот прислал ссылку на MR
Прошу бота сделать так, чтобы для доменов с Intent = Revoked статус определялся как NOT_CONFIGURED независимо от текущего состояния. Бот прислал ссылку на MR

Бонус тем, кто дочитал до конца: инструкция для проектирования ядра

Делюсь сокращенным файлом с инструкцией, которой агент руководствуется при проектировании функционального ядра.

FUNCTIONAL_CORE.md

# Functional Core — короткий референс

## Iron Rule

> Если инвариант может быть выражен типами — он выражается типами.

> Shell берет на себя только то, что принципиально невыразимо: уникальность

> в распределенном хранилище, атомарность двух I/O-операций, идемпотентность

> повторной доставки, устойчивость к отказам сети.

Любой guard clause, nullable-поле или throw внутри Core — сигнал, что инвариант

не выражен, а проверка выполняется постфактум.

## Принципы

1. Functional Core / Imperative Shell — бизнес-логика это pure функции;

   I/O только на границе. Core не знает о Shell.

2. Parse, don't validate — невалидные данные отсекаются на входе через типы.

3. Make illegal states unrepresentable — каждый инвариант обеспечен типами.

4. Total functions — функция определена на всем домене входного типа.

   Нет nullable как скрытый union, нет exceptions для доменных failures.

5. Иммутабельность по умолчанию — любое «изменение» = новая версия.

6. ADT вместо наследования — варианты через sealed DU/record + pattern matching.

7. Ошибки через DU с доменными именами вариантов

   (TransferCompleted | InsufficientFunds | AccountFrozen) — не bool, не null, не throw.

8. Композиция вместо оркестрации в Core — оркестрация I/O живет в Shell.

## Good / Bad на C#

Bad — nullable + строковый код:

    public class TransferResult {

        public bool Success { get; set; }

        public string? ErrorCode { get; set; }   // "INSUFFICIENT_FUNDS" | null

        public decimal? NewBalance { get; set; }

    }

Good — ADT:

    public abstract record TransferResult {

        public sealed record Completed(Money NewBalance) : TransferResult;

        public sealed record InsufficientFunds(Money Available, Money Requested) : TransferResult;

        public sealed record AccountFrozen(DateTime Since, string Reason) : TransferResult;

    }

Bad — guard clauses:

    public void SendWelcome(string email) {

        if (string.IsNullOrWhiteSpace(email)) throw new ArgumentException();

        if (!email.Contains('@'))             throw new ArgumentException();

    }

Good — smart constructor:

    public sealed record Email {

        public string Value { get; }

        private Email(string value) => Value = value;

        public static Result<Email, EmailError> Parse(string raw) =>

            string.IsNullOrWhiteSpace(raw) ? EmailError.Empty

            : !raw.Contains('@')           ? EmailError.MissingAt

                                           : new Email(raw);

    }

    public void SendWelcome(Email email) { /* без проверок */ }

Bad — enum + if как state machine:

    public void Ship(string tracking) {

        if (Status != OrderStatus.Paid) throw new InvalidOperationException();

        Status = OrderStatus.Shipped; ShippedAt = DateTime.UtcNow;

    }

Good — состояния как типы:

    public sealed record PaidOrder(OrderId Id, Money Paid);

    public sealed record ShippedOrder(OrderId Id, Money Paid, DateTime ShippedAt, TrackingNumber Tracking);

    public static ShippedOrder Ship(PaidOrder o, TrackingNumber t, DateTime now) =>

        new(o.Id, o.Paid, now, t);

    // Ship(pendingOrder, ...) — compile error. ShippedAt забыть невозможно.

## Verification Checklist (ревью ядра не пройдено, пока не отмечено все)

- [ ] ADT покрывает все состояния — нет default без UnreachableException

- [ ] Инварианты защищены конструктором (parse, don't validate) — нет guard clauses

- [ ] Value objects иммутабельны (record + init; никаких { get; set; })

- [ ] Pattern matching exhaustive

- [ ] В pure-функциях нет DateTime.Now / Random / Guid.NewGuid / I/O — инжектятся из Shell

- [ ] Нет null как сигнала отсутствия → Option<T> / вариант ADT

- [ ] Нет throw как control flow для доменных ошибок → Result<T, E> / DU

- [ ] Переходы состояний принимают конкретный тип-состояние (Ship(PaidOrder), не Ship(Order))

 

Комментарии (1)


  1. Alex_Kond
    20.08.2026 07:57

    Перешли с ревью кода на ревью спеки? Без подключения к работе опытных специалистов в любом случае никак. В целом конечно да, чем детальнее распишешь ИИ задачу (пусть через спеку), то более хороший будет результат. Подход интересный