golang

Как я раздаю агентам 733 инструмента из 23 мцп серверов

  • пятница, 31 июля 2026 г. в 00:00:08
https://habr.com/ru/articles/1064908/

В прошлой статье про корпоративного 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> прямо как есть, то массив там, где сервер ждёт строку.

Первое, что я сделал - написал в промте, чтобы модель так не делала. Но локальные модели это локальные модели, они не особо то следуют инструкциям, поэтому промт то срабатывал, то не срабатывал. Да и модель ведь не знает, что у неё в аргументе заглушка, для неё это такой же текст, как всё остальное. Так что запрещать было бесполезно, и я сделал то же самое кодом. Теперь пустые строки и известный набор заглушек (undefinednullnone<none><unknown>) до сервера не доезжают, а всё остальное проверяется по жсон-схеме тулза - так ловится и массив там, где ждали строку. Отклонение приходит модели текстом, с перечнем того, что именно не так, и следующая попытка обычно уже корректная.

А потом выяснилось, что и с нормальными аргументами всё не так гладко, и вот из этого уже выросли нормализаторы.

Мцп сервера все разные, и поэтому один условно ждёт podName, другой pod, третий просто name. Один принимает массив, другой строку через пробел. Модель субагента про это хоть и знает, но не всегда соблюдает, поэтому нормализация живёт в конфиге на уровне вида:

"kind_settings": {
  "kubernetes": {
    "args_transformer": ["camelCase"],
    "field_map": {"podName": "name", "pod": "name"}
  }
}

Применяется это всё до вызова, модель просто зовёт инструмент и получает результат.

Встроенных нормализаторов на самом деле всего три: camelCasejoinArrays и 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 и вопросам.

И вопрос к тем, кто такое строит. Сколько у вас мцп серверов и на каком количестве вы упёрлись в размер каталога? Мне кажется, я упёрся довольно рано, и интересно, у всех ли это происходит на втором десятке.