LLM говнокодит не хуже людей. Только быстрее

от автора

Полгода назад я начал писать пет-проект практически полностью с помощью LLM-агентов. Первые недели это был чистый дофамин: фичи вылетали за вечер, я успевал за неделю столько, сколько раньше делала небольшая команда. А потом всё начало вязнуть — ровно так же, как вязнет любой проект с говнокодом. Каждая новая фича ломала две старые, агент правил код «на ощупь», а я всё больше времени тратил не на продукт, а на разгребание.

Эта статья — про то, что я понял и что в итоге сработало: почему LLM деградирует на плохой архитектуре точно так же, как команда людей; почему классика (SOLID, DDD, чистая архитектура) не только не устарела, но стала важнее; и как превратить архитектурные договорённости из «пожеланий в CLAUDE.md» в контракт, который машина не может нарушить. С конкретикой: два конфига deptrac для Symfony-бакенда и пайплайн разработки с субагентом-ревьювером.

Медовый месяц и похмелье

Проект — сервис AI-обработки изображений: пользователь загружает фото, выбирает инструмент, платит кредитами, получает результат. Symfony на бакенде, Next.js на фронте, ничего экзотического.

Первый месяц vibe coding выглядел как реклама будущего. «Добавь оплату кредитами» — готово. «Сделай очередь обработки с ретраями» — готово. Я почти не читал код, только проверял, что работает.

К третьему месяцу картина изменилась:

  • Агент добавляет проверку баланса — и она оказывается четвёртой реализацией этой проверки в проекте, чуть-чуть отличающейся от трёх предыдущих.

  • Правка в модуле обработки фото ломает админку, потому что они, оказывается, делят одну общую «помойку» — сервис на полторы тысячи строк.

  • Каждый диалог с агентом начинается с десяти минут «изучения кодовой базы», и всё равно он регулярно промахивается.

Знакомо? Это же классическая кривая легаси-проекта. Скорость внедрения фич падает по той же траектории, что и у команды людей — просто у LLM весь цикл «от стартапа до болота» занимает не три года, а три месяца.

И это логично, если вспомнить, на чём модель училась. LLM обучен на человеческом коде с GitHub — включая весь говнокод, которого там статистически больше, чем хорошего кода. Модель не знает «как правильно», она знает «как обычно». А «как обычно» — это god-объекты, копипаста и логика, размазанная по слоям.

Контекстное окно — это рабочая память джуна

Ключевая механика деградации простая.

LLM видит только то, что влезло в контекст. Чтобы безопасно изменить код, агент должен «загрузить в голову» всё, что этим кодом затронуто. И тут вступает связность:

  • Низкий coupling: правка локальна, агенту хватает одного-двух файлов, он делает изменение уверенно и правильно.

  • Высокий coupling: чтобы понять последствия правки, нужно прочитать 20 файлов. Агент читает 5, про остальные не догадывается — и правит на ощупь. Что-то ломается в месте, которое в контекст не попало.

Отсюда главный тезис статьи:

Архитектура — это не эстетика. Это способ минимизировать объём контекста, необходимый для одного изменения.

Хорошая архитектура делает изменения локальными. Локальные изменения влезают в контекстное окно целиком. Значит, LLM-friendly код — это ровно тот же код, который friendly для новичка в команде: понятный за пять минут, а не за две недели онбординга. Всё, что мы двадцать лет рассказывали друг другу про качество кода «для будущих поколений разработчиков», внезапно стало иметь мгновенный, измеримый эффект: либо агент справляется с фичей за итерацию, либо вы платите токенами и своим временем за его блуждания.

SOLID, DRY, KISS, YAGNI — теперь с LLM-оптикой

Классические принципы никуда не делись, но у каждого появилось новое прочтение.

SRP. Класс с одной ответственностью — маленький. Маленький класс целиком влезает в контекст вместе со своими тестами. Правка не задевает чужую ответственность, потому что чужой ответственности в файле нет. God-объект же гарантированно не влезет в голову агента вместе со всеми своими использованиями.

OCP + DIP. Если новая фича — это новый класс, реализующий существующий интерфейс, агенту вообще не нужно трогать старый код. Он смотрит на интерфейс и одну соседнюю реализацию как на образец — и пишет новую. Риск регрессии около нуля. Если же новая фича — это if в десяти местах, агент найдёт восемь из них.

