Борьба двух якодзун

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

Звучит как менеджмент.

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

Спойлер: можно.

Но, как обычно, есть нюанс.

Якодзуна номер один против якодзуны номер два

Изначальная схема была предельно примитивной.

Берём задачу. Отдаём её одной модели:

Вот тебе архитектура на сто страниц, вот код, вот тесты, вот история проекта, прочитай всё и реализуй.

Она читает.

Долго читает.

Потом думает.

Потом ещё думает.

Потом пишет код.

После этого берём вторую модель:

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

Она тоже читает.

Долго читает.

Потом думает.

Потом находит BLOCKER.

После чего мы возвращаемся к первой:

Вот review. Прочитай всё ещё раз и почини.

В какой-то момент status у Codex начал показывать контекст больше 185 тысяч токенов, и стало понятно, что мы изобрели не разработку при помощи AI, а очень дорогой способ заставить два трансформера по очереди читать «Войну и мир».

При этом подход работал.

Что особенно обидно.

Модели действительно находили ошибки друг у друга. Причём не только банальное «забыли проверить None», а вполне неприятные вещи на границах API: потерю фактического состояния, неверную интерпретацию отсутствующего поля, aliasing входных provider objects, исключения из hostile Mapping, уничтожающие evidence соседних записей, и прочие радости, которые обычно обнаруживаются примерно через полгода после запуска системы в production.

Один агент реализовывал. Второй пытался сломать. Первый чинил. Второй снова пытался сломать.

Иногда второй находил ошибку в исправлении ошибки.

Например, у нас была совершенно прекрасная цепочка:

implementation
    ↓
review
    ↓
finding
    ↓
remediation
    ↓
rereview
    ↓
новая дырка в remediation
    ↓
remediation remediation
    ↓
rereview remediation remediation

В какой-то момент это начало напоминать сертификаты TLS, где очень хочется спросить, кто сертифицировал сертификатора сертификатора.

Зато результат действительно становился лучше: в одном из таких циклов reviewer воспроизвёл ситуацию, когда кастомный Mapping, у которого keys() кидает RuntimeError, мог выбить целиком чтение quota вместе с нормальными соседними записями.

Исправление потом отдельно проверялось adversarial cases, включая особенности dict() в CPython.

То есть это уже не «AI написал мне Flask todo application».

Машины натурально срались из-за семантики collections.abc.Mapping.

Моё почтение.

Проблема оказалась не в моделях

Достаточно быстро стало понятно, что главный расход здесь вообще не генерация кода.

Главный расход – восстановление состояния мира.

Каждый новый агент приходил как сотрудник в первый рабочий день:

Здравствуйте. А что мы тут вообще строим?

И мы давали ему:

architecture.md
implementation-spec.md
17 ADR
8 implementation reports
11 reviews
6 remediation reports
tests/
source/
README
CHANGELOG
и на всякий случай всё остальное

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

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

В нём одновременно находились:

И модель должна сама понять, кто из них папа.

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

READ ALL оказался антипаттерном

Особенно смешно, что некоторое время мы сами буквально называли задания READ_ALL.

Логика была понятна: пусть модель знает максимум контекста и не пропустит важную архитектурную деталь.

На практике получалось примерно:

READ ALL EVERYTHING AND THEN DO ONE SMALL THING.

После чего агент действительно читал всё.

Машина инструкции выполнила. Какие к ней вопросы.

Постепенно родилась противоположная идея:

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

Это довольно важная разница.

Не:

прочитай историю проекта

а:

прочитай AGENTS.md
прочитай CURRENT_PROJECT_STATE.md
прочитай спецификацию текущего slice
открой только файлы, непосредственно связанные с ним
историю трогай только если обнаружилось конкретное противоречие

И внезапно модели перестали начинать каждую задачу с раскопок Трои.

AGENTS.md как BIOS для электронного сотрудника

Вторая полезная штука – вынести повторяющиеся правила из prompt'ов.

Раньше каждый prompt выглядел примерно так:

не трогай production
не читай секреты
не делай provider mutations
не начинай следующий milestone
не меняй архитектуру
не решай Proposed ADR самостоятельно
не ходи за пределы repository
не коммить
не пушь
не ешь жёлтый снег

Причём это повторялось снова.

И снова.

И снова.

