Когда эксперт впервые увидел, как эта машина ведёт себя в динамике, он сказал, что без курсов по двигателям и трансмиссиям меня к такому тренажёру подпускать нельзя. Он был прав.
Меня позвали перенести с Unity на Unigine тренажёр гусеничной машины: железная кабина на динамической платформе, заказчик, сроки, приёмка и эксперты, которые ездили на этой машине и помнили, как она ведёт себя в разных режимах и условиях. К середине проекта я решил, что новую физику машины проще написать с нуля, чем дальше вбивать костыли в старую модель. И да, там будет Патрик Суэйзи.
Меня зовут Сергей. Тридцать лет в C++, C# и чуть Python; был техлидом, тимлидом, архитектором, principal developer, head of engineering, senior delivery manager и даже tribe lead (чур меня). Я делал системы подготовки векторных данных для морских симуляторов, обрабатывал навигационные карты. Занимался дистрибуцией данных для морских судов. Гусеничных машин не делал.
Код матмодели когда-то написал моделист (который, как обычно, недоступен). Кто видел такой код, поймёт, почему его хочется сжечь. Программисты, судя по гит-логу, отчаянно пытались модель докостылить. Довести её до нужного поведения не вышло: после каждого нового костыля она становилась всё более непредсказуемой.
Домен я тоже не знал. Слов вроде FMEP, гидротрансформатора или планетарной передачи я раньше не слышал. Про двигатель помнил на уровне «поршни, что-то крутится». Гусеничную машину знал на уровне водителя: нажал газ — поехала, повернул руль — перестала ехать прямо.
Почему появилась Rumba
В начале проекта я пользовался браузером для общения с ИИ. ChatGPT и Claude отвечали на вопросы про физику, термины и уравнения, вытаскивали знания из документации. Код я писал сам.
Консольный Claude Code был глючный, я слабо представлял, как его можно использовать как полноценного и тем более автономного помощника.
Потом в VS Code появился плагин Claude Code, и ощущение поменялось. Я поэкспериментировал с md-шками и увидел, что Claude Code может генерить вполне сносный код, если обложить его контекстом и дать проверять собственную работу. Контекст хранился в .md-файлах: стиль, правила, запреты, описание архитектуры, выдержки из руководства по эксплуатации (далее РЭ), алгоритмы работы агрегатов — статический контекст. Промпты, wip-задачи — динамический. Проверять результат агент мог по автотестам и логам.
При этом я видел, как агенту становится плохо на легаси-коде. В попытках его понять и изменить я чувствовал его страдание, скрытое под маской уверенности, с которой он нёс совершенную чушь. Мне было его жалко. А новый код в новых файлах генерил без проблем. Агент мимикрирует под окружающий код: рядом с легаси продолжает легаси, на чистом месте пишет чисто. Вам известно это чувство, когда хочется всё переписать, и как противно это чувство подавлять, потому что переписывание почти всегда несёт больше риска, чем кажется.