DRY. У LLM патологическая тяга к копипасте. Ему всегда «дешевле» сгенерировать проверку заново, чем найти существующую: генерация — это его естественный режим, а поиск по кодовой базе — дорогая операция, съедающая контекст. Без внешнего контроля вы получите пять версий одной валидации, и при изменении бизнес-правила агент обновит три из них.

KISS + YAGNI. А вот тут обратная беда. LLM обожает «на всякий случай»: фабрику для класса с одной реализацией, конфигурируемость, которую никто не просил, слой абстракции над слоем абстракции. Выглядит солидно, ревью проходит легко («ну, аккуратно же написано»), а через месяц эта преждевременная гибкость становится тем самым лишним контекстом, который мешает следующим правкам.

Из этого следует неприятный вывод: знать принципы самому уже недостаточно. Когда код пишет агент со скоростью тысячи строк в час, ревьюить его глазами на соответствие архитектуре невозможно — вы станете бутылочным горлышком или начнёте пропускать. Принципы нужно кодифицировать так, чтобы их проверяла машина. К этому и придём.

DDD: бакенду — да, фронтенду — нет

Прежде чем перейти к инструментам — два слова про доменно-ориентированное проектирование, потому что именно оно даёт LLM самое ценное: карту.

Ограниченные контексты и единый язык — это, по сути, способ разбить систему на куски, каждый из которых можно понять изолированно. Для агента неймспейс Core\Billing\Domain\Entity\CreditBalance — это уже документация: понятно, что это, где живёт и кого касается, ещё до чтения кода. Ubiquitous language работает с LLM даже лучше, чем с людьми: модель отлично оперирует словами, и если код называет вещи так же, как их называет постановка задачи, полдела сделано.

Но выстраданное уточнение: DDD — это про бакенд. Именно там живёт то, ради чего DDD придуман: инварианты, транзакции, бизнес-правила, согласованность данных.

Я честно пробовал тащить ту же структуру на фронтенд — entities, репозитории, use cases в React-приложении. Получился оверинжиниринг в чистом виде: слои ради слоёв. Фронтенд — это отображение и UX-состояние. Его домен — «что сейчас на экране и что произойдёт по клику», а не «может ли пользователь потратить кредиты». Бизнес-инварианты фронту доверять всё равно нельзя — их проверит бакенд. Фронту хватает компонентного подхода или feature-sliced design, а агенту на фронте нужна не доменная модель, а типизированный API-клиент, сгенерированный из контрактов бакенда.

(Дисклеймер для комментариев: да, существуют rich-client приложения с настоящей доменной логикой на клиенте — графические редакторы, офисные пакеты. Речь про типичные продуктовые SPA и мобильные приложения.)

Пример: модульный монолит на Symfony

Дальше конкретика. Бакенд проекта устроен так:

apps/backend/├── core/                  # framework-agnostic бизнес-логика│   ├── Auth/│   ├── Billing/           # кредиты, тарифы, платежи│   ├── Media/             # хранение файлов│   ├── Task/              # оркестрация AI-пайплайна│   ├── Tool/              # каталог инструментов обработки│   ├── Monitoring/        # здоровье AI-провайдеров│   ├── Shared/            # примитивы: Money, Uuid, Clock...│   └── Port/              # ICommandBus, IQueryBus, IJobDispatcher└── src/                   # Symfony-адаптеры    ├── Api/               # тонкие HTTP-контроллеры    └── Infrastructure/    # реализации портов: Doctrine, S3, шины

Каждый контекст внутри повторяет одну и ту же структуру:

core/Billing/├── Domain/        # Entity, ValueObject, Enum, Exception, Repository (интерфейсы)├── Application/   # Command + Handler, Query + Handler, Job, View (DTO ответов)└── Port/          # интерфейсы наружу: IPaymentGateway...

Правила игры:

  • core/ не знает про Symfony, HTTP и Doctrine. Вообще.

  • Контроллеры тонкие: валидируют вход, диспатчат Command/Query через порт шины, возвращают View. Вся логика — в хендлерах.

  • Зависимости между контекстами направленные: Task оркестрирует пайплайн, поэтому ему можно видеть Billing (списать кредиты), Media (взять файл) и Tool (взять рецепт обработки). Monitoring видит только Task. Листовые контексты (Auth, Billing, Media, Tool) не знают ни о ком, кроме Shared.