В итоге prompt на исправление одной функции мог быть размером с небольшую кандидатскую диссертацию.

Теперь большая часть постоянного контракта живёт в AGENTS.md: границы filesystem, секреты, правила live access, разделение read/write capabilities, порядок завершения milestone и так далее.

А task prompt начинается с простого:

READ AGENTS.md FIRST.

Это оказалось неожиданно важным.

Потому что одно дело – иметь файл с правилами.

Другое – эксплицитно сказать машине его прочитать.

Да, мы пришли к необходимости напоминать компьютеру прочитать инструкцию к компьютеру.

Будущее наступило.

Не передавать контекст. Передавать решение

Следующий шаг оказался ещё полезнее.

Предположим, один агент несколько часов исследовал API и выяснил:

Раньше следующему агенту мы говорили:

Теперь сам всё это исследуй и реализуй.

Зачем?

У нас уже есть результат исследования.

Например, сегодняшний pagination research выяснил вполне конкретную вещь: ovh.Client.call() уничтожает requests.Response после JSON decode, поэтому текущий OvhApiReader.get() физически не может увидеть X-Pagination-*.

При этом менять общий OvhApiReader ради одного endpoint – слишком большой blast radius, поэтому предложен узкий OvhNetworkV2ApiReader с дополнительным raw_call.

Следующему агенту больше не надо заново доказывать это.

Ему надо сказать:

Вот authoritative design.
Реализуй §11.
Если repository reality ему противоречит — остановись.
В остальных случаях не переизобретай архитектуру.

То есть handoff между моделями должен содержать не весь мыслительный процесс предыдущей модели, а сжатое доказанное состояние задачи.

По сути мы независимо пришли к тому же принципу, на котором построены нормальные distributed systems:

не реплицировать весь журнал событий каждому участнику, если ему достаточно актуального materialized state.

Разделение ролей

Очень полезным оказалось и то, что модели перестали быть взаимозаменяемыми «сделай всё».

Теперь работа естественно делится примерно так:

research
   ↓
design
   ↓
implementation
   ↓
independent review
   ↓
bounded remediation
   ↓
bounded rereview
   ↓
closure

И каждый агент получает только свою роль.

Research-agent не пишет код.

Implementation-agent не устраивает повторный архитектурный симпозиум.

Reviewer не исправляет найденную ошибку.

Remediation-agent не начинает следующий milestone, потому что «ну я уже тут».

Это кажется бюрократией, пока reviewer не находит реальную проблему.

Тогда становится понятно, зачем она нужна.

Если reviewer одновременно исправляет собственную находку, то проверяет он в итоге самого себя. Гораздо интереснее дать результат обратно другому якодзуне.

Пусть дерутся.

Review тоже не должен читать Вселенную

Тут мы сначала наступили на те же грабли второй ногой.

После небольшой реализации запускался:

FINAL COMPREHENSIVE READ-ALL INDEPENDENT CLOSURE REVIEW.

И reviewer снова читал всё.

Теперь review стараемся строить от falsification targets.

Например, design прямо говорит, что в следующем изменении опасны четыре вещи:

Вот их reviewer и должен пытаться сломать в первую очередь.

Не «проведи философский анализ всего репозитория».

А:

Вот invariants. Вот diff. Докажи, что хотя бы один из них нарушен.

Это гораздо более продуктивная форма агрессии.

Машины очень любят расширять задачу

Есть ещё одна особенность LLM, которая особенно хорошо проявляется на больших проектах.

Они хотят помочь.

Очень хотят.

Ты просишь:

Добавь наблюдение двух pagination headers.

Модель думает:

А не построить ли нам generic pagination framework для всех будущих provider APIs?

Нет.

Сядь.

Именно поэтому у каждой задачи появились explicit stop conditions:

no cursor following
no completeness promotion
no v1 changes
no generic HTTP abstraction
no next slice
STOP

Причём STOP – возможно, одна из самых полезных инструкций во всём prompt engineering.

Потому что хороший человеческий разработчик тоже иногда смотрит на соседний код и думает: «ой, тут заодно можно красиво переделать».

Но человеку хотя бы можно сказать это голосом.

Машине приходится писать капсом.

Доказательство важнее уверенности

Ещё одна вещь, которую пришлось достаточно жёстко прибить к проекту: различие между:

