Привет, Хабр!Последний год мы были участниками бума разработки AI-агентов, в процессе которого мы сталкивались с определёнными трудностями: Проблемы с интеграцПривет, Хабр!Последний год мы были участниками бума разработки AI-агентов, в процессе которого мы сталкивались с определёнными трудностями: Проблемы с интеграц

От монолита к модулям: строим масштабируемую архитектуру AI-агентов с FastMCP и LangChain

Привет, Хабр!

Последний год мы были участниками бума разработки AI-агентов, в процессе которого мы сталкивались с определёнными трудностями:

  • Проблемы с интеграциями

  • Галлюцинации

  • Переполнение контекста

  • Зацикливание

Но самая большая проблема, с которой я столкнулся, — архитектура и масштабирование.

Писать монолитных агентов удобно, но с ростом количества проектов и инструментов возникают определенные трудности:

  • Копирование функционала из одного агента в другого

  • Сложность тестирования

  • Необходимость менять большое количество кода для добавления нового инструмента

Поэтому нам жизненно необходимо создавать правильную архитектуру агентских систем, которую можно будет легко масштабировать и тестировать.

В этой статье я расскажу, как создать MCP-сервер с помощью библиотеки fastmcp и как подключиться к нему с использованием LangChain, а также рассмотрим примеры работы нового React-агента.

MCP

На помощь к нам приходит Anthropic со своим протоколом для взаимодействия агентов с инструментами.

Model Context Protocol (MCP) - открытый стандарт, представленный компанией Anthropic в ноябре 2024 года. До его появления индустрия находилась в состоянии фрагментации: каждый разработчик AI-агента или IDE писал свои собственные коннекторы к базам данных, GitHub, Slack или Google Drive. Это порождало проблему M×NM×N, где MM — количество AI-приложений (Claude Desktop, Cursor, LangChain-агенты), а NN — количество внешних сервисов.

Основная цель MCP — создать универсальный интерфейс для подключения AI-моделей к внешним данным и инструментам. Создатели сравнивают его с портом USB-C: вместо того чтобы искать уникальный кабель для каждого устройства, вы используете один стандартный разъем. Один раз написав MCP-сервер (например, для доступа к внутренней базе знаний компании), вы можете подключить его к любому MCP-клиенту — будь то локальное приложение Claude Desktop или агент на LangChain.

Архитектура взаимодействия:

Протокол работает по классической клиент-серверной схеме

  1. MCP Host (Клиент): Приложение, в котором находится LLM (Наш агент или какая то другая программа). Оно инициирует соединение.

  2. MCP Server: Легковесное приложение, которое предоставляет доступ к трем примитивам:

    • Resources (данные для чтения),

    • Tools (функции для выполнения),

    • Prompts (шаблоны запросов).

  3. Транспорт: Общение происходит либо через локальные потоки ввода-вывода (stdio), либо через HTTP для удаленных подключений.

Cам протокол агностичен к языку программирования. Вы можете написать сервер на Python (с помощью fastmcp), Rust или Node.js, а клиент будет взаимодействовать с ним одинаково.

8b2e2fcb74b6d8926dbcb60e7c3581b2.png

FastMCP

Про fastmcp:

FastMCP - высокоуровневая Python-библиотека для быстрой разработки MCP-серверов и клиентов. Она оборачивает спецификацию Model Context Protocol (MCP) и даёт удобный API для объявления инструментов, ресурсов, шаблонных промптов

uv pip install fastmcp

Основные компоненты

Библиотека разграничивает элементы для создания агентов на 3 группы (в дальнейшем я подробнее рассмотрю каждый из них):

  • Tools — инструменты, которые предоставляет сервер и которые может самостоятельно вызывать языковая модель.

Создать инструмент в fastmcp так же просто, как и в LangChain. Достаточно добавить декоратор:

@mcp.tool def add(a: int, b: int) -> int: """Adds two integer numbers together.""" return a + b

  • Resources & Templates — это механизм FastMCP для предоставления языковой модели или клиентскому приложению данных в режиме «только для чтения». Главное отличие от инструментов — они не выполняют никаких действий и не вызываются непосредственно моделью (не передаются в список инструментов агента).

