Агентская разработка и Documentation Driven Development: когда ИИ пишет не код, а контракты
Привет, Хабр! Я Матвей Лихота, старший Go-разработчик. В предыдущем материале я показывал, как получить Go-код из OpenAPI. Теперь хочу сделать еще один шаг назад — к моменту, когда контракта еще нет.
На демо частенько можно увидеть, как по простому запросу «напиши CRM» или «собери интернет-магазин» LLM уже через минуту готовит сервер, модели, миграции и тесты. Но в реальности, конечно, все происходит чуть иначе: проверяем код, а там — сюрприз-сюрприз ошибки. Повторив тот же промпт, мы получим другую архитектуру и снова потратим время на проверку. И нет гарантии, что после переделки ошибок не станет еще больше.
Конечно, все дело в промпте. Согласитесь, странно просить модель понять задачу, выбрать архитектуру, определить границы и все реализовать за один шаг? Что, если LLM сначала проектирует систему и выпустит OpenAPI, Proto, JSON Schema, CloudEvents и AsyncAPI? А код мы добавим чуть позже: за повторяемую часть будут отвечать обычные генераторы, а за проектно-зависимую — разработчики и агент в контексте репозитория.
Такой подход, где главным артефактом становится контракт, а код — его производной, можно назвать Documentation Driven Agent Development или Specification Driven Agent Development. Как он работает — покажу на примере разработки простой автоматической телефонной станции (АТС), а еще проведу границу между генераторами и агентами.