Однотипность структуры — это отдельный подарок агенту: «новая фича = новый Command + Handler, вот три соседних примера» работает почти безотказно. LLM — машина продолжения паттернов; дайте ей чистый паттерн, и она будет его воспроизводить. Дайте ей зоопарк — и она будет воспроизводить зоопарк.

Проблема в одном: сами по себе эти правила — джентльменское соглашение. И LLM — не джентльмен.

Deptrac: архитектура, которую нельзя нарушить

Сначала я описал правила слоёв в CLAUDE.md. Агент их читал. И всё равно нарушал — не назло, а потому что в обучающей выборке инжектить репозиторий куда попало «нормально». Пара реальных примеров из моих диффов:

// core/Task/Domain/Entity/ProcessingTask.phpuse App\Infrastructure\Task\DoctrineTaskRepository; // инфраструктура в домене// core/Monitoring/Application/GetProviderHealthHandler.phpuse Core\Billing\Domain\Entity\CreditBalance;       // чужой контекст напрямую

Работает? Работает. Тесты проходят? Проходят. PHPStan молчит? Молчит. А архитектура уже потекла, и следующие правки поедут по этой колее.

Вывод, к которому я пришёл: правила, которые не проверяются автоматически, для LLM не существуют. Нужен enforcement — на pre-commit и в CI. Для PHP это deptrac: статический анализатор, который строит граф зависимостей между классами и проверяет его против декларативных правил. Слои описываются коллекторами (по неймспейсу, регулярке, атрибуту), ruleset говорит, кому кого можно видеть. Любая зависимость вне правил — violation, ненулевой код выхода, красный CI.

Конфиг №1: вертикальные слои

Первый конфиг режет систему горизонтальными пластами — классическая луковица чистой архитектуры. Упрощённая версия:

# deptrac.yaml — вертикальное разделение слоёвdeptrac:  paths: [./src/, ./core/]  layers:    - name: Api      collectors:        - { type: classNameRegex, value: '#^App\\Api\\#' }    - name: Infrastructure      collectors:        - { type: classNameRegex, value: '#^App\\Infrastructure\\#' }    - name: Domain      collectors:        - { type: classNameRegex, value: '#^Core\\.*\\Domain\\#' }    - name: Application      collectors:        - type: bool          must: [{ type: classNameRegex, value: '#^Core\\.*\\Application\\#' }]          must_not:            # контракты выносим в отдельный слой            - { type: classNameRegex, value: '#(Command|Query|View|Job)$#' }    - name: Contracts        # CQRS-DTO и порты — публичная поверхность core      collectors:        - { type: classNameRegex, value: '#^Core\\.*\\Application\\.*(Command|Query|View|Job)$#' }        - { type: classNameRegex, value: '#^Core\\.*\\Port\\#' }        - { type: classNameRegex, value: '#^Core\\Port\\#' }    - name: Shared      collectors:        - { type: classNameRegex, value: '#^Core\\Shared\\#' }    - name: Symfony      collectors:        - { type: classNameRegex, value: '#^Symfony\\#' }    - name: Doctrine      collectors:        - { type: classNameRegex, value: '#^Doctrine\\#' }  ruleset:    Api: [Contracts, Shared, Symfony]        # контроллер видит только DTO и порты    Infrastructure: [Contracts, Application, Domain, Shared, Symfony, Doctrine]    Application: [Domain, Contracts, Shared]    Contracts: [Domain, Shared]    Domain: [Shared]                          # домен не знает ни о чём    Shared: ~    Symfony: ~    Doctrine: ~

Обратите внимание на слой Contracts. Контроллеру из Api нельзя видеть ни домен, ни хендлеры — только команды, запросы, view-DTO и интерфейсы шин. Это заставляет держать контроллеры тонкими автоматически: захотел агент «по-быстрому» дёрнуть доменную сущность из контроллера — deptrac не пустит.

Вендорные слои (Symfony, Doctrine) объявлены явно — так намерение видно в ruleset: Domain не имеет их в списке, значит попытка заюзать Doctrine-аннотацию в доменной сущности — это violation, а не «ну, все так делают».

