Привет, Хабр!
Примерно год назад наша команда загорелась идеей создать продукт, который позволил бы «поговорить с кодом». Мы, как и многие, находились под впечатлением от возможностей LLM. Казалось, что ещё немного – и нейросеть возьмёт на себя всю рутину по анализу легаси, аудиту систем и онбордингу новых разработчиков.
Мы представляли себе идеальную картинку: загружаем исходники, документацию, ТЗ в модель, нажимаем кнопку и на выходе получаем JSON с описанием архитектуры, связей, интеграций и методов. Вишенкой на торте должен был стать умный чат, в котором можно спросить что-то вроде «как у нас реализованы выплаты по убыткам?» и почти мгновенно получить ответ.
В начале пути всё это выглядело довольно прямолинейно. LLM же обучены на массе источников в интернете, умеют читать код, у нас есть фреймворки для аудита. Казалось, напишем крутой промпт, загрузим его в модель и будем пожинать плоды.
Но не тут-то было. Идея разбилась о суровую реальность enterprise-разработки. За несколько месяцев мы собрали коллекцию из 12 ошибок, которые едва не похоронили наш проект Code Scope (именно так мы назвали решение). Сегодня расскажу о пяти, на мой взгляд, самых показательных. Спойлер: в итоге наш код на 99% состоит из «инженерии», и только 1% – это тексты промптов.
Ошибка 1: Один запрос обо всём
Мы начали красиво. Взяли внутреннюю систему, написали «классный» промпт и попросили LLM вернуть все возможные факты о коде в виде структурированного JSON-объекта: описание методов, интеграции, точки входа, расчёт метрик и так далее.
Сначала всё шло хорошо, пока мы извлекали только описания методов. Но как только JSON-схема и промпт распухли до гигантских размеров, начался хаос. Например, мы видели, что в Java-классе есть восемь методов, а модель упорно возвращала шесть. Результаты одинаковых запросов тоже воспроизводились по-разному: в одном прогоне LLM «видела» интеграцию с внешним сервисом, в другом – нет.
Первая реакция – начать тюнить промпт. Мы добавляли «улучшайзинги», ограничения, условия. Промпт разрастался, поддержка его превратилась в кошмар: меняешь одно – ломается другое. Смотреть на текст в несколько тысяч токенов стало страшно.
Решение оказалось простым и элегантным: мы разделили одну большую задачу на несколько узкоспециализированных. Вместо одного гигантского запроса «обо всём» мы стали делать отдельные вызовы на описание методов, отдельные – на поиск интеграций, отдельные – на поиск эндпоинтов. Итог: качество и стабильность резко выросли, потому что у модели появился фокус. Да, вызовов стало больше, но по времени мы не сильно проиграли: точечные запросы выполняются быстро, и их легко запускать параллельно.
Кстати, есть ряд интересных статей по этой теме. Даю ссылки на несколько из них:

