Друзья, возвращаюсь с финальной частью цикла.

За две предыдущие статьи мы подняли собственную локальную модель на облачном сервере с GPU на 16 ГБ VRAM, разобрались с vLLM и tool calling, собрали агентный бэкенд на LangGraph с MCP-серверами, получили вокруг него полноценный REST API из коробки и обернули все это в FastAPI-сервис через LangGraph SDK.

Серьезная инфраструктура — и она реально работает.

Но есть нюанс. Все, что мы собрали, — это бэкенд. Красивый, правильно устроенный, с трейсингом и персистентностью. Только вот показать его обычному пользователю, который привык к ChatGPT, пока не получится — curl в терминале его не впечатлит, а давать доступ к LangSmith, мягко говоря, неправильно. Сегодня это исправляем.

Предыдущие материалы серии:

Оказывается, у LangChain есть официальный ответ на вопрос «А где фронтенд?» — проект agent-chat-ui. Это готовый Next.js-интерфейс, который из коробки умеет подключаться к LangGraph Server, стримить токены, показывать шаги агента, вызовы инструментов и размышления модели в реальном времени. По духу — тот же ChatGPT, только работает на вашей инфраструктуре и полностью под вашим контролем.

Схема подключения предельно простая: клонируем репозиторий, прописываем в .env адрес нашего LangGraph Server и имя агента, запускаем. Примерно так же, как в прошлой части мы одной командой получали REST API вокруг графа, — здесь одной командой получаем UI вокруг этого API.

Сегодня закрываем полный цикл:

Цикл: инфраструктура → локальная LLM → агентный бэкенд → MCP-инструменты → production-ready фронтенд → деплой.
Цикл: инфраструктура → локальная LLM → агентный бэкенд → MCP-инструменты → production-ready фронтенд → деплой.

Подходы к разработке UI и безопасность

Прежде чем лезть в код, хочу сделать небольшое отступление — про подходы к UI в контексте агентов. Чтобы то, что мы будем делать дальше, воспринималось как осознанный выбор, а не единственно возможный путь.

Подходов, по большому счету, два, но есть и третий — гибридный, и о нем тоже расскажу.

Первый — вы берете REST API от LangGraph Server, пишете вокруг него собственную обертку через LangGraph SDK (как мы делали в прошлой части) и уже поверх этой обертки реализуете фронтенд с нуля под свои нужды.

Второй — берете тот же REST API, подключаете к нему готовый фронтенд и кастомизируете его под себя. Именно этим мы займемся сегодня.

Третий — гибрид. agent-chat-ui умеет работать в режиме API Passthrough: все запросы к LangGraph Server идут не напрямую с клиента, а через Next.js API routes на том же сервере. Фронтенд смотрит наружу, LangGraph API остается внутри контура, LangSmith-ключ инжектируется на сервере и клиент его никогда не видит.

Разница между подходами не только в объеме работы, но и в вопросах безопасности — и это важно понимать до старта.

В первом случае вы контролируете все: авторизацию, очереди, ограничения доступа. Ваша FastAPI-обертка стоит между пользователем и графом, и даже если кто-то доберется до Swagger — без нужных токенов он там ничего не сделает.

Во втором случае вы подключаете к «голому» LangGraph API готовый фронтенд напрямую. Быстро и удобно, но с жестким условием: API не должен быть доступен из сети. Только изнутри контура. Это не рекомендация — это обязательное требование такой схемы.

Третий путь закрывает эту дыру без необходимости писать собственный бэкенд — и по итогу мы получаем вполне серьезный уровень защиты даже при использовании готового фронта. 

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

Чем мы сегодня займемся

Статья вышла объемным лонгридом, поэтому расскажу конкретнее — что будем делать и в каком порядке.

Шаг первый — поднимем локально LangGraph CLI и добавим три агента. Этот блок пробежим быстро: архитектуру графов детально разбирали в прошлой части, здесь просто возьмем мой проект с GitHub и немного доработаем под наши нужды. В итоге у нас будет запущенный LangGraph Server с несколькими графами — это и станет бэкендом для нашего фронтенда.

Шаг второй — клонируем agent-chat-ui и запустим его «из коробки» с одним агентом. Никакой безопасности, никакой кастомизации — просто три строки в .env, pnpm dev и смотрим что получается. На этом этапе вы увидите стриминг токенов в реальном времени, размышления Qwen, визуализацию вызовов MCP-инструментов и историю тредов — все это работает сразу, без единой строчки кода с нашей стороны. 

Цель этапа — понять общий принцип и потрогать руками до того, как начнем что-то менять.

Шаг третий — отдельно займемся авторизацией и аутентификацией. Здесь разберем оба встроенных механизма которые LangChain уже вложил в проект: простой API Passthrough через Next.js routes — когда все запросы к LangGraph идут через сервер и LangSmith-ключ никогда не попадает на клиент, и кастомную авторизацию через Bearer-токен для тех, кто хочет полный контроль. Никаких велосипедов — только то, что уже есть в проекте.

Шаг четвертый — кастомизация. Это самый объемный блок. Переведем UI на русский язык, добавим переключатель между несколькими агентами, чтобы пользователь мог выбирать с каким графом работать прямо в интерфейсе, и докрутим пару фич, которых в оригинальном проекте нет. Покажу конкретные места в коде, которые нужно трогать, и объясню, почему именно они.

Шаг пятый — финал. Деплоим все на сервер: собираем Next.js, поднимаем рядом с LangGraph Server через docker-compose, закрываем все это Nginx с SSL. LangGraph API в прошлой частиостается внутри контура, наружу смотрит только фронт. Закрываем цикл и обсуждаем, что со всем этим делать дальше.

Будет интересно. Поехали.

Готовим бэкенд и первого агента

Для работы с фронтендом нам нужен запущенный LangGraph Server хотя бы с одним графом. Здесь у вас два пути: берете свои наработки из прошлой части — тогда этот раздел можно пропустить, — или клонируете мой проект и идете по инструкции ниже.

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

Клонируем проект

git clone https://github.com/Yakvenalex/HabrGraphCLI

Проект открытый, проблем с доступом не будет.

Настраиваем окружение

В корне создаем файл .env.