Конфиг №2: горизонтальная изоляция контекстов

Первый конфиг ничего не говорит о том, может ли Billing залезть в Media. Это второй, отдельный конфиг:

# deptrac-contexts.yaml — изоляция ограниченных контекстовdeptrac:  paths: [./src/, ./core/]  layers:    - name: Api      collectors: [{ type: classNameRegex, value: '#^App\\Api\\#' }]    - name: Infrastructure      collectors: [{ type: classNameRegex, value: '#^App\\Infrastructure\\#' }]    - name: Auth      collectors: [{ type: classNameRegex, value: '#^Core\\Auth\\#' }]    - name: Billing      collectors: [{ type: classNameRegex, value: '#^Core\\Billing\\#' }]    - name: Media      collectors: [{ type: classNameRegex, value: '#^Core\\Media\\#' }]    - name: Task      collectors: [{ type: classNameRegex, value: '#^Core\\Task\\#' }]    - name: Tool      collectors: [{ type: classNameRegex, value: '#^Core\\Tool\\#' }]    - name: Monitoring      collectors: [{ type: classNameRegex, value: '#^Core\\Monitoring\\#' }]    - name: Shared      collectors:        - { type: classNameRegex, value: '#^Core\\Shared\\#' }        - { type: classNameRegex, value: '#^Core\\Port\\#' }  ruleset:    # адаптеры обслуживают все контексты    Api: [Auth, Billing, Media, Task, Tool, Monitoring, Shared]    Infrastructure: [Auth, Billing, Media, Task, Tool, Monitoring, Shared]    # листовые контексты не знают друг о друге    Auth: [Shared]    Billing: [Shared]    Media: [Shared]    Tool: [Shared]    # Task оркестрирует пайплайн: кредиты, файлы, рецепты инструментов    Task: [Billing, Media, Tool, Shared]    # Monitoring смотрит на здоровье Task и не лезет никуда больше    Monitoring: [Task, Shared]    Shared: ~

Ruleset здесь — это, по сути, карта контекстов (context map) из DDD, записанная исполняемым кодом. Она читается как документация: видно, кто оркестратор, кто листья, и почему Monitoring не может дотянуться до Billing.

Почему два конфига, а не один

Первый порыв — засунуть всё в один файл: и слои, и контексты. Я попробовал, и это те грабли, ради которых стоило писать статью.

Deptrac позволяет классу принадлежать нескольким слоям и проверяет каждую пару «слой зависимого — слой зависимости». Класс Core\Billing\Domain\Entity\Invoice в объединённом конфиге принадлежит одновременно слою Domain и слою Billing. Его зависимость от Core\Billing\Domain\ValueObject\Money (тоже Domain + Billing) порождает четыре проверки: Domain→Domain, Domain→Billing, Billing→Domain, Billing→Billing. И если вертикальная матрица разрешает не всё то же самое, что горизонтальная, вы получаете ложные violations на абсолютно легальном коде. Начинаешь давить их разрешениями — и матрица правил превращается в решето, где уже непонятно, что вообще запрещено.

Решение: два независимых конфига с взаимоисключающими наборами слоёв. Один отвечает на вопрос «правильный ли это слой», второй — «правильный ли это контекст». Каждый файл маленький, читаемый, у каждого violation — однозначная причина. Бонус: правила эволюционируют с разной скоростью. Вертикаль застыла ещё в начале проекта, а вот контекстная карта живёт — новый контекст появляется, связь добавляется осознанным коммитом в deptrac-contexts.yaml, и этот коммит виден на ревью как архитектурное решение.

Запускается всё в pre-commit хуке и в CI:

vendor/bin/deptrac analyse --config-file=deptrac.yamlvendor/bin/deptrac analyse --config-file=deptrac-contexts.yaml

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

 ----------- ------------------------------------------------------------  Reason      Monitoring ----------- ------------------------------------------------------------  Violation   Core\Monitoring\Application\GetProviderHealthHandler must              not depend on Core\Billing\Domain\Entity\CreditBalance ----------- ------------------------------------------------------------