Ошибка 2: «Я доверяю фактам от LLM + Self-Confidence»
После первой ошибки в голове засел важный вопрос: можно ли вообще доверять фактам, которые возвращает LLM? Мы же своими глазами видели, что она может врать.
Мы подумали: «А что, если просить модель возвращать не только сам факт, но и цитату из кода, где она его нашла, плюс ее уровень уверенности в ответе (self-confidence) от 0 до 10?». Звучало классно. В наших мечтах при правильной реализации мы бы поставили порог и отсеивали всё, в чём модель сомневается.
На деле это оказалось ложным чувством контроля. Модель часто возвращала уверенность 10 там, где факт был откровенно ложным. Например, она могла увидеть переменную с названием Kafka и выдать это за факт использования брокера сообщений.
Попытка использовать «LLM-as-a-Judge», когда одна модель проверяет другую, тоже не дала твёрдой почвы под ногами, к тому же это дорого и не гарантирует истины.
Мы пришли к печальному, но честному выводу: универсальной вероятностной проверки не существует. Нужен детерминированный слой валидации на основе кода.
Как это работает у нас сейчас? Перед анализом системы мы собираем о ней метаинформацию: стек технологий, фреймворки, зависимости. На основе этого генерируются маркеры. Например, для Java со Spring Boot маркером внешнего вызова будет использование RestTemplate, Feign-клиента или Kafka Producer. Когда LLM говорит «я нашёл внешний вызов», мы уже своими линейными алгоритмами проверяем, есть ли в коде соответствующие зависимости и подтверждающие маркеры. В итоге мы перестали говорить «здесь есть интеграция». Мы говорим: «Мы с высокой/средней/низкой уверенностью считаем, что здесь есть интеграция».
И опять делюсь ссылками на несколько дополнительных интересных материалов по этой теме:
Вот как выглядит то, что для Java со Spring Boot, маркеры указывающие на то, что сервис потенциально может взаимодействовать с чем-то снаружи у нас вот такие:
{ "grpc": [ "@GrpcClient", "@GrpcService" ], "http": [ "@RequestMapping", "@PostMapping", "@DeleteMapping", "@PutMapping", "@PatchMapping", "@GetMapping" ], "rest": [ "@FeignClient", "RestTemplate", "WebClient" ], "cache": [ "@CachePut", "RedisTemplate", "@Cacheable", "CacheManager", "@CacheEvict" ], "cloud": [ "@CloudFoundryApplication", "@EnableCloudStream", "@EnableBinding" ], "database": [ "EntityManager", "@Entity", "JdbcTemplate", "JpaRepository", "@Repository" ], "messaging": [ "@KafkaListener", "@RabbitListener" ], "apiGateway": [ "RouteLocator", "@EnableZuulProxy", "@Bean", "GatewayFilter" ], "fileStorage": [ "MinioClient", "BlobServiceClient", "FileSystemResource", "S3Client" ], "configServer": [ "ConfigClient", "@EnableConfigServer", "@EnableConfigClient" ], "projectClassHash": "-114374103", "serviceDiscovery": [ "ConsulClient", "EurekaClient", "@EnableDiscoveryClient" ] }
Ошибка 3: Формат – не главное
Итак, когда у нас было понимание, что с качеством и валидацией мы разобрались , мы решили запустить анализ реального большого проекта. И тут нас добили токены. Проект был огромный, вызовов к LLM – тысячи. Некоторые запросы стали выполняться по 10 минут!
Первым делом мы грешили на инфраструктуру и пошли к LLMOps-команде. Но нагрузка была в норме. Спасибо Google и форумам, мы осознали простую, но критически важную вещь: формат – это тоже токены. Латентность запроса линейно зависит от количества выходных токенов.
Мы написали простой тест. В первом случае показали модели «красивый», человекочитаемый JSON с отступами и переносами строк и попросили вернуть такой же. Во втором – тот же JSON, но в компактном виде (в одну строку), и попросили ответить так же. Разница в ответе составила более 200 токенов! В масштабе тысячи вызовов эти «пустые» переносы строк превращаются в часы ожидания и пустую трату денег.
С тех пор у нас железное правило: везде, где только можно, мы используем компактный single-line JSON и в few-shot примерах, и в требованиях к ответу модели. Формат – это часть производительности.
Здесь также добавлю интересную статью по теме: https://arxiv.org/html/2508.13666v1