я думаю, что API работает так

и:

мы видели, что API работает так

В документах появились довольно скучные, но полезные категории:

LIVE_OBSERVED
CODE_PROVEN
DERIVED_INFERENCE
UNVERIFIED

Например, реальный live-read показал, что OVH действительно отдаёт X-Pagination-Elements, рекламирует X-Pagination-Cursor и X-Pagination-Size, а X-Pagination-Cursor-Next в конкретном ответе отсутствовал.

Из этого не следует, что отсутствие Cursor-Next всегда означает конец коллекции.

Очень хочется, конечно.

Но не следует.

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

В результате текущая модель честно говорит:

UNVERIFIED_PAGINATION

Хотя на реальном запросе мы получили все 59 сетей.

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

Она прекрасно умеет писать убедительный текст для всех трёх.

В этом и проблема.

Тестов скоро будет больше, чем программы

Есть, правда, побочный эффект.

Каждый раз происходит примерно следующее:

нашли edge case
    ↓
написали regression test
    ↓
reviewer придумал более мерзкий edge case
    ↓
ещё test
    ↓
remediation обнаружила соседний случай
    ↓
ещё пять tests

В какой-то момент мы дошли до полутора тысяч unit tests, а потом бодро пошли дальше.

И есть серьёзное подозрение, что тестовая база в итоге будет в разы больше кодовой.

Но если подумать, это довольно забавный способ использовать LLM.

Генерировать boilerplate они умеют прекрасно.

Значит можно тратить человеческое время не на написание двадцатого assertEqual, а на вопрос:

Каким максимально уёбищным объектом можно сломать вот эту границу?

И тут второй якодзуна иногда проявляет настоящее вдохновение.

Deep nested JSON до recursion limit?

Держи.

Custom Mapping, который кидает исключение из keys()?

Почему бы и нет.

Hostile subclass?

Конечно.

Mutation after capture?

Сейчас проверим.

Обычный разработчик в пятницу вечером до некоторых из этих вещей просто не доберётся.

У машины пятницы нет.

Самое смешное – стало быстрее

И вот тут случилась неожиданная часть эксперимента.

После всей этой дополнительной бюрократии:

работа ускорилась.

Потому что исчезло главное пожирающее время действие:

заново понять весь проект.

Раньше новый агент мог потратить огромную часть контекста просто на восстановление того, что предыдущий уже выяснил.

Теперь implementation prompt иногда помещается буквально в несколько абзацев:

READ AGENTS.md FIRST.

Implement R1-S3-D.

Authoritative design:
docs/.../R1_S3_PAGINATION_ENVELOPE_DESIGN.md

Implement §11 exactly.

Here are hard stops.
Here are review risks.
Run validation.
Write report.
STOP.

И всё.

Не потому что задача стала проще.

А потому что контекст уже скомпилирован предыдущим этапом.

Контекст – тоже ресурс

Наверное, главный вывод из всей этой истории оказался довольно банальным.

LLM context надо воспринимать примерно как RAM.

Если у тебя её много, это не означает, что туда обязательно надо загрузить весь диск.

Можно.

Linux даже постарается сделать из этого cache.

Но приложение от этого не обязано стать быстрее.

Большой контекст полезен, когда задача действительно требует большого количества одновременно доступной информации.

Но огромный исторический контекст – это ещё и:

Поэтому сейчас наша цель не «дать модели всё».

Наша цель:

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

Это очень похоже на проектирование API.

И, наверное, неудивительно.

А кто из якодзун победил?

Никто.

И в этом весь смысл.

Один периодически лучше исследует большую область и строит связный design.

Другой неожиданно хорошо включает режим параноика и начинает искать способы заставить Python сделать какую-нибудь совершенно противоестественную хуйню.

Потом они меняются местами.

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

Потому что две модели, согласные друг с другом, – это просто две модели.

А две модели, которым поручили доказать, что другая облажалась, – уже какой-никакой engineering process.

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

Сидишь такой утром.

С одной стороны Claude.

С другой Codex.

Посередине репозиторий.

Говоришь:

Так, этот утверждает, что всё PASS.

И передаёшь diff второму.

Через некоторое время оттуда:

CHANGES_REQUIRED

BLOCKER findings: 1

Ну.

Погнали ещё кружочек.