Хабр, привет! Сегодня мы попробуем сделать свой ИИ с телеграм ботом для возможности простого общения с ней. Сразу оговорюсь, мы не будем в очередной раз использовать открытый API ChatGPT или новомодного Deepseek. Мы развернем свой полноценный ИИ локально и сынтегрируем его с телеграм ботом.

LLM модель

Первое, что нам нужно сделать – это запустить LLM локально. Задача не такая простая как кажется – современные LLM модели имеют нетривиальные алгоритмы взаимодействия с видеокартой, токенизации текста и т.д. Процесс функционаирования уже обученной LLM называется инференсом. Основные характеристики инференса – это точность и скорость. Подробно останавливаться на них мы не буем, важно понимать саму концепцию. Для реализации такого инференса на чистой java была разработана библиотека Jlama – максимально простой движок инференса для LLM, написанной на голой java без тяжеловесных фрэймворков. В этом и заключается главное отличие Jlama от имеющихся альтернатив.

По факту Jlama дает возможность обслуживать LLM в java окружении напрямую, то есть в той же jvm, где работает наше приложение. В таком подходе есть ряд плюсов: прежде всего это возможность разработчика гибко влиять на функциональность своего приложения с LLM. У Jlama есть одна особенность – для оптимизации движок использует Vector API, а это значит, что какое-то время придется включать preview фичи.

Список доступных моделей перечислен в документации к проекту:

  • Gemma & Gemma 2 Models

  • Llama & Llama2 & Llama3 Models

  • Mistral & Mixtral Models

  • Qwen2 Models

  • IBM Granite Models

  • GPT-2 Models

  • BERT Models

  • BPE Tokenizers

  • WordPiece Tokenizers

Как видите некоторые модели порядком устарели. Я выберу Llama 3 – неплохо обученная модель от Meta. Пока что модель будет воспринимать только английский язык.

Добавляем в проект необходимые зависимости:

implementation("com.github.tjake:jlama-core:$jlamaVersion")
implementation("com.github.tjake:jlama-native:$jlamaVersion:linux-x86_64")

Есть три доступные версии jlama-native: linux-x86_64, macos-x86_64/aarch_64 и windows-x86_64. Выбирайте свою версию в соответствии с вашей ОС, я запускал на ubuntu, поэтому версия linux-x86_64.

Так библиотека очень проста, весь код умещается в одном небольшом классе ArtificialIntelligenceModel:

@Component
public class ArtificialIntelligenceModel {
    @Value("${llm.model-name}")
    private String modelName;

    @Value("${llm.working-directory}")
    private String workingDirectory;

    @Value("${llm.temperature}")
    private String temperature;

    @Value("${llm.ntokens}")
    private String ntokens;

    public String ask(String prompt) throws IOException {
        File localModelPath = new Downloader(workingDirectory, modelName).huggingFaceModel();

        try (AbstractModel model = ModelSupport.loadModel(localModelPath, DType.F32, DType.I8);) {
            PromptContext ctx;
            if (model.promptSupport().isPresent()) {
                ctx = model.promptSupport()
                        .get()
                        .builder()
                        .addSystemMessage("You are a helpful chat bot who writes short responses.")
                        .addUserMessage(prompt)
                        .build();
            } else {
                ctx = PromptContext.of(prompt);
            }
            Generator.Response response = model.generate(UUID.randomUUID(), ctx,
                    Float.parseFloat(temperature),
                    Integer.parseInt(ntokens), (s, f) -> {
                    }
            );
            return response.responseText;
        }
    }
}

Это вся наша модель, довольно компактно. В application.yaml указываем переменную llm.model-name, у нас это будет tjake/Llama-3.2-1B-Instruct-JQ4. Эта модель будет скачана с помощью объекта Downloader и сохранена в директорию llm.working-directory:

File localModelPath = new Downloader(workingDirectory, modelName).huggingFaceModel();

Скачанная модель будет загружена в переменную model:

AbstractModel model = ModelSupport.loadModel(localModelPath, DType.F32, DType.I8);

Теперь нам нужно создать наш промт и отправить его модели. Для этого нам нужно сгенерировать PromptContext с определенными параметрами. Помимо самого текста мы должны указать температуру и количество токенов.

Теперь нам нужно создать наш промт и отправить его модели. Для этого нам нужно сгенерировать PromptContext с определенными параметрами. Помимо самого текста мы должны указать температуру и количество токенов.

Температура — это числовое значение (часто устанавливаемое между 0 и 1, но иногда и выше), которое регулирует распределение вероятностей следующего слова. Другими словами – температура влияет на креативность текста: чем меньше значение, тем меньше будет и креативность полученного результата. В качестве примера выставим значение температуры в 0.

Остался последний важный параметр – это количество токенов. Токен — это набор текстовых символов. LLM разбивают текст не на слова, а на токены. Слишком большие значения ntokens локально лучше не выставлять, чтобы сильно не грузить наши вычислительные ресурсы. Поставим 256.

В итоге получаем PromptContext:

PromptContext ctx;
if (model.promptSupport().isPresent()) {
     ctx = model.promptSupport()
                        .get()
                        .builder()
                        .addSystemMessage("You are a helpful chat bot who writes short responses.")
                        .addUserMessage(prompt)
                        .build();
   } else {
                ctx = PromptContext.of(prompt);
   }

И получение ответа от модели:

Generator.Response response = model.generate(UUID.randomUUID(), ctx,
                    Float.parseFloat(temperature),
                    Integer.parseInt(ntokens), (s, f) -> {
   }

Итоговый результат доступен в поле responseText.

Телеграм бот

Для начала создадим бота с помощью всем известного BotFather.

Тут все достаточно просто, вызываем /newbot и вводим нужное нам имя нашего бота. В моем примере это FranticticticChatBot. После создания бота не забываем скопировать API ключ (Use this token to access the HTTP API).

Сам бот также очень простой:

@Slf4j
@Component
@RequiredArgsConstructor
public class AiChatBot implements SpringLongPollingBot, LongPollingSingleThreadUpdateConsumer {
    private static final String START = "/start";

    @Value("${bot.token}")
    private String token;

    private TelegramClient telegramClient;

    private final ArtificialIntelligenceModel model;

    @PostConstruct
    private void init() {
        telegramClient = new OkHttpTelegramClient(getBotToken());
    }

    @Override
    public String getBotToken() {
        return this.token;
    }

    @Override
    public LongPollingUpdateConsumer getUpdatesConsumer() {
        return this;
    }

    @Override
    public void consume(Update update) {
        if (update.hasMessage() && update.getMessage().hasText()) {
            long chatId = update.getMessage().getChatId();
            var text = update.getMessage().getText();

            if(!text.equals(START)) {
                try {
                    var answer = model.ask(text);

                    SendMessage message = SendMessage.builder()
                            .chatId(chatId)
                            .text(answer)
                            .build();

                    telegramClient.execute(message);
                } catch (TelegramApiException | IOException e) {
                    log.error("Error {}", e.getMessage());
                }
            }
        }
    }

    @AfterBotRegistration
    public void afterRegistration(BotSession botSession) {
        log.info("Registered bot running state is: " + botSession.isRunning());
    }
}

Нам потребуются две зависимости:

implementation("org.telegram:telegrambots-springboot-longpolling-starter:$telegramVersion")
implementation("org.telegram:telegrambots-client:$telegramVersion")

В bot.token прописываем полученный при создании бота токен. В качестве клиента будем использовать OkHttpTelegramClient. В методы consume первое, что надо сделать – это получить текстовое сообщение из чата если оно есть:

var text = update.getMessage().getText();

Для получения ответа от модели достаточно вызвать метод ask с полученным из чата текстом:

var answer = model.ask(text);

Ответ от нашего ИИ заворачиваем в объект SendMessage и возвращаем обратно в чат:

telegramClient.execute(message);

Тестируем

Проверяем работу нашего бота с ИИ. Например, отправим ему вопрос: «What is java?». Получаем результат:

Ответ получим небыстро, так как модель требует определённого времени на работу своих алгоритмов. Кроме того, не стоит забывать, что качество ответа будет зависеть как от самой модели, так и от ее параметров – в основном это температура и количество токенов. Если попробовать поиграться с этими значениями, то можно получить разные результаты.

Пример кода доступен тут. В ближайшее время я попробую развернуть бота в Yandex Cloud.

 Подписывайтесь на мой телеграм-канал. Буду рад любым вашим комментариям

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


  1. sshmakov
    29.01.2025 21:44

    Я правильно понимаю, что Java был выбран только потому, что он вам более знаком? Потому как запуск Llama на Python делается примерно в 20 строк кода, причем эти строки обычно уже написаны в readme к модели.


    1. franticticktick Автор
      29.01.2025 21:44

      Я правильно понимаю, что Java был выбран только потому, что он вам более знаком?

      Java был выбран, потому что есть определенный запрос, а информации по Java реализации очень мало.

      Потому как запуск Llama на Python делается примерно в 20 строк кода, причем эти строки обычно уже написаны в readme к модели.

      Функция ask в ArtificialIntelligenceModel ровно 22 строки кода, а это и есть весь запуск инференса. Вообще в мире ai много строк кода не бывает о чем много раз говорил Андрей Карпатый.


      1. sshmakov
        29.01.2025 21:44

        Спасибо, понятно. Стоило отметить это в статье, кмк.


  1. Dmitry2019
    29.01.2025 21:44

    Наконец-то можно и на православной Jave ИИ пользоваться. Ждём Vector API.


    1. franticticktick Автор
      29.01.2025 21:44

      Пока что запускайте с ключом: --add-modules jdk.incubator.vector --enable-preview. Vector API скорее всего появится только в 25 Java, а это сентябрь этого года, не раньше.


  1. Shael
    29.01.2025 21:44

    Идея крутая, но не хватает деталей по производительности и требованиям к железу. Локальный ИИ – это не всегда практично, особенно если железо слабое. Но если модель потянет, то можно сэкономить на API и убрать зависимость от OpenAI/DeepSeek. Вопрос только в качестве ответов у 1B-модели – по-хорошему, надо тестить что-то побольше, например, Llama 3-8B


    1. franticticktick Автор
      29.01.2025 21:44

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

      Пока что трудно дать какие-либо детали. Но могу сразу сказать - не все так плохо. Да, модель может есть очень много ресурсов, но это связано с прикладными задачами. Для локальных целей можно выставить адекватные параметры контекстного окна и температуры, например, при большой температуре ИИ будет сильно "креативить", что в свою очередь повлияет на ответ и, как следствие, на утилизацию ресурсов. Это кстати все неправда, что есть якобы "креативно" обученные модели, как пишут некоторые "эксперты". Все зависит от температуры, которую вы укажите в промте. Ну и количество токенов, разумеется, тут сильно не разгуляешься, но более менее оптимальный рзамер подобрать можно, чтобы сильно не проседать по ресурсам.

      В общем, когда дойду до деплоя в яндекс облако, тогда уже понятно будет.

      Вопрос только в качестве ответов у 1B-модели – по-хорошему, надо тестить что-то побольше, например, Llama 3-8B

      Я планирую в следующей итерации добавить меню выбора доступных моделей. Пока еще руки не дошли, но как будет готово сделаю еще одну статью.