Ошибка 4: Один универсальный тул «в базу»
Вернёмся к нашей «вишенке на торте» – чату с кодовой базой. Мы реализовали мультиагентский RAG. Здесь мы использовали графовую базу для хранения связей и векторную для хранения семантики (описания методов и классов). Для того чтобы делать запросы в граф, мы написали агента, который писал запросы на языке Cypher. Подавали ему схему, описание запроса и он генерировал код.
На простых вопросах это работало отлично. Но как только запросы усложнялись, начинался ад. Агент писал гигантские простыни кода, которые либо не проходили валидацию, либо падали при выполнении в базе. Запрос уходил на перегенерацию, потом ещё и ещё. В итоге ответа можно было ждать несколько десятков минут.
Мы пытались улучшать промпты, учить агента писать нормально, но проблема оставалась. Тогда мы включили «режим архитектора» и проанализировали реальные сценарии использования. Оказалось, что 80–90% запросов в граф укладываются в несколько паттернов: «покажи контекст метода», «построй цепочку вызовов от эндпоинта», «найди все интеграции вокруг узла».
Мы просто написали эти запросы сами, оттестировали и завернули их в готовые тулы с понятными параметрами. Агенту теперь не нужно думать, как писать сложный Cypher — он просто дёргает нужный инструмент. Универсальная генерация осталась как fallback, на случай совсем уж уникальных сценариев, но мы попадаем в неё крайне редко. Количество перегенераций упало в разы, скорость ответа взлетела.
Ошибка 5: Observability «потом»
Система усложнялась: векторное хранилище, графовое хранилище, куча инструментов, несколько агентов. Мы, как true-фанаты ИИ, хотели быстрее добавить «магии» и запилить демо, а наблюдаемость решили прикрутить потом. Ведь в классических системах даже с распределённой архитектурой по логам и алгоритму можно найти проблему.
Но с системами на LLM это не работает. Логика здесь определяется данными, промптами и вызовами инструментов. И в один прекрасный момент мы поймали странное поведение: nо один из агентов не вызывает нужный инструмент, то отвечает, что создал 11 объектов через MCP сервер, а мы видим, что их было создано 9. И, естественно, ничего не воспроизводится, каждый раз получаем разное поведение.
Мы побежали покрывать всё логами, в том числе и цепочки рассуждений модели. Частично стало понятнее, но это превратилось в археологию: копаться в логах, сопоставлять их, пытаясь найти причину.
Плюнув на это через пару дней, мы за час подняли в dev-кластере платформу Langfuse и подключили трейсинг. И первый же запрос показал проблему «по рельсам»: LLM просила вызвать один инструмент, а в коде у нас по ошибке вызывался первый попавшийся из списка MCP-сервера. При этом сам MCP-сервер был написан криво и на любой запрос отвечал 200 OK, не возвращая ошибку. Модель думала, что всё хорошо. Баг был исправлен за пару часов.
Трейсинг дал нам ту самую прозрачность, без которой разработка сложных ИИ-систем превращается в шаманство.
На картинке ниже видно, что запрос в мультиагентную систему - это очень большой трейс с многими шагами, где что-то может пойти не так.

Вывод
Сейчас, оглядываясь на этот восьмимесячный путь, я понимаю главное. Та красивая картинка «просто загрузи код в LLM и получи промпт» осталась лишь красивой мечтой. В нашем репозитории тексты промптов занимают от силы 1% объёма. Остальные 99% – это инженерный код: пайплайны обработки данных, контракты и JSON-схемы, детерминированные слои валидации, наблюдаемость, интеграции.
LLM – это всего лишь компонент системы, причём вероятностный. Реальный продукт для Enterprise-разработки создают инженеры, и ценность продукта определяется именно инженерной обвязкой вокруг модели, которая делает систему предсказуемой и надёжной.
Если вы только начинаете строить что-то подобное, не тратьте время на поиск идеального промпта с первого дня. Начните с другого:
Golden Dataset и тесты качества, чтобы понимать, что для вас «приемлемый ответ», а что – нет.
Structured Output везде, где можно. JSON-схемы – первый шаг к воспроизводимости.
Не пишите промпты руками. Пусть LLM сама сгенерирует вам промпт с примерами и учётом граничных случаев. Это быстрее и часто качественнее.
Минимальная наблюдаемость с первого дня. Иначе потом вместо магии агентов вы будете заниматься археологией логов.
Спасибо, что дочитали! Если у вас были похожие грабли или хотите обсудить другие наши ошибки из списка (например, почему не стоит просить LLM посчитать то, что можно вычислить кодом) – добро пожаловать в комментарии. И маякните – продолжать ли серию по другим ошибкам, которые мы опробовали на проекте и своей шкуре.