Коротко по каждой переменной:

  • LANGSMITH_PROJECT — имя проекта для группировки трейсов в дашборде LangSmith. Любое значение на ваш вкус; 

  • LANGSMITH_API_KEY — ключ для трейсинга. С ним в дашборде видно каждый шаг графа: активные узлы, входы и выходы каждого вызова модели, латентность, токены, ошибки. Без него граф работает нормально — просто без мониторинга; 

  • LLM_BASE_URL — адрес вашей модели. Поднимали LLM через vLLM вместе с первой частью и пробросили наружу — указываем https://your_domain/v1. Модель работает локально без проброса — http://localhost:8000/v1

  • LLM_KEY — ключ, который задавали при запуске vLLM; 

  • LLM_NAME — название модели, которое будет передаваться в запросах. 

Детально каждую переменную разбирал в предыдущей статье.

Запуск

Устанавливаем зависимости и поднимаем CLI в dev-режиме:

uv sync 
uv run langgraph dev

Структура проекта

Открываем langgraph.json — там конфиг вида:

json{
  "$schema": "https://langgra.ph/schema.json",
  "dependencies": ["."],
  "graphs": {
    "agent": "./src/agent/graph.py:graph",
    ...
  },
  "env": ".env",
  "image_distro": "wolfi",
  "dockerfile_lines": [
    "RUN apk add --no-cache curl && curl -Ls https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh"
  ]
}

На момент выхода статьи в проекте три графовых агента, но сейчас сосредоточимся на первом — agent. Именно его будем подключать к фронтенду.

Что умеет agent

По архитектуре это классический ReAct-граф: узел call_model отправляет историю диалога в локальную LLM через OpenAI-совместимый протокол, узел tools исполняет вызванные моделью инструменты.

Между ними условное ребро tools_condition — если модель запросила инструмент, идем в tools и возвращаемся обратно; если ответила текстом — завершаем. Состояние — история сообщений с редьюсером add_messages, системный промпт прокидывается через Context и легко переопределяется с фронта. Детальную сборку разбирал в прошлой части, повторяться не буду.

Инструментарий живет отдельно от графа: список TOOLS в tools.py плюс тулзы от MCP-серверов — fetch для чтения произвольных веб-страниц и time для работы с временем. На старте граф складывает все это в ALL_TOOLS и биндит к модели. Добавить новый инструмент можно, не трогая схему графа.

За последнее время агент вырос на три новых инструмента:

  • calculate — калькулятор на базе AST-парсера вместо опасного eval. Разбирает выражение и вычисляет только разрешенные арифметические операции — никакого произвольного Python внутрь не просочится. Лечит классическую болячку LLM, которые уверенно ошибаются в простой арифметике.

  • convert_currency — конвертация фиатных валют по актуальному курсу через бесплатный API Европейского центрального банка (frankfurter).

  • get_hacker_news_top — топовые новости с Hacker News. Карточки новостей тянутся параллельно через asyncio.gather, чтобы не ждать их по очереди.

Итоговый набор получился небольшим, но самодостаточным: погода, курсы крипты и фиата, Википедия, координаты МКС, калькулятор, свежие новости и чтение любых веб-страниц. Все инструменты работают на публичных API без ключей — граф заводится из коробки и уже готов к подключению фронтенда. Чем мы и займемся.

Каталог готовых ИИ-моделей

Сервис для запуска и управления LLM в облаке Selectel. Выберите модель, конфигурацию и получите готовый эндпоинт для работы с ней.

Подробнее →

Поднимаем Agent Chat UI

Переходим к главному герою статьи. Сейчас мы поднимем полноценный AI-интерфейс буквально за несколько минут — без написания фронтенд-кода, без настройки WebSocket-соединений, без возни с UI-библиотеками. Просто клонируем, настраиваем три переменных окружения и запускаем.

Клонируем и настраиваем

git clone https://github.com/langchain-ai/agent-chat-ui

Открываем проект в редакторе. В корне создаем .env:

NEXT_PUBLIC_API_URL=http://127.0.0.1:2024
NEXT_PUBLIC_ASSISTANT_ID=agent
LANGSMITH_API_KEY=lsv2_pt_...
NEXT_PUBLIC_AUTH_SCHEME=

Здесь:

  • NEXT_PUBLIC_API_URL — адрес LangGraph Server, который мы подняли на предыдущем шаге;

  • NEXT_PUBLIC_ASSISTANT_ID — имя графа. Смотрим в langgraph.json и берем оттуда — в нашем случае agent;

  • NEXT_PUBLIC_AUTH_SCHEME — оставляем пустым, вернемся к этой переменной в разделе про авторизацию.

Устанавливаем зависимости и запускаем:

npm install

Запустим в dev режиме:

npm run dev

Переходим на http://localhost:3000.

Что получили из коробки

И вот здесь стоит сделать паузу и оценить момент. Мы не написали ни строчки фронтенд-кода. Не описывали API-контракт, не поднимали WebSocket, не придумывали как отображать стриминг. Просто указали адрес нашего LangGraph Server — и получили готовый продукт, который выглядит и работает как ChatGPT. Это именно тот случай когда «из коробки» — не маркетинговое преувеличение.

Разберем что конкретно доступно прямо сейчас:

  • Стриминг токенов — работает сразу и работает правильно. Ответ появляется по мере генерации, пользователь не смотрит в пустой экран по 30 секунд ожидая результата. Для продакшн-интерфейса это не фича — это базовое требование, и здесь оно закрыто без нашего участия.

  • Размышления агента и вызовы инструментов — все видно в реальном времени. Как агент думает, какие инструменты вызывает, что получает в ответ. Если вам это не нужно — переключатель «Hide Tool Calls» скрывает технические детали и оставляет только финальный ответ.

  • Остановка генерации — кнопка «Cancel» прерывает ответ в любой момент. Мелочь, но без нее интерфейс ощущается незаконченным.

  • История тредов — в левой панели видны все предыдущие диалоги. Можно вернуться в любой из них и продолжить — контекст сохранен. Именно для этого мы и говорили про поддержку истории на бэкенде.

  • Загрузка файлов — кнопка «Upload PDF or Image» уже на месте. Работает если модель поддерживает мультимодальный ввод — Qwen3 умеет, так что это сразу в деле.

  • Отладочная панель — иконка в левом нижнем углу. Удобна в процессе разработки: показывает состояние графа, сообщения в треде и прочую техническую информацию прямо в браузере, не переключаясь в LangSmith.

Это довольно богатый набор для нулевых усилий с нашей стороны.

Такой подход особенно оправдан когда нужно быстро показать рабочий прототип заказчику или команде, развернуть внутренний инструмент в компании без выделенного фронтенд-разработчика, или просто проверить гипотезу не тратя недели на разработку UI.

Но «из коробки» — это стартовая точка, а не финал. Интерфейс на английском, один агент захардкожен в конфиге, некоторых нужных фич нет вовсе. Давайте это исправим.

