惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

N
News and Events Feed by Topic
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
月光博客
月光博客
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
大猫的无限游戏
大猫的无限游戏
T
Tailwind CSS Blog
S
SegmentFault 最新的问题
V
V2EX
阮一峰的网络日志
阮一峰的网络日志
C
Cisco Blogs
博客园 - 叶小钗
P
Privacy International News Feed
Jina AI
Jina AI
Apple Machine Learning Research
Apple Machine Learning Research
T
Threatpost
IT之家
IT之家
博客园 - 聂微东
Know Your Adversary
Know Your Adversary
Help Net Security
Help Net Security
罗磊的独立博客
I
Intezer
S
Schneier on Security
博客园_首页
C
CERT Recently Published Vulnerability Notes
雷峰网
雷峰网
Cisco Talos Blog
Cisco Talos Blog
宝玉的分享
宝玉的分享
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
Webroot Blog
Webroot Blog
TaoSecurity Blog
TaoSecurity Blog
MyScale Blog
MyScale Blog
P
Privacy & Cybersecurity Law Blog
T
The Exploit Database - CXSecurity.com
PCI Perspectives
PCI Perspectives
Security Latest
Security Latest
H
Heimdal Security Blog
S
Secure Thoughts
Hacker News: Ask HN
Hacker News: Ask HN
Y
Y Combinator Blog
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Microsoft Security Blog
Microsoft Security Blog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
SecWiki News
SecWiki News
The GitHub Blog
The GitHub Blog
A
Arctic Wolf
A
About on SuperTechFans
aimingoo的专栏
aimingoo的专栏
T
Threat Research - Cisco Blogs
Engineering at Meta
Engineering at Meta
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Облачная LLM на 16 ГБ VRAM — часть 3: ChatGPT-интерфейс для ваших LangGraph-агентов
Алексей Яковенко · 2026-06-27 · via Все публикации подряд на Хабре

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

За две предыдущие статьи мы подняли собственную локальную модель на облачном сервере с 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 до работающего сервиса по своему домену. Если были вопросы по ходу — пишите в комментариях, разберемся.

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