Как я раздаю агентам 733 инструмента из 23 мцп серверов
- пятница, 31 июля 2026 г. в 00:00:08
В прошлой статье про корпоративного AI-ассистента был абзац о том, что мцп серверов у меня много, поэтому пришлось написать прослойку, которая раздаёт тулзы дозированно. Расскажу, как я к этому пришел, потому что на самом деле не все так очевидно.
Прослойку я написал, когда мцп серверов было штук пять, и написал скорее на упреждение. Уже тогда было понятно, что будет много кубовых кластеров, несколько гитлабов плюс всякие мониторинги прометеусы и тд. То есть однотипных серверов будет много, а тулзы у них называются одинаково, и хотелось не перегружать контекст однотипными описаниями ну и чтобы модель в них не путалась.
Каждый мцп сервер это отдельный инстанс, который предоставляет свою пачку тулзов и описаний. k8s-cluster-1, k8s-cluster-2 и тд, это разные инстансы одного и того же мцп, но каждый отдаёт свой набор тулзов с одинаковыми именами. Модели незачем видеть kubectl_get пять раз. Ей надо знать, что такой тул есть и что кластеров пять, а какой именно кластер - это уже параметр вызова, имя сервера.
Поэтому у каждого сервера в конфиге появилось поле kind:
{ "servers": [ {"name": "k8s-cluster-1", "kind": "kubernetes", "url": "..."}, {"name": "k8s-cluster-2", "kind": "kubernetes", "url": "..."}, {"name": "gitlab-a", "kind": "gitlab", "url": "..."}, {"name": "gitlab-b", "kind": "gitlab", "url": "..."} ] }
А мультиплексор сворачивает инстансы в вид и складывает имена в сет:
// упрощённо for name, srv := range mx.servers { kind := srv.config.Kind serversByKind[kind] = append(serversByKind[kind], name) for _, t := range srv.tools { toolSets[kind][t.Name] = struct{}{} } }
KindGroups() отдаёт наружу три вещи: виды, серверы каждого вида и дедуплицированные имена тулзов. В промт оркестратора из этого едут только первые две, по строке на вид:
kubernetes: k8s-cluster-1, k8s-cluster-2, k8s-cluster-3, k8s-cluster-4, k8s-cluster-5 gitlab: gitlab-a, gitlab-b, gitlab-a-write, gitlab-b-write prometheus: prometheus-a, prometheus-b, prometheus-c opensearch: opensearch-a, opensearch-b jira: jira wiki: wiki ...
Когда появилсь субагенты, имена мцпшных тулзов и описания стали оркестратору не нужны, он инструменты сам не вызывает, он выбирает сервер и спавнит субагента. Весь его справочник по 23 серверам превратился в тринадцать строк и 180 токенов.
А субагенту достаётся полный набор: все тулзы его серверов, с полными описаниями и схемами, потому что какие именно понадобятся, заранее не знает никто, и понять это может только сам агент, которого ещё не запустили. Зато серверов у него обычно два-три, и полный набор в этих границах стоит недорого: разбор инцидента это три сервера, 40 инструментов и 9 тысяч токенов при окне в 131 тысячу. А точность попадания в аргументы напрямую зависит от полноты описаний, так что резать тут - экономия на спичках при цене в лишний цикл вызовов.
Дальше я подключал серверы по одному, по мере надобности: гитлаб, кубы, Jira, прометей, трейсы. Каждое подключение по отдельности безобидное, ну ещё один сервер, ну ещё десяток тулзов. Сейчас их 23, и они отдают 733 пары (сервер, инструмент) - а уникальных имён среди этих пар всего 284, остальные - дубли.
С выбором тула стало нормально, и почти сразу вылезло следующее. Модель зовёт правильный инструмент, но с мусором в аргументах. То пустая строка, то undefined, то <none> прямо как есть, то массив там, где сервер ждёт строку.
Первое, что я сделал - написал в промте, чтобы модель так не делала. Но локальные модели это локальные модели, они не особо то следуют инструкциям, поэтому промт то срабатывал, то не срабатывал. Да и модель ведь не знает, что у неё в аргументе заглушка, для неё это такой же текст, как всё остальное. Так что запрещать было бесполезно, и я сделал то же самое кодом. Теперь пустые строки и известный набор заглушек (undefined, null, none, <none>, <unknown>) до сервера не доезжают, а всё остальное проверяется по жсон-схеме тулза - так ловится и массив там, где ждали строку. Отклонение приходит модели текстом, с перечнем того, что именно не так, и следующая попытка обычно уже корректная.
А потом выяснилось, что и с нормальными аргументами всё не так гладко, и вот из этого уже выросли нормализаторы.
Мцп сервера все разные, и поэтому один условно ждёт podName, другой pod, третий просто name. Один принимает массив, другой строку через пробел. Модель субагента про это хоть и знает, но не всегда соблюдает, поэтому нормализация живёт в конфиге на уровне вида:
"kind_settings": { "kubernetes": { "args_transformer": ["camelCase"], "field_map": {"podName": "name", "pod": "name"} } }
Применяется это всё до вызова, модель просто зовёт инструмент и получает результат.
Встроенных нормализаторов на самом деле всего три: camelCase, joinArrays и singularResourceType (этот приводит pods к pod и прочие плюралы, кубовые мцп такое любят). Но можно подкинуть свой - регистрируешь функцию под именем через WithArgsTransformer, и дальше ссылаешься на это имя из конфига наравне со встроенными.
Тут важно, что дело не в количестве серверов, а в их разнородности. Пока у меня их было три и все от одного автора, никакая нормализация не требовалась, у них общие соглашения и всё сходилось само. Разъезжаться начало, когда серверов стало полтора десятка и писали их разные люди. Так что если у вас пачка мцп работает из коробки - скорее всего вам просто повезло с набором )
Через мультиплексор проходит каждый вызов к внешней системе, и постепенно выяснилось, что это удобное место, чтобы навесить хуки вызовов: до вызова, после вызова, преобразование результата и обогащение метаданных. На них сейчас живут права на инструменты, вынос слишком больших ответов в память, обезвреживание инструкций, и метрики по каждому серверу.
Когда сел писать эту статью, стало любопытно, сколько теперь весит в токенах вся информация про мцп сервера и их инструменты? Думал выйдет дорого, но терпимо.
Вышло 170 256 токенов, в то время как окно у основной модели - 131 072.
То есть сейчас каталог в чистом виде в модель уже вобще не влезает. Даже если больше ничего в контекст не класть - ни системный промт, ни история диалога, ни сам вопрос пользователя.
Померил все варианты: отдать всё, отдать обзор по видам и отдать набор под конкретную подзадачу.
что отдаём | серверов | инструментов | токенов |
|---|---|---|---|
весь каталог | 23 | 733 | 170 256 |
обзор по видам, с именами тулзов | 23 | 284 имени | 3 121 |
справочник видов, как он едет оркестратору | 23 | - | 180 |
набор субагенту: разбор инцидента | 3 | 40 | 9 132 |
набор субагенту: поиск по коду | 2 | 127 | 26 970 |
Обзор по видам выходит в 55 раз дешевле полного каталога, потому что в нём одни имена, без схем и описаний. А оркестратору и имена тулзов не нужны, поэтому у него в промте 180 токенов - в 950 раз меньше каталога. Дальше выбранный им набор мцп серверов уезжает агенту целиком, со схемами.
Из всего этого всего я сделал два вывода: запрещать модели что то в промте бесполезно, рано или поздно модель забьет на инструкции, нужны детерминированные гейты и проверки кодом. И однотипные мцп надо сворачивать в вид сразу, и делать кластер, контур или инстанс аргументом вызова, а не отдельным мцп, а иначе каталог будет расти линейно от числа стендов и ты упираешься в лимит контекстного окна на ровном месте.
Итак, для стандартизации вызовов к разным мцп получилась опен сорс библиотека которая убирает дубли тулзов и их описаний из контекста модели, а заодно предоставляет хуки для вызовов тулзов и нормализацию аргументов. Сама библиотека тут. Транспорты stdio, HTTP и SSE, адаптер под eino. Буду рад issue и вопросам.
И вопрос к тем, кто такое строит. Сколько у вас мцп серверов и на каком количестве вы упёрлись в размер каталога? Мне кажется, я упёрся довольно рано, и интересно, у всех ли это происходит на втором десятке.