Переводим проект на русский язык

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

Хорошая новость: проект не использует библиотеки локализации вроде i18n. Все надписи зашиты прямо в JSX-разметку компонентов — значит, никакой магии с конфигами, просто находим строки и меняем.

Где искать

Весь пользовательский текст сосредоточен в src/components и нескольких файлах рядом:

  • src/providers/Stream.tsx — стартовая форма подключения: «Deployment URL», «Assistant / Graph ID», кнопка «Continue»; 

  • src/components/thread/index.tsx — основной экран чата: плейсхолдер поля ввода, кнопки «Send» / «Cancel», переключатели; 

  • src/components/thread/messages/* — подписи у сообщений и тултипы действий (копировать, редактировать, отправить снова); 

  • src/components/thread/agent-inbox/* — блок Human-in-the-loop: кнопки «Approve», «Reject», уведомления о прерывании; 

  • src/hooks/use-file-upload.tsx и src/lib/multimodal-utils.ts — тексты ошибок при загрузке файлов; 

  • src/app/layout.tsx — заголовок и описание страницы в метаданных. 

Быстро собрать список всех строк для перевода поможет простой grep по проекту:

grep -rnE 'placeholder=|aria-label=|toast\.(error|success)' src --include="*.tsx"

Что переводить, а что не трогать

Переводим только то, что реально видит пользователь: текст между тегами, значения атрибутов placeholder, aria-label, title, tooltip, сообщения toast-уведомлений.

Не трогаем:

  • бренды и имена собственные — Agent Chat, LangGraph, LangSmith; 

  • строки внутри console.error(...) и throw new Error(...) — это технические логи для разработчика; 

  • строковые литералы по которым в коде идет сравнение (type === "approve"), ключи localStorage, имена переменных окружения, MIME-типы. 

И еще один момент который сэкономит время: заранее заведите небольшой словарь повторяющихся терминов и держитесь его во всех файлах. 

Например: thread → «чат», tool calls → «вызовы инструментов», Submit → «Отправить», Reject → «Отклонить». Иначе один и тот же элемент в разных местах окажется назван по-разному — выглядит небрежно.

Проверяем результат

После правок проверяем что не разъехалась типизация:

npx tsc --noEmit

Если ошибок нет, а npm run dev показывает русский интерфейс — готово. Если переводить вручную не хочется — я уже сделал это за вас. 

Просто клонируйте себе мой репозиторий с GitHub:

git clone https://github.com/Yakvenalex/Agent-chat-ui-habr

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

Удаление чатов

По умолчанию история в боковой панели только накапливается — удалить ненужный диалог через интерфейс невозможно. При этом в REST API LangGraph нужный эндпоинт уже есть. Добавим кнопку удаления, чтобы пользователь мог навести порядок прямо из UI.

Логика удаления

Список тредов живет в ThreadProvider (src/providers/Thread.tsx). Там уже есть метод getThreads, который загружает диалоги через SDK-клиент. По той же схеме добавляем метод удаления — @langchain/langgraph-sdk умеет client.threads.delete() из коробки:

const deleteThread = useCallback(
  async (threadId: string): Promise<void> => {
    if (!apiUrl) return;
    const client = createClient(
      apiUrl,
      getApiKey() ?? undefined,
      authScheme || undefined,
    );

    await client.threads.delete(threadId);
    setThreads((prev) => prev.filter((t) => t.thread_id !== threadId));
  },
  [apiUrl, authScheme],
);

Обратите внимание на оптимистичное обновление: после успешного запроса мы не перезагружаем весь список с сервера, а просто вычеркиваем удаленный тред из локального состояния. Интерфейс реагирует мгновенно, без лишнего запроса. Не забудьте добавить deleteThread в тип контекста и в объект value, который провайдер отдает наружу.

Кнопка в списке

Теперь выводим крестик у каждого элемента в src/components/thread/history/index.tsx. Чтобы не загромождать интерфейс, показываем кнопку только при наведении на тред — связка классов group на контейнере и opacity-0 group-hover:opacity-100 на кнопке:

<div className="group relative w-full px-1">
  <Button /* открытие чата */>
    <p className="truncate pr-6 text-ellipsis">{itemText}</p>
  </Button>

  <button
    type="button"
    aria-label="Удалить чат"
    onClick={(e) => handleDelete(e, t.thread_id)}
    disabled={deletingId === t.thread_id}
    className="absolute top-1/2 right-2 -translate-y-1/2 rounded-md p-1 text-gray-400 opacity-0 transition-colors hover:bg-gray-200 hover:text-red-500 group-hover:opacity-100"
  >
    {deletingId === t.thread_id ? (
      <LoaderCircle className="size-4 animate-spin" />
    ) : (
      <X className="size-4" />
    )}
  </button>
</div>

Обработчик handleDelete решает три задачи: не дает клику провалиться на кнопку открытия чата (stopPropagation), показывает спиннер вместо крестика, пока идет запрос (состояние deletingId), и сбрасывает активный диалог если удалили именно его:

const handleDelete = async (e, id) => {
  e.preventDefault();
  e.stopPropagation();
  if (deletingId) return;

  setDeletingId(id);
  try {
    await deleteThread(id);
    if (id === threadId) setThreadId(null);
    toast.success("Чат удален");
  } catch (error) {
    console.error(error);
    toast.error("Не удалось удалить чат");
  } finally {
    setDeletingId(null);
  }
};

Тосты дают пользователю понятную обратную связь — успех или ошибка. В итоге получаем полноценное удаление: крестик появляется при наведении, тред мгновенно исчезает из списка, активный диалог при необходимости сбрасывается корректно.

Добавляем в конец раздела после основного кода.

Важный нюанс с активным чатом. Если удаляется тред, который сейчас открыт, порядок операций имеет значение. Сначала нужно уйти с него — await setThreadId(null) — и только потом отправлять запрос на удаление:

if (id === threadId) await setThreadId(null);
await deleteThread(id);

setThreadId из nuqs возвращает промис обновления URL, поэтому await здесь не формальность — он гарантирует что useStream успеет переключиться на null и перестанет обращаться к состоянию треда до того как тот физически исчезнет на сервере. Без этого получите HTTP 404 в самый неподходящий момент.

Знакомимся с остальными агентами

Помните, в начале статьи мы говорили что в langgraph.json у нас висит три агента, а к фронту подключили пока только одного? Пришло время исправить это — но сначала познакомимся с теми двумя, которые еще не в деле.