Под АТС здесь будем понимать простой сервис. Клиент звонит на общий номер, нажимает 1 для продаж или 2 для поддержки, после чего система соединяет его с сотрудником по внутреннему номеру вроде 101. Если никто не ответил, АТС фиксирует пропущенный звонок. Аудиопоток и телеком-протоколы оставим за рамками.
Проектируем сервис
Если ввести запрос «спроектируй сервис АТС», то вряд ли на выходе у нас получится полноценный контракт.. Поэтому в качестве первого промпта, если нет конкретных бизнес-требований, я указываю основные требования, ограничения и собираю вопросы:
Ты проектируешь контракт сервиса автоматической телефонной станции (АТС) для небольшой компании.
Не предлагай эндпоинты и не пиши код.
Сначала составь список архитектурно значимых вопросов, без ответов на которые публичный контракт будет неоднозначным.
Сгруппируй вопросы по темам:
внутренние номера сотрудников;
прием и завершение звонка;
голосовое меню «нажмите 1, нажмите 2»;
очередь операторов;
правила перенаправления;
пропущенные звонки и уведомления;
одновременное изменение настроек;
авторизация;
пагинация и фильтры;
ошибки;
история звонков и срок хранения данных.
Для каждого вопроса укажи:
почему он влияет на контракт;
безопасное решение по умолчанию;
риск этого решения.
Не придумывай требования молча. Все предположения вынеси отдельно.
В этом случае LLM предлагает решения, с которыми можно работать, при желании — переопределить последующим промптом.
Пример
Генерируем OpenAPI-контракт
Теперь можно переходить к HTTP API. И тут аналогично: если мы ограничимся фразой «сделай хороший OpenAPI», результат будет соответствующий. Поэтому в промпте сразу укажем требования, полученные в результате первого шага выявленные на первом шаге:
Спроектируй REST API управления АТС для компании из 50 сотрудников.
Результат: один валидный документ OpenAPI 3.1 в YAML.
Никакого кода, псевдокода и описания реализации.АТС должна уметь:
создать внутренний номер сотрудника, например 101;
подписать номер именем сотрудника;
отключить внутренний номер;
настроить простое голосовое меню;
направить входящий звонок на сотрудника или очередь операторов;
показать историю входящих, отвеченных и пропущенных звонков.
Требования:
идентификаторы ресурсов — UUID, внутренние номера — строки из 2–6 цифр;
внешние телефонные номера — строки в формате E.164;
ошибки — application/problem+json по RFC 9457;
создание внутреннего номера поддерживает Idempotency-Key;
повтор с тем же ключом и тем же телом возвращает прежний результат;
повтор с тем же ключом и другим телом возвращает конфликт;
изменение голосового меню и правил маршрутизации защищено If-Match/ETag;
история звонков использует cursor pagination, а не offset;
все даты передаются в UTC в формате RFC 3339;
запись о звонке содержит понятный статус: ringing, answered, completed или missed;
отключение внутреннего номера моделируется как отдельное действие,
а не как произвольный PATCH поля status;у каждой операции есть стабильный operationId;
описаны 400, 401, 403, 404, 409, 412, 422 и 429 там, где они имеют смысл;
схемы запросов и ответов разделены;
readOnly/writeOnly и обязательность полей заданы явно;
неизвестные поля в командах запрещены, если это не мешает эволюции контракта.
Перед YAML выведи короткий раздел Decisions: не более 12 решений.
Если требование неоднозначно, не скрывай выбор: добавь его в Decisions.
В промпте я прямо запрещаю модели писать код. На мой взгляд, это принципиальный момент, так как некоторые современные «особо умные» модели, натренированные на предугадывание дальнейших шагов пользователя, могут сгенерировать совершенно не устраивающий нас контракт и вкусно покушать токенов, заставив нас угрюмо потянуться за кошельком.
Делаем ревью контрактов
В результате запроса мы получаем спецификацию, но радоваться рано. LLM легко пишет документ, который на первый взгляд выглядит согласованным, хотя на самом деле семантика внутри расходится. Например, разрешает один внутренний номер двум сотрудникам или забывает некоторые нужные коды ошибок в ответах.
Поэтому следующим шагом я создаю новый чат, задача которого — сломать контракт:
Проведи adversarial review файла openapi.yaml.
Не переписывай спецификацию целиком.
Ищи только дефекты, которые приведут к несовместимости, потере данных,
двойной обработке или неоднозначной реализации.
Проверь:
уникальность внутреннего номера;
семантику Idempotency-Key при создании номера;
гонки при изменении голосового меню и использование ETag/If-Match;
соответствие ошибок RFC 9457;
различие 409, 412 и 422;
стабильность cursor pagination при появлении новых звонков;
утечки внутренних полей;
совместимость nullable/required;
возможность сгенерировать строгие клиенты;
полноту security requirements;
одинаковый смысл повторного запроса после тайм-аута;
понятное поведение звонка, если ни один оператор не ответил.
Ответ оформи как таблицу:
Severity | Location | Problem | Failure scenario | Minimal fix.
Не предлагай стилистические улучшения.
Конечно, LLM-ревьюер не сможет полностью заменить человека. Но как минимум этот промпт поможет быстро найти очевидные проблемы, а ответ удобно будет скормить в следующей итерации, что немного сэкономит время команде.
Пример
Генерируем событийные контракты
С OpenAPI разобрались — перейдем к событиям. Здесь импровизация может обойтись еще дороже: HTTP-клиент быстро замечает несовместимый ответ, а сломанный консьюмер может неделями писать в DLQ или молча неверно читать поле.
Для контрактов событийного взаимодействия мне очень нравится использовать CloudEvents. Да, в нем передается большое количество обязательной отладочной и служебной информации, но, поверьте, это того стоит. О пользе CloudEvents можно долго рассказывать, это тема для отдельной статьи, а сейчас сгенерируем сами контракты:
Спроектируй событийное взаимодействие простой облачной АТС.
Контекст:
PBX принимает входящий звонок на общий номер компании;
CallRouting выбирает внутренний номер или очередь операторов;
CallHistory хранит факты о звонках, но не аудиозапись;
Analytics считает отвеченные и пропущенные звонки;
Notification отправляет сотруднику уведомление о пропущенном звонке;
Notification и Analytics не должны мешать соединению звонка;
доставка сообщений at-least-once;
публикация выполняется через transactional outbox.
Сначала предложи каталог событий. Для каждого события укажи:
событие-факт в прошедшем времени;
понятное имя: CallStarted, CallRouted, CallAnswered, CallEnded или CallMissed;
владельца и источник;
что именно произошло со звонком;
обязательных и необязательных потребителей;
callId как ключ партиционирования;
требования к порядку;
телефонный номер как PII и правила его маскирования;
политику retention;
реакцию потребителя на дубликат.
Затем опиши события как CloudEvents 1.0:
id, source, specversion, type, subject, time, datacontenttype, dataschema и data.Продумай:
уникальность пары source + id;
correlationid и causationid как extension attributes;
версии типов событий;
backward-compatible evolution;
поведение при replay;
дедупликацию на стороне каждого consumer;
различие event time и processing time.
Не проектируй топики до завершения каталога событий.
Не помещай аудиозапись разговора в событие.
Не используй имя CallUpdated: из имени должно быть понятно, что произошло.
На выходе получается понятная история звонка: когда и во сколько начало, на какой внутренний номер был направлен, ответил ли собеседник, когда разговор завершился.
Контракты вебсокетов
Допустим, на фронте должны динамически обновляться статусы. Для этого можно использовать те же смысловые события, но другой транспорт. Браузер в качестве асинхронного канала использует WebSocket-соединение и принимает понятные обновления: звонок поступил, был направлен на определенный номер, сотрудник ответил, разговор завершился. Такой интерфейс я описываю отдельным контрактом AsyncAPI:
Спроектируй AsyncAPI 3.0-контракт WebSocket API для панели оператора простой облачной АТС.
Контекст:
браузер оператора подключается по WSS и подписывается на свой внутренний номер, например 101, и на одну очередь поддержки;
сразу после подписки сервер отправляет CurrentCallState — актуальный снимок звонков, которые уже ожидают ответа или находятся в разговоре;
после снимка сервер отправляет обновления CallRinging, CallAnswered, CallEnded и CallMissed;
команды оператора, например принять или завершить звонок, остаются в REST API; WebSocket используется только для уведомлений;
обрыв соединения и повторное подключение считаются нормальным сценарием.
В контракте опиши:
WSS endpoint, аутентификацию во время handshake и правила подписки; не передавай access token в query string;
конверт сообщения с messageId, type, version, occurredAt, subscriptionId, callId и sequence; payload должен ссылаться на существующие JSON Schema;
порядок сообщений в рамках одного callId, поведение при дубликатах и сообщениях не по порядку, а также переподключение с resumeFrom; если восстановление невозможно, сервер отправляет новый снимок;
heartbeat, idle timeout, ограничение размера сообщения, backpressure, формат ошибок и WebSocket close codes.
Отдельно зафиксируй версионирование типов сообщений и правила совместимой эволюции. Не добавляй bindings-брокера, topics, consumer groups, retry queues, DLQ и schema registry. Результат должен проходить AsyncAPI validation. Перед документом выведи список решений и предположений.
Этот контракт, как и все остальные, проверяю ревью-промптом.
Внутренний API
Самый простой путь — механически превратить каждый REST endpoint в RPC. Я делаю иначе, поскольку у этих взаимодействий разные задачи. Внешнему клиенту важны HTTP-семантика, кеширование и модель ресурсов. Внутреннему вызову — дедлайны, стриминг, компактное сообщение и совместимость полей. Поэтому Proto проектирую отдельно:
Спроектируй внутренний gRPC API между PBX и CallRouting.
Не переводись механически с существующего REST API.
Сначала прочитай:
— docs/domain-glossary.md;
— openapi/pbx-admin-api.yaml;
— docs/call-routing-sla.md;
Сценарий:
— на общий номер компании поступает входящий звонок;
— пользователь может нажать 1 для отдела продаж или 2 для поддержки;
— PBX передает набранную цифру и callId в CallRouting;
— CallRouting возвращает назначение: внутренний номер, очередь или voicemail;
— API управляет только метаданными звонка и не передает аудиопоток.
Требования:
— proto3;
— package и service имеют версию v1;
— основной RPC ResolveDestination — read-only и безопасен для повтора;
— для каждого RPC определены deadline expectations;
— ошибки используют google.rpc.Status и типизированные details;
— внешний номер представлен строкой в формате E.164;
— внутренний номер представлен строкой из 2–6 цифр;
— номера удаленных полей резервируются;
— enum начинается с UNSPECIFIED;
— transport message не копирует внутреннюю entity целиком;
— не добавляй streaming: для выбора назначения достаточно unary RPC;
— комментарии объясняют семантику, а не повторяют имя поля.
Верни:
routing.proto;
список нерешенных вопросов.
Не генерируй сервер и клиент.
В результате получаем Proto-контракт, в котором нет ничего лишнего.
Генерируем код из контрактов
Код я предлагаю генерировать из спецификаций, тем самым добиваясь детерминированности и повторяемости, что как раз ложится в логику подхода Documentation Driven Development.
Для каждого языка программирования и типа спецификации можно выбрать устраивающий нас генератор, отредактировать шаблоны и сгенерировать код. Контракты вместе со сгенерированным кодом я помещаю в отдельный репозиторий, версионирую его и слежу за актуальностью импортируемой версии в сервисах. О генерации и основах Documentation Driven Development подробнее можно почитать в наших материалах: первый и второй.
Подход Documentation Driven Agent Development
В итоге мы получаем подход, сочетающий Documentation Driven Development, в котором документацию нам помогает составлять и редактировать агент, а потом эта документация используется как источник правды. Агент-архитектор получает требования и ADR, а возвращает контракты и список решений. Агент-ревьюер ищет несовместимости и при необходимости отправляет контракты на доработку. Дальше люди должны согласиться с контрактами и сгенерировать по этим контрактам код.
Время сэкономлено, дедлайн не сдвинут, все довольны 🙂
Что в итоге
При подходе Documentation Driven Agent Development один агент проектирует и фиксирует решения в контрактах, а другой проверяет контракты на неоднозначность и несовместимость. Генераторы остаются: они создают детерминированный и повторяемый код и могут быть интегрированы в CI. Бизнес-логика реализуется внутри уже согласованных границ, а документация остается главным артефактом и источником правды в проекте.
Мы в команде придерживаемся позиции, что не стоит использовать LLM для кодогенерации: они гораздо лучше проектируют систему и помогают минимизировать «бюрократические» моменты, а генераторы реализуют контракты и дают детерминированность и повторяемость.
Ссылки на источники
А с чего сейчас начинается новый проект у вас — с контракта, кода или уже с промпта?
Автор: cadeusept

