Глава 2 Встраиваем LLM модель в Telegram бота

В первой главе мы создали интеллект и дали ему «лицо» в виде Shiny-приложения. Но настоящий ассистент аналитика должен быть доступен в один клик прямо в смартфоне.

В этой главе мы переместим наш AI-движок в Telegram. Главный вызов здесь — многопользовательская среда. Мы разберем, как сделать так, чтобы бот не путал контекст диалогов разных людей и помнил историю переписки даже после перезагрузки сервера. Мы превратим Telegram из простого мессенджера в полноценную точку доступа к вашему кастомному ИИ.

Если вы не знакомы с принципами разработки telegram ботов на языке R то можете пройти курс “Разработка telegram ботов на языке R”.

2.1 Видео

2.1.1 Тайм коды

  • 00:00 — О чём это видео
  • 00:46 — Генерация API ключа
  • 02:20 — Введение в пакет ellmer
  • 03:20 — Создаём объект чата
  • 06:44 — Извлечение структурированных данных из текста
  • 10:35 — Классификация текста с помощью LLM моделей
  • 13:53 — Интеграция LLM моделей со сторонними API
  • 18:33 — Как интегрировать LLM модель в Telegram-бота
  • 23:27 — Как сохранять состояние чатов (Session Handling)
  • 26:24 — Как дообучить бота своими данными (System Prompt)
  • 28:58 — Заключение

2.2 Презентация

2.3 Конспект

2.3.1 Интеграция LLM модели в бот

Давайте разберёмся с тем, как добавить весь описанный в первой главе функционал в Telegram бота. Основная сложность с которой вы можете столкнуться это то, как хранить одновременно отдельные объекты chat для каждого отдельного чата в Telegram. Т.к. если вы просто создадите один объект чата, и в него будут прилетать сообщения из разных telegram чатов, и разных пользователей, то контекст чата будет запутан, и соответвенно модель в чате не сможет осмысленно связать все входящие сообщения, и качество ответов будет оставлять лучшего. Поэтому одним из вариантов хранения информация о разных чатах является создание списка, в котором в виде отдельных элементов будут хранится разные объекты чата для разных telegram чатов, название каждого элемента списка будет соответствовать идентификатору чата в telegram.

Простейший пример кода, для интеграции LLM модели в telegram бот выглядит так:

library(telegram.bot)
library(ellmer)

# Создаём глобальную переменную для хранения сессий
# в которую будут добавляться новые чаты
sessions <- list()

# Handler для команды /start
start_handler <- function(bot, update) {
  
  chat_id <- update$message$chat$id
  
  # Создаём новый чат-объект для пользователя с уникальным чатом
  chat <- chat_google_gemini(
    system_prompt = "
        Ты специалист по разработке кода и анализу данных на языке R, 
        в этом чате помогаешь с разработкой кода на R. Твои ответы должны занимать не более 3000 символов.
    ",
    api_args = list(
      generation_config = list(
        max_output_tokens = 1500
      )
    )
  )
  
  # Сохраняем чат-объект в глобальной переменной sessions
  sessions[[as.character(chat_id)]] <<- chat
  
  bot$sendMessage(chat_id = chat_id, text = "Здравствуйте, чем могу вам помочь?")
  
}

# Handler для текстовых сообщений
message_handler <- function(bot, update) {
  
  chat_id <- update$message$chat$id
  
  # Получаем чат-объект для пользователя
  chat <- sessions[[as.character(chat_id)]]

  # Если чат не найден то просим выполнить команду start для его запуска
  if (is.null(chat)) {
    bot$sendMessage(chat_id = chat_id, text = "Используйте /start для начала AI чата.")
    return(NULL)
  }
  
  # текст запроса
  user_message <- update$message$text
  
  # отправляем запрос пользователя в LLM
  response <- chat$chat(user_message, echo = FALSE)
  
  # отправляем в чат полученный от LLM ответ
  bot$sendMessage(
    chat_id = chat_id, 
    text = response,
    parse_mode = 'markdown'
  )

}

# Инициализируем бот и добавляем обработчики
updater <- Updater(bot_token('TEST'))

# Обработчики
h_start <- CommandHandler("start", start_handler)
h_msgs  <- MessageHandler(message_handler, MessageFilters$text)