"graphs": {
    "agent": "./src/agent/graph.py:graph",
    "router_agent": "./src/agent/router_graph.py:graph",
    "chef_agent": "./src/agent/chef_graph.py:graph"
  }

router_agent — диспетчер с несколькими специализациями

Если первый agent — это универсал с одним мешком инструментов, то router_agent устроен принципиально иначе. Это уже не один узел, а небольшая мультиагентная схема с маршрутизацией. В начале графа стоит узел-роутер: отдельная LLM с одной-единственной задачей — посмотреть на запрос пользователя и одним словом решить, куда его отправить. 

Дальше срабатывает условное ребро, и запрос уходит в одну из трех веток:

  • chat — обычная беседа без инструментов: шутка, мнение, объяснение. Самый быстрый путь, внешние данные не дергаются; 

  • web_agent — веб-исследователь: читает произвольные страницы, ищет в Википедии, знает где сейчас МКС и что в топе Hacker News; 

  • data_agent — специалист по актуальным данным и расчетам: погода, курсы криптовалют, конвертация валют, точная арифметика, текущее время и работа с часовыми поясами.

Главная фишка — у каждой ветки свой системный промпт и свой набор инструментов, а web_agent и data_agent внутри работают как полноценные ReAct-циклы. То есть роутер не просто «угадывает категорию», а передает управление профильному под-агенту, заточенному именно под этот класс задач.

Отдельно стоит отметить решение по самому роутеру: он не использует function calling и structured output, а возвращает один токен текстом который мы парсим вручную. Так маршрутизация не зависит от капризов модели с вызовом функций и остается предсказуемой.

chef_agent — узкопрофильный эксперт

Третий агент — другая крайность спектра. Если router_agent показывает, как расширяться вширь (много тем, маршрутизация между ними), то chef_agent — это движение вглубь. Намеренно узкий специалист: шеф-повар и нутрициолог, который не делает ничего кроме еды. Архитектурно простой (тот же ReAct-цикл), но вся его «экспертность» собрана из двух вещей: фокусного набора инструментов и сильного доменного промпта, который задает роль, тон и границы.

Инструментарий (все API публичные, без ключей):

  • product_nutrition — состав и пищевая ценность на 100 г: калории, БЖУ, сахара, соль, NutriScore через Open Food Facts; 

  • find_recipes_by_ingredient — какие блюда можно приготовить из заданного ингредиента через TheMealDB; 

  • get_recipe — полный рецепт по названию: ингредиенты с граммовками, шаги и фото; 

  • random_meal — случайное блюдо с рецептом, на случай «не знаю что приготовить». 

Несколько характерных деталей. Эксперт держит границы темы: на вопрос не про еду мягко отвечает, что это вне его специализации — прямое следствие доменного промпта. Кулинарные API понимают в основном английский, поэтому агент переводит запрос перед вызовом инструмента, а ответ возвращает на русском. Тулзы устойчивы к реальным сбоям: Open Food Facts любит отвечать 503 под нагрузкой, поэтому запросы идут с ретраями и backoff, а агент проинструктирован честно помечать когда дополняет ответ из собственных знаний.

Вместе три агента дают наглядный срез архитектурных подходов: генералист (agent) → диспетчер-мультиагент (router_agent) → узкий эксперт (chef_agent).

Теперь, когда познакомились, подключим всех троих к фронту и дадим пользователю возможность переключаться между ними прямо в интерфейсе.

Выбор агента: подключаем несколько графов

Бэкенд у нас уже отдает три графа, фронтенд пока знает только об одном. Исправим это — добавим переключатель агентов прямо в шапку интерфейса.

Шаг 1. Список агентов в переменной окружения

Начнем с конфигурации. Чтобы не хардкодить состав агентов в коде, опишем его одной строкой в .env — имена графов через запятую:

# Имена графов, между которыми можно переключаться в UI.
# Первый в списке — агент по умолчанию.
NEXT_PUBLIC_ASSISTANT_IDS=agent,router_agent,chef_agent

Префикс NEXT_PUBLIC_ обязателен: переменная нужна на клиенте, так как переключатель работает в браузере.

Хочу подчеркнуть, Next.js подставляет такие переменные на этапе сборки, поэтому после правки .env сервер разработки нужно перезапустить.

Шаг 2. Разбираем переменную в конфиг

Создаем модуль src/lib/agents.ts, который превращает строку из .env в готовый список агентов. ID берем из окружения, человекочитаемые подписи — из небольшого словаря. Если для какого-то ID подписи нет, она соберется из самого ID (router_agent → Router Agent):

// src/lib/agents.ts
const AGENT_META: Record<string, { label: string; description: string }> = {
  agent:        { label: "Генералист",  description: "Универсальный агент на все случаи" },
  router_agent: { label: "Диспетчер",   description: "Мультиагент: маршрутизирует запрос" },
  chef_agent:   { label: "Шеф-эксперт", description: "Узкий специалист в своей области" },
};

function parseAgentIds(): string[] {
  const ids = (process.env.NEXT_PUBLIC_ASSISTANT_IDS ?? "")
    .split(",")
    .map((value) => value.trim())
    .filter(Boolean);

  if (ids.length === 0) {
    return [process.env.NEXT_PUBLIC_ASSISTANT_ID?.trim() || "agent"];
  }
  return ids;
}

export const AGENTS = parseAgentIds().map((id) => ({
  id,
  label: AGENT_META[id]?.label ?? prettifyId(id),
  description: AGENT_META[id]?.description ?? "",
}));

export const DEFAULT_AGENT_ID = AGENTS[0].id;

Добавить или убрать агента теперь можно прямо в .env, не трогая код. Разделение чистое: что подключать — в переменной окружения, как подписать — в словаре.

Шаг 3. Одна точка опоры — assistantId

Прежде чем рисовать UI, важно понять как агент вообще «выбирается» в проекте. Все держится на единственном параметре — assistantId. Он хранится в URL через nuqs и пробрасывается сразу в два места:

  • в хук useStream — кто обрабатывает сообщения; 

  • в поиск истории — треды какого графа показывать в боковой панели. 

Значит, чтобы переключить агента, достаточно поменять это одно значение — остальное подтянется автоматически. Именно поэтому правок в итоге минимум.

Шаг 4. Компонент-переключатель

Сам AgentSwitcher — легкий выпадающий список на обычном useState, без новых UI-библиотек. Вся суть в обработчике выбора:

const handleSelect = async (id: string) => {
  setOpen(false);
  if (id === currentId) return;
  // Чаты привязаны к агенту: сначала уходим с текущего треда, потом меняем агента
  await setThreadId(null);
  setAssistantId(id);
};

