В наше время тяжело представить разработку цифровых продуктов, в которые хоть в какой-то степени не включили так называемый ИИ на больших языковых моделях (LLM). И я вовсе не против, но у меня вызывают вопросы подходы разработчиков к способам внедрения интеллектуальных инструментов в свои продукты.

Думаю, абсолютное большинство оптимальным способом внедрения интеллекта в продукт выбрали использование проприетарных моделей через API, с добавлением кастомного функционала через вызовы MCP серверов. Кажется, это уже даже стало стандартом, и в этом я вижу проблему.

Давайте кратко разберем схему работы какого-то нашего приложения с официальным LLM-клиентом (например, OpenAI) + MCP:

  • наш код получает запрос пользователя в свободной форме

  • подмешивается описание инструментов, доступных через MCP сервер

  • запрос улетает в LLM API

  • клиент парсит JSON из ответа модели

  • вызывает MCP-сервер

  • отправляет результат обратно модели

  • и только сейчас, в лучшем случае, получает финальный текст ответа, который можно отдать пользователю

Вероятно, суть моих претензий видна уже на этом моменте, но давайте продолжим. Как выглядит минимальное описание инструментов? Вот так:

TOOLS = [
	    {
		"name": "mongo_find",
		"description": "Find documents in MongoDB",
		"parameters": {
		    "type": "object",
		    "properties": {
		        "collection": {"type": "string"},
		        "filter": {"type": "object"},
		    },
		    "required": ["collection", "filter"]
		}
	    },
	    {
		"name": "run_search",
		"description": "Run a request for transportation prices between two points",
		"parameters": {
		    "type": "object",
		    "properties": {
		        "departure_id": {"type": "string"},
		        "arrival_id": {"type": "string"},
		        "payload": {"type": "object"}
		    },
		    "required": ["departure_id", "arrival_id", "payload"]
		}
	    }
	]

И это только парочка. Системный промт будет выглядеть примерно так:

SYSTEM_PROMPT = f"""
You are an agent with access to external tools.

Available tools:
{TOOLS}

To call a tool, respond ONLY with JSON in the format:
{{
  "tool": "",
  "args": {{
      ... arguments ...
  }}
}}

If no tool is needed, return:
{{ "tool": null }}
"""

Далее, к этой немаленькой пачке знаков будет добавлено еще два промпта: промежуточный ответ LLM и результат вызова MCP. Под капотом будет что-то типа:

messages.append({"role": "assistant", "content": first_response})
messages.append({
        "role": "tool",
        "content": json.dumps(tool_result),
        "name": tool
    })
    

Всё это вновь улетит к LLM, и только после обработки этой кучи информации мы получим от модели финальный ответ для пользователя. И это еще самый маленький цикл из возможных.

Так почему это плохо (дорого и неэффективно):

Каждая итерация удваивает или утраивает количество токенов.

OpenAI-клиент добавляет:

  • System prompt с описанием всех инструментов

  • User сообщение

  • Ответ модели

  • Tool вызов

  • Tool результат

  • Финальное сообщение

И всё это передается назад в модель в каждом раунде.

Даже если инструмент возвращает пустяк (например, один документ Mongo), SDK отправит всё это снова в контекст.

Если MCP звонков несколько подряд, токены в контексте растут экспоненциально. Это увеличение расходов и ухудшение результата.

Модель «захламляется» историей инструментальных вызовов.

LLM вообще не нужно помнить:

  • какие вызовы инструментов ты делал

  • какие аргументы ты использовал

  • какие промежуточные JSON приходили.

Но OpenAI клиент держит всё это в messages, и модель вынуждена:

  • держать это в контексте

  • оплачивать эти токены

  • учитывать шум при генерации

Систему легко перегрузить большим количеством tool_calls. А это опять деньги и проблемы с результатом.

Наше любимое – галюны. Модель начинает путаться.

Когда в контексте появляется длинная цепь:

  • system

  • user

  • assistant toolcall

  • tool response

  • assistant follow-up

  • еще один toolcall

  • еще один ответ

  • ...

Модель перестает точно понимать, в каком «режиме» она сейчас работает: генерирует ли она текст пользователю, вызывает ли инструмент или принимает входные данные инструмента.

Ошибки, когда модель начинает смешивать текст и JSON, и прочее, появл��ются именно из-за «распухшего» контекста. В итоге результат или ниже ожидаемого, или его нет.

Вывод: официальные клиенты работают слишком расточительно, особенно если MCP-инструменты вызываются каскадно.

Этот подход удобен для конечного пользователя (в «Desktop»), но неприменим для высоконагруженных или оптимизированных серверных систем. Сойдет для вайбкодеров, иначе говоря, но не для настоящих проектов.

Что предлагаю я:

  1. Вспоминать, как прогали деды, выжимая максимум из каждого килобайта скромного железа.

  2. Не пользоваться готовыми решениями от всяких OpenAI, сколько бы вам не рассказывали, что это "стандарт", а делать хардкор самим.

  3. Запомнить требования к системе нормального человека:

  • предсказуемое поведение

  • малые токены

  • строгая детерминированность

  • минимальные задержки

  • простая отладка.

В своих проектах я эти требования чту, как священные писания, поэтому расход токенов в них может рассмешить, а результат легко прогнозировать.

Главное, контролировать логику и изолировать LLM от лишних данных. Например:

  1. Дали модели описание инструментов.

  2. Получили только один JSON от модели: {"tool": "...", "args": {...}}

  3. Вызвали инструмент без LLM.

  4. Вернули пользователю или обработали, построили следующую итерацию...

  5. Если нужно, сделали еще один запрос модели, но уже минимальный и чистый.

Да, это уже не MCP архитектура, а функциональные вызовы, но в этом и есть преимущество. Вы не переусложняете процесс, контролируете его, повышаете шанс корректной работы даже слабеньких моделей, и не надеетесь на разработчиков OpenAI. Ну и на токенах экономите, в конце концов.