И — важный момент — агент читает эту ошибку и сам чинит код правильно: заводит порт, событие или запрос через шину. Ему не нужно объяснять, что не так: сообщение об ошибке само по себе обучающий сигнал, попавший точно в контекст в нужный момент. Это вообще главное свойство машиночитаемых правил: они превращают нарушение из «дефекта, который заметят через месяц» в «ошибку компиляции, которую агент исправит за одну итерацию».

CLAUDE.md: намерение против факта

Deptrac проверяет факт. Но агенту всё ещё нужно знать намерение — иначе он будет писать код методом «нагенерил → отхватил от линтера → переделал». Работает, но дорого и медленно.

Для намерений есть файл правил проекта (CLAUDE.md, AGENTS.md — у кого что). Что туда стоит класть, по опыту:

  • Карту репозитория — та схема каталогов, что выше, с одной строкой о назначении каждого куска.

  • Команды проверки. У меня это одна команда make validate, которая гоняет cs-fixer, phpstan, оба deptrac-конфига и тесты. Агенту прописано запускать её после любых изменений бакенда.

  • Архитектурные инварианты словами: «core/ не зависит от Symfony и Doctrine», «новая фича = новый Command/Query + Handler», «контроллер: валидация → диспатч → View, ничего больше».

  • Запрещённые приёмы, на которых агент попадался: «никогда не редактируй файлы миграций руками — только генерация», «никакого PDO/SQL напрямую в обход репозиториев», «не добавляй абстракцию, пока нет второй реализации» (это против YAGNI-графомании).

Формулировки — короткие и императивные, в идеале с парой «плохо/хорошо». LLM следует правилам тем лучше, чем меньше они похожи на эссе.

Принцип, который я вывел для себя:

Правила описывают намерение. Deptrac проверяет факт. Одно без другого не работает.

Правила без enforcement агент нарушает. Enforcement без правил превращает разработку в игру «угадай, что запрещено, по сообщениям об ошибках». Вместе они дают агенту то же, что хорошему новому сотруднику: понятный онбординг плюс CI, который не пустит халтуру.

Субагент-ревьювер: второй взгляд до PR

Остался последний слой защиты — от того, что статика не ловит в принципе: соответствие реализации задаче, лишние абстракции, дубли логики, пропущенные edge cases.

Сначала я просто просил того же агента «проверь свой код». Это почти бесполезно: агент, только что написавший код, смотрит на него теми же глазами, тем же контекстом и с той же уверенностью, что всё правильно. Он «заражён» собственными решениями — как и человек, перечитывающий свой код через минуту после написания.

Работает другое: отдельный субагент-ревьювер с чистым контекстом. Он не видел рассуждений кодера, не привязан к его решениям и читает диф так, как читал бы чужой PR. В Claude Code это отдельный агент в .claude/agents/, а весь процесс оформлен скилом — тем самым «пайплайном разработки».

Скил: пайплайн разработки

---name: development-pipelinedescription: Используй для нетривиальных задач разработки: новые фичи,  изменения домена/API/схемы БД, многофайловые правки, рискованные  рефакторинги. Цикл план → developer → reviewer → фиксы до принятия.---# Пайплайн разработкиОбязательный процесс для нетривиальных задач:1. Главный агент составляет конкретный план.2. Реализацию делает субагент `developer`.3. Диф проверяет субагент `reviewer` (чистый контекст, диф + план,   без истории рассуждений разработчика).4. Блокирующие замечания → сфокусированный fix-план → снова `developer`.5. Цикл повторяется, пока `reviewer` не примет диф.6. Главный агент запускает `make validate` и отчитывается пользователю.## Требования к плану для developer- цель и границы (что в скоупе, что нет);- файлы и документы, которые нужно посмотреть;- шаги реализации;- ограничения (слои, контексты, запрещённые приёмы);- обязательные проверки (`make validate`);- формат ответа: изменённые файлы + результаты проверок.## Правило приёмкиЗадача не считается выполненной, пока у reviewer остаются блокирующиезамечания или `make validate` красный. Замечания уровня «вкусовщина»reviewer помечает как non-blocking — они не крутят цикл.

Субагент-разработчик

---name: developerdescription: Реализует переданный план. Не принимает решений о скоупе.model: sonnet---Ты — разработчик. Тебе дают готовый план — выполни его точно.- Не выходи за скоуп плана. Заметил проблему рядом — упомяни в отчёте,  но не чини.- Следуй паттернам соседнего кода: новая фича = Command/Query + Handler  по образцу существующих.- После изменений запусти `make validate` и приложи результат.- Верни: список изменённых файлов, что сделано, результаты проверок.