Порядок здесь принципиален. Сначала await setThreadId(null) — сбрасываем открытый чат, потому что он принадлежал прежнему агенту и у нового графа его попросту нет. И только потом setAssistantId(id) записывает нового агента в URL, после чего стрим пересоздается под выбранный граф.

Готовый <AgentSwitcher /> встраиваем в шапку — и до начала диалога, и во время него, рядом с логотипом.

Шаг 5. Обновляем историю под выбранного агента

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

const [assistantId] = useQueryState("assistantId");

useEffect(() => {
  setThreadsLoading(true);
  getThreads().then(setThreads).catch(console.error)
    .finally(() => setThreadsLoading(false));
}, [assistantId]); // ← перезагрузка при смене агента

Шаг 6. Убираем форму первичной настройки

Есть неочевидный момент. Если задать только NEXT_PUBLIC_ASSISTANT_IDS и забыть про старую NEXT_PUBLIC_ASSISTANT_ID, приложение решит что агент не выбран и покажет форму первичной настройки. 

Чтобы этого не происходило, даем assistantId откат на первого агента из списка — в провайдере стрима и в поиске тредов:

const finalAssistantId = assistantId || envAssistantId || DEFAULT_AGENT_ID;

Теперь выбор агента в шапке мгновенно меняет и собеседника, и историю переписки под него. А поскольку все завязано на один параметр assistantId, расширение списка — это одна строчка в .env: добавили четвертого агента в NEXT_PUBLIC_ASSISTANT_IDS, перезапустили сервер — и он уже доступен в переключателе.

Добавляем авторизацию на бэкенде

К этому моменту три агента крутятся на локальном сервере и до них может достучаться кто угодно, кто знает адрес. Перед тем как прикручивать авторизацию на фронтенд — закроем бэкенд простой Bearer-авторизацией: каждый запрос к API должен принести токен в заголовке Authorization, иначе отказ. LangGraph дает для этого официальный механизм — декоратор @auth.authenticate. 

Реализация уместится в три шага

Шаг 1. В .env кладем токен, в .env.example — плейсхолдер чтобы настоящий не утек:

AUTH_SECRET_TOKEN=ваш_секретный_токен

Шаг 2. Создаем src/auth.py

import os

from dotenv import load_dotenv
from langgraph_sdk import Auth
from starlette.exceptions import HTTPException

load_dotenv()

auth = Auth()
SECRET_TOKEN = os.getenv("AUTH_SECRET_TOKEN", "")


@auth.authenticate
async def authenticate(authorization: str | None) -> str:
    if not authorization or not authorization.startswith("Bearer "):
        raise HTTPException(
            status_code=401,
            detail="Missing or malformed Authorization header",
            headers={"WWW-Authenticate": "Bearer"},
        )

    token = authorization.removeprefix("Bearer ").strip()
    if not SECRET_TOKEN or token != SECRET_TOKEN:
        raise HTTPException(status_code=403, detail="Invalid token")

    return "user"

Шаг 3. Регистрируем в langgraph.json

{
  "auth": {
    "path": "./src/auth.py:auth",
    "disable_studio_auth": false
  }
}

Готово. Запрос без токена ловит 401, с верным токеном — отрабатывает штатно. Но за этой простотой прячется несколько мест где легко запутаться — и именно о них стоит рассказать, потому что в документации они либо размазаны, либо описаны неточно.

Нюанс 1. 401 против 403: почему официальный пример «не работает»

В документации LangGraph для отказа предлагают бросать Auth.exceptions.HTTPException(status_code=401, ...). Делаешь по доке — клиент получает 403 вместо 401.

Причина внутри самого LangGraph: auth-слой ловит Auth.exceptions.HTTPException и превращает любой отказ в AuthenticationError, теряя при этом статус-код и заголовки, после чего отдает жестко зашитый 403. То есть и 401, и 403 поднятые через Auth.exceptions на проводе становятся 403 — и в dev, и в проде.

Лечится это тем, что видно в коде выше: бросаем starlette.exceptions.HTTPException. Auth-слой пропускает starlette-исключение как есть, не схлопывая — поэтому до клиента доходит честный 401 с заголовком WWW-Authenticate: Bearer. Это не хак: пробрасывание starlette HTTPException заложено в код намеренно.

Семантику тоже стоит соблюдать: 401 — не аутентифицирован (нет или битый токен), 403 — аутентифицирован, но доступ запрещен (токен есть, но неверный). В нашем хендлере именно так.

Нюанс 2. disable_studio_auth — флаг с обратной интуицией

Самое коварное место. По названию кажется, что disable_studio_auth: true отключает авторизацию для Studio и тот начинает работать свободно. 

На деле все наоборот:

Значение

Что происходит со Studio

false

Studio работает — для него включен доверенный проход в обход вашей проверки

true

Studio-байпас выключен → запросы Studio идут через ваш Bearer → без токена ловит 401

Запомнить проще так: флаг отключает не «авторизацию Studio», а встроенный механизм который пускает Studio мимо вашего хендлера. Хотите визуально дебажить граф — оставляйте false. Хотите закрыть вообще все включая Studio — ставьте true.

Нюанс 3. Два разных «LangSmith» — не путать

Когда возникает вопрос «А трейсинг не сломается?» — важно понимать что это две независимые вещи.

LangSmith-трейсинг — сервер сам отправляет трейсы в api.smith.langchain.com по LANGSMITH_API_KEY. Это исходящие вызовы, наша авторизация защищает входящие — трейсинг работает всегда и от нее не зависит.

LangSmith Studio — подключается к вашему серверу как входящий клиент, и его поведение определяет disable_studio_auth из нюанса выше.

Нюанс 4. Dev-пропуск Studio — это «пустой» пропуск

Важная оговорка по безопасности о которой легко забыть. В локальном langgraph dev при disable_studio_auth: false Studio использует StudioNoopAuthBackend — он доверяет любому запросу с заголовком x-auth-scheme: langsmith. То есть локально авторизацию можно обойти так:

curl -H "x-auth-scheme: langsmith" ...  # пройдет без токена

Нюанс 5. Проверять нужно на правильном эндпоинте

Тестируя авторизацию легко обмануться: служебный health-check /ok намеренно не требует токена и всегда отдает 200. 

Проверять нужно на реальной ручке:

# без токена → 401
curl -i -X POST http://localhost:2024/assistants/search \
  -H "Content-Type: application/json" -d '{}'

