РоссияНовостьМодели
Что такое JEV и зачем он нужен: собираем AI-агента на связке LLM + open-source аналог JEV
Автор: Алексей Яковенко , опытный python разработчик с многолетним стажем. Со времени моей последней статьи в мире ИИ успел появиться очередной «убийца всех LLM» и чуть ли не будущее всей AI-индустрии...
Автор: Алексей Яковенко, опытный python разработчик с многолетним стажем.
Со времени моей последней статьи в мире ИИ успел появиться очередной «убийца всех LLM» и чуть ли не будущее всей AI-индустрии - JEV. По крайней мере, именно так его сейчас местами преподносят.
Сегодня предлагаю познакомиться с этим зверем поближе. Разберёмся, что из себя представляет JEV, почему вокруг него столько шума, как он работает и чем отличается от привычных нам языковых моделей. Заодно посмотрим, какие у него уже появились open-source аналоги.
Ну а чтобы не ограничиваться одной теорией, перейдём к практике. Возьмём один из открытых аналогов JEV - Laya, развернём его на собственном сервере вместе с полноценной LLM семейства Gemma 4 и попробуем объединить обе модели в одну систему.
Причём соберём не просто очередной чат с нейросетью, а вполне самостоятельное приложение - AI-агента для автоматической обработки, написания и публикации новостей.
Работать всё будет следующим образом: на вход поступает новость, Laya определяет её тематику и отсеивает материалы, которые нам неинтересны. Подходящие публикации передаются большой LLM, которая уже самостоятельно работает с инструментами, анализирует информацию, готовит статью и публикует её в нашем блоге.
В результате получим полноценный сервис на FastAPI с базой данных, фоновым обработчиком задач, собственным Agent Harness и небольшим веб-интерфейсом, через который можно будет наблюдать за всем процессом - от классификации новости до появления готовой статьи на сайте.
Причём специально усложнять архитектуру не будем. Никаких LangGraph, CrewAI и прочих высокоуровневых агентных фреймворков. Всю логику взаимодействия между моделями и инструментами реализуем самостоятельно на Python.
Выделенные и виртуальные серверы в Европе, США и России
Готовые серверы + предустановленное программное обеспечение, а также индивидуальные конфигурации серверов.
Посмотреть
Что такое JEV и почему столько шума?
JEV - это специализированная нейросеть от компании TypeSafe AI, предназначенная для быстрого принятия решений внутри программных систем. В отличие от привычных нам LLM, она не умеет вести диалог, писать код или генерировать произвольный текст. Да ей это, собственно, и не нужно.
Чтобы понять, в чём её особенность, давайте вспомним, как работают классические языковые модели.
Допустим, мы разрабатываем AI-агента, которому необходимо выбрать один из нескольких инструментов. Например, обратиться к RAG, выполнить поиск в интернете или вызвать какой-нибудь API.
Обычная LLM получает описание задачи, анализирует контекст и генерирует ответ. Даже если мы попросим её вернуть небольшой JSON с названием нужного инструмента, она всё равно будет генерировать его последовательно, токен за токеном.
Понятное дело, что сейчас существуют Structured Outputs, нативный Tool Calling, ограниченная генерация по JSON Schema и другие механизмы, позволяющие получать вполне предсказуемые результаты. Однако сам принцип работы авторегрессионной модели от этого не меняется. Она продолжает последовательно генерировать токены, даже когда от неё требуется всего лишь выбрать один вариант из заранее известного списка.
И вот здесь возникает вполне логичный вопрос: а зачем вообще использовать генеративную модель там, где нам не нужно ничего генерировать?
Именно эту задачу и пытается решить JEV.
Разработчики TypeSafe AI называют JEV первой публичной моделью нового класса — System One Models.
Название отсылает к концепции двух систем мышления, популяризированной психологом и нобелевским лауреатом Даниэлем Канеманом.
Согласно этой концепции, наше мышление условно можно разделить на две системы:
- Система 1 — быстрое, автоматическое, интуитивное мышление. Например, когда мы практически мгновенно узнаём знакомое лицо или понимаем, что человек чем-то недоволен.
- Система 2 — медленное, последовательное мышление, требующее сознательных усилий. Например, когда решаем сложную математическую задачу или анализируем проблему с несколькими неизвестными.
Разумеется, это аналогия, а не буквальное описание устройства нейросетей.
Идея JEV заключается в том, чтобы выполнять задачи, похожие на работу первой системы. Вместо длинных рассуждений и последовательной генерации текста модель получает входные данные, анализирует их и возвращает структурированное решение.
Причём здесь есть несколько интересных особенностей.
Во-первых, типизированные результаты. Мы заранее определяем, какой именно ответ хотим получить: выбор из нескольких вариантов, оценку по шкале или вероятность истинности утверждения.
Модель не может внезапно вернуть нам пространное объяснение вместо ожидаемого значения. Формат ответа определяется самой архитектурой взаимодействия.
Правда, здесь важно не путать корректность формата с корректностью самого решения. JEV вполне может выбрать неправильный вариант. От того, что ответ типизированный, модель не становится безошибочной.
Во-вторых, параллельная обработка. В отличие от привычной авторегрессионной генерации, JEV умеет оценивать несколько независимых вопросов к одному контексту параллельно. Не нужно последовательно генерировать ответы на каждый из них.
За счёт этого на подходящих задачах можно получить существенный выигрыш в скорости и стоимости обработки. Разработчики заявляют весьма впечатляющие цифры, но о них поговорим отдельно. Всё-таки сравнивать специализированную модель с большой LLM без учёта конкретной задачи было бы не совсем правильно.
Кстати, выходные токены у JEV не тарифицируются отдельно. Оплачиваются только входные. Однако это особенность тарификации API, а не отсутствие вычислений на стороне самой модели.
И третья особенность - оценка уверенности. Вместо сгенерированного текста вроде «Я практически уверен, что это ошибка авторизации» мы получаем числовые вероятности.
Например, модель может оценить вероятность того, что обращение связано с проблемой авторизации, в 0.92.
Дальше уже обычным Python-кодом можем решить, что с этим делать: выполнить соответствующее действие, отправить задачу другой модели или запросить дополнительную информацию.
При этом сами вероятности тоже не стоит воспринимать как абсолютную истину. Насколько хорошо они соответствуют реальной точности модели, зависит от конкретной задачи и качества её калибровки.
На момент написания статьи у JEV есть три основных примитива, позволяющих решать довольно широкий круг задач.
- Choice (выбор) — выбирает один вариант из заранее определённого списка. Например, определяет категорию обращения: bug, feature или spam. Дополнительно возвращает вероятности для каждого варианта.
- Score (оценка) — оценивает входные данные по заданной шкале. Например, степень критичности инцидента или качество найденного RAG-контекста.
- Noul (да/нет) — возвращает вероятность истинности заданного утверждения, то есть число от 0 до 1. Например, насколько вероятно, что пользователь требует возврата средств.
Все три типа вопросов можно передать в одном запросе, и модель обработает их параллельно относительно общего контекста.
Давайте посмотрим на небольшой пример.
Представим, что у нас есть система технической поддержки. Пользователь присылает сообщение, а нам необходимо определить срочность обращения и понять, насколько клиент недоволен ситуацией.
С обычной LLM мы могли бы написать системный промпт, описать JSON-схему и получить структурированный ответ. С JEV всё немного иначе: мы передаём состояние (state) и набор вопросов (questions), для каждого из которых сразу задаём ожидаемый тип результата.
import os
import requests
url = "https://api.typesafe.ai/v1/systemone"
payload = {
"model": "jev-latest",
"state": (
"Привет! Я пытаюсь подключить свой аккаунт Stripe "
"уже три дня, постоянно выдаёт ошибку. "
"Я теряю продажи, решите это немедленно!"
),
"questions": {
"urgency": {
"type": "choice",
"instructions": "Насколько срочное это обращение?",
"criteria": {
"low": "Обычный вопрос без срочности",
"medium": "Проблема требует внимания",
"urgent": "Критичная проблема, нужны срочные действия",
},
},
"is_angry_customer": {
"type": "noul",
"instructions": "Клиент выражает сильное недовольство?",
},
},
}
response = requests.post(
url,
headers={
"Authorization": f"Bearer {os.environ['TYPESAFE_API_KEY']}",
},
json=payload,
timeout=30,
)
response.raise_for_status()
answers = response.json()["answers"]
print(answers["urgency"]["choice"])
print(answers["urgency"]["probabilities"])
print(answers["is_angry_customer"]["noul"])В ответ получим готовые структурированные значения. Для urgency — выбранную категорию, распределение вероятностей по всем вариантам и показатель уверенности. Для is_angry_customer — число от 0 до 1, отражающее оценку вероятности недовольства клиента.
Никакого извлечения JSON из сгенерированного текста, дополнительных промптов с требованиями к форматированию и прочих ухищрений.
Конечно, по сети результат всё равно передаётся в JSON. Разница в том, что JEV изначально формирует типизированные решения, а не генерирует текст, который потом необходимо преобразовать в нужную структуру.
И, пожалуй, самое интересное здесь то, что мы можем использовать эти решения непосредственно в программной логике.
Например, определять, какой инструмент должен вызвать AI-агент, нужно ли обращаться к большой LLM или можно обойтись более простой операцией.
Именно последнее нас сегодня интересует больше всего. Но прежде чем переходить к агентам, предлагаю посмотреть, какие у JEV существуют открытые альтернативы и насколько они пригодны для самостоятельного развёртывания.
Кстати, когда я анонсировал подготовку этой статьи в своём Telegram-канале «Лёгкий путь в Python», один из подписчиков задал вполне логичный вопрос: «Чем JEV от reranker отличается?»
И действительно, на первый взгляд принцип работы похож. И там и там мы передаём какие-то данные, а на выходе получаем оценки или вероятности. Но есть существенная разница.
Начнём с того, что RAG - это вообще не отдельная модель, а подход к построению систем, при котором мы сначала извлекаем информацию из внешних источников, а затем передаём её языковой модели для формирования ответа.
Reranker же - это специализированная модель, которая оценивает релевантность найденных документов относительно запроса пользователя. Например, наш RAG нашёл 20 фрагментов документации. Reranker определяет, какие из них лучше всего отвечают на вопрос, и позволяет выбрать наиболее подходящие.
JEV решает более широкий класс задач. Ему необязательно сравнивать документы с поисковым запросом. Он может оценивать состояние системы, классифицировать обращения, выбирать действия или определять, нужно ли вообще обращаться к RAG.
Допустим, пользователь попросил AI-агента найти ошибку в коде.
JEV может помочь выбрать следующий шаг: изучить файлы проекта, обратиться к документации через RAG, выполнить тесты или передать собранную информацию большой LLM.
Если агент решит обратиться к RAG, тот найдёт подходящие документы, а Reranker поможет выбрать наиболее релевантные фрагменты.
Получается, все три подхода вполне могут работать в одной системе, просто каждый выполняет свою задачу.
При этом JEV технически можно использовать и для оценки релевантности документов, например через механизм Score. Но это не означает, что он автоматически заменит специализированный Reranker. Последний обучался непосредственно для поисковых задач и вполне может показывать лучшие результаты.
Так что воспринимать JEV как замену RAG или Reranker я бы не стал. Скорее это ещё один инструмент, который можно встроить в архитектуру приложения. И в нашем случае мы как раз попробуем использовать его для выбора действий внутри AI-агента.
Зачем всё это AI-агентам? Разделяем логику и творчество
Если вы когда-нибудь собирали собственного AI-агента, то наверняка знаете: в красивых демонстрациях всё работает практически магически, а вот в реальной эксплуатации начинаются проблемы. Где-то модель слишком долго думает, где-то вызывает не тот инструмент, а где-то мы тратим тысячи токенов на принятие решения, которое вполне можно было бы уложить в несколько строк Python-кода.
И вот здесь модели класса System One, к которым относится JEV, выглядят довольно интересно. Они позволяют вынести часть простых решений из большой LLM в отдельный, значительно более лёгкий компонент.
Давайте разберёмся, зачем это вообще нужно и где такой подход может пригодиться.
Для начала вспомним, как устроен типичный AI-агент.
Если сильно упростить, его работа строится вокруг повторяющегося цикла, который принято называть Agent Loop:
Наблюдение → Принятие решения → Действие → Наблюдение.
Агент получает запрос пользователя, определяет дальнейшие действия, вызывает нужный инструмент, анализирует полученный результат и снова решает, что делать дальше. И так до тех пор, пока задача не будет выполнена или не сработает одно из ограничений.
В классической реализации за принятие решений зачастую отвечает одна и та же большая LLM.
Допустим, пользователь попросил агента изучить GitHub-репозиторий и найти потенциальные проблемы.
Сначала модель решает получить список файлов. Затем изучает README. После этого обращается к Issues, запрашивает содержимое отдельных файлов, анализирует результаты и наконец формирует ответ.
В зависимости от архитектуры на каждом из этих этапов может происходить полноценное обращение к большой языковой модели.
Сам по себе такой подход вполне рабочий. Но у него есть несколько недостатков.
Во-первых, скорость. Даже если модели требуется всего лишь выбрать следующий инструмент, ей всё равно приходится обрабатывать входной контекст и генерировать ответ. Один такой вызов может занимать доли секунды или несколько секунд, а в агентном цикле подобных обращений бывает достаточно много.
Во-вторых, расход ресурсов. По мере выполнения задачи накапливается история диалога, результаты инструментов и прочая информация. Если мы постоянно передаём всё это большой модели, вычислительные затраты растут. А при использовании коммерческого API растут ещё и расходы на токены.
Конечно, никто не запрещает оптимизировать контекст, кешировать результаты или использовать более компактные LLM. Но возникает другой вопрос: а действительно ли большая языковая модель нужна на каждом этапе?
Далеко не каждое действие агента требует сложных рассуждений.
Представьте, что мы разрабатываем систему технической поддержки. Пользователь обращается с проблемой, а агенту необходимо определить дальнейший маршрут:
- Обратиться к базе знаний через RAG.
- Проверить состояние сервера через API.
- Передать обращение живому оператору.
- Подключить большую LLM для более подробного анализа.
И вот ради такого выбора мы вполне можем отправить запрос к модели на десятки миллиардов параметров.
Согласитесь, это всё равно что нанять профессора МГУ, чтобы он сортировал обращения в техподдержку по трём папкам.
Причём я сейчас не говорю, что подобные задачи обязательно нужно решать через JEV или его аналоги. Если у нас есть чёткие правила, обычный if/else зачастую будет быстрее, дешевле и надёжнее любой нейросети.
Но что делать, если решение зависит от смысла обращения, текущего состояния системы и результатов предыдущих действий? Когда заранее предусмотреть все возможные условия уже не так просто?
Вот здесь и появляется место для специализированной модели принятия решений.
Мы передаём ей текущее состояние агента и список доступных действий. Она оценивает варианты и возвращает наиболее подходящий вместе с вероятностями.
Дальше наш Python-код вызывает выбранный инструмент, обновляет состояние и при необходимости снова обращается к модели.
Таким образом, для относительно простых решений необязательно каждый раз задействовать большую LLM.
Здесь, кстати, есть ещё один интересный момент.
Когда мы говорим про вызов инструментов, обычно подразумеваем, что языковая модель должна выбрать функцию и подготовить аргументы для её выполнения.
Например:
{
"name": "get_server_status",
"arguments": {
"server_id": "srv-123"
}
}Понятное дело, современные LLM уже умеют работать с нативным Tool Calling и Structured Outputs. При правильной настройке получить корректную JSON-структуру обычно не составляет особой проблемы.
Но сама модель по-прежнему занимается генерацией. Она должна определить необходимый инструмент, подготовить аргументы и сформировать соответствующий ответ.
JEV предлагает немного другой подход.
Вместо генерации произвольной структуры мы заранее определяем набор возможных действий:
actions = [
"check_server_status",
"get_server_logs",
"search_documentation",
"ask_llm",
"finish",
]Модель получает состояние системы и выбирает один из предложенных вариантов.
При этом она не сможет придумать несуществующее название инструмента, поскольку выбор ограничен нашим списком. Но вполне может ошибиться и выбрать неподходящий вариант.
И здесь есть важное ограничение: JEV не предназначен для генерации произвольных аргументов функций. Если инструменту требуется сложный JSON с динамическими параметрами, их придётся сформировать отдельно - обычным Python-кодом либо с помощью генеративной LLM.
Поэтому полностью заменить Tool Calling большой языковой модели таким подходом не получится. Зато можно вынести в отдельный компонент выбор дальнейшего действия.
Согласитесь, уже интересно.
В обычной разработке мы давно привыкли разделять ответственность между компонентами системы. База данных занимается хранением информации, бэкенд - бизнес-логикой, фронтенд - взаимодействием с пользователем.
Почему бы не применить похожий подход к AI-агентам?
Вместо одной модели, которая пытается делать всё подряд, можно использовать две, каждая из которых решает свой класс задач.
Генеративная LLM занимается тем, ради чего мы её, собственно, и подключаем: анализирует сложную информацию, пишет код, формирует ответы, обобщает документы и решает задачи, требующие развёрнутых рассуждений.
Небольшая модель класса System One отвечает за ограниченные решения: выбрать инструмент, определить маршрут выполнения, оценить результат предыдущего шага или решить, нужно ли обращаться к большой LLM.
А между ними находится наш Agent Harness - обычный Python-код, который управляет циклом агента, хранит состояние, вызывает инструменты, контролирует ограничения и передаёт задачи нужной модели.
При этом окончательные правила выполнения остаются именно на стороне приложения. Если модель решила вызвать инструмент, это ещё не означает, что мы обязаны разрешить ей такое действие.
Получается довольно любопытная архитектура: одна модель принимает быстрые локальные решения, вторая занимается сложной обработкой информации, а Python связывает всё это в единую систему.
В практической части мы соберем такую систему.
Но прежде чем писать код, предлагаю посмотреть, что сегодня происходит с открытыми аналогами JEV и какую модель мы вообще можем развернуть на собственном сервере.
Что там в Open Source? Ищем замену JEV для своего сервера
Коммерческие API - это, конечно, удобно. Особенно когда нужно быстро проверить какую-нибудь идею или собрать прототип. Но когда дело доходит до продакшена с большим количеством запросов или работы с чувствительными данными, например медицинскими или корпоративными, постоянно обращаться к стороннему сервису хочется уже далеко не всегда. Да и не всегда можно.
В идеале нам бы хотелось развернуть подобную модель на собственном сервере. Причём так, чтобы для её работы не потребовалось арендовать несколько A100.
К счастью, после появления JEV open-source сообщество довольно быстро подхватило идею специализированных моделей принятия решений. На момент написания статьи уже существует несколько интересных альтернатив.
Если немного покопаться в проектах, можно выделить три основных подхода:
- Адаптация обычных LLM - Kev, NanoJev, Bespoke Nimble. За основу берутся существующие языковые модели, например Qwen, которые адаптируют для принятия структурированных решений. Вместо привычной генерации текста могут использоваться дополнительные обучаемые головы для оценки предложенных вариантов.
- Работа с логитами - SemIf, Rizzo Flow. Здесь используют обычную LLM, но вместо генерации полноценного ответа оценивают вероятности заранее заданных вариантов. Подход относительно простой, хотя качество сильно зависит от самой модели и способа представления ответов.
- Специализированные энкодеры - Laya, Von. В отличие от предыдущих вариантов, здесь используются модели, изначально предназначенные для анализа информации, а не генерации текста. Они обрабатывают входные данные и сразу возвращают оценки предложенных решений.
Меня больше всего заинтересовал третий подход, особенно Laya.
Почему именно Laya?
Для начала - её размеры. У проекта есть англоязычная версия на 421 миллион параметров и мультиязычная на 322 миллиона. Обе существенно компактнее привычных нам больших LLM и могут работать не только на GPU, но и на обычном процессоре.
В нашем случае это особенно удобно: Laya запустим на CPU, а всю видеопамять оставим для большой языковой модели.
Вторая интересная особенность - отсутствие последовательной генерации текста. Laya построена на базе энкодеров ModernBERT и mmBERT. Она получает контекст, вопрос и допустимые варианты ответа, после чего возвращает структурированное решение с вероятностями.
Разработчики приводят результаты порядка 33–40 мс на короткий вопрос на Tesla T4. Правда, это их собственные измерения, и на CPU скорость будет чуть медленнее (порядка 120-150 мс).
Также у Laya есть мультиязычный чекпоинт с поддержкой русского языка, а сам проект распространяется под лицензией Apache 2.0. Доступны исходники, веса моделей и готовый HTTP-сервер с API, совместимым с JEV.
То есть нам не придётся писать отдельную обёртку для инференса. Достаточно поднять готовый сервис и обращаться к нему через /v1/systemone.
Что будем строить? Архитектура нашего AI-агента
Итак, с теорией разобрались, модель для экспериментов выбрали. Теперь давайте определимся, что именно будем разрабатывать.
Наша задача — собрать полноценного AI-агента, который будет автоматически обрабатывать входящие новости, отбирать интересные материалы, готовить на их основе статьи и самостоятельно публиковать их в блоге.
Причём всю работу разделим между двумя нейросетями. Laya возьмёт на себя первичную классификацию входящей информации, а Gemma 4 будет заниматься анализом, генерацией текста и взаимодействием с инструментами.
Управлять всем этим будет собственный Agent Harness на Python. Без LangGraph, CrewAI и других высокоуровневых агентных фреймворков. Хочется показать, что для создания вполне самостоятельного AI-агента необязательно тащить за собой целую платформу.
Для реализации будем использовать следующие компоненты:
- Laya - локальная модель принятия решений. Запустим её нативный HTTP-сервер на CPU. Она будет классифицировать поступающие новости по тематическим категориям.
- Gemma 4 - основная генеративная LLM. Развернём её на GPU через llama.cpp с OpenAI-совместимым API. Для взаимодействия используем Python-библиотеку openai.
- FastAPI - основа нашего приложения. Здесь реализуем приём новостей, авторизацию, управление задачами и собственный Agent Harness.
- SQLite + SQLAlchemy - для хранения входящих новостей, результатов классификации, истории выполнения задач и подготовленных публикаций.
- Jinja2 - небольшой веб-интерфейс, через который будем отправлять новости на обработку и наблюдать за действиями агента.
- Ghost - готовый движок блога, в который агент будет публиковать подготовленные статьи через Admin API.
- Docker Compose - для развёртывания всех компонентов на сервере.
Отдельно реализуем фоновый обработчик задач, чтобы работа агента не блокировала HTTP-запросы. Если задача выполняется долго или сервер перезапускается, её состояние останется в базе данных.
Как всё это будет работать?
Представим, что у нас есть технический блог, посвящённый программированию, нейросетям и другим IT-направлениям.
На вход нашего API поступают новости из различных источников. Это могут быть Telegram-каналы, RSS-ленты или сторонние парсеры. В рамках статьи сами источники подключать не будем - отправим тестовые материалы через API и веб-интерфейс.
Сначала приложение проверяет, не обрабатывали ли мы эту новость раньше. Если материал новый, передаём его в Laya.
Она определяет тематику публикации и возвращает распределение вероятностей по категориям: технологии, бизнес, спорт и мировые события.
Например, если поступила новость о хоккейном матче, а Laya уверенно определила категорию «Спорт», дальнейшая обработка заканчивается. Зачем тратить ресурсы большой языковой модели на материал, который совершенно не соответствует тематике нашего блога?
А вот если новость посвящена новой Python-библиотеке или очередной AI-модели, передаём её Gemma 4.
Здесь уже запускается полноценный Agent Loop.
Gemma получает исходную информацию и набор доступных инструментов. Она самостоятельно определяет дальнейшие действия: проверяет наличие похожих публикаций, получает дополнительные сведения из сохранённого источника, готовит статью и сохраняет результат.
Наш Python Harness выполняет выбранные инструменты, возвращает ответы модели, контролирует количество шагов и обрабатывает возможные ошибки.
После подготовки материала агент выполняет предусмотренные проверки и публикует статью в Ghost через API. Готовую публикацию можно сразу открыть на сайте.
Для удобства добавим и режим черновиков, чтобы при необходимости можно было посмотреть результат перед публикацией. Но основным сценарием останется полностью автоматическая работа.
В итоге получим собственную небольшую AI-редакцию, в которой одна нейросеть отвечает за первичный отбор информации, вторая - за её содержательную обработку, а обычный Python-код связывает всё это в единую систему.
Причём весь исходный код, конфигурации и инструкции по запуску будут доступны на GitHub. Так что при желании проект можно будет развернуть самостоятельно или адаптировать под совершенно другие задачи.
Ну а теперь определимся, на каком оборудовании будем всё это запускать.
Для нашего эксперимента понадобится достаточно мощный сервер, на котором мы сможем одновременно запустить обе нейросети.
Причём Laya планирую разместить непосредственно на процессоре, а всю видеопамять отдать Gemma 4. Таким образом, модели не будут конкурировать за ресурсы GPU.
Под эту статью я взял выделенный сервер с поминутной тарификацией у HOSTKEY — моего давнего и уже проверенного партнёра, с которым мы не первый раз готовим технические материалы.
Выбор неслучайный. У HOSTKEY есть широкий выбор серверов с GPU, а поминутная тарификация особенно удобна для подобных экспериментов. Не нужно оплачивать мощную машину на целый месяц, если она требуется буквально на несколько дней для разработки, тестирования и подготовки статьи.
Для нашего проекта остановились на следующей конфигурации:
- AMD Ryzen 9 5950X - 16 ядер / 32 потока.
- 64 ГБ оперативной памяти.
- NVIDIA RTX 4090 с 24 ГБ видеопамяти.
- NVMe SSD объёмом 1 ТБ.
- Ubuntu Server 24.04.
Кстати, сам сервер для подготовки материала HOSTKEY предоставил в рамках нашего сотрудничества, за что ребятам отдельное спасибо.
С подготовкой оборудования тоже всё оказалось довольно удобно. Сервер получил уже с установленной Ubuntu 24.04 и драйверами NVIDIA, поэтому не пришлось тратить время на первоначальную настройку GPU. Можно было практически сразу переходить к развёртыванию нейросетей.
В целом конфигурация получилась с хорошим запасом. Ryzen 5950X вполне подходит для экспериментов с компактной Laya на CPU, а RTX 4090 с 24 ГБ видеопамяти позволяет запустить достаточно серьёзную генеративную модель в квантизованном виде.
Причём никаких многокарточных конфигураций или дорогостоящих ускорителей уровня A100/B200 нам не потребуется.
Ну а дальше перейдём непосредственно к практике. Подготовим сервер, настроим Docker и последовательно развернём обе модели.
Заодно покажу все необходимые конфигурации и команды, чтобы при желании вы могли повторить эксперимент на собственном оборудовании или арендованном сервере.
Подготовка сервера к запуску нейросетей
К этому моменту у вас уже должен быть доступ к серверу по SSH. Также желательно иметь собственное доменное имя с возможностью настройки DNS-записей, если планируете организовать внешний доступ к приложению.
Если всё готово, переходим к настройке серверного окружения и развёртыванию наших нейросетей.
Как и договаривались, Laya будем запускать на CPU, а Gemma 4 разместим на видеокарте через llama.cpp. Обе модели поднимем в Docker, причём для каждой подготовим отдельный compose.yaml. Так будет удобнее управлять сервисами, обновлять их и при необходимости перезапускать независимо друг от друга.
Все конфигурационные файлы Docker Compose, инструкции по развёртыванию и исходный код самого AI-агента вы найдёте в моём публичном GitHub-репозитории:
https://github.com/Yakvenalex/Habr-Jev-Agent
Поэтому вручную переписывать объёмные конфигурации и код не придётся. Достаточно клонировать репозиторий и адаптировать необходимые параметры под своё окружение.
Начнём с подготовки сервера.
Проверяем систему и видеокарту
В моём случае используется Ubuntu Server 24.04 с уже установленными драйверами NVIDIA. Если вы арендовали готовый GPU-сервер, вполне возможно, что драйверы у вас тоже уже установлены.
Для начала подключаемся по SSH:
ssh root@YOUR_SERVER_IP
Проверяем версию операционной системы:
lsb_release -aЗатем убедимся, что видеокарта определяется системой:
nvidia-smiВ моём случае определилась NVIDIA RTX 4090 с 24 ГБ видеопамяти. Команда также покажет установленную версию драйвера, поддерживаемую версию CUDA и текущее потребление памяти GPU.
Если nvidia‑smi отрабатывает корректно, значит, драйвер установлен и видеокарта доступна операционной системе.
Если команда не найдена или сообщает, что не удалось связаться с драйвером NVIDIA, сначала необходимо разобраться с драйверами. К запуску контейнеров с GPU пока переходить не стоит.
Обновим список пакетов и установим несколько базовых утилит:
sudo apt update
sudo apt install -y \
curl \
ca-certificates \
gnupg \
gitЭтого пока достаточно.
Всю инфраструктуру будем поднимать через Docker Compose, поэтому сначала проверим, установлен ли Docker:
docker --version
docker compose versionОбратите внимание: используется именно команда docker compose, без дефиса. Это современный Docker Compose V2, который распространяется как плагин Docker CLI.
Если обе команды возвращают версии, можно переходить к следующему этапу.
Если Docker отсутствует, установим его из официального репозитория. Для Ubuntu 24.04 это делается следующим образом.
Сначала добавим GPG‑ключ:
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL \
https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.ascТеперь подключим репозиторий Docker:
sudo tee /etc/apt/sources.list.d/docker.sources > /dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOFУстанавливаем Docker Engine вместе с Compose:
sudo apt update
sudo apt install -y \
docker-ce \
docker-ce-cli \
containerd.io \
docker-buildx-plugin \
docker-compose-pluginЕсли на сервере ранее устанавливался Docker из репозиториев Ubuntu, перед переходом на официальные пакеты может потребоваться удалить конфликтующие пакеты. На чистой Ubuntu такой проблемы обычно нет.
После установки проверяем, что Docker запущен:
sudo systemctl enable --now docker
sudo docker run --rm hello-worldЕсли увидели приветственное сообщение от Docker, значит, движок работает нормально.
Для дальнейшей работы я буду использовать команды с sudo. При желании можно настроить запуск Docker от обычного пользователя, но учитывайте, что членство в группе docker фактически предоставляет пользователю привилегированный доступ к серверу.
Здесь есть один нюанс. Даже если команда nvidia-smi прекрасно работает на самом сервере, это ещё не означает, что видеокарта будет доступна внутри Docker-контейнеров.
Для этого нам понадобится NVIDIA Container Toolkit.
Проверим, установлен ли он:
nvidia-ctk —versionЕсли команда найдена, скорее всего, Toolkit уже присутствует. Останется проверить его настройку.
Если нет, устанавливаем.
Добавим официальный репозиторий NVIDIA:
curl -fsSL \
https://nvidia.github.io/libnvidia-container/gpgkey \
| sudo gpg --dearmor \
-o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -fsSL \
https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
| sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
| sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.listУстанавливаем необходимые пакеты:
sudo apt update
sudo apt install -y nvidia-container-toolkitТеперь сообщим Docker, что у нас имеется NVIDIA Runtime:
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart dockerПосле этого проверим доступность видеокарты уже непосредственно из контейнера:
sudo docker run --rm --gpus all ubuntu:24.04 nvidia-smiЕсли всё настроено правильно, снова увидим информацию о нашей RTX 4090, только на этот раз команда будет выполнена внутри Docker.
Собственно, это именно то, что нам нужно для дальнейшего запуска llama.cpp с поддержкой CUDA.
Laya же будет работать непосредственно на процессоре, поэтому доступ к GPU ей не потребуется.
Чтобы не складывать все конфигурации в одну кучу, предлагаю сразу организовать небольшую структуру проекта.
Создадим директории:
mkdir -p ~/jev-agent/{llm,laya,app}
cd ~/jev-agentВ результате получим примерно следующую структуру:
jev-agent/
├── llm/
│ └── compose.yaml
├── laya/
│ └── compose.yaml
└── app/
├── compose.yaml
└── ...В директории llm будем хранить конфигурацию сервера llama.cpp, в laya - настройки модели принятия решений, а в app позднее разместим наше FastAPI-приложение с Agent Harness и веб-интерфейсом на Jinja2.
Сами модели и необходимые кеши будем сохранять на диске сервера через Docker volumes или примонтированные каталоги, чтобы не загружать веса повторно после каждого пересоздания контейнера.
Отдельно замечу, что API моделей пока не будем выставлять напрямую в интернет. Для начала нам вполне хватит взаимодействия между контейнерами через внутреннюю сеть Docker.
Правда, чуть позже мы всё-таки организуем временный доступ к обеим моделям через отдельные поддомены с HTTPS и авторизацией. Это позволит разрабатывать и тестировать FastAPI-приложение на локальном компьютере, обращаясь к нейросетям, которые работают на удалённом сервере.
После завершения разработки временные адреса отключим, а само приложение оставим работать с моделями через внутреннюю сеть Docker. Публичным останется только наш веб-интерфейс и API приложения.
Остался ещё один момент, который лучше решить заранее.
Веса большинства открытых моделей мы будем скачивать с Hugging Face. Для публичных репозиториев авторизация обычно не обязательна, но некоторые модели требуют принятия условий использования, а загрузка с токеном позволяет использовать доступ, привязанный к вашему аккаунту.
Поэтому рекомендую сразу подготовить собственный токен.
Переходим на Hugging Face, авторизуемся и открываем раздел Access Tokens.
Создаём новый токен. Для нашей задачи будет достаточно прав read. Если хотите ограничить доступ конкретными репозиториями, можно использовать fine-grained токен.
Если выбранная модель требует подтверждения лицензии или отдельного разрешения на скачивание, сначала необходимо получить доступ к ней на странице репозитория. Сам по себе токен этого ограничения не снимает.
Полученный токен понадобится нам на этапе загрузки весов моделей.
Хранить его непосредственно в compose.yaml я не рекомендую. Лучше использовать локальный .env или другой механизм передачи секретов, а сам файл с токеном обязательно исключить из Git.
На этом базовая подготовка закончена.
У нас есть сервер с установленными драйверами NVIDIA, работающий Docker Compose, настроенный доступ к GPU из контейнеров и токен Hugging Face для загрузки моделей.
Теперь можно переходить к самому интересному - развёртыванию нейросетей.
Начнём с большой языковой модели. Для неё будем использовать llama.cpp, который позволит запустить квантованную Gemma 4 на нашей RTX 4090 и получить OpenAI-совместимый API для дальнейшего взаимодействия с Python.
Поднимаем LLM через llama.cpp
Начнём с большой языковой модели. Как я уже говорил ранее, в нашем случае это будет Gemma 4, которую мы развернём на RTX 4090 с 24 ГБ видеопамяти.
Для запуска будем использовать llama.cpp.
Если кто-то ещё не знаком с этим проектом, то llama.cpp - это открытый движок для локального запуска языковых моделей, написанный преимущественно на C/C++. Он поддерживает разные процессорные архитектуры, работу с NVIDIA GPU через CUDA, квантованные модели и многое другое.
Причём нам не придётся самостоятельно писать сервер для взаимодействия с нейросетью. В состав llama.cpp уже входит llama-server, который предоставляет HTTP API, совместимый с форматом OpenAI.
Другими словами, после запуска мы сможем обращаться к собственной Gemma примерно так же, как к моделям через OpenAI API. Только запросы будут обрабатываться непосредственно на нашем сервере.
Сейчас существует много движков для запуска языковых моделей. Например, vLLM, SGLang, Ollama и другие.
Я выбрал llama.cpp по нескольким причинам. Во-первых, он достаточно прост в развёртывании. Во-вторых, хорошо работает с квантованными моделями в формате GGUF. Ну и, наконец, поддерживает нужный нам Tool Calling, без которого полноценного AI-агента собрать будет проблематично.
Собственно, теперь немного о квантизации.
Большие языковые модели обычно хранят свои веса в форматах BF16 или FP16, где на каждый параметр приходится 16 бит. И если мы возьмём модель на 26 миллиардов параметров, то только под хранение весов понадобится порядка 52 ГБ памяти в десятичном исчислении.
Разумеется, на нашу RTX 4090 с 24 ГБ VRAM такая модель целиком не поместится.
Здесь и помогает квантизация - уменьшение точности представления весов модели. Например, вместо 16 бит мы можем использовать примерно 4 бита на параметр.
В результате размер модели существенно сокращается, хотя и появляется определённый риск потери качества.
Причём важно понимать, что квантизация не уменьшает количество параметров модели. Мы просто храним их в более компактном представлении.
Для нашего эксперимента я выбрал Gemma 4 26B A4B IT в квантизации Q4_0.
Это модель с архитектурой Mixture of Experts (MoE). У неё около 26 миллиардов параметров суммарно, но при обработке каждого токена задействуется только часть из них — порядка 4 миллиардов активных параметров. Отсюда, собственно, обозначение A4B.
Такой подход позволяет снизить вычислительные затраты по сравнению с плотными моделями аналогичного общего размера.
В нашем случае будем использовать Q4_0-вариант на основе Quantization-Aware Training (QAT). В отличие от обычного сжатия уже обученной модели, при QAT влияние низкой точности учитывается ещё в процессе обучения. Это позволяет лучше сохранить качество после квантизации.
Размер выбранного GGUF составляет примерно 14,6 ГБ. На первый взгляд, в наши 24 ГБ видеопамяти вписываемся вполне уверенно.
Правда, не стоит забывать, что помимо весов память потребуется для KV-кеша, вычислительных буферов и других компонентов инференса. Поэтому максимальный контекст и количество одновременных запросов будем подбирать уже после запуска.
Для начала ограничимся контекстом в 8192 токена и одним одновременно обрабатываемым запросом. Для наших первых тестов этого вполне достаточно.
Теперь перейдём непосредственно к развёртыванию.
На предыдущем этапе мы уже создали директорию ~/jev-agent, внутри которой есть папка llm.
Переходим в неё:
cd ~/jev-agent/llmДля начала создадим директорию, в которой будет сохраняться кеш скачанных моделей:
mkdir -p cacheЧтобы позднее FastAPI-приложение и Laya могли взаимодействовать с нашей LLM через внутреннюю сеть Docker, создадим общую сеть:
sudo docker network inspect agent-net >/dev/null 2>&1 \
|| sudo docker network create agent-netДалее подготовим файл с переменными окружения:
nano .envДобавляем токен Hugging Face, который получили ранее:
HF_TOKEN=hf_ваш_токенДля выбранного публичного репозитория токен обычно необязателен, но в дальнейшем может пригодиться при загрузке других моделей.
Не забудьте ограничить права доступа к файлу:
chmod 600 .envНу и сам .env, разумеется, не должен попадать в GitHub.
Теперь создаём Docker Compose:
nano compose.yamlСодержимое будет следующим:
services:
llm:
image: ghcr.io/ggml-org/llama.cpp:server-cuda
container_name: gemma4-llm
restart: unless-stopped
gpus: all
ports:
- "127.0.0.1:8080:8080"
environment:
HF_TOKEN: ${HF_TOKEN:-}
LLAMA_CACHE: /cache
volumes:
- ./cache:/cache
command:
- --hf-repo
- ggml-org/gemma-4-26B-A4B-it-GGUF:Q4_0
- --alias
- gemma4
- --host
- 0.0.0.0
- --port
- "8080"
- --ctx-size
- "8192"
- --parallel
- "1"
- --n-gpu-layers
- "99"
- --jinja
- --no-mmproj
networks:
- agent-net
networks:
agent-net:
external: trueРазберём несколько параметров.
gpus: all — предоставляем контейнеру доступ к NVIDIA GPU.
--hf-repo — указываем репозиторий модели на Hugging Face. llama.cpp самостоятельно скачает нужный GGUF при первом запуске.
--alias gemma4 — задаём короткое имя модели, которое будем использовать в API.
--ctx-size 8192 — устанавливаем размер контекста. В дальнейшем при необходимости увеличим.
--n-gpu-layers 99 — пытаемся выгрузить все слои модели на видеокарту. При достаточном количестве VRAM основная вычислительная нагрузка будет выполняться на GPU.
--jinja — важный для нас параметр. Он включает обработку шаблонов диалога через Jinja, необходимую в том числе для корректной работы Tool Calling через OpenAI-совместимый API.
--no-mmproj — отключаем загрузку мультимодального проектора. Gemma 4 умеет работать не только с текстом, но в нашей статье обработка изображений пока не нужна.
Отдельно обратите внимание на проброс порта:
ports:
- "127.0.0.1:8080:8080"Мы намеренно не публикуем API большой модели на внешнем интерфейсе сервера. Пока он доступен только локально через 127.0.0.1:8080, а другие контейнеры смогут обращаться к нему через общую сеть agent-net.
Никакой необходимости открывать незащищённый API нейросети всему интернету у нас пока нет.
Теперь можно запускать:
sudo docker compose up -dПри первом запуске Docker скачает образ llama.cpp, после чего начнётся загрузка модели с Hugging Face.
Учитывая, что GGUF весит около 15 ГБ, этот процесс может занять некоторое время. Всё зависит от скорости соединения с Hugging Face.
После запуска контейнера не спешите проверять API. Сначала llama.cpp должен скачать веса модели с Hugging Face, а затем загрузить их в память видеокарты.
Посмотрим, что происходит внутри контейнера:
sudo docker compose logs -f --tail=100Здесь могут появиться предупреждения о настройках HTTP-сервера, отсутствии API-ключа и переопределении некоторых переменных окружения. Сами по себе такие сообщения ещё не означают, что при запуске возникла ошибка.
Кстати, в моём случае llama.cpp практически ничего не сообщал о процессе скачивания модели. В логах отображалась только инициализация сервера, хотя загрузка весов уже шла полным ходом.
Поэтому, если у вас возникнет аналогичная ситуация, откройте второй терминал и проверьте состояние контейнера:
sudo docker compose ps
sudo docker stats --no-stream gemma4-llmВ моём случае Docker показывал статус Up (unhealthy), а в статистике уже было видно около 10 ГБ входящего трафика и 15 ГБ операций записи на диск.
Это хороший признак того, что модель действительно скачивается, даже если в логах пока тишина. Сам по себе статус unhealthy в этот момент не обязательно свидетельствует об ошибке: HTTP-сервер ещё не готов обслуживать запросы.
Дополнительно можно проверить размер каталога с кешем:
du -sh ~/jev-agent/llm/cacheЕсли размер постепенно увеличивается, значит, файлы сохраняются на диск. При этом не забывайте, что сетевой трафик и дисковые операции Docker не всегда позволяют точно определить процент загрузки.
Когда скачивание закончится, llama.cpp приступит к загрузке весов и инициализации модели. На этом этапе полезно посмотреть, сколько видеопамяти уже занято:
watch -n 2 nvidia-smiЕсли всё идёт по плану, увидим, что процесс начинает занимать видеопамять RTX 4090.
Теперь проверим готовность HTTP-сервера:
curl -i http://127.0.0.1:8080/healthВ случае успешной инициализации должны получить HTTP 200 и примерно такой ответ:
{
"status": "ok"
}Если сервер возвращает HTTP 503, модель может ещё загружаться. Если соединение вообще не устанавливается или контейнер постоянно перезапускается, стоит вернуться к логам и искать причину.
Как только получили {"status":"ok"}, можно считать, что модель загружена и сервер готов принимать запросы.
Теперь осталось убедиться, что Gemma действительно отвечает через OpenAI-совместимый API и, что особенно важно для нашего будущего агента, умеет работать с инструментами.
Теперь убедимся, что наша модель действительно умеет отвечать на запросы.
Для начала получим список доступных моделей:
curl -sS http://127.0.0.1:8080/v1/models \
| python3 -m json.toolЗдесь должен появиться наш алиас gemma4.
Для корректного отображения кириллицы при вызове через curl поставим:
sudo apt install -y jqТеперь отправим обычный запрос:
curl -sS http://127.0.0.1:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "gemma4",
"messages": [
{
"role": "user",
"content": "Объясни в двух предложениях, что такое Docker."
}
],
"chat_template_kwargs": {
"enable_thinking": false
},
"temperature": 0.2,
"max_tokens": 256,
"stream": false
}' | jq .Если всё настроено правильно, в поле choices[0].message.content получим ответ нашей локальной Gemma.
Но обычного общения нам недостаточно. Для дальнейшей реализации Agent Harness необходимо убедиться, что модель поддерживает вызовы инструментов.
Здесь сделаем небольшой тест.
Представим, что у нас есть инструмент get_server_status, который возвращает состояние сервера по его идентификатору.
Разумеется, сам инструмент пока не реализован. Сейчас нам важно проверить, что Gemma умеет выбрать функцию и сформировать аргументы вызова.
Отправляем запрос:
curl -sS http://127.0.0.1:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "gemma4",
"messages": [
{
"role": "system",
"content": "Ты системный администратор. Для проверки состояния сервера обязательно вызови доступный инструмент."
},
{
"role": "user",
"content": "Проверь состояние сервера demo-server."
}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_server_status",
"description": "Получить состояние сервера",
"parameters": {
"type": "object",
"properties": {
"server_id": {
"type": "string",
"description": "Идентификатор сервера"
}
},
"required": ["server_id"]
}
}
}
],
"tool_choice": "required",
"chat_template_kwargs": {
"enable_thinking": false
},
"temperature": 0,
"max_tokens": 512,
"stream": false
}' | jq .Теперь посмотрим на ответ.
Нас интересует поле choices[0].message.tool_calls. В нём модель должна вернуть название инструмента get_server_status и аргумент server_id со значением demo-server.
В моём случае всё отработало корректно. Gemma 4 вернула finish_reason: "tool_calls", выбрала нужную функцию и сформировала аргументы в JSON.
Причём скорость оказалась весьма неплохой. На нашей RTX 4090 обработка запроса заняла около 60 мс, а генерация вызова инструмента — ещё 107 мс. Итого примерно 167 мс при скорости генерации 187 токенов в секунду.
Разумеется, никакой реальной проверки сервера сейчас не происходило. Gemma только сформировала вызов функции. Непосредственно выполнять инструменты, обрабатывать результаты и управлять дальнейшими действиями будет наш Python Harness.
Также мы проверили возможность отключения reasoning непосредственно на уровне запроса через chat_template_kwargs с параметром enable_thinking: false. Это позволит управлять режимом работы Gemma в зависимости от сложности задачи, не меняя настройки самого сервера.
Таким образом, наша локальная LLM полностью готова к дальнейшей работе: OpenAI-совместимый API функционирует, Tool Calling поддерживается, а режимом рассуждений можно управлять отдельно для каждого запроса.
Кстати, после запуска я обратил внимание на интересный момент. Наша Gemma 4 занимает около 15 ГБ видеопамяти, а ведь у RTX 4090 её целых 24 ГБ. Получается, почти 9 ГБ остаются свободными.
Почему бы этим не воспользоваться?
Изначально мы выставили размер контекста в 8192 токена. Для обычного общения или небольших запросов этого вполне достаточно, но мы ведь собираемся разрабатывать AI-агента. А в таких системах контекст довольно быстро заполняется историей вызовов инструментов, результатами их выполнения, документацией и прочей информацией.
Поэтому предлагаю попробовать увеличить его до 131072 токенов, то есть до 128K.
Заодно включим Flash Attention для более эффективной работы механизма внимания. А чтобы llama.cpp самостоятельно не уменьшил контекст при нехватке видеопамяти, отключим автоматическую подгонку параметров через --fit off.
Открываем наш Docker Compose:
cd ~/jev-agent/llm
nano compose.yamlВ списке command меняем параметры следующим образом:
# Контекст 128K
- --ctx-size
- "131072"
# Один параллельный запрос
- --parallel
- "1"
# Flash Attention
- --flash-attn
- "on"
# Все слои на GPU
- --n-gpu-layers
- "99"
# Отключаем автоматическую подгонку
- --fit
- "off"Остальные параметры оставляем без изменений. После этого пересоздаём контейнер:Остальные параметры оставляем без изменений. После этого пересоздаём контейнер:
sudo docker compose up -d --force-recreateПовторно скачивать модель не потребуется, поскольку её веса уже находятся в нашем кеше.
Смотрим логи:
sudo docker compose logs -f --tail=100В моём случае инициализация заняла около четырёх с половиной минут. Сначала в логах практически ничего не происходило, но затем модель начала загружаться и выделять память.
В итоге получили следующие сообщения:
load_model: initializing, n_slots = 1,
n_ctx_slot = 131072, kv_unified = 'false'
llama_server: model loaded
llama_server: listening on http://0.0.0.0:8080Отлично! llama.cpp действительно создал слот с контекстом 131072 токенов и успешно загрузил Gemma 4.
При этом мы обошлись без дополнительной квантизации KV-кеша, оставив его в стандартной конфигурации.
После перезапуска ещё раз проверяем готовность API:
curl -sS http://127.0.0.1:8080/health | jq .И заодно посмотрим на потребление видеопамяти:
nvidia-smiЕсли модель успешно загрузилась, а API возвращает {"status":"ok"}, можно повторить предыдущие тесты генерации и Tool Calling.
На этом с большой языковой моделью пока закончим. У нас есть локальная Gemma 4 с OpenAI-совместимым API, поддержкой вызова инструментов, возможностью управлять reasoning на уровне запроса и увеличенным до 128K контекстом.
Теперь перейдём ко второй части нашей архитектуры - Laya. Её будем запускать уже на CPU, не занимая видеопамять RTX 4090.
В следующем разделе поднимем нативный сервер Laya, познакомимся с её API и попробуем получить первые структурированные решения. А затем уже начнём объединять обе модели в собственном Agent Harness.
Поднимаем Laya на CPU
Как я уже говорил ранее, Laya достаточно компактная. В отличие от Gemma 4, для её работы необязательно использовать видеокарту. Поэтому, в рамках данной статьи, запустим модель на процессоре сервера.
Напомню, что у нас установлен AMD Ryzen 9 5950X с 16 физическими ядрами и 64 ГБ оперативной памяти. Для такой небольшой модели ресурсов хватит с запасом.
Кстати, у Laya уже есть собственный HTTP-сервер laya-serve, совместимый с API оригинального JEV. Он предоставляет эндпоинт /v1/systemone и позволяет получать структурированные решения через обычные HTTP-запросы.
Поэтому писать дополнительную обёртку на FastAPI для самой модели не придётся. Мы воспользуемся готовой реализацией разработчиков.
На предыдущих этапах мы уже создали директорию ~/jev-agent/laya. Переходим в неё:
cd ~/jev-agent/layaДля запуска будем использовать исходники официального репозитория Laya. Там уже есть Dockerfile, который устанавливает необходимые зависимости, включая PyTorch, и подготавливает окружение для работы модели.
Скачаем проект в отдельную директорию source:
git clone --depth 1 \
https://github.com/NandhaKishorM/laya.git sourceПочему не используем готовый Docker-образ от сторонних разработчиков? Мне хочется сохранить максимально прозрачную сборку, основанную на официальном проекте. Заодно будем понимать, какие именно зависимости устанавливаются внутри контейнера.
При этом редактировать исходники Laya нам не потребуется. Будем использовать только её стандартный HTTP-сервер.
Как и в случае с Gemma 4, подготовим собственный compose.yaml.
nano compose.yamlДобавляем следующую конфигурацию:
services:
laya:
build:
context: ./source
args:
TORCH_INDEX: cpu
TORCH_VERSION: "2.14.0"
container_name: laya-cpu
restart: unless-stopped
command: ["laya-serve"]
ports:
- "127.0.0.1:8000:8000"
environment:
LAYA_HOST: "0.0.0.0"
LAYA_PORT: "8000"
# Запускаем только на процессоре
LAYA_DEVICE: cpu
# Количество потоков
LAYA_THREADS: "8"
OMP_NUM_THREADS: "8"
# Загружаем модель при старте
LAYA_PRELOAD: "1"
# Пока только мультиязычный чекпоинт
LAYA_MODELS: multilingual
LAYA_DEFAULT_MODEL: multilingual
# Кеш Hugging Face
HF_HOME: /home/laya/.cache/huggingface
volumes:
- laya-cache:/home/laya/.cache/huggingface
networks:
- agent-net
init: true
healthcheck:
test:
- CMD
- python
- -c
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=4)"
interval: 30s
timeout: 5s
retries: 3
start_period: 10m
volumes:
laya-cache:
networks:
agent-net:
external: trueЗдесь есть несколько моментов, на которых стоит остановиться.
TORCH_INDEX: cpu — принципиально важный параметр. Мы устанавливаем CPU-сборку PyTorch, без CUDA-зависимостей. Нам совершенно не нужно занимать дополнительную видеопамять, которая уже используется Gemma 4.
LAYA_DEVICE: cpu — явно указываем устройство для выполнения инференса.
LAYA_THREADS: 8 — ограничиваем количество потоков PyTorch. У нашего Ryzen 5950X достаточно ядер, но это не означает, что модель обязательно будет работать быстрее при использовании всех 32 логических потоков. Начнём с восьми, а затем попробуем подобрать оптимальное значение по результатам измерений.
LAYA_PRELOAD: 1 — модель будет загружаться при запуске контейнера, а не при первом обращении к API.
LAYA_MODELS: multilingual — пока загружаем только мультиязычную версию Laya на 322 миллиона параметров. Она поддерживает русский язык, что пригодится для дальнейших экспериментов.
У проекта есть и другие чекпоинты, включая специализированную модель laya-typed-decisions. К ней можно вернуться, если базовая мультиязычная версия окажется недостаточно точной под вашу задачу.
Отдельно обратите внимание на тома Docker. Веса модели будут сохраняться в laya-cache, поэтому после пересоздания контейнера повторно скачивать их не потребуется.
Также мы подключаем Laya к уже созданной сети agent-net. Таким образом, наше будущее FastAPI-приложение сможет обращаться к обеим моделям:
- http://llm:8080/v1/chat/completions — Gemma 4.
- http://laya:8000/v1/systemone — Laya.
При этом наружу API Laya не публикуем: порт 8000 доступен только через localhost сервера.
Сначала проверим конфигурацию Docker Compose:
sudo docker compose config -qЕсли ошибок нет, запускаем сборку:Если ошибок нет, запускаем сборку:
sudo docker compose up -d --buildПри первом запуске Docker соберёт образ, установит CPU-версию PyTorch и остальные зависимости проекта. После этого Laya скачает мультиязычный чекпоинт с Hugging Face.
Загрузка может занять несколько минут, поэтому посмотрим логи:
sudo docker compose logs -f --tail=100Параллельно можно открыть второй терминал и проверить состояние контейнера:
sudo docker compose ps
sudo docker stats --no-stream laya-cpuЕсли всё прошло успешно, контейнер должен запуститься, а модель — загрузиться в оперативную память.
Причём видеокарта нам здесь вообще не нужна. Можем дополнительно выполнить:
nvidia-smiИ убедиться, что появление Laya не увеличило потребление видеопамяти RTX 4090.
Теперь попробуем обратиться к нативному HTTP-серверу Laya.
Для начала проверим состояние:
curl -sS http://127.0.0.1:8000/health | jq .При успешном запуске сервер должен вернуть status: "ok".
Также в ответе можно увидеть информацию о загруженных чекпоинтах и используемом устройстве. Нас интересует, чтобы модель действительно работала на CPU.
Но здесь есть важный нюанс. Успешный ответ /health подтверждает готовность HTTP-сервера и состояние загрузки модели, но ещё не означает, что инференс работает корректно.
Поэтому перейдём к настоящему запросу.
Ну что, с развёртыванием Laya разобрались. Теперь предлагаю посмотреть, как она работает на практике.
Причём возьмём не совсем абстрактный пример. В дальнейшем мы будем разрабатывать собственного AI-агента, который сможет автоматически обрабатывать входящий поток новостей, отбирать интересные публикации, готовить на их основе материалы и самостоятельно размещать их в Telegram-канале.
Представим, что у нас есть технический блог, посвящённый программированию, нейросетям и другим IT-направлениям. Естественно, публиковать подряд всё, что приходит из различных источников, мы не хотим.
Допустим, на вход поступают новости о технологиях, спорте, политике, финансах и прочих событиях. Обрабатывать каждое сообщение большой LLM было бы не самым рациональным решением, особенно если источников много.
Поэтому сначала попробуем определить тематику входящей информации с помощью Laya, а уже затем решать, стоит ли передавать её большой языковой модели.
Для начала ограничимся четырьмя категориями: технологии, бизнес, спорт и мировые события. В дальнейшем этот список можно будет расширить, но сейчас нам интересен сам принцип работы.
Возьмём следующую новость:
Группа разработчиков представила новый открытый движок базы данных, оптимизированный для векторного поиска и приложений с искусственным интеллектом. Благодаря новой архитектуре удалось существенно повысить скорость выполнения запросов и снизить потребление оперативной памяти.
Для нас очевидно, что речь идёт о технологиях. Посмотрим, что на этот счёт думает Laya.
Отправляем запрос к нашему локальному серверу:
curl -sS http://127.0.0.1:8000/v1/systemone \
-H "Content-Type: application/json" \
-d '{
"model": "multilingual",
"state": {
"article": "Группа разработчиков представила новый открытый движок базы данных, оптимизированный для векторного поиска и приложений с искусственным интеллектом. Благодаря новой архитектуре удалось существенно повысить скорость выполнения запросов и снизить потребление оперативной памяти."
},
"questions": {
"category": {
"type": "choice",
"instructions": "Определи основную тематику этой новости.",
"criteria": {
"мир": "Международная политика, правительства, дипломатия и мировые события",
"спорт": "Спортивные соревнования, спортсмены и спортивные события",
"бизнес": "Компании, финансы, рынки, прибыль и экономические события",
"технологии": "Программное обеспечение, искусственный интеллект, научные исследования и вычислительные технологии"
}
}
}
}' | jq .Обратите внимание на структуру запроса.
В поле state передаём исходную информацию, в нашем случае текст новости. Внутри questions описываем, какие именно решения хотим получить.
Здесь используется тип choice, то есть выбор одного варианта из заранее определённого списка.
Причём нам не нужно составлять огромный системный промпт, объяснять модели формат JSON или просить её не добавлять лишний текст. Достаточно передать описание задачи и доступные варианты.
Теперь посмотрим на результат.
Вот какой ответ я получил на нашем сервере:
{
"model": "laya-rl-agent",
"answers": {
"category": {
"type": "choice",
"choice": "технологии",
"probabilities": {
"мир": 0.0002,
"спорт": 0.0001,
"бизнес": 0.0017,
"технологии": 0.998
},
"confidence": 0.9887,
"answer_confidence": 0.998,
"action": {
"act_probability": 1.0
},
"x_jev_confidence": 0.9973
}
},
"usage": {
"input_tokens": 141,
"output_tokens": 0,
"state_tokens": 56,
"state_tokens_dropped": 0,
"truncated": false,
"truncated_questions": []
},
"routing": {
"model": "multilingual",
"repo": "convaiinnovations/laya/multilingual",
"reason": "explicit model='multilingual'",
"detection": null,
"workflow": null
}
}Отлично, Laya правильно определила категорию новости. Причём с достаточно высокой вероятностью.
Давайте теперь подробнее разберём ответ, поскольку именно с такими структурами нам предстоит работать при разработке собственного агента.
Начнём с объекта answers.
{
"category": {
"type": "choice",
"choice": "технологии",
"probabilities": {
"мир": 0.0002,
"спорт": 0.0001,
"бизнес": 0.0017,
"технологии": 0.998
},
"confidence": 0.9887,
"answer_confidence": 0.998,
"action": {
"act_probability": 1.0
},
"x_jev_confidence": 0.9973
}
}Первое, что нас интересует, — поле choice. Именно здесь находится выбранное моделью решение.
В нашем случае это технологии.
Но самое интересное скрывается в probabilities. Здесь Laya возвращает распределение вероятностей по всем предложенным вариантам:
- Мир — 0,02%.
- Спорт — 0,01%.
- Бизнес — 0,17%.
- Технологии — 99,8%.
Получается, модель не просто выбрала категорию, а оценила все четыре варианта.
И вот это уже довольно удобно с точки зрения обычной программной логики. Мы можем самостоятельно определять, при каких значениях доверять выбранной категории, а при каких отправлять материал на дополнительную проверку.
Например, если вероятность категории «технологии» составляет 99,8%, решение выглядит достаточно однозначным.
А если модель распределила вероятности между технологиями и бизнесом примерно поровну? Здесь уже есть повод подключить большую LLM для дополнительного анализа.
Кстати, такие случаи я тоже проверял. Например, при классификации новости об ограничениях экспорта AI-чипов Laya распределила вероятности между технологиями (52,47%) и бизнесом (44,9%).
Вполне ожидаемая ситуация, поскольку материал действительно находится на пересечении нескольких направлений.
В нашем ответе есть сразу несколько показателей уверенности, и здесь важно не запутаться.
answer_confidence — вероятность выбранного варианта. В нашем случае это 0.998, что полностью соответствует значению категории «технологии» в probabilities.
А вот confidence — немного другой показатель. Для типа choice он рассчитывается на основе нормализованной энтропии распределения вероятностей.
Если говорить проще, он показывает, насколько определённо модель предпочитает один из предложенных вариантов.
Когда практически вся вероятность сосредоточена в одной категории, значение получается высоким. Если несколько вариантов имеют похожие вероятности, показатель снижается.
В нашем случае:
{
"confidence": 0.9887,
"answer_confidence": 0.998
}Модель не только выбрала технологии, но и достаточно однозначно отделила их от остальных категорий.
Есть ещё x_jev_confidence — дополнительный показатель, рассчитываемый по схеме, совместимой с форматом JEV.
Для нашей первой реализации использовать сразу все показатели необязательно. Нам вполне хватит выбранной категории и распределения вероятностей.
Однако здесь важно понимать одну вещь: 99,8% — это оценка самой модели, а не гарантия того, что в 998 случаях из 1000 она будет принимать правильное решение. Реальную точность и калибровку вероятностей необходимо проверять на размеченных данных.
Теперь посмотрим на блок usage:
{
"input_tokens": 141,
"output_tokens": 0,
"state_tokens": 56,
"state_tokens_dropped": 0,
"truncated": false,
"truncated_questions": []
}И вот здесь мы снова возвращаемся к главному отличию Laya от привычных генеративных моделей.
На вход мы передали 141 токен, включая текст новости, инструкции и описания категорий.
При этом output_tokens равен нулю.
Хотя, казалось бы, на выходе получили довольно большой JSON.
Дело в том, что Laya не генерирует этот JSON последовательно, как это сделала бы обычная авторегрессионная LLM. Модель вычисляет оценки, а сервер формирует из них структурированный ответ.
Сами вычисления, разумеется, никуда не исчезают. Но нам не приходится ждать, пока модель последовательно сгенерирует названия полей, категории и числа.
state_tokens показывает размер сериализованного входного состояния — в данном случае 56 токенов.
А поля state_tokens_dropped: 0 и truncated: false сообщают, что входные данные не пришлось обрезать при обработке.
Остался ещё один блок:
{
"routing": {
"model": "multilingual",
"repo": "convaiinnovations/laya/multilingual",
"reason": "explicit model='multilingual'",
"detection": null,
"workflow": null
}
}Здесь сервер сообщает, какой именно чекпоинт использовался.
В нашем случае это мультиязычная версия Laya на 322 миллиона параметров. Напомню, что всю обработку выполняет обычный CPU нашего Ryzen 9 5950X, без использования видеокарты.
Теперь вернёмся к нашему AI-агенту.
Представим, что на вход поступают сотни публикаций из различных источников: Telegram-каналов, RSS-лент, новостных сайтов или просто через API.
Отправлять весь этот поток большой LLM особого смысла нет. Особенно если наш блог посвящён технологиям, а среди материалов встречаются спортивные новости, политика и прочие темы.
Поэтому первичную классификацию поручим Laya. Она определит тематику публикации, а наш Python-код на основании полученных вероятностей решит, что делать дальше.
Явно посторонние новости отсеем сразу, без обращения к большой модели. А технологические материалы и неоднозначные случаи передадим Gemma 4 для дальнейшего анализа.
Здесь уже начинается полноценная агентная работа. Gemma получит доступ к инструментам, проверит наличие похожих публикаций, подготовит самостоятельную статью и опубликует её в нашем блоге на Ghost.
Всем процессом будет управлять собственный Python Harness: от обработки результатов Laya до выполнения инструментов и публикации готового материала.
В итоге получим систему, в которой небольшая модель занимается первичным отбором информации, а большая LLM подключается только там, где действительно нужна её работа.
Собственно, именно такую архитектуру мы и реализуем в следующем разделе. Причём не ограничимся обычным API — добавим небольшой веб-интерфейс, через который сможем наблюдать за всем процессом работы агента.
Приступаем к практической части. Поднимаем блог для нашего AI-агента
С нейросетями разобрались, теперь займёмся подготовкой площадки, на которой наш агент будет публиковать готовые материалы.
Для этого нам понадобится собственный блог с поддержкой API, чтобы агент мог не только создавать статьи, но и самостоятельно размещать их на сайте.
Причём писать собственную CMS и заниматься разработкой отдельного фронтенда мы, разумеется, не будем. Воспользуемся готовым open-source решением.
Для демонстрации я также предусмотрел полностью автоматический режим публикации. То есть после классификации новости и обработки через Gemma 4 готовый материал будет появляться в блоге без участия человека.
За проверку дубликатов, ограничения и выполнение необходимых действий будет отвечать наш Python Harness.
Ну а теперь давайте подготовим саму площадку для публикаций.
Чтобы не отвлекаться на разработку отдельного фронтенда, предлагаю воспользоваться готовым open-source движком Ghost.
По сути, это полноценная CMS для ведения блогов и публикации материалов. У неё есть готовый веб-интерфейс, редактор, система тегов, управление публикациями и, самое главное для нас, Admin API.
Через этот API можно создавать статьи, изменять их, загружать изображения и публиковать материалы программным способом.
Другими словами, нашему агенту не придётся имитировать действия человека в браузере. Достаточно будет отправить HTTP-запрос с подготовленным текстом и необходимыми параметрами публикации.
Ghost можно развернуть через Docker Compose. Для работы в production-режиме ему также понадобится MySQL.
На этом пока и остановимся: полноценный блог с собственной базой данных и доступом по HTTPS.
Для нашего проекта понадобятся два поддомена третьего уровня.
Например, если у вас есть домен example.com, можно использовать:
- blog.example.ru — непосредственно для Ghost и публикации статей.
- api.example.ru — для нашего будущего FastAPI-приложения, через которое будем передавать новости агенту.
Разумеется, example.com здесь используется исключительно для примера. Во всех последующих командах необходимо подставить собственное доменное имяВ
Теперь открываем панель управления DNS у своего регистратора и создаём две A-записи:
Тип
Имя
Значение
A
blog
Публичный IPv4 сервера
A
api
Публичный IPv4 сервера
В моем случае получилось так:
Таким образом, оба поддомена будут указывать на одну машину. А уже Nginx в дальнейшем определит, куда направлять запрос в зависимости от доменного имени.
Для блога запросы отправим в Ghost, а для API — в наше FastAPI‑приложение.
После создания записей проверяем, что DNS обновился:
dig +short A blog.example.com
dig +short A api.example.comЕсли команда dig отсутствует:
sudo apt install -y dnsutilsВ ответе должен появиться публичный IPv4-адрес нашего сервера.
Учитывайте, что распространение DNS-записей может занять некоторое время. Также не создавайте AAAA-запись, если IPv6 на сервере не настроен.
Пока оба домена будут указывать на один сервер, но полноценно работать будет только блог. К FastAPI вернёмся в следующем разделе.
Вернёмся к нашему проекту.
На предыдущих этапах мы уже создали директории для Gemma 4 и Laya. Теперь добавим ещё одну — для блога:
mkdir -p ~/jev-agent/blog
cd ~/jev-agent/blogСначала создадим файл с переменными окружения:
nano .envДобавим следующие настройки:
BLOG_DOMAIN=blog.example.com
MYSQL_ROOT_PASSWORD=YOUR_STRONG_ROOT_PASSWORD
MYSQL_PASSWORD=YOUR_STRONG_GHOST_PASSWORDЗдесь необходимо заменить домен на собственный, а вместо значений паролей указать две разные случайные строки.
Например, их можно сгенерировать следующей командой:
openssl rand -hex 24Обратите внимание: пароль пользователя Ghost и пароль администратора MySQL должны отличаться.
Ограничим доступ к файлу:Обратите внимание: пароль пользователя Ghost и пароль администратора MySQL должны отличаться.
Ограничим доступ к файлу:
chmod 600 .envИ сразу добавим его в .gitignore, чтобы случайно не отправить секреты в публичный репозиторий:
echo ".env" >> .gitignoreТеперь создаём Docker Compose:
nano compose.yamlДобавляем конфигурацию:
services:
db:
image: mysql:8.0
container_name: ghost-db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ghost
MYSQL_USER: ghost
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- ghost-db-data:/var/lib/mysql
healthcheck:
test:
- CMD-SHELL
- "mysql -h 127.0.0.1 -u\"$$MYSQL_USER\" -p\"$$MYSQL_PASSWORD\" -e 'SELECT 1' >/dev/null 2>&1"
interval: 10s
timeout: 5s
retries: 15
start_period: 40s
ghost:
image: ghost:6-alpine
container_name: ghost-blog
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "127.0.0.1:2368:2368"
environment:
NODE_ENV: production
url: https://${BLOG_DOMAIN}
database__client: mysql
database__connection__host: db
database__connection__user: ghost
database__connection__password: ${MYSQL_PASSWORD}
database__connection__database: ghost
volumes:
- ghost-content:/var/lib/ghost/content
volumes:
ghost-db-data:
ghost-content:Разберём несколько основных моментов.
Для Ghost используем официальный Docker-образ шестой версии. База данных — MySQL 8, которую также запускаем в отдельном контейнере.
В environment передаём параметры подключения Ghost к базе. Обратите внимание на двойные подчёркивания в названиях переменных - именно так Ghost позволяет задавать вложенные настройки через переменные окружения.
Для хранения данных используем два отдельных Docker volume.
В ghost-db-data будут находиться данные MySQL, включая статьи и настройки. А ghost-content будет хранить загруженные изображения, темы оформления и другие файлы самого Ghost.
Это важно: при пересоздании контейнеров мы не хотим терять опубликованные материалы.
Ещё обратите внимание на параметр:
ports:
- "127.0.0.1:2368:2368"Мы намеренно не публикуем Ghost напрямую в интернет. Его HTTP-сервер будет доступен только локально на порту 2368.
А за внешний доступ, HTTPS и обработку доменного имени будет отвечать Nginx.
Проверяем конфигурацию:
sudo docker compose config -qЕсли всё нормально, запускаем:
sudo docker compose up -dПри первом запуске Docker скачает образы Ghost и MySQL, подготовит базу данных и запустит приложение.
Посмотрим состояние контейнеров:
sudo docker compose psИ логи:
sudo docker compose logs -f --tail=100При первой инициализации Ghost может потребоваться некоторое время на подготовку базы данных.
Когда контейнеры запущены, проверим локальный HTTP-сервер:
curl -I \
-H "X-Forwarded-Proto: https" \
http://127.0.0.1:2368Здесь нас интересует успешный HTTP-ответ. Возможны перенаправления, особенно пока сайт не прошёл первоначальную настройку.
Обратите внимание, что мы передаём заголовок X-Forwarded-Proto: https. Дело в том, что в настройках Ghost уже указан публичный адрес с HTTPS, а сам контейнер пока работает через обычный HTTP.
Этот заголовок сообщает приложению, что внешний запрос будет приходить через защищённое соединение.
Если контейнер отвечает, можно переходить к настройке Nginx.
Для начала проверим, установлен ли Nginx:
nginx -vЕсли команда отсутствует, устанавливаем:
sudo apt update
sudo apt install -y nginxПеред запуском стоит убедиться, что порты 80 и 443 не заняты другим веб-сервером:
sudo ss -lntp | grep -E ':(80|443)[[:space:]]'Если это чистый сервер и конфликтов нет, запускаем Nginx:
sudo systemctl enable --now nginxТеперь создадим отдельную конфигурацию для нашего блога.
sudo nano /etc/nginx/sites-available/blog.example.comНе забудьте заменить имя файла и домен на собственные.
Содержимое:
server {
listen 80;
server_name blog.example.com;
client_max_body_size 50m;
location / {
proxy_pass http://127.0.0.1:2368;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
}Здесь всё довольно просто.
Nginx принимает запросы к blog.example.com и перенаправляет их на локальный порт 2368, где работает Ghost.
Также передаём необходимые HTTP-заголовки, чтобы приложение правильно определяло исходный домен, IP-адрес клиента и используемый протокол.
Особенно важен X-Forwarded-Proto. После настройки HTTPS он позволит Ghost корректно обрабатывать защищённые запросы, несмотря на то что внутри сервера соединение между Nginx и контейнером остаётся обычным HTTP.
Теперь активируем конфигурацию:
sudo ln -s \
/etc/nginx/sites-available/blog.example.com \
/etc/nginx/sites-enabled/blog.example.comПроверяем синтаксис:
sudo nginx -tЕсли ошибок нет, перезагружаем конфигурацию:
sudo systemctl reload nginxНа этом Nginx уже должен направлять запросы к нашему блогу. Однако HTTPS мы ещё не настроили, поэтому открывать сайт в браузере пока рановато.
Для полноценной работы Ghost нам понадобится SSL-сертификат.
Используем бесплатный сертификат Let's Encrypt и утилиту Certbot. Она самостоятельно получит сертификат и внесёт необходимые изменения в конфигурацию Nginx.
На Ubuntu 24.04 можно установить Certbot через Snap:
sudo apt install -y snapd
sudo snap install certbot --classicПеред выпуском сертификата убедитесь, что A-запись действительно указывает на сервер и входящие подключения к портам 80 и 443 разрешены.
Если используется UFW, сначала посмотрите его состояние:
sudo ufw statusЕсли UFW активен, разрешите HTTP и HTTPS:
sudo ufw allow 'Nginx Full'Также проверьте сетевые правила в панели хостинг-провайдера, если они используются.
Теперь получаем сертификат:
sudo /snap/bin/certbot --nginx \
-d blog.example.com \
--redirectCertbot проверит владение доменом, выпустит сертификат и настроит перенаправление с HTTP на HTTPS.
После успешного выполнения перезагружаем Nginx:
sudo nginx -t
sudo systemctl reload nginxНа всякий случай проверим механизм автоматического продления сертификатов:
sudo /snap/bin/certbot renew --dry-runЕсли тест проходит без ошибок, можно считать HTTPS настроенным.
Ну что, теперь давайте посмотрим, что у нас получилось.
Открываем браузер и переходим по адресу:
https://blog.example.com
Если всё настроено правильно, увидим стартовую страницу Ghost.
Также проверить доступность сайта можно непосредственно из терминала:
curl ‑I https://blog.example.com
А теперь перейдём в административную панель:
https://blog.example.com/ghost/
При первом посещении Ghost предложит выполнить первоначальную настройку блога: указать название, создать учётную запись администратора и задать пароль.
После этого получаем полноценную систему управления публикациями.
Предлагаю сразу создать одну тестовую статью вручную и убедиться, что она открывается по публичной ссылке. Заодно проверим, корректно ли работают оформление текста, изображения и теги.
Кстати, пока мы не настроили SMTP, некоторые почтовые функции Ghost, включая приглашения и восстановление доступа, могут быть недоступны. Для первоначальной проверки самого блога это не мешает, но для полноценной эксплуатации почту позднее потребуется настроить.
И давайте сразу создадим токен, который позволит управлять блогом через API. Для этого заходим в админку и там кликаем на шестеренку настроек:
В настройках выбираем интеграции:
Далее кликаем на добавление кастомной интеграции:
Даем имя ключу:
Там нас будет интересовать API-ключ:
Сохраняем.
На этом с площадкой для публикаций пока закончим.
У нас есть собственный блог на Ghost, база данных MySQL, публичный домен и HTTPS. Причём всё это работает на том же сервере, где уже запущены Gemma 4 и Laya.
В дальнейшем нам останется подключить к Ghost собственный Python-инструмент, который сможет создавать и публиковать статьи через Admin API ключ.
Но сначала нужно написать само FastAPI-приложение и Agent Harness, который будет связывать наши две нейросети в единую систему.
Подготавливаем проект к локальной разработке
Ну что, с инфраструктурой разобрались. У нас уже работают обе модели, поднят собственный блог, а значит, можно наконец приступать к написанию самого AI-агента.
Кстати, чтобы не заниматься разработкой непосредственно на сервере, я решил немного изменить организацию проекта.
Все Docker Compose конфигурации и исходники буду собирать в одном публичном GitHub-репозитории:
Habr-Jev-Agent
Само FastAPI-приложение удобнее разрабатывать локально, а уже готовый результат отправлять на сервер.
Для этого я также временно пробросил обе нейросети через Nginx на отдельные поддомены с HTTPS и ограничением доступа. Таким образом, локальное приложение может обращаться к Laya и Gemma через HTTP API, практически так же, как если бы модели работали на моём компьютере.
После завершения разработки эти временные адреса нам больше не понадобятся, поэтому их отключим. В рабочем окружении все сервисы будут взаимодействовать непосредственно через внутреннюю сеть Docker.
Заодно такой подход позволит не перегружать статью повторяющимися инструкциями по созданию файлов. Все конфигурации и полный исходный код проекта будут доступны в репозитории.
Собираем собственного AI-агента на Python
Обе модели запущены, классификация через Laya работает, Ghost готов принимать публикации. Теперь объединим эти компоненты в одну систему.
Основная идея эксперимента — подключать большую LLM только там, где нужны её возможности. Laya выполняет первичную классификацию новостей и определяет маршрут обработки. Gemma получает материалы, которые могут быть интересны технологическому блогу, анализирует их и готовит статьи.
Управлять работой Gemma будет собственный Agent Harness на Python. Он отвечает за цикл вызовов модели, выполнение инструментов, сохранение состояния задачи и ограничения по числу шагов и времени работы.
Приложение построим на FastAPI, а для демонстрации добавим панель управления на Jinja2. Слева отправляем исходную новость, справа наблюдаем весь путь материала: решение Laya, действия Gemma, вызовы инструментов и результат. Готовый черновик можно просмотреть и отправить в Ghost отдельной кнопкой.
Полный исходный код приложения, Docker Compose, настройки и инструкции по запуску доступны в репозитории проекта на GitHub. Далее разберём основные архитектурные решения и их реализацию.
Принимаем новость и отправляем её в Laya
Приложение построено на FastAPI. На вход поступает обычный HTTP-запрос с заголовком, текстом новости, источником и ссылкой на оригинальную публикацию.
Для начала покажу, как выглядит наше API. FastAPI автоматически генерирует Swagger UI, поэтому отправлять новости на обработку можно прямо из браузера, даже без отдельного клиента.
Но работать через Swagger UI не всегда удобно, особенно когда хочется не просто отправить запрос, а проследить за всем процессом его выполнения.
Поэтому я предусмотрел отдельный веб-интерфейс приложения. Через него можно отправлять новости на обработку, наблюдать за результатами классификации Laya, отслеживать этапы работы Gemma 4 и вызовы инструментов, а также просматривать подготовленные статьи и управлять их публикацией.
Причём всё это в одном окне, без необходимости вручную отправлять HTTP-запросы или изучать логи контейнеров.
Вот так выглядит главная страница приложения:
Первым делом сохраняем материал в SQLite и проверяем, не обрабатывали ли мы его раньше.
Если находим точное совпадение по содержимому, ссылке или идентификатору источника, возвращаем информацию о существующей записи. Повторно запускать нейросети в таком случае нет никакого смысла.
Если материал новый, передаём его в Laya.
Всё работает по уже знакомому нам принципу: формируем состояние, передаём доступные категории и получаем структурированное решение с распределением вероятностей.
Например, я отправил на обработку новость о результатах соревнований сварщиков.
Laya определила категорию «Спорт» с вероятностью 99,3%. Поскольку наш блог посвящён технологиям, дальнейшая обработка на этом закончилась. Gemma для этого материала даже не потребовалась.
Собственно, именно такого поведения мы и добивались: незачем тратить вычислительные ресурсы большой языковой модели на материалы, которые явно не соответствуют тематике блога.
Но давайте перейдём к более интересному примеру.
Теперь отправим материал о JEV — той самой модели, из-за которой мы вообще затеяли эту статью.
В этот раз Laya классифицировала публикацию как технологическую с вероятностью 99,9%, после чего FastAPI поставил задачу на дальнейшую обработку.
Здесь уже начинает работать Gemma 4.
Причём в отличие от Laya, которая просто выбирала категорию, Gemma получает полноценный набор инструментов и собственный агентный цикл.
В нашем случае реализованы четыре инструмента:
- get_source_context — получить исходную информацию о новости.
- find_similar_news — найти похожие материалы, которые мы уже обрабатывали.
- save_article — сохранить подготовленную статью.
- publish_article — выполнить необходимые проверки и опубликовать результат в Ghost.
Взаимодействие с моделью построено через OpenAI-совместимый API llama.cpp и обычный Python-клиент AsyncOpenAI.
На каждом шаге Harness передаёт Gemma историю задачи и описание разрешённых инструментов. Модель выбирает дальнейшее действие, наш Python-код проверяет аргументы и выполняет соответствующую функцию, после чего результат возвращается обратно модели.
Цикл повторяется до завершения работы или достижения установленных ограничений.
И вот здесь, кстати, хорошо видно отличие агентного цикла от обычного workflow.
Мы не прописываем заранее жёсткую последовательность всех вызовов. Gemma самостоятельно определяет, какие инструменты ей необходимы на конкретном этапе.
При этом свобода действий модели ограничена нашим приложением. Она не может выполнить произвольную команду в терминале, запустить Python-код или обратиться к неизвестному API.
Для наглядности записал короткую видеодемонстрацию работы приложения. На видео показан весь процесс: от отправки новости и её классификации через Laya до подготовки статьи агентом, публикации в Ghost и просмотра готового материала в блоге.
В результате Gemma подготовила полноценную статью: придумала заголовок, сформировала краткое описание, разбила материал на разделы и добавила тематические теги.
Причём для наблюдения за всем процессом я сделал отдельный веб-интерфейс на Jinja2. Здесь можно отправить новость, посмотреть результат классификации, проследить за работой агента и открыть подготовленную публикацию.
Таким образом, у нас получился не просто набор API-эндпоинтов, а вполне удобное приложение, через которое можно управлять всей системой.
Отдельно реализовал предпросмотр готовой статьи, чтобы можно было оценить результат до публикации.
Кстати, здесь есть ещё один важный момент. В рамках нашей демонстрации Gemma работает прежде всего с переданным исходным материалом. У неё нет полноценного доступа к внешнему поиску и независимым источникам проверки фактов.
Поэтому полностью доверять достоверность публикаций такому агенту пока не стоит. Он может сопоставлять подготовленный текст с исходными данными и отказываться от публикации при недостатке информации, но полноценный фактчекинг потребует дополнительных инструментов.
Для нашей демонстрации этого достаточно. А вот для реальной автоматизированной редакции я бы обязательно добавил независимую проверку источников.
Для удобства реализовал два режима работы.
В первом агент обрабатывает новость и сохраняет результат как черновик. Это удобно для тестирования: можно проверить работу модели, посмотреть получившийся текст и только потом отправить его на публикацию.
Во втором режиме статья автоматически отправляется в Ghost после прохождения предусмотренных проверок.
Кроме того, в интерфейсе появилась кнопка «Опубликовать черновик». Она позволяет передать уже подготовленный материал на повторную проверку агентом и затем разместить его в блоге.
Для демонстрации я воспользовался именно этим вариантом.
После публикации приложение получило от Ghost идентификатор статьи и публичную ссылку, которую можно сразу открыть в браузере.
А вот и сам результат.
Получается, мы прошли весь путь: от входящей новости через классификацию Laya и агентный цикл Gemma до готовой статьи на собственном сайте.
И всё это на одной машине с RTX 4090, без облачных LLM API и сложных агентных фреймворков.
Ещё несколько моментов, которые хотелось бы отметить.
Для хранения новостей, результатов классификации, подготовленных статей и состояния задач я использовал SQLite с SQLAlchemy.
А чтобы агент не зависел от времени выполнения HTTP-запроса, добавил отдельный фоновый worker.
Он забирает задачи из базы, запускает агентный цикл и сохраняет результаты выполнения. Если процесс завершился с ошибкой или сервер перезапустился, незавершённую задачу можно восстановить.
Также предусмотрены ограничения на количество агентных шагов, число попыток выполнения, частоту публикаций и повторную отправку уже опубликованных материалов.
Особенно важно последнее: если Ghost создал статью, но наше приложение по какой-то причине не получило ответ, мы не должны слепо отправлять её повторно. Поэтому каждой публикации назначается стабильный идентификатор, по которому можно проверить её наличие в блоге.
В итоге получился небольшой, но вполне самостоятельный сервис с API, веб-интерфейсом, базой данных, фоновыми задачами и собственным Agent Harness.
Все исходники находятся в директории app. Там же есть подробный README с инструкциями для локального запуска и развёртывания через Docker Compose.
Ну а теперь, когда вся система работает, вернёмся к вопросу, с которого мы начинали статью: действительно ли отдельная модель принятия решений даёт нам преимущество перед использованием одной большой LLM?
Подводим итоги
Ну что, друзья, на этом будем заканчивать. Изначально я планировал просто разобраться с JEV и его открытыми аналогами, но в итоге эксперимент немного вышел из-под контроля. Вместо нескольких тестовых запросов у нас получился полноценный AI-агент с веб-интерфейсом, базой данных, собственным Harness и автоматической публикацией статей.
В рамках статьи мы подняли две совершенно разные нейросети: Laya Multilingual на CPU и Gemma 4 26B A4B на RTX 4090. Первая занимается классификацией входящих новостей, вторая - анализом информации, работой с инструментами и подготовкой публикаций.
Причём всё это работает на собственном сервере, без облачных LLM API, LangGraph, CrewAI и прочих агентных фреймворков. Обычный Python, немного инфраструктуры и понятная логика взаимодействия компонентов.
На мой взгляд, это и есть главный результат эксперимента. Необязательно заставлять одну большую LLM выполнять абсолютно всю работу. Часть задач можно передать небольшим специализированным моделям, а генеративную нейросеть подключать там, где действительно необходимы её возможности.
Сам проект получился достаточно универсальным. Вместо новостного блога можно реализовать обработку документов, корпоративного контента, обращений пользователей или совершенно другие сценарии.
Все исходники, Docker Compose и инструкции по развёртыванию доступны на GitHub:
https://github.com/Yakvenalex/Habr-Jev-Agent
Отдельное спасибо моим партнёрам из HOSTKEY за предоставленный сервер. Благодаря этому удалось провести полноценный практический эксперимент и поделиться не только теорией, но и готовым работающим проектом.
Ну и по традиции приглашаю в свой Telegram-канал «Лёгкий путь в Python». Там у нас уже собралось большое сообщество людей, интересующихся Python, нейросетями и современными технологиями. Всё бесплатно, делюсь интересными находками, собственными разработками и различными экспериментами, которые далеко не всегда доходят до отдельных статей на Хабре.
На этом всё. Надеюсь, материал оказался полезным. Спасибо за внимание и до встречи в следующих статьях!
Выделенные и виртуальные серверы в Европе, США и России
Готовые серверы + предустановленное программное обеспечение, а также индивидуальные конфигурации серверов.
Посмотреть
Комментарии
Обсуждайте статью, отвечайте и оценивайте комментарии. Вход — через ваш Яндекс ID.
Загружаем обсуждение…