Все элементы в fastmcp можно создать с помощью декоратора:

@mcp.resource("data://config") def get_config() -> dict: """Provides application configuration as JSON.""" return { "theme": "dark", "version": "1.2.0", "features": ["tools", "resources"], }

  • Prompts - создает параметризованные шаблоны сообщений, которые помогают LLM генерировать структурированные ответы.

Наиболее частый случай использования — загрузка готовых промптов для разных моделей в IDE и добавление специфичного контекста. Это освобождает вас от задачи самостоятельного написания запроса. Но помимо этого их можно использовать для быстрого и удалённого изменения поведения агента. Пример создания:

@mcp.prompt def ask_about_topic(topic: str) -> str: """Generates a user message asking for an explanation of a topic.""" return f"Can you please explain the concept of '{topic}'?"

Сервер

Для того чтобы создать сервер достаточно написать:

from fastmcp import FastMCP mcp = FastMCP()

Теперь у нас есть экземпляр, который готов работать. Но при реальной разработке я советую использовать дополнительные параметры:

mcp = FastMCP( name="MathMCPServer", instructions=''' Сервер для математических операций ''', version='1.0', website_url="@ViacheslavVoo", on_duplicate_tools="error", ''' on_duplicate_tools - если вы каким то образом добавите несколько одинаковых интрументов, то при запуске сервера ваш код упадет с ошибкой ''' tools=[test_func, test_func], ''' tools = [func1, func2] - позволяет не навешивать декоратор @mcp.tool на каждую функцию. Особенно полезно когда функции находятся в других модулях. Важно чтобы функция отвечала требованиям инструмента ''' include_tags = ["public"], exclude_tags = ["deprecated"] ''' include_tags - показывает компоненты у которых есть хотя бы один совпадающий тег exclude_tags - скрывает компоненты с любым совпадающим тегом Приоритет: теги исключения всегда имеют приоритет над тегами включения ''' mask_error_details=True #скрывает ошибки от языковой модели # пример использования в блоке тестирования агента )

Запуск сервера

Транспортные протоколы

Вы можете поднять mcp с использованием нескольких протоколов (для локального и удаленного развертывания):

  • STDIO (стандартный ввод/вывод) — это транспортный протокол по умолчанию для серверов FastMCP. Если вы вызываете run() без аргументов, ваш сервер использует транспортный протокол STDIO. Этот протокол обеспечивает связь через стандартные потоки ввода и вывода, что делает его идеальным для инструментов командной строки и приложений, таких как Claude Desktop.

Сервер считывает сообщения MCP из стандартного потока ввода и записывает ответы в стандартный поток вывода, поэтому серверы STDIO не работают постоянно — они запускаются по требованию клиента.

STDIO подходит для:

  • Локальная разработка и тестирование

  • Интеграция с Desktop инструментами

  • Командной строки

  • Однопользовательские приложения

HTTP

  • HTTP-транспорт превращает ваш MCP-сервер в веб-сервис, доступный по URL-адресу. Этот транспорт использует потоковый HTTP-протокол, который позволяет клиентам подключаться по сети. В отличие от STDIO, где каждому клиенту выделяется отдельный процесс, HTTP-сервер может одновременно обслуживать несколько клиентов.

HTTP-протокол обеспечивает двустороннюю связь между клиентом и сервером и поддерживает все операции MCP, включая потоковую передачу ответов. Поэтому он рекомендуется для сетевых развертываний.

HTTP-транспорт обеспечивает:

  • Доступность сети

  • Несколько одновременных подключений

  • Интеграция с веб-инфраструктурой

  • Возможность удаленного развертывания

Примеры запуска:

mcp.run()

mcp.run(transport="http", host="0.0.0.0", port=8000)

Помимо этих способов fastmcp поддерживает SSE, но этот вариант считается устаревшим.

Инструменты

Как я уже говорил, инструмент в fastmcp — это такой же инструмент, как в LangChain. Основные требования к инструментам, которые улучшат работу агента:

  • Функция должна иметь doc string (обязательное требование)

  • Название функции и наименования аргументов должны соответствовать назначению

  • Аргументы должны иметь аннотации типов

Инструменты проще всего создать с использованием декоратора @tool

@mcp.tool def add(item) -> str: """Some desc""" return "entry has been added to the list"

В этом случае декоратор возьмёт всю информацию из описания функции. Если вы хотите использовать другое описание и/или добавить информацию, вы можете использовать дополнительные параметры:

  • name="add_in_list" - имя функции, которое будет использоваться вместо указанного

  • description="adds an entry to the list" - новое описание doc string

  • tags={"list", "add", "public"} - метки, с помощью которых можно фильтровать инструменты

  • meta={"version": "1.2", "author": "product-team"}

  • exclude_args - позволяет скрыть аргументы инструмента от языковой модели (это могут id, ключи и другая информация, которая не должна попадать в LLM)

Здесь я хочу подробнее остановиться на создании и изменение инструментов

Создание инструмента из функции, к которой у нас нет доступа напрямую. Например, если вы хотите использовать встроенную функцию или какой-то код из legacy

import secrets from fastmcp import Tool # Функция из стандартной библиотеки Python для генерации безопасных токенов token_tool = Tool.from_function( secrets.token_hex, name="generate_secure_token", description=""" Generates a cryptographically strong random hex string. Use this when the user needs a secure API key, password, or unique identifier. Default length is 32 bytes if not specified. """ )

Добавление описания к аргументам функции.

Модель будет лучше ориентироваться в инструментах, если будет знать не только описание функции, но и описание аргументов. Когда у нас есть возможность, то мы можем сделать это с помощью Field или Annotated:

@mcp.tool def find_user_field( user_id: Annotated[str, ''' "The unique identifier for the user, " "usually in the format 'usr-xxxxxxxx'."'''] ): """Finds a user by their ID.""" ...

Но такая возможность есть не всегда, поэтому fastmpc позволяет оборачивать функции в инструменты и добавлять к ним описание:

@mcp.tool def find_user(user_id: str): """Finds a user by their ID.""" ... new_tool = Tool.from_tool( find_user, transform_args={ "user_id": ArgTransform( description=( "The unique identifier for the user, " "usually in the format 'usr-xxxxxxxx'." ) ) } )

Если посмотреть схему, то мы увидим практически одинаковое описание:

name='find_user' title=None description='Finds a user by their ID.' icons=None tags=set() meta=None enabled=True parameters={'type': 'object', 'properties': {'user_id': {'type': 'string', 'description': "The unique identifier for the user, usually in the format 'usr-xxxxxxxx'."}}, 'required': ['user_id']} name='find_user' title=None description='Finds a user by their ID.' icons=None tags=set() meta=None enabled=True parameters={'properties': {'user_id': {'description': ' "The unique identifier for the user, "\n "usually in the format \'usr-xxxxxxxx\'."', 'type': 'string'}}, 'required': ['user_id'], 'type': 'object'}

Помимо добавления описания можно изменять названия аргументов, устанавливать default value и скрывать часть аргументов:

@mcp.tool def search(q: str): """Searches for items in the database.""" return "database.search(q)" new_tool = Tool.from_tool( search, transform_args={ "q": ArgTransform(name="search_query", default=10) } )

Скрытие аргументов:

def send_email(to: str, subject: str, body: str, api_key: str, timestamp): """Sends an email.""" ... new_tool = Tool.from_tool( send_email, name="send_notification", transform_args={ "api_key": ArgTransform( hide=True, default=os.environ.get("EMAIL_API_KEY"), ), 'timestamp': ArgTransform( hide=True, default_factory=lambda: datetime.now(), ) } )

Клиент

Когда мы разобрались с созданием сервера и инструментов, пора подключить к ним клиента. Для разработки я использую LangChain, поэтому примеры будут с использованием этой библиотеки.

  • Экземпляр класса MultiServerMCPClient

#make_mcp_client from langchain_mcp_adapters.client import MultiServerMCPClient #Используем возможности langchain async def make_client(server_config: dict) -> MultiServerMCPClient: multi_client = MultiServerMCPClient(server_config) return multi_client ''' Примечание: у fastmcp также есть класс Client, который принимает url сервера, но я учитываю, что сервер и клиент находятся в разном окружении, а установка fastmcp в окружение клиента ради одного класса излишне '''

В качестве необходимо передать config, который представляет собой словарь вида:

MCP_URL = "http://127.0.0.1:8081/mcp" MCP_CONFIG = { "my_tools": { "transport": "streamable_http", "url": MCP_URL, } }

  • Получение инструментов от сервера

#load_tools from agent_mcp.mcp_connection.make_mcp_client import make_client async def load_tools(server_config: dict) -> list: """Получает инструменты от MCP-сервиса. Если сервис недоступен — бросает ConnectionError.""" client = await make_client(server_config) try: tools = await client.get_tools() except Exception as e: raise ConnectionError(f"Не удалось получить инструменты от сервиса: {e}") for tool in tools: tool.handle_tool_error = True #позже покажу зачем нам эта строка _langchain_tools = tools return _langchain_tools

Таким образом в main файле агента достаточно написать:

#main MCP_TOOLS_URL = "http://127.0.0.1:8080/mcp" MCP_TOOL_CONFIG = { "graphics-tools": { "transport": "streamable_http", "url": MCP_TOOLS_URL, } } _langchain_tools: Optional[list] = None async def load_agent_tools() -> list: """Получает инструменты от MCP-сервиса. Если сервис недоступен — бросает ConnectionError.""" global _langchain_tools, MCP_TOOL_CONFIG if _langchain_tools is not None: return _langchain_tools _langchain_tools = await load_tools(MCP_TOOL_CONFIG) return _langchain_tools

Кстати, ссылка на полный код будет в моем Telegram канале

Примеры использования агента

Пришло время создать агента, который будет пользоваться нашими инструментами. Я использую LangChain 1.0, поэтому примеры будут с обновлённым react agent.

Но для начала создадим инструмент на сервере для приготовления кофе:

# FastMCP использует аннотации типов и Pydantic Field для генерации JSON схемы @mcp.tool() def brew_coffee( temperature: int = Field( ..., ge=85, le=98, description="Температура воды в °C. Должна быть строго между 85 и 98." ), intensity: int = Field( ..., ge=1, le=10, description="Крепость кофе по шкале от 1 до 10." ), coffee_type: str = Field(default="эспрессо"), ) -> str: """Приготовить чашку кофе с заданными параметрами.""" return f"Приготовлен кофе {coffee_type}. Температура: {temperature}°C, Крепость: {intensity}/10."

Я задал ограничения для параметров, чтобы показать, как агент воспринимает аннотации

Экземпляр агента:

async def agent(query: str) -> str: tools = await load_agent_tools() react_agent = create_agent(llm, tools) resp = await react_agent.ainvoke({"messages": [HumanMessage(content=query)]}) answer = resp["messages"][-1].content return answer

Запрос 1 (пробуем превысить ограничения)

  • Свари эспрессо, выкрути температуру на максимум, хочу 150 градусов

Ответ агента:

  • Извините, но я не могу установить такую высокую температуру. Максимально допустимая температура 98°C.

Запрос 2:

  • Свари максимально крепкий эспрессо, выкрути температуру на максимум

Ответ агента:

  • Ваш эспрессо готов! Температура 98, крепость 10 из 10.

Лог работы в Langsmith:

128bf2e5ddee48df1f27f6f2a1038c0a.png

Также агент мог бы воспринимать default value при их наличии.

Обработка ошибок

Если наш инструмент по каким-то причинам не сможет корректно отработать, то мы можем вернуть обычную питонячью ошибку, но у такого подхода есть недостатки:

  • Мы можем раскрыть детали реализации нашего кода

  • Языковая модель не поймет, что делать с таким ответом инструмента

Поэтому fastmcp предлагает возвращать отдельный вид исключений, которые будут передаваться в языковую модель.

Модернизируем наш инструмент для приготовления кофе (обратите внимание на параметр mask_error_details при инициализации сервера):

@mcp.tool() def brew_coffee( coffee_type: str = Field(), temperature: int = Field( description="Температура воды в °C.", default=90 ), intensity: int = Field( description="Крепость кофе ", default=10 ), ) -> str: """Приготовить чашку кофе с заданными параметрами. Если парамтеры не указаны, используй параметры по умолчанию""" # 1. ToolError: Явная ошибка для LLM # Мы используем ToolError, потому что хотим, чтобы модель узнала, # что она запросила недоступный тип кофе, и могла исправить свой запрос. # Это сообщение будет отправлено клиенту даже при mask_error_details=True. available_menu = ["американо", "капучино", "латте"] if coffee_type.lower() not in available_menu: raise ToolError( f"Кофе типа '{coffee_type}' нет в меню. " f"Пожалуйста, выберите из: {', '.join(available_menu)}." ) # 2. Стандартное исключение (Internal Error) # Симулируем внутреннюю проблему оборудования. # Если mask_error_details=True, LLM не увидит текст про "бойлер", # а получит общее сообщение об ошибке. Это безопасно для скрытия внутренней логики. if temperature > 86 and intensity == 10: raise RuntimeError("INTERNAL FAULT: Boiler pressure critical! Maintenance required.") return f"Приготовлен кофе {coffee_type}. Температура: {temperature}°C, Крепость: {intensity}/10."

Пробуем сломать наш инструмент:

Запрос:

  • Свари эспрессо крепости 10

Ответ агента:

  • К сожалению, я не могу приготовить эспрессо. В нашем меню есть американо, капучино и латте

Лог в langsmith:

afb733a3e983193f903d0fc3073f9785.png

Запрос 2:

  • Свари американо температуры 90 крепости 10

Ответ агента:

  • Извините, у меня не получилось приготовить американо. Пожалуйста, попробуйте еще раз.

Лог в langsmith:

704c166147c6958cc664f6f4f2fd2b52.png

Передача скрытых аргументов

Бывают ситуации, когда инструменту необходимо знать какой-нибудь id или ключ, который не должна видеть языковая модель. Создадим такой простой инструмент на сервере:

@mcp.tool( exclude_args=["user_id"] ) def add_item_to_card(item_name: str, quantity: int, user_id: str = None) -> str: '''Добавить товар в корзину пользователя''' return f"Товар {item_name} добавлен в корзину. user_id: {user_id}"

и добавим к агенту функцию для перехвата вызова инструментов:

@wrap_tool_call async def safe_inject_params(request: ToolCallRequest, handler: Callable[[ToolCallRequest], ToolMessage | Command], ) -> ToolMessage | Command: tool_name = request.tool_call['name'] original_args = request.tool_call['args'] new_args = original_args.copy() if tool_name == "add_item_to_card": # Добавляем скрытый параметр new_args['user_id'] = "123" # Подменяем аргументы в запросе request.tool_call['args'] = new_args try: result = await handler(request) print(f"Tool completed successfully") return result except Exception as e: print(f"Tool failed: {e}") raise

react_agent = create_agent(llm, tools, middleware=[safe_inject_params])

Такую возможность нам предоставляет новая версия LangChain. Возможно, я как-нибудь напишу статью с разбором последнего обновления).