Но тут соблазн был слишком велик. Я увидел больше рисков в модификации легаси, чем в написании матмодели с нуля. Синдром агентской мимикрии был одним из доводов. В чистой базе Клод не галлюцинировал. Способ перехода был понятен: старая и новая модели могли какое-то время жить бок о бок на общей шине переменных. Новую модель я назвал Rumba. Почему так, не знаю. Может быть, потому что в бульдозере есть штурвал.
Я сразу решил: в Rumba не будет ни одной строки кода, написанной вручную. Поправить за Клодом пару строк — это как поправить байты в готовом бинарнике: результат будет, только причина правки в исходник уже не вернётся. Исходник у агента — контекст. Если я молча правлю код, следующая генерация про правку не знает и наступает на те же грабли. Поэтому любое исправление шло обратно в контекст: в тест, в правило, в память, в таску.
И нужно это было не только агенту. Компетенции в этом домене у меня не было, а источники расходились между собой — удержать всё это в голове я не мог. Поэтому каждое уточнение — голос эксперта, строчку из РЭ, спор о поведении машины, странность в логе — приходилось доводить до проверяемого теста: только так оно переставало зависеть от моей памяти. Тест можно прогнать в любой момент. Разговор и спор так не проверишь. Большая часть моего времени ушла на то, чтобы выстроить вокруг Клода такой контур, в котором ему было комфортно. Под словом «комфортно» для агента я понимаю высокую вероятность нагенерить ожидаемый результат с первого промпта сессии.
Как переносили подсистемы
Из кода старой модели и РЭ Клод выделил подсистемы: двигатель, гидротрансформатор, регулятор оборотов, коробка, охлаждение, отказы и т.д. Проанализировав зависимости между компонентами, он написал план переноса. Сначала переносим то, что ни от чего не зависит, потом то, что опирается на уже перенесённое. Каждая подсистема при этом становилась отдельным модулем с одной ответственностью и явной границей: что читает на входе, что отдаёт на выходе, от кого зависит и кто зависит от неё.
Первым куском новой модели стал двигатель: он задаёт обороты, на которые опираются почти все остальные узлы. Дальше шло то, что висит на нём, — гидротрансформатор, регулятор, коробка. Систему отказов оставили на конец: она завязана почти на всё.
Под каждую подсистему Клод заранее заводил WIP-файл задачи. Туда по ходу переноса шло всё новое: неудачные попытки, найденные расхождения с РЭ, принятые решения. К моменту переноса следующей подсистемы её файл уже обрастал деталями — и на ней набивали меньше шишек.
Пример. При переносе топливной подсистемы Клод сверил старые параметры с РЭ и наткнулся на расход топлива штатного отопителя: в коде стояло ~0,6 л/мин, это около 36 л/ч, по РЭ — единицы литров в час. Похоже на перепутанные единицы, расхождение примерно в двадцать раз. Значение он перенёс как было и пометил отдельной строкой в таблице несоответствий, чтобы вернуться потом. В том же файле всплыла «загадочная формула»: обороты двигателя умножали на нагрузку и на коэффициент ~0,04, потом прибавляли 25. Рядом — комментарий автора: «не знаю, что это, но оно потом везде используется». И правда использовалось: по этому числу, например, подсистема отказов решала, что топливо кончилось, и глушила двигатель. Клод вынес формулу в задачу: разобраться, что это, и заменить осмысленным. Разбираться так и не пришлось: нехватку топлива румба считала без всяких магических констант, а старый файл со временем удалили целиком. Таких мест в легаси было много, и каждая такая находка убеждала меня, что решение переписать модель было правильным.
Перенос каждой подсистемы шёл по одинаковому шаблону: найти в старом коде все места, где она живёт; выписать её входы, выходы и зависимости; собрать отдельным модулем; влить в общий шаг модели одним коммитом.
Старая и новая модель какое-то время работали рядом. Их связывал общий контракт: каждая подсистема читала входы и писала выходы через одну общую шину переменных.
Пять версий реальности
Перед переносом Клод делал автотест на подсистему. Помогало это слабо: большинство старых узлов вели себя не по РЭ, и тест падал. Поведение машины описывали пять источников, и все расходились:
-
РЭ;
-
эксперт;
-
старая модель;
-
информация из интернета;
-
лог.
Интернет в этом списке — общая физика: как узел обязан вести себя по учебнику, без привязки к нашей машине. Тут Клод был силён: сам искал в открытых источниках данные и формулы и сверял их с тем, что есть — с РЭ, кодом, логами.
Пример: расход топлива. Цифры из разных источников не сходились. В РЭ стоял паспортный расход. Общая физика из интернета давала прикидку по мощности дизеля, заметно другую. В старой модели был зашит свой расход. Лог со стенда показывал, как бак пустел на самом деле, под реальную работу газом, с учётом горок и разных грунтов. Эксперт помнил, сколько километров выходило у него с одной заправки.
Сложить эти пять ответов в один можно было только одним способом: зафиксировать спор повторяемым тестом. В первой строке такого теста стоял номер тикета, а сам тикет собирал артефакты из всех пяти версий.
Разработка системы тестирования
Обычные unit-тесты тут были почти бесполезны. Модель работает в составе симулятора, компоненты которого общаются с помощью сетевых сигналов. Кнопки, рукоятки, обороты, давление, лампочки, скорость, позиция и т.п. Поведение машины живёт в этом обмене между процессами. Проверять модель надо было в динамике, а готового инструмента не было. Пришлось ещё в доагентскую эпоху изобретать рукописный тестовый фреймворк.
Фреймворк собрался в три шага:
-
Логирование: симулятор стал писать каждый входящий и исходящий сигнал в файл, строкой «время, сигнал, значение».
-
Проигрыватель: скармливает такой лог обратно в модель в соответствии с таймкодами.
-
Проверки: к проигрывателю добавили проверки состояний — и такие минимальные логи стали сценарными тестами.
Тест подаёт в модель те же команды, что приходят с пульта: питание, селектор, газ, отказы. Потом ждёт нужного состояния и смотрит на поведение во времени — вышли ли обороты на режим, попала ли скорость в диапазон, включилась ли блокировка с нужной задержкой.
Здесь меня ждало важное открытие. Сам формат этих сценариев я придумал ещё до агента, под себя. Модель обучена на горах кода, моего формата логов в этом обучении не было. И всё равно Клод разобрался в нём по нескольким примерам из проекта и начал писать правильные тесты за секунды. Это и определило устройство контура: раз он так легко подхватил формат, вокруг тестов выстроилась вся работа. Если бы он на них буксовал, контур не на чем было бы замкнуть.
Мы с Клодом расширили тестовый фреймворк: FAST_FORWARD прокручивает симуляционное время, WA ждёт условия с таймаутом, MEASURE и RATE меряют динамику — сколько занял разгон, как быстро падают обороты, растёт ли температура под нагрузкой быстрее, чем на холостом.
Вот характерный тест целиком: разгон на фиксированном режиме, с замером времени:
# тест: разгон на фиксированном режиме - замер времени до контрольной скорости0.0 | R | cmd_session_prepare ; 110.1 | WA | evt_sim_ready ; eq ; 1 ; timeout:100.1 | R | sim_start ; 10.15 | R | sim_init ; ambient_temp=20, air_pressure=1013, fuel=1, battery=0.950.5 | R | engine_instant_start # быстрый запуск двигателя3.0 | R | drive_mode ; set ; 03.0 | R | pedal_throttle ; set ; 03.0 | ASSERT | vehicle_speed ; lt ; 2 # машина стоит на месте3.5 | R | drive_mode ; set ; 1 # рабочий режим, полный газ3.5 | R | pedal_throttle ; set ; 1.03.5 | MEASURE | accel_time ; start3.6 | WA | vehicle_speed ; gte ; 25 ; timeout:15 # ждём контрольную скорость3.6 | MEASURE | accel_time ; stop3.7 | ASSERT | accel_time.duration ; in ; 3 ; 5 # время разгона в диапазоне от 3 до 5 секунд8.0 | EXIT
Спотыкался он постоянно на одном — на FAST_FORWARD, который умножает симуляционное время. Клод упорно читал его как реальное время, хотя в памяти лежало чёткое описание.
Из РЭ — сразу в код и тест
Клод вытащил из РЭ ограничение на автоматический запуск подогревателя.
Автоматический запуск подогревателя возможен только при температуремасла в масляном баке не выше ~25 °C.
По этому требованию агент сначала нашёл, чего в модели не хватает. До этого она смотрела только на температуру охлаждающей жидкости. Если ОЖ холодная, подогреватель мог уйти в запуск, даже когда по инструкции автозапуск уже должен быть заблокирован температурой масла.
Клод добавил во входы модели температуру масла в баке. В обвязке она считалась из температуры масла двигателя через коэффициент:
// температуру масла в баке оцениваем из температуры масла двигателяfloat oil_tank_temp = engine_oil_temp * OIL_TANK_COEF; // ~0.4
А в FSM подогревателя появилась проверка перед входом в цикл запуска:
if (mode == HeaterMode::Auto){ if (oil_tank_temp > MAX_OIL_TANK_TEMP) // ~25 °C { log("auto-start blocked: oil tank too hot"); break; } if (coolant_temp < AUTO_START_TEMP) enterState(HeaterState::Startup);}
Тест шёл через те же команды, что и реальный пульт: поднимал питание, включал подогреватель в автоматический режим и смотрел, что при горячем масле в баке подогреватель не стартует:
# тест: блокировка автозапуска подогревателя при горячем масле в баке# РЭ: автозапуск возможен только при температуре масла в баке ≲ ~25 °C# старт: воздух -10 °C, двигатель тёплый ~80 °C; при коэффициенте ~0.4# масло в баке ≈ 32 °C - выше порога0.0 | R | cmd_session_prepare ; 110.1 | WA | evt_sim_ready ; eq ; 1 ; timeout:100.1 | R | sim_start ; 10.15 | R | sim_init ; ambient_temp=-10, engine_temp=80, fuel=1, battery=0.950.16 | FAST_FORWARD | 101.0 | R | board_net ; set ; 1 # бортсеть4.2 | R | heater_mode ; set ; auto # подогреватель в автоматический режим5.1 | ASSERT | oil_tank_temp ; gt ; 25 # масло в баке горячее6.1 | ASSERT | heater_on ; eq ; 0 # автозапуск заблокирован7.0 | FAST_FORWARD | 209.0 | ASSERT | heater_on ; eq ; 0 # и дальше не стартует10.0 | R | heater_mode ; set ; off13.0 | EXIT
Замечание приходило голосом
Стенд был за сотни километров, без удалённого доступа. Поэтому проверка шла так: эксперт садился в кабину, крутил ручки, нажимал педали и вслух говорил, что не так; я снимал на телефон; симулятор в это время писал лог.
Whisper расшифровывал голос, Клод соединял расшифровку и лог. Получался один документ: строки сигналов вперемешку с репликами из аудио.
12:01 | R | pedal_throttle ; 0.412:03 | R | turn_cmd ; -1.012:03 | S | vehicle_speed ; 812:04 | S | yaw_rate ; 0.40# 12:04 srt: «руль до упора, на малом режиме доворачивает нормально»12:05 | R | pedal_throttle ; 0.912:08 | S | vehicle_speed ; 1712:08 | S | yaw_rate ; 0.2112:11 | S | vehicle_speed ; 2412:11 | S | yaw_rate ; 0.10# 12:12 srt: «а на скорости еле-еле»
По получившемуся аннотированному логу Клод заводил обычный тикет с ошибкой. Дальше решал лог: подтвердилось замечание эксперта в сигналах — брались за фикс; не подтвердилось — я просил проехать ещё раз и снять новый лог. Со временем логирование расширили настолько, что баг стало видно с одного проезда. По тикету Клод работал сам: делал автотест, убеждался, что поведение повторяется, добавлял диагностические логи, смотрел состояние модели, сверял с РЭ, предлагал правку в YAML или в коде и прогонял тесты. Если что-то оставалось непонятным — я шёл к эксперту с конкретным вопросом (как правило, ответа не было, решали по ходу). В этой части ИИ был полезен с неожиданной стороны: он быстро находил противоречия между РЭ, логом, экспертом и кодом.
Иногда это выглядело почти неприлично просто. Утром со стенда присылали голосовое и лог. Я отдавал их Клоду и шёл чистить зубы. К завтраку фикс был готов. К обеду релиз был на стенде. Обычно сходилось за две-три итерации.
Постепенно стало понятно, что главная ценность проекта — накопленный набор сценариев: они описывают поведение машины точнее любого документа.
Проблема архитектурного дрейфа
Странная вещь: старого кода выпилили гору, а кодовая база меньше не стала. Новая модель вышла не легче выпиленной. Отчасти это физика, которую наконец написали по РЭ. Остальное — издержки самой агентской разработки.
Что лежит рядом в окне, влияет на Клода сильнее, чем файл с правилами. Новому узлу нужны обороты двигателя — по-хорошему он берёт их из общей шины, а Клод видел, что рядом, в соседнем классе, величина уже посчитана, делал геттер и тянул прямо оттуда. Задача решалась, тест проходил, а два узла, которым незачем знать друг о друге, оказывались сцеплены.
И почти не убирал за собой: ненужная функция, осиротевшие поля, мёртвые промежуточные переменные оставались в коде и копились осадком. Новое он добавляет легко, а вот убирать за собой старое почти не умеет. И это не только мой опыт: GitClear на 211 млн строк намерил то же по индустрии: с приходом AI-ассистентов доля рефакторинга упала с 24% до 9%, дублирование выросло вчетверо, и впервые скопированного кода стало больше, чем перенесённого. Причина простая: модель пишет слева направо и почти не возвращается переписать сделанное — добавить ей легче, чем убрать.
Снаружи всё работает. Тесты зелёные, эксперты довольные. Лишняя зависимость и мёртвый код на поведении не сказываются — и контур, который ловил мне физику, этого не видит: прогон зелёный, а код тихо дрейфует. Поймать дрейф можно только глазами — читать диффы и прямо требовать у Клода «убери старое».
Поэтому оставлять агента одного нельзя. Сколько правил ему ни напиши, без присмотра он рано или поздно уверенно навалит кучу.
Итог
В «Призраке» есть сцена у гончарного круга: руки поверх рук, мокрая глина, один лепит, другой направляет. Румбу мы «слепили» примерно по тому же принципу. Глиной было всё, что можно было превратить в токены: C+±код, логи, РЭ, YAML-параметры, тесты, замечания эксперта, git history. Клод лепил этот материал в код, тесты и гипотезы, а я следил за тем, чтобы ничего не поплыло — где граница компонента, чему верить при конфликте источников, какой тест нужен, когда идти к эксперту, когда заставить переделать.
В моём случае ИИ снял большой кусок ручной работы: поиск по РЭ, первичный разбор логов, перенос требований в код и тест, перебор гипотез. За счёт этого один человек мог тянуть объём работы, который без такого контура распался бы на длинную цепочку ручных проходов между доменом, кодом и проверкой.
Инженерная работа никуда не делась. Я по-прежнему решал, чему верить при конфликте источников, какой тест действительно закрывает поведение и когда идти к эксперту. Клод ускорял цикл. Ответственность за то, что считается доказательством, осталась на мне.
Тренажёр приняли. Проект я сдал и жду следующего этапа. Вся машина теперь на Rumba. Старую модель я сжёг. Любимый рефакторинг — удаление модуля. Здесь удалось удалить целую систему за нереально короткий срок. Ощущение, как на американских горках.
По следам этого проекта я взялся за архитектурный чекер на C++: граф включений, циклы, god-заголовки, дрейф архитектуры между ревизиями, проверка прямо в CI. Подтолкнули статьи про constraint decay — когда к функциональной задаче добавляют структурные ограничения, и агент начинает их терять. Ровно тот дрейф, который кабина и тесты не видят. Но это уже совсем другая история.
ссылка на оригинал статьи https://habr.com/ru/articles/1045911/