Привет! Я Андрей и сегодня расскажу, как сделал мультиагентную систему, которая автоматизировала ревизию доступов в бэкофисе Авито, копившихся годами. Вы узнаете, как собрать LLM-систему с четырьмя агентами и супервизором, которая не только сгенерировала описания прав доступа, но и с точностью 77% нашла их владельцев без передачи кода и документации внешним моделям. Вперед к прочтению!

Содержание:
Контекст: что не так с доступами?
Права доступа в Авито мы называем ACL — Access Control List, список контроля доступа, определяющий, кто и что может делать в админке. В Авито она всегда развивалась параллельно основному продукту, но по остаточному принципу: админка всё-таки для внутренних пользователей, а они не такие притязательные, поэтому внимания ей уделяли меньше. И спустя годы в ней накопилось 1171 ACL-ок. Про 754 мы вообще не знали, кому они принадлежат и какие функции закрывают, — нужно было что-то делать.
Почему ACL стали нам нужны?
Сложно запустить ролевую модель
Недавно в компании мы начали перестраивать ролевую модель, и поэтому нужно было понять, какой юнит и кто конкретно в этом юните отвечает за ACL. Правильная ролевая модель помогает вместо точечного назначения доступов выдавать все нужные в привязке к роли сотрудника с первого дня работы (или почти все, с минимумом дополнительных запросов). И никто не бегает в попытках узнать, почему эта функция у него не работает.
Страдает безопасность
Без описаний ACL сложно сделать ревью доступов, когда владельцы на регулярной основе решают, кому всё ещё нужен доступ, а у кого лучше отозвать, чтобы снизить риски утечки.
Примеры ACL
Name | ACL | Расшифровка (была не для всех) |
Content / promo / main | content-promo | |
System / Groups / Edit | system-groups-edit | Редактирование группы в админке прав |
Comment / Save | comment-save | Оставить комментарий |
Получается, что на входе у нас текстовые данные об особенностях ACL, на выходе — описание и имя владельца, тоже текст. Похоже на задачку для LLM?
Закинув просто названия ACL в LLM, мы получим плохой результат — по сути просто попробуем отгадать. А если добавим дополнительный контекст: связанный с ACL код и документацию, то LLM уже сможет все это проанализировать и выдать более релевантный результат.
Архитектура решения
Кроме LLM, нам нужно несколько агентов. Агент помогает генеративной модели получить информацию или совершить действие через сторонние ресурсы. Самый популярный и коммерчески-доступный — Operator от OpenAI. Эта штука может с помощью одного промпта забронировать вам отель для путешествия, найти подходящий товар на маркетплейсе или сделать кучу других полезных дел.
Я использовал 3 агента: Code Analyzer, Wiki Analyzer и Org Structure Validator.
Code Analyzer.
Делал запросы в Sourcegraph, отправляя туда название ACL и в ответ получал 10 репозиториев. Он анализировал именно код, потому что, как известно, код wins argument: документация может быть некорректной, а разработчики, у которых что-то можно прояснить, могли уволиться.
Кроме этого, Code Analyzer был интегрирован с реестром микросервисов (мы зовём его Atlas), потому что знания о владельце сервиса — самая надёжная связь между кодовой базой и оргструктурой компании. Если же агент не мог найти ответ в коде, то супервизор отдавал задачу следующему.
Wiki Analyzer.
Дальше в работу включался Wiki Analyzer и искал всю информацию по ACL во внутренней документации.
Org Structure Validator.
На финальном этапе супервизор передавал работу валидатору. Он был интегрирован с Avito People — корпоративным порталом, который упрощает HR-процессы. Валидатор проверял, существует ли такой сотрудник внутри или нет. Я добавил проверку, чтобы не выдавались галлюцинации по типу «Петр Петров». А если найденный разработчик, писавший код, уже уволился, и от него осталось только понимание юнита — валидатор искал текущего руководителя юнита.
Почему многоагентность — лучше?
Моя архитектура с супервизором — не единственный выбор в этой ситуации. Работу мог выполнить и один агент с четырьмя тулами (Sourcegraph, Confluence, Atlas и сервис Avito People). Но в таком случае он начал бы в них путаться: дробление промпта на подзадачи приводило к лучшему результату.
К тому же разделение на супервизора и агентов позволяет использовать разные модели, что лучше для безопасности. Для работы первого я брал reasoning-модель о4-mini от OpenAI, а для агентов — наш внутренний LLM Gateway и опенсорсную модель Qwen 2.5 на 32 млрд параметров. Агент выкачивал данные из Sourcegraph и Confluence, там же анализировал, а супервизору отдавал только агрегированную информацию с 5 полями:
justification (не просто так идёт первым — заставляет модель «порассуждать», имитируя технику промптинга Chain-of-Thought),
владелец,
unit владельца,
developer,
confidence (уверенность в правильности определения).
OpenAI не видел ни кодовую базу, ни статьи в Confluence.
Протокол для LLM, который всё решает
Не обошлось и без модного MCP (Model Context Protocol) — протокола для взаимодействия LLM с тулами. Его основная идея — сделать их взаимозаменяемыми: один раз пишешь MCP-сервер, например, для Sourcegraph или Confluence, — и потом переиспользуешь в других задачах почти без изменений.
MCP поддерживает два режима работы:
HTTP-сервис — поднимается как отдельное приложение, принимает и обрабатывает запросы.
STDIO-режим — запускается как CLI-интерпретатор (в нашем случае — на Python), получает команды через stdin, возвращает ответы через stdout.
Для разметки я написал 4 MCP-сервера: для Sourcegraph, Confluence, Atlas и Avito People.
Важный момент — отладка MCP. Поскольку MCP работает как отдельный сервис, поднимаемый каждый раз интерпретатором, увидеть ошибку в нём сложно: на промежуточных шагах эксепшны в другом процессе вы не увидите.
Поэтому для наблюдаемости лучше сразу заложить обработку всех возможных сбоев через защитное программирование. Даже если такую ошибку вы видели только в документации — всё равно лучше, если MCP выдаст текстовую строку.
А LLM, как показала практика, достаточно умна, чтобы повторно дёргать инструмент, если, скажем, случился тайм-аут или аргументы были невалидны. У меня такие кейсы случались, и обработка оказалась удобной.
Почему стоит делать сниппетинг?
Не стоит класть в LLM кодобазу сервиса или даже отдельного файла целиком: это будет элементарно дорого — стоимость запроса зависит от числа входных токенов. И к тому же не очень эффективно — в большом промпте она начнёт больше внимания отдавать начальной и конечной части запроса, упуская из вида середину.
Поэтому есть смысл сделать сниппетинг. Он помогает вытащить только минимально нужный контекст из всей базы. Например, релевантные запросу методы или классы. А чтобы не пилить свою реализацию сниппетинга под каждый язык программирования и разметки, я воспользовался tree-sitter.
Эта библиотека строит абстрактное синтаксическое дерево (AST) для кода и помогает удобно вытаскивать нужные фрагменты — функции, методы, классы. Причём под большинство языков уже есть готовые библиотеки. То есть на выходе я получаю структурированное дерево, из которого легко достать нужный сниппет.
У tree-sitter есть особенность: под каждый язык нужна отдельная грамматика, и деревья получаются разные. Если хочется универсальности, нужно писать кастомный обработчик под каждый язык. Но в Авито их не так много, поэтому парсинг был несложный.
В тех языках, которые я взял, проверял, что выводятся одинаковые имена, например, function, class. Сложности были только с YAML: ноды там назывались по-другому, и его пришлось обрабатывать отдельно.
В языках без готовых грамматик или в случаях, когда парсер падал, — я реализовал fallback: Sourcegraph возвращает строку, в которой найдена сущность, а дальше мы просто берём контекст ±5 строк вокруг неё.
Вот весь код, который понадобился, чтобы вытащить информацию:
def get_context(filename: str, source: str, line: int, context_size: int = 5) -> str:
ext = filename.split('.')[-1]
lang_map = {
'go': tsgo.language(),
'js': tsjavascript.language(),
'php': tsphp.language.php(),
'py': tspython.language(),
'ts': tstypescript.language_typescript(),
'yaml': tsyaml.language(),
}
if ext not in lang_map:
return get_region(source, line, context_size)
lang = Language(lang_map[ext])
parser = Parser(lang)
tree = parser.parse(source.encode('utf8'))
query = lang.query('(_) @node')
matches = [
m['node'][0]
for _, m in query.matches(tree.root_node)
if m['node'][0].start_point[0] == line
]
if not matches:
return get_region(source, line, context_size)
# Find the parent class, method, function, variable declaration or export statement
parent = matches[0].parent
while (
parent is not None
and parent.type not in [
tree.root_node.type,
'class_declaration',
'method_declaration',
'function_declaration',
'var_declaration',
'export_statement',
]
):
parent = parent.parent
if parent is not None and parent.text is not None:
context = parent.text.decode('utf8')
if len(context) > 100 and len(context.split('\n')) < context_size * 2:
return context
return get_region(source, line, context_size)
Напоследок — про трейсинг. Не забывайте про инструменты наблюдаемости, особенно если вы строите систему с вызовами внешних тулов. Я использовал опенсорсный MLflow — он помогает отслеживать, какие запросы LLM отправляет агентам, какие ответы получает и где происходят ошибки. Это сильно упростило отладку. Ну а теперь — к результатам.
Что получилось?
Качество генерации комментариев было на уровне ~65% (LangChain labeled_score_string). С учётом скорости создания — результат хороший. Работу инструмента я проверял, посмотрев на ответы LLM по тем 78 ACL, которые были максимально подробно размечены вручную.
Но самое интересное — это точность разметки владельцев:
17% — точное попадание LLM во владельца ACL в юните;
42% — правильный юнит;
18% — правильный кластер;
Итог: в 77% случаев разметка попадает в нужный кластер. По сути только в четверти случаев останется доразметить данные вручную.
Какие планы на будущее?
Есть гипотеза, что таким же методом при помощи кодовой базы можно различать в контрактах сервисов персональные данные. Её только предстоит проверить. Но другую задачу уже получилось решить.
В Авито есть 1500 правил iptables между офисом и дата-центром — некоторые из них небезопасные. При помощи LLM-ки и одного тула удалось найти самые рисковые, и теперь мы можем их скорректировать.
Что думаете о таком использовании LLM? Делитесь в комментариях!
А если хотите вместе с нами помогать людям и бизнесу через технологии — присоединяйтесь к командам. Свежие вакансии есть на нашем карьерном сайте.