# с токеном → 200
curl -s -X POST http://localhost:2024/assistants/search \
  -H "Authorization: Bearer ваш_секретный_токен" \
  -H "Content-Type: application/json" -d '{}'

И еще одна частая ловушка: секции auth и disable_studio_auth читаются только при старте сервера. Код графов dev-сервер перезагружает на лету, а изменения в langgraph.json — нет. Поменяли конфиг авторизации — перезапустите langgraph dev, иначе будете смотреть на старое поведение и недоумевать.

Для разработки оставляем disable_studio_auth: false — API под токеном, Studio доступен для отладки. На проде токен защищает REST-поверхность которую дергает фронт, а доступ к Studio валидируется через LangSmith. Дальше прикручиваем фронтенд — и он будет ходить к бэкенду уже с этим токеном.

Авторизация на фронте

До этого момента фронтенд общался с LangGraph Server открыто. Как только на сервере включается защита — каждый запрос упирается в 401 Unauthorized. Разберем, что нужно сделать на фронте, и честно поговорим о границах того, что получится.

Что происходит на бэкенде

LangGraph перехватывает каждый входящий запрос через @auth.authenticate. Логика простая: сервер сверяет заголовок Authorization с секретом из AUTH_SECRET_TOKEN. Нет заголовка — 401, токен неверный — 403, токен совпал — пропускаем.

Важный момент: авторизация навешивается на сервер целиком, а не на отдельный граф. Все три агента сразу под защитой — фронтенд обязан слать токен в каждом запросе, будь то стрим сообщений или загрузка истории.

Что делаем на фронте

Задача сводится к одному: добавить заголовок Authorization: Bearer <token> в каждое обращение к серверу.

Токен кладем в .env:

NEXT_PUBLIC_AUTH_TOKEN=ваш_секретный_токен  # тот же что AUTH_SECRET_TOKEN на бэке

Заводим маленький хелпер чтобы не разбрасывать process.env по коду:

// src/lib/api-key.tsx
export function getAuthToken(): string | undefined {
  return process.env.NEXT_PUBLIC_AUTH_TOKEN || undefined;
}

Теперь главное: фронтенд обращается к серверу не из одного места, а из трех, и каждое поднимает своего клиента. 

Значит заголовок нужно добавить во всех трех точках:

  • стрим сообщений — хук useTypedStream в Stream.tsx; 

  • история чатов — createClient которым пользуются getThreads и deleteThread; 

  • проверка доступности графа — отдельный fetch на /info. 

Везде выглядит одинаково — заголовок подставляется только если токен задан, чтобы локальная разработка без защиты продолжала работать:

const authToken = getAuthToken();

defaultHeaders: {
  ...(authToken && { Authorization: `Bearer ${authToken}` }),
}

Отдельно отмечу, что мы не трогали. В проекте уже есть getApiKey() — но это ключ для LangGraph Cloud, который SDK отправляет в заголовке x-api-key. Другой механизм, к нашему секрету отношения не имеет — оставляем как есть.

После перезапуска npm run dev в DevTools → Network видно Authorization: Bearer ... в каждом запросе. Сообщения стримятся, история подгружается, 401 исчез.

О границах этого решения

Формально задача закрыта — сервер защищен, фронтенд авторизуется. Но нужно ясно понимать что именно мы построили, и все ограничения растут из префикса NEXT_PUBLIC_.

Токен уезжает в браузер. Все что помечено NEXT_PUBLIC_ Next.js вшивает в клиентский бандл на этапе сборки. Секрет физически оказывается на странице — его видно в DevTools и исходниках. Любой посетитель может его достать и обращаться к серверу напрямую в обход интерфейса.

Токен один на всех. Это не «вход пользователя», а общий пароль от двери. Нельзя понять кто сделал запрос, нельзя отозвать доступ у конкретного человека, нельзя разграничить права. На бэке это видно буквально — identity всегда возвращается как «user».

Токен статичен. Фиксируется при сборке и живет до следующей. Нельзя сгенерировать после входа, нельзя протухнуть по таймеру, нельзя обновить — это константа, не сессия.

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

Как авторизация должна выглядеть в реальном продакшене

Настоящая авторизация — это ответственность бэкенда, не фронта. Фронтенд лишь «носит» токен; решает кому его выдать и насколько он валиден — сервер. 

В минимальном проде это три вещи:

  • Сервис пользователей и токенов. Отдельный модуль с хранилищем пользователей, который проверяет логин/пароль и выдает токены — у каждого свой, со сроком жизни и возможностью отзыва. Вместо token == SECRET_TOKEN появляется полноценная проверка: кто это, что ему можно, не истек ли токен.

  • Эндпоинт входа. POST /login принимает учетные данные и возвращает токен. После входа токен живет в рантайме — в localStorage, cookie или памяти браузера, — а не в переменной сборки. Никакого NEXT_PUBLIC_: значение динамическое и у каждого свое.

  • Секрет не доходит до браузера. Запросы к LangGraph идут не напрямую с клиента, а через серверный прокси Next.js — в репозитории для этого уже есть заготовка: langgraph-nextjs-api-passthrough и роут src/app/api/[..._path]/route.ts. Браузер ходит на свой /api, реальный токен подставляется на сервере и наружу не утекает.

При этом наша работа на фронте никуда не девается — те же три точки куда мы прокинули Authorization остаются. Меняется только источник токена: не статичный process.env, а то что вернул логин, и не напрямую а через прокси. Граница проходит четко: фронтенд отвечает за то чтобы токен оказался в заголовке, бэкенд — за то чтобы этому токену можно было верить.

Приступаем к деплою

Что ж, настало время выпустить наш проект в мир.

До этого момента все крутилось локально — и бэкенд, и фронтенд. Это удобно для разработки, но показать такое коллегам или заказчику уже не получится. Исправим это прямо сейчас.

Я решил показать полный цикл — от покупки домена до работающего сервиса по красивому адресу. Никаких «А сервер у меня уже был» — берем все с нуля и на одной площадке. 

В качестве провайдера выбрал Selectel: домен и VPS покупаются в одном личном кабинете, DNS настраивается там же, без лишних телодвижений между разными сервисами.

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

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

Берем доменное имя

Регистрируемся в панели управления, если ранее не было там регистрации.

Далее переходим в раздел управления DNS. Для этого нажимаем Продукты и в поле «Внешние сетевые сервисы» нажимаем на Домены. Тут же вы найдете вкладку Доменные зоны. Сейчас нас будут интересовать Домены.

Пополняем личный кабинет и затем кликаем на Зарегистрировать домен.

На новом экране проверяем чтоб доменное имя было свободно. Если оно свободно — кликаем на оформить.

