Привет! Я Андрей и сегодня расскажу, как сделал мультиагентную систему, которая автоматизировала ревизию доступов в бэкофисе Авито, копившихся годами. Вы узнаете, как собрать 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? Делитесь в комментариях!
А если хотите вместе с нами помогать людям и бизнесу через технологии — присоединяйтесь к командам. Свежие вакансии есть на нашем карьерном сайте.
Комментарии (5)
UtrobinMV
28.08.2025 00:05Какой фреймворк использовался для написания агентов? Какие llm?
toogle Автор
28.08.2025 00:05Использовал LangGraph. Плюс очень рекомендую langgraph-supervisor — это удобная реализация архитектуры с супервайзером поверх LangGraph.
По моделям: для супервайзора o4-mini, для агентов qwen2.5-32b, развёрнутый внутри Авито.
SerJ_82
28.08.2025 00:05Как-то писал, но на всякий случай еще раз попробую))) Передайте пожалуйста там своим начальникам, а они ещё выше: сделайте вы уже наконец поиск по картинке!! Невозможно огромное количество времени тратится когда ищешь конкретный лот среди тысяч похожих. Например видеокассету со старым фильмом среди сотен одинаковых объявлений типа "видеокассеты продам".
Сделайте хоть платно! Как угодно. Тем более что раньше такая функция была.
Это по моим прикидкам так же снимет опреденную часть нагрузки с серверов когда идёт подгрузка фотографий.
mantyr
Это какая-то дичь:)
toogle Автор
Почему?