Многолетний опыт использования LLM в разработке показывает заметный прирост продуктивности — в greenfield-проектах и небольших системах модели справляются хорошо. Но в повседневной работе агентов приходится внедрять в legacy-кодовые базы с тяжёлыми деревьями зависимостей, сильной связностью и техдолгом, до которого никогда не доходили руки. Именно здесь качество работы LLM резко падает.
У этого провала есть конкретная форма. Попросите добавить поле «статус отклика на вакансию» в greenfield-репозитории — и получите его. Попросите то же самое в системе, которая живёт четыре года, — и модель изобретёт четвёртое написание понятия, которое уже существует в трёх вариантах, потому что сама кодовая база никогда не решала, какое из них настоящее. Модель напишет адаптер там, где было достаточно прямого вызова, или наоборот — вызовет напрямую там, где смысл был именно в адаптере. Каждый такой случай — это вопрос к системе, на который она нигде не отвечает. Модель гадает, и часто гадает неверно.
Brownfield-проекты глубоки, и техническая глубина — лишь первый слой. Под ней лежит второй: путаница, отсутствие смысла и общего языка, чтобы её разрешить. Именно в этот слой проваливается модель. Апгрейда требует не модель — код не готов, а готовность можно выстроить. Постепенно, часть за частью.
Стало проще, чем раньше#
В начале разработки ПО было только одно — техдолг. Это естественное следствие того, к чему стремятся разработчики: невозможно заранее предугадать бизнес-решения будущего, которые изменят текущий взгляд на код. Нужно поставлять функциональность, и поставлять быстро, идя на компромиссы. В результате code smell растёт и растёт. Обычный ответ на это — тратить часть инженерного бюджета на чистку: закладывать 10-20% техбюджета на закрытие техдолга. В теории... В следующем квартале...
Пятая часть бюджета — это плата за то, чтобы решить, что нужно изменить, и затем напечатать это изменение, и у этих двух половин никогда не была одинаковая цена. Решение осталось примерно таким же дорогим, каким было. Печать кода рухнула в цене. LLM выполнит механическую половину чистки (вынесенный модуль, рефакторинг между двумя пакетами, увеличение покрытия тестами) по цене, которая уже не похожа на 2020 год. Закрытие техдолга по-прежнему требует времени. Требует значительно меньше времени, и остаётся только часть, связанная с принятием решений.
Стратегическое против тактического#
Работа делится на две части, и здесь заимствуются термины из книги Джона Остерхаута A Philosophy of Software Design, честно признавая, что смысл их немного изгибается под свои нужды. Остерхаут использует тактическое и стратегическое для двух подходов к написанию кода: тактическое программирование — это «сделать, чтобы работало прямо сейчас», стратегическое — это инвестиции в дизайн по ходу работы. Та же пара терминов используется для разделения авторства, потому что экономика, описанная выше, режет именно по этой линии. Стратегическая работа — это принятие решений: чтение системы, выяснение, что и почему должно измениться, и действительно ли изменение служит нужной фиче. Тактическая работа — это перенос решения в файлы. Первая часть требует держать систему в голове. Вторая — та часть, что подешевела.
Как это выглядит на практике#
В первой части приходится быть полностью вовлечённым, во второй — скорее ревьюером, чем реализатором. На первом пути кодовая база анализируется в более общем виде, оцениваются изменения, которые нужно внести, и их соответствие фичам, которые нужно доставить. Результат этого подхода — GitHub issues, создаваемые в каждом репозитории.
Дальше issues обрабатывает AI-система на основе skills и суб-агентов. Skill — это записанная процедура: markdown-файл с инструкциями, который модель загружает, когда задача ему соответствует, так что «обработать issue» или «перегенерировать карту контекста» выполняется одинаково каждый раз, а не так, как это случайно сформулировали в конкретное утро. Суб-агент — это отдельная сессия модели со своим свежим контекстом и узкой задачей (реализовать, проверить на безопасность, сверить со спецификацией), которая возвращает результат, а не сваливает весь свой транскрипт в основной контекст.
Когда issues реализованы, PR готовы к разбору. Ревью проходит поэтапно: изменения принимаются или запрашиваются доработки. Это можно делать инкрементально, заботясь о покрытии тестами и о том, что может сломаться: перед тем как изменение попадёт в прод, нужно знать, какие ещё части системы потребляют то, что затрагивается, и переживут ли они это изменение. Роль инженера теперь — координировать, планировать и прокладывать путь для улучшений. Но реализовывать всё это самостоятельно уже не нужно. Время экономится.
DDD как фундамент#
Остаётся стратегическая половина, и она стоит ровно столько, сколько стоит язык, на котором она написана. Здесь в игру вступает DDD.
DDD всегда был одним из вариантов для ПО, которое можно будет менять и через год. Подход, представленный Эриком Эвансом, дал способ сократить разрыв в коммуникации между бизнесом и технической стороной. Domain-driven design, основанный на едином языке (ubiquitous language) и ограниченных контекстах (bounded contexts), напрямую переводит потребности бизнеса в техническую часть. Обе стороны говорят на одном языке. С агентами в контуре эта связь становится ещё важнее: именно через неё формулируются потребности для модели и считывается её рассуждение в ответ. Поэтому на DDD делается такая сильная ставка.
Как это выглядит на практике#
Каждый репозиторий несёт в корне файл .workflow.json. Это собственный манифест — место, где репозиторий сообщает инструментарию, что он собой представляет: какие языки в нём используются, какие директории агент должен прочитать в первую очередь, какие проверки должны пройти перед тем, как работа в нём может быть отправлена. Один блок в этом файле посвящён домену, и объявление этого блока — единственная регистрация, которая нужна репозиторию. Второго реестра, с которым можно было бы разойтись, не существует.
Блок называет проект, его ограниченные контексты, где хранится глоссарий каждого контекста, тип поддомена и каждую связь с соседним контекстом. Пример взят из проекта job-offer-box — трекера откликов на вакансии, построенного из двух репозиториев: Rust-бэкенда под названием hyperion и веб-фронтенда. Ниже — манифест фронтенда, сокращённый до одной связи:
{
"domain": {
"project": "job-offer-box",
"contexts": [
{
"name": "job-box-web",
"docs": "CONTEXT.md",
"subdomain": "supporting",
"edges": [
{
"to": "hyperion/job-offer-backend",
"direction": "outbound",
"pattern": "unclassified",
"owner": "supplier",
"shape": "codegen from the backend's document (scripts/generate-api.ts:12) ... conformist on write (src/lib/api/jobs.ts:37), ACL on read (src/lib/api/adapters/offer.ts:50)",
"note": "conformist on write and an anticorruption layer on read; two patterns hold at once, so neither name alone is true"
}
]
}
]
}
}Читать нужно по порядку. to — это адрес: какой контекст на другом конце. direction говорит, кто кого вызывает; веб-репозиторий вызывает бэкенд, поэтому outbound (собственный манифест бэкенда объявляет ту же связь как inbound). owner говорит, чья модель побеждает, если стороны когда-нибудь разойдутся во мнениях: побеждает модель бэкенда, поэтому supplier. pattern — это сама природа связи, выбираемая из закрытого словаря; здесь это unclassified, потому что веб-репозиторий делает одновременно две разные вещи. Он принимает форму данных бэкенда как есть при записи и переводит её в собственную форму при чтении. Поле note проговаривает это явно; одна метка была бы верна для одного случая и неверна для другого.
Рядом с манифестом лежит файл CONTEXT.md для каждого контекста — живой глоссарий с точным значением каждого термина и намеренно отвергнутыми синонимами. Два файла на контекст, оба принадлежат репозиторию, который владеет кодом. Ничего выше этих файлов не пишется вручную: карта контекста (единый документ, показывающий все контексты в портфеле и все связи между ними) выводится автоматически. Генератор — скрипт, который обходит каждый репозиторий на диске, объединяет блоки domain и выдаёт единый файл CONTEXT-MAP.md. Карта одноразовая и легко перегенерируемая.
Возвращаясь к job-offer-box: hyperion/job-offer-backend владеет продуктовым языком. Он хранит Job Offer, Profile, Profile Variant, Resume, Cover Letter, по правилу, что там, где два контекста называют один и тот же термин, владеет им тот, который хранит устойчивое состояние. job-offer-box/job-box-web владеет только словарём экрана (View Model, Filter State, Facet Stats) и помечает всё остальное как [published] — приходящее дословно как сгенерированный TypeScript из OpenAPI-документа бэкенда. Это тот уровень точности, который нужен агенту. Указав его на веб-репозиторий, он будет знать, что переименование Job Offer там относится к бэкенду, что адаптеры на пути чтения существуют не случайно, и какие слова разрешено придумывать самому. С картой модель знает, в каком контексте находится, а с глоссарием — знает слова, используемые в нём.
Обе стороны декларируют — расхождение проверяется механически#
Каждая связь объявляется дважды, с каждой стороны, и это дублирование — весь смысл. Генератор сверяет пары и внимательно относится к тому, что считать расхождением: поставщик называет свою позицию (published-language), потребитель — свою (conformist, anticorruption-layer), поэтому проверка представляет собой таблицу соответствий.
Эта проверка запускается как skill в трёх моментах: после изменения манифеста, при подключении нового репозитория и перед изменением чего-либо, от чего зависит другой контекст. Каждое обнаруженное расхождение — это находка: одна связь, один способ, которым две декларации не совпадают. По одному флагу skill заводит каждую находку как DDD-issue в репозитории, который владеет неверной стороной. Issue несёт отпечаток (тип находки плюс два адреса), поэтому повторный запуск после частичного исправления обновляет тот же issue вместо создания второго, а находка, которая больше не появляется, закрывает свой issue. Дальше всё идёт по тому же пути, что и остальное: issue, агент, PR, ревью.
Что дальше#
Это стратегический слой, и он уже на месте. Он определяет, где заканчивается контекст и как он общается с соседями — форму карты. Внутри же любого отдельного контекста всё ещё остаётся обычный код, позволяющий собрать бессмысленный объект и сохранить его.
Имея карту контекстов и определённый глоссарий, можно сосредоточиться на самой кодовой базе: брать по одному контексту за раз и мигрировать его к настоящей доменной модели, построенной из примитивов DDD (value objects, агрегаты, доменные сервисы и другие). Этот процесс заставляет кодовую базу отвечать на вопросы, о которых модель раньше только гадала: что означает это слово, кто им владеет, где заканчивается этот контекст.