Запрос 1:

  • Добавь в корзину 2 упаковки зернового кофе

Ответ агента:

  • Добавлено 2 упаковки зернового кофе в корзину.

Лог в langsmith:

c28a81a4b07a6f995a0bd33b6f5da288.png

Ресурсы и шаблоны

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

Создание ресурса:

@mcp.resource("config://vibe") def get_vibe(): return "Сегодня ты должен отвечать как суровый системный администратор из 90-х." @mcp.resource("functions://with-hidden-id") def get_func_names_with_id() -> list: """ Возвращает список функций, которые содержат скрытый параметр 'id'.""" return [...]

При их создании также можно указать дополнительные параметры:

@mcp.resource( uri="data://app-status", name="ApplicationStatus", description="Provides the current status of the application.", mime_type="application/json", tags={"status"}, meta={"version": "2.1", "owner": "Viacheslav"} )

В langchain ресурсы можно загрузить с помощью get_resources:

client = MultiServerMCPClient({...}) # Load all resources from a server res = await client.get_resources("server_name") res = await client.get_resources("server_name", uris=["file:///path/to/file.txt"])

Пример использования:

async def complex_analysis_pipeline(query: str): """Анализа с предзагрузкой данных""" # 1. Параллельная загрузка нескольких ресурсов resource_uris = [ "metrics://system/health", "logs://errors/recent", "config://current-settings" ] # Асинхронная загрузка всех ресурсов resources = await asyncio.gather(*[ client.get_resources(uri) for uri in resource_uris ]) # 2. Объединение контекста combined_context = "\n\n".join([ f"## {uri}\n{data}" for uri, data in zip(resource_uris, resources) ]) # 3. Создание промпта с богатым контекстом prompt = ChatPromptTemplate.from_messages([ ("system", """Ты эксперт по анализу систем. У тебя есть следующие данные: {context} Анализируй проблему шаг за шагом."""), ("human", "{question}") ]) chain = prompt | ChatOpenAI(temperature=0) return await chain.ainvoke({ "context": combined_context, "question": query })

Prompts

Я нашёл для себя вариант использования prompt в виде быстрой замены системных промптов у агента. Это можно было бы делать с помощью обычного обращения к базе, но я предпочитаю использовать минимально возможный стек технологий. Здесь я приведу примеры максимально упрощённого использования, чтобы вы сами нашли для себя способы применения.

@mcp.prompt def generate_code_request(language: str, task_description: str) -> dict: """Запрос на генерацию кода""" content = f"Write a {language} function that performs: {task_description}" return { "role": "user", "content": content }

#Сервер @mcp.prompt def analyze_data( numbers: list[int], metadata: dict[str, str], threshold: float ) -> str: """Анализ числовых данных""" return f"Analyze numbers: {numbers} with metadata: {metadata}. Threshold: {threshold}" #Клиент # Создаем инструмент из FastMCP промпта def data_analysis_tool(inputs: dict) -> str: prompt_text = analyze_data( inputs["numbers"], inputs["metadata"], inputs["threshold"] ) return llm([HumanMessage(content=prompt_text)]).content

Заключение

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

Связка fastmcp и LangChain — отличное сочетание, которая решает проблемы:

  • Масштабирование: Разделение сервера (MCP) и клиента (агент) позволяет обновлять инструменты независимо, запускать сервера на разных нодах.

  • Тестирование: MCP-сервер можно тестировать изолированно, мокать, что упрощает CI/CD.

Если хотите обсудить архитектуру AI-агентов или поделиться своим опытом — добро пожаловать в мой Telegram

Источник

Возможности рынка
Логотип Sleepless AI
Sleepless AI Курс (AI)
$0.0368
$0.0368$0.0368
-4.16%
USD
График цены Sleepless AI (AI) в реальном времени
Отказ от ответственности: Статьи, размещенные на этом веб-сайте, взяты из общедоступных источников и предоставляются исключительно в информационных целях. Они не обязательно отражают точку зрения MEXC. Все права принадлежат первоисточникам. Если вы считаете, что какой-либо контент нарушает права третьих лиц, пожалуйста, обратитесь по адресу service@support.mexc.com для его удаления. MEXC не дает никаких гарантий в отношении точности, полноты или своевременности контента и не несет ответственности за любые действия, предпринятые на основе предоставленной информации. Контент не является финансовой, юридической или иной профессиональной консультацией и не должен рассматриваться как рекомендация или одобрение со стороны MEXC.

Вам также может быть интересно