Субагент-ревьювер

---name: reviewerdescription: Ревьюит диф после developer. Чистый контекст, только диф и план.model: opustools: Read, Grep, Glob, Bash---Ты — архитектурный ревьювер. Тебе дают план задачи и диф. Кода ты неписал, решений разработчика не видел — оценивай только результат.Проверь по порядку:1. **Соответствие плану.** Всё ли из скоупа сделано? Нет ли изменений   вне скоупа?2. **Архитектура.** Слои и контексты (сверх deptrac — семантика:   доменная логика не утекла в контроллер или инфраструктуру?).3. **DRY.** Поищи по кодовой базе: нет ли уже существующей реализации   того, что написано заново?4. **YAGNI.** Есть ли абстракции без второй реализации, параметры,   которые никто не передаёт, «задел на будущее»?5. **Корректность.** Edge cases, обработка ошибок, конкурентность,   безопасность (авторизация, инъекции, доступ к чужим данным).6. **Тесты.** Покрывают ли новое поведение? Не подогнаны ли под   багованную реализацию вместо ожидаемого поведения?Формат ответа: список замечаний, каждое с пометкой BLOCKING илиNON-BLOCKING, с файлом, строкой и предложением, как исправить.Если блокирующих нет — напиши APPROVED первой строкой.

Пара наблюдений после нескольких месяцев работы этой схемы.

Разделение окупается экономически. Кодеру уже не нужно быть самой умной моделью — план составлен, паттерны в кодовой базе чистые, за спиной линтеры. Реализацию тянет модель подешевле. А вот ревьювер и планировщик — та точка, где ум стоит своих денег: одна пойманная до PR проблема экономит целый цикл «человек посмотрел → написал замечание → агент переделал».

Чистый контекст ревьювера — не деталь, а суть. Пункты 3 и 4 его чек-листа (DRY и YAGNI) — это ровно те болезни LLM, о которых была первая половина статьи. Ловить их может только тот, кто не участвовал в написании: кодер всегда считает свою абстракцию нужной, а свою копипасту — уникальным случаем.

Человек смещается в конец конвейера. Итоговый пайплайн: план → генерация → pre-commit (cs-fixer, phpstan, deptrac ×2) → субагент-ревью → и только потом мои глаза. До меня доезжают споры и продуктовые решения, а не «тут забыли проверить null» и не «зачем здесь фабрика».

Выводы

Если сжать полгода набивания шишек в несколько строк:

  1. LLM — усилитель, а не замена инженерной культуры. Хорошую архитектуру он масштабирует, плохую превращает в болото быстрее, чем любая команда людей.

  2. Качество кода теперь измеряется в токенах. Чем меньше контекста нужно для изменения, тем лучше архитектура. SRP, низкий coupling, ограниченные контексты — это буквально оптимизация под контекстное окно.

  3. Правила, которые не проверяются автоматически, для LLM не существуют. Намерение — в правилах проекта, факт — в deptrac/phpstan/тестах. Сообщение об ошибке — лучший способ доставить знание в контекст агента точно в момент, когда оно нужно.

  4. DDD — на бакенд, фронту — feature-sliced. Не тащите слои туда, где нет инвариантов.

  5. Ревьювер должен быть отдельным агентом с чистым контекстом. Self-review — это плацебо.

Чек-лист внедрения на существующем PHP-проекте:

  • [ ] Зафиксировать целевые слои и контексты (хотя бы на салфетке — это будущий ruleset).

  • [ ] Поставить deptrac, описать вертикальные слои; текущие нарушения — в baseline, новые — в запрет.

  • [ ] Вторым конфигом описать карту контекстов. Два файла, не один.

  • [ ] Собрать одну команду make validate (стиль + статика + deptrac + тесты) и включить её в pre-commit и CI.

  • [ ] Написать правила проекта: карта репо, инварианты, запрещённые приёмы, команда проверки.

  • [ ] Завести субагентов developer и reviewer и оформить пайплайн скилом.

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

ссылка на оригинал статьи https://habr.com/ru/articles/1058064/