Далее, в новом окне, даем согласие на обработку персональных данных и кликаем на оформить повторно. Если все  прошло корректно, то в списке доменов вы увидите купленный вами домен:

При желании можно отключить автопродление. Далее переходим в раздел «Доменные зоны» и нажимаем Добавить зону.

Ожидайте, пока домен получит статус «Делегирован». Изменения вносятся в реестр за один рабочий час, но для полной работы DNS-серверов может потребоваться до 24 часов.

Теперь, пока ожидаем перехода доменной зоны в статус «Делегирована», займемся арендой VPS-сервера, а затем и его настройкой с последующим запуском на нем наших двух проектов.

Арендуем VPS сервер

Так как облачная LLM у нас крутится на отдельной машине, сегодня мы будем брать самый базовый облачный VPS без видеопамяти и с минимальным количеством ресурсов.

Переходим в раздел ПродуктыОблачные серверы. Далее кликаем на Создать сервер и выбираем подходящий для себя VPS-сервер. 

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

  • 1 vCPU;

  • 2 ГБ RAM;

  • 15 ГБ Универсальный SSD диск.

Сервер будет активен через пару минут, в чем вы сможете убедиться в разделе «Серверы».

При аренде сервера мы заполняли поле с SSH-ключем. Поэтому, если все было настроено корректно, то команды ssh root@ваш_ip будет достаточно для входа на сервер.

Вход без запроса пароля.

Новые GPU в облаке Selectel от 132,18 ₽/час

Видеокарты для ресурсоемких задач — NVIDIA® H200, RTX™ 6000 Pro.

Подробнее →

Настраиваем VPS-сервер

Первое, что делаем на любом свежем сервере — обновляем пакеты:

apt update && apt upgrade -y

Ставим Docker и Docker Compose

LangGraph CLI в продакшен-режиме работает через Docker — поднимает полный стек через Docker Compose. Поэтому Docker нам обязателен.

# ставим зависимости
apt install -y ca-certificates curl gnupg

# добавляем официальный репозиторий Docker
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
  https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  tee /etc/apt/sources.list.d/docker.list > /dev/null

# устанавливаем
apt update
apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Проверим, что все корректно установилось:

docker --version
docker compose version

Поднимаем GraphCLI

Для начала давайте определимся с папкой на сервере в которую мы будем ставить наши проекты (GraphCli и AgentUI). 

Я создам папку /opt/yakvenalex и буду клонировать проекты туда:

mkdir /opt/yakvenalex && cd /opt/yakvenalex

Поставим GIT на машину:

apt install git

Клонируем проект:

git clone https://github.com/Yakvenalex/HabrGraphCLI

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

Заходим в папку с проектом, создаем внутри файл .env и заполняем переменные окружения.

cd HabrGraphCLI && nano .env

Для сохранения комбинация Ctrl+X, затем Y и затем Enter.

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

Вводим:

apt install -y python3 python3-pip python3-venv

Теперь поднимем виртуальное окружение и установим сам GraphCLI (это нам нужно будет для выполнения команды для компиляции проекта в продовой среде).

python3 -m venv venv
source venv/bin/activate
pip install "langgraph-cli[inmem]" —break-system-packages

Теперь можно собрать образ:

langgraph build -t habr-graph-cli

CLI прочитает langgraph.json, подтянет зависимости из pyproject.toml и соберет образ. При первой сборке это займет несколько минут.

Запускаем в прод-режиме:

langgraph up

Если вы видите после ввода этой команды картину, как на скрине ниже, то все запустилось у нас корректно.

Остановим и запустим сервер в фоне.

Ctrl + C
langgraph up --wait

--wait дожидается старта и возвращает управление

Проверим, что все контейнеры успешно запущены.

docker ps

Видим три контейнера: LangGraph API, Redis и PostgreSQL — все в статусе healthy. Стек поднялся полностью.

Теперь обратите внимание на колонку PORTS. Здесь есть важный момент который нельзя пропустить:

  • Redis (6379) — доступен только внутри Docker-сети, наружу не торчит. Все правильно; 

  • PostgreSQL — биндится на 0.0.0.0:5433, то есть доступен на всех интерфейсах; 

  • LangGraph API — биндится на 0.0.0.0:8123, тоже на всех интерфейсах.

Docker Compose по умолчанию биндит проброшенные порты на все интерфейсы — это его стандартное поведение. Само по себе не критично, но закрыть лишнее файрволом обязательно. Наружу должны смотреть только 80 и 443 — все остальное только внутри машины.

Устанавливаем ufw (файервол), если еще не стоит:

apt install ufw
Затем откроем только конкретные порты:
ufw allow 22
ufw allow 80
ufw allow 443
ufw enable

После этого PostgreSQL и LangGraph API физически недоступны снаружи, даже несмотря на 0.0.0.0 в выводе docker ps.

И еще один момент который стоит зафиксировать: в dev-режиме LangGraph Server слушал на порту 2024, в продовом запуске через langgraph up порт изменился на 8123. Это важно — в .env фронтенда нужно будет указать именно его:

NEXT_PUBLIC_API_URL=http://127.0.0.1:8123

Не забудьте об этом когда будете настраивать фронтенд — иначе получите ошибку подключения и будете долго искать причину.

Поднимаем фронтенд

Деактивируем виртуальное окружение и переходим в корневую папку с нашими проектами:

deactivate && cd ../

Теперь клонируем уже фронтенд-проект:

git clone https://github.com/Yakvenalex/Agent-chat-ui-habr

Затем переходим в папку с проектом.

Внутри создаем файл .env и ставим в него необходимые переменные окружения:

nano .env
Ctrl + X, Y, Enter

Обратите внимание на NEXT_PUBLIC_API_URL — указываем внутренний адрес с портом 8123, именно на нем крутится LangGraph Server в продовом режиме.

Ставим Node.js 20 через NodeSource:

curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt install nodejs -y

Проверяем, что установилось:

node -v
npm -v

Устанавливаем зависимости и собираем продовый билд:

npm install
npm run build

Запускаем:

npm start

Видим, что проект поднялся, но как только закроем терминал, процесс остановится.

Логично было бы создать Dockerfile и поднять образ через Docker — но для демонстрационного проекта это лишний оверхед. Пойдем по более короткому пути через PM2.

Останавливаем фронт. Для этого нажимаем CTRL + C.

Устанавливаем PM2 глобально:

npm install -g pm2

Запускаем фронтенд через PM2:

pm2 start npm --name "agent-chat-ui" -- start

Настраиваем автозапуск при перезагрузке сервера:

pm2 startup

PM2 автоматически создаст systemd-сервис и включит его. Сохраняем текущий список процессов:

pm2 save

Проверяем что все работает:

pm2 status

Видим наш процесс agent-chat-ui в статусе online. Фронтенд живет независимо от терминала — можно закрывать сессию.

Несколько полезных команд на будущее:

pm2 logs agent-chat-ui     # смотреть логи
pm2 restart agent-chat-ui  # перезапустить
pm2 stop agent-chat-ui     # остановить

Фронтенд поднят и работает. Осталось прикрутить к нему домен через Nginx — и сервис будет доступен по красивому адресу.

Проверим, что фронтенд отвечает:

curl http://127.0.0.1:3000

Настраиваем Nginx и SSL

Фронтенд работает на 127.0.0.1:3000 — снаружи не виден. Теперь поставим Nginx который будет принимать запросы на 80 и 443 порты и проксировать их на фронт. Плюс сразу получим SSL-сертификат через Certbot.

Устанавливаем Nginx и Certbot:

apt install nginx certbot python3-certbot-nginx -y

Настраиваем DNS

Прежде чем двигаться дальше — важный момент. Certbot при выпуске сертификата обращается к вашему домену и проверяет что он реально указывает на этот сервер. Если A-запись не настроена, сертификат не выпустится.

Заходим в личный кабинет Selectel → Продукты и в поле «Внешние сетевые сервисы» нажимаем на Доменные зоны и убеждаемся, что статус изменился на «Делегирована».

Если этот так, то кликаем на домен и проваливаемся в меню управления DNS-записями.

И добавляем две А-записи.

С пустым именем группы:

На выходе получаем это:

После сохранения DNS propagation может занять от нескольких минут до нескольких часов. Проверить с любой машины, что домен уже указывает на наш сервер, можно командой:

ping ваш_домен.ru

Если в ответе видите IP вашего VPS — можно двигаться дальше.

Готовим конфиг Nginx

Создаем конфигурационный файл для нашего домена:

nano /etc/nginx/sites-available/agent-chat-ui

Вставляем конфиг:

server {
    listen 80;
    server_name ваш_домен.ru www.ваш_домен.ru;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_cache_bypass $http_upgrade;
    }
}

Активируем конфиг и проверяем что нет синтаксических ошибок:

ln -s /etc/nginx/sites-available/agent-chat-ui /etc/nginx/sites-enabled/
nginx -t

Перезапускаем Nginx:

systemctl restart nginx

Получаем SSL-сертификат

certbot --nginx -d ваш_домен.ru -d www.ваш_домен.ru

Certbot попросит email для уведомлений об истечении сертификата, примет соглашение — и автоматически получит сертификат и обновит конфиг Nginx для работы по HTTPS. После этого проверяем:

systemctl status nginx

Настраиваем фронтенд для работы в проде

Прежде чем открывать браузер — важный момент. Из-за того что порт 8123 закрыт файрволом и из-за особенностей Next.js (NEXT_PUBLIC_ переменные вшиваются в клиентский бандл и выполняются в браузере пользователя, а не на сервере), прямое подключение фронта к 127.0.0.1:8123 работать не будет. Браузер будет искать этот адрес на машине пользователя, а не на сервере.

Решаем это через встроенный в проект API Passthrough — Next.js сам проксирует запросы на LangGraph изнутри сервера. Браузер ходит на наш домен, сервер проксирует дальше на 127.0.0.1:8123. Порт наружу не открываем.

Обновляем .env фронтенда:

nano /opt/yakvenalex/Agent-chat-ui-habr/.env
# сервер ходит на LangGraph напрямую — без NEXT_PUBLIC_
LANGGRAPH_API_URL=http://127.0.0.1:8123

# браузер ходит на свой же Next.js через домен
NEXT_PUBLIC_API_URL=https://ваш_домен.ru/api
# остальное без изменений
NEXT_PUBLIC_ASSISTANT_IDS=agent,router_agent,chef_agent
NEXT_PUBLIC_AUTH_TOKEN=ваш_токен
NEXT_PUBLIC_AUTH_SCHEME=

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

bashnpm run build
pm2 restart agent-chat-ui

Теперь открываем браузер и переходим на https://ваш\_домен.ru — должен открыться наш AI-интерфейс с валидным SSL-сертификатом.

Напомню про безопасность: наружу через файрвол у нас торчат только порты 80 и 443. LangGraph API на порту 8123 и PostgreSQL на 5433 закрыты — доступны только внутри машины. Пользователь видит только чистый интерфейс по вашему домену, до бэкенда снаружи не добраться.

Итоги

Вот мы и добрались до финала.

Давайте зафиксируем что сделали в этой части.

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

Затем развернули профессиональный Next.js-фронтенд, кастомизировали его под реальное использование: перевели на русский, добавили переключатель между агентами, удаление чатов, авторизацию во всех точках. И выкатили все это на боевой сервер с доменом и SSL.

Теперь посмотрим на картину целиком — что мы построили за три части:

  • Часть 1 — подняли облачную LLM на 16 ГБ VRAM через vLLM, получили OpenAI-совместимый API и научились с ним работать;

  • Часть 2 — собрали агентный бэкенд на LangGraph с MCP-серверами, получили полноценный REST API через LangGraph Server и обернули его в FastAPI-сервис;

  • Часть 3 — дали всему этому лицо. Развернули production-ready фронтенд, настроили авторизацию, задеплоили с доменом в приватный контур;

Итоговая схема того, что работает сейчас:

Это полноценный AI-продукт, который работает на вашей инфраструктуре, под вашим контролем, без зависимости от OpenAI и без утечки данных наружу. Именно к этому мы и шли.

Что можно делать дальше, если хочется развивать этот стек

  • заменить общий Bearer-токен на полноценную систему пользователей с JWT и ролями;

  • добавить собственные MCP-серверы под конкретную предметную область — корпоративные данные, внутренние API, базы знаний;

  • докрутить агентов под реальные бизнес-задачи: анализ документов, работа с БД, интеграция с внутренними системами;

  • заменить agent-chat-ui на полностью кастомный фронтенд когда бизнес-требования вырастут из готового решения.

Цикл закрыт. Спасибо всем, кто прошел этот путь вместе — от первого vllm serve до работающего сервиса по своему домену. Если были вопросы по ходу — пишите в комментариях, разберемся.

Все репозитории из цикла:

Комментарии (1)


  1. OpenEducationOfficial
    27.06.2026 12:15

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