updater <- updater + h_start + h_msgs

# Запускаем бота
updater$start_polling()

Безопасность прежде всего: Обратите внимание на функцию bot_token(). Никогда не вставляйте токен бота строкой прямо в код. Самый надежный способ — прописать его в файле .Renviron под именем R_TELEGRAM_BOT_TEST. Подробнее о том, почему это важно и как это настроить, смотрите в уроке “Работа с секретными данными на языке R”.

Что делает этот код и как он устроен: В этом примере мы создаём Telegram-бота, который использует LLM-модель через пакет ellmer и умеет вести отдельные диалоги с каждым пользователем, сохраняя контекст общения.

  1. Инициализация переменной sessions: В начале мы создаём глобальный список sessions, куда будут добавляться объекты чата chat_google_gemini для каждого Telegram-пользователя. Ключом в этом списке будет ID Telegram-чата (chat_id). Это позволяет хранить независимый LLM-контекст для каждого пользователя.

  2. Обработчик команды /start: Когда пользователь впервые запускает бота командой /start, срабатывает start_handler. В нём:

  • Получается chat_id пользователя.
  • Создаётся новый объект chat_google_gemini с заданным system_prompt. В prompt задаётся роль модели: помощник по разработке на языке R.
  • Так же мы ограничиваем максимальную длинну ответа от LLM передав через api_args параметр max_output_tokens, поскольку в telegram есть лимит на длинну сообщения в 4096 символов. api_args - аргумент, который позволяет передавать в запросе различные параметры, такие как температура, максимальная длинна ответа и так далее, набор этих параметров у каждой модели свой.
  • Этот объект сохраняется в список sessions, где ключом служит chat_id.
  • Пользователю отправляется приветственное сообщение.
  1. Обработчик текстовых сообщений: Этот обработчик вызывается каждый раз, когда пользователь пишет что-то в чат. Внутри:
  • Получаем chat_id и по нему — соответствующий объект из sessions.
  • Если объект чата не найден (например, пользователь не вызвал /start), то бот просит сначала это сделать.
  • Если всё ок — берём текст сообщения пользователя и отправляем его в LLM через chat$chat().
  • Полученный ответ от модели возвращается пользователю в Telegram.
  1. Запуск бота: Далее создаётся объект Updater с токеном бота, к нему добавляются два обработчика: для команды /start и для всех обычных сообщений. Затем вызывается start_polling(), чтобы бот начал слушать входящие сообщения.

В чём суть логики? Главное в этом подходе — поддержка сессий для каждого пользователя. Благодаря списку sessions мы можем вести независимые диалоги с разными пользователями одновременно. Каждый chat_gemini живёт в своей “ячейке” и помнит контекст диалога. Это особенно важно для того, чтобы LLM не путала запросы между разными пользователями, и могла давать максимально точные и уместные ответы.

Обратите внимание, что в системном промпте я отдельно указал “Твои ответы должны занимать не более 3000 символов.”, это сделано для того, что полученный от модели ответ помещался в одно сообщение telegram, которое на данный момент имеет лимит в 4096 символов.

Приведённый выше бот будет хранить все чаты в рамках одной своей сессии, после перезапуска все чаты будут удалены из его памяти. Если вам нужен бот, который будет хранить информацию о всех чатах между сессиями то объекты чата надо хранить в виде локальных rds файлов. Для реализации надо:

  1. Написать функции для сохранения и записи объектов чатов в RDS файлы
  2. Доработать функции start_handler() и message_handler() так, что бы они читали и сохраняли объекты чата в отдельные RDS файлы.

Сохраним код функций для работы с RDS файлами в отдельный session_func.R файл.

save_chat <- function(chat_id, chat_object) {
  file_path <- file.path("chat_sessions", paste0(chat_id, ".rds"))
  saveRDS(chat_object, file_path)
}

load_chat <- function(chat_id) {
  file_path <- file.path("chat_sessions", paste0(chat_id, ".rds"))
  if (file.exists(file_path)) {
    return(readRDS(file_path))
  } else {
    return(NULL)
  }
}

И доработаем код бота:

library(telegram.bot)
library(ellmer)

# Загрузка функций чтения объектов чата
source('session_func.R')

# Handler для команды /start
start_handler <- function(bot, update) {
  
  chat_id <- update$message$chat$id
  
  # Проверяем был ли ранее создан чат
  chat <- load_chat(chat_id)
  # Создаём новый чат-объект для пользователя с уникальным чатом
  if (is.null(chat)) {
    chat <- chat_gemini(
      system_prompt = paste(readLines(here::here('system_prompt.md')), collapse = "\n"),
      api_args = list(
        generation_config = list(
          max_output_tokens = 1500
        )
      )
    )
    
    # сохраняем объект чата
    save_chat(chat_id, chat)
    
  }
  
  bot$sendMessage(chat_id = chat_id, text = "Здравствуйте, чем могу вам помочь?")
  
}

# Handler для текстовых сообщений
message_handler <- function(bot, update) {
  
  chat_id <- update$message$chat$id
  
  # Получаем чат-объект для пользователя
  chat <- load_chat(chat_id)
  
  # Если чат не найден то просим выполнить команду start для его запуска
  if (is.null(chat)) {
    bot$sendMessage(chat_id = chat_id, text = "Используйте /start для начала AI чата.")
    return(NULL)
  }
  
  # текст запроса
  user_message <- update$message$text
  
  # отправляем запрос пользователя в LLM
  response <- chat$chat(user_message, echo = FALSE)
  
  # сохраняем объект чата
  save_chat(chat_id, chat)
  
  # отправляем в чат полученный от LLM ответ
  bot$sendMessage(
    chat_id = chat_id, 
    text = response,
    parse_mode = 'markdown'
  )
  
}

# Инициализируем бот и добавляем обработчики
updater <- Updater(bot_token('TEST'))

# Обработчики
h_start <- CommandHandler("start", start_handler)
h_msgs  <- MessageHandler(message_handler, MessageFilters$text)

updater <- updater + h_start + h_msgs

# Запускаем бота
updater$start_polling()

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

Основные изменения:

  1. Загрузка и сохранение сессий: Вместо хранения объектов chat_gemini в оперативной памяти (в списке sessions) теперь используется файловая система. Добавлены функции load_chat() и save_chat(), которые читают и записывают объекты чата в .rds-файлы (по одному на каждый chat_id). Эти функции подключаются из внешнего файла session_func.R.

  2. Обновлён start_handler(): Теперь при вызове /start бот сначала проверяет, есть ли сохранённый .rds-файл сессии для данного пользователя. Если есть — загружает его. Если нет — создаёт новый чат-объект и сохраняет его.

  3. Обновлён message_handler(): Здесь всё то же, что и раньше, но теперь после каждого запроса дополнительно сохраняется обновлённый объект чата обратно в .rds. Это важно, чтобы вся история общения сохранялась между сессиями.

Таким образом, теперь бот умеет “помнить”, о чём вы говорили с ним ранее, даже если он был выключен или перезапущен — очень полезно для долгосрочного взаимодействия с пользователями.

2.3.2 Конвертация ответа от LLM в Telegram MarkdownV2

Описанный выше код будет вполне корректно работать при простых ответах от LLM. Но есть одна проблема, с которой сталкиваются почти все разработчики, которые пытаются переслать полученный от LLM ответ сразу в telegram - Bad Request: can't parse entities. Дело в том, что все LLM отдают свой ответ в разметке GitHub-flavored Markdown (GFM), а telegram требуеют свою Markdown разметку, причём довольно строго относится к ошибкам в ней. У Telegram строгие правила экранирования множества символов и немного иной набор допустимых конструкций.

Один из вариантов решения “в лоб” - просто поменять разметку в parse_mode на HTML, она обычно не возвращает ошибку Bad Request: can't parse entities, но и само сообщение полученное в Telegram будет выглядеть сырым, с элементами разметки, смотреться это будет откровенно говоря не презентабельно.

Писать собственный конвертер GFM -> Telegram MarkdownV2 задача достаточно непростая, но можно использовать готовое решение, правда написанное на Python - модуль telegramify-markdown.

Для начала установите пакет reticulate, который позволяет выполнять python код внутри R. Далее, если у вас не установлен Python запустите процесс установки:

install.packages('reticulate')

# Импортировать модуль
library(reticulate)

# установка python
install_miniconda()

# Создать окружение и установить пакет
virtualenv_create("r-telegram-env")
use_virtualenv("r-telegram-env")
py_install("telegramify-markdown")

Далее мы можем написать R обёртку:

# Импортировать модуль
telegramify <- import("telegramify_markdown")

# Функция на R
markdown_to_telegram <- function(markdown_text, 
                                 max_line_length = NULL,
                                 normalize_whitespace = FALSE) {
  converted <- telegramify$markdownify(
    markdown_text,
    max_line_length = max_line_length,
    normalize_whitespace = normalize_whitespace
  )
  return(converted)
}

И использовать её для конвертации полученного от LLM ответа в корректный Telegram MarkdownV2 внутри нашего message_handler:

# отправляем запрос пользователя в LLM
response <- chat$chat(user_message, echo = FALSE)

# конвертируем в Telegram MarkdownV2
tg_response <- markdown_to_telegram(response)
  
# отправляем в чат полученный от LLM ответ
bot$sendMessage(
  chat_id = chat_id, 
  text = tg_response,
  parse_mode = 'MarkdownV2'
)

Функция markdown_to_telegram() делает следующее: 1. Парсит Markdown в AST (Abstract Syntax Tree) с помощью библиотеки mistune 2. Рендерит каждый элемент отдельно: заголовки, ссылки, списки, таблицы, код 3. Сохраняет контекст форматирования: отслеживает, находимся ли мы внутри жирного текста, курсива и т.д. 4. Правильно обрабатывает вложенности: например, bold italic bold bold 5. Экранирует только нужные символы: внутри кода не экранирует, внутри ссылок экранирует по-особому 6. Обрабатывает Latex: распознает формулы \(...\) и \[...\] 7. Работает с таблицами: конвертирует их в читаемый формат 8. Обрабатывает цитаты: правильно форматирует > блоки 9. Тестирована на тысячах реальных случаев

Т.е. является полноценным, стабильно работающим, и постоянно обновляющимся конвертером полученных от LLM ответов в корректный Telegram MarkdownV2.

2.4 Заключение

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

Однако пока наш бот заперт внутри своих знаний и того, что мы передали ему в промпте. Он не может заглянуть в вашу базу данных или прочитать свежую Google-таблицу. В следующей главе мы сотрем эти границы. Мы разберем протокол MCP (Model Context Protocol) и научим бота выходить во “внешний мир”: выполнять ваши функции на R, ходить по API и работать с реальными файлами на сервере. Время дать вашему ассистенту настоящие руки!

2.5 Вопросы для самопроверки

  1. Почему нельзя использовать один глобальный объект chat для всех пользователей Telegram-бота? Контексты сообщений от разных людей перемешаются в одной сессии. Модель будет пытаться связать вопрос Пользователя Б с ответом, который она только что дала Пользователю А, что приведет к путанице и потере логики диалога.
  2. Какую роль играет ID чата (chat_id) в архитектуре хранения сессий? Он служит уникальным ключом. По этому идентификатору бот понимает, какой именно объект чата (из списка в памяти или из файла RDS) нужно подтянуть для текущего собеседника.
  3. Зачем в системном промпте бота рекомендуется ограничивать длину ответа (например, до 3000 символов)? Это техническая подстраховка. Лимит одного сообщения в Telegram — 4096 символов. Если модель сгенерирует слишком длинный текст (например, большой кусок кода с пояснениями), бот выдаст ошибку при попытке отправить такое сообщение.
  4. В чем преимущество хранения объектов чата в RDS-файлах по сравнению со списком в оперативной памяти? Это обеспечивает «персистентность» (постоянство) данных. Если сервер или скрипт перезагрузится, данные в оперативной памяти (список sessions) сотрутся, а файлы на диске останутся. Бот сможет продолжить диалог с того же места.
  5. Какая функция в пакете ellmer отвечает за отправку сообщения и получение ответа? Метод $chat() (например, chat$chat(user_message)). Он отправляет текст провайдеру LLM и возвращает строку с ответом.
  6. Зачем ограничивать длину ответа и как это сделать надежнее всего? Лимит сообщения в Telegram — 4096 символов. Надежнее всего использовать комбинацию: просить модель быть краткой в system_prompt и выставлять жесткий лимит через api_args = list(max_output_tokens = 1500).