Забавная история про то, как я решил разбавить актив в своем чате для друзей, добавив самого настоящего Т-800, который мог бы свободно общаться с участниками, не потратив копейки денег на API - чисто бесплатные модели с OpenRouter. Впрочем, впоследствий была добавлена и локальная модель.

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

И знаете что? Именно ради такого я в айти в свое время и пришел. В каком-то смысле, этой статьей и связанным с ним проектом я подвожу итоги моих 6 лет в программирований, которые начались летом 2020 года, когда я написал свою первую программу на Python. В заключений вы поймете, почему у меня именно такие вайбы от этого проекта.

А тем временем мы начинаем.

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

Проект (как и любые пет-проекты на данный момент) я писал через Claude Code.

Саму статью я планировал на 10 минут хронометража, но к сожалению у меня даже после редактуры и многочисленных правок оказался на руках талмуд, прошу отнестись с пониманием.

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

Исходники проекта будут приведены в конце (Github/Codeberg).

Оглавление

  1. Что по итогу получилось?

  2. Первые шаги - выбираем модель на OpenRouter

  3. Добавляем команды для бота

  4. Добавляем терминатору зрение

  5. Переезжаем на мой ноутбук

  6. Добавляем детектор меметической опасности

  7. Веб-поиск, новости и исследования

  8. Читаем книги и комиксы, а так же делаем досье на участников

  9. Боремся с самоцензурой у модели

  10. Заключение

  11. Исходники

1. Что по итогу получилось?

Телеграм-бот для моего чата на 15 человек, который умел

  • отвечать на сообщения,

  • подкалывать участников, учитывая контекст

  • вкидывать провокативные утверждения

  • читать картинки, аудио, гифки и видео

  • Детектор-классификатор, который не допускал до бота задачи, которые вызваны "перегрузить" языковую модель абсурдными и противоречащими условиями.

  • имел долгосрочную и краткосрочную память по чату, реализованную через RAG-систему,

  • Пересказывать содержание книг и комиксов/манги.

  • Выполнять веб-поиск по выбранной теме

  • Читать актуальные новости и комментировать в своей манере

  • А так же выполнять небольшое исследование и работу с источниками по теме, которую предложил юзер.

2. Первые шаги - выбираем модель на OpenRouter

Перед тем как бот "оживет", ему нужно было выбрать языковую модель, я уже знал что на OpenRouter есть пул довольно качественных бесплатных моделей, так что сразу полез туда. Нужно было выбрать не просто самую мощную модель, а ту которая имела более-менее адекватные rate limits и стабильное соединение, после некоторого перебора выбал пал на модель Nemotron-3-Super-120b от Nvidia.

3. Добавляем команды для бота

Для начала было добавлено небольшое количество команд, ниже привожу слова самого T-800 при отправке команды /help

Доступные директивы:
/tease — вызвать анализ и подколоть случайного участника чата.
/react — среагировать на случайное недавнее сообщение в чате.
/horn — вбросить провокационный тейк для обсуждения.
/help — эта сводка.

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

При отправке команды /tease боту предлагается подколоть случайного участника, опираясь на историю чата (а позднее - и долгосрочную память о юзере), изначально боту в общих чертах предлагалась подколоть юзера N, но это приводило к тому что он постоянно шутил с вероятностями, получалось однообразно.

Чтобы это исправить, был добавлен список тем (или "углов"), по которым бот мог написать подколку, отправляются они в рандомном порядке. Помимо этого убрано дублирование ника в сообщений подколки.

Позднее так же была добавлена память о выбранном юзере, чтобы подколки были более точечными. Бот копит архив сообщений каждого участника (до 300 на человека).

При подколке из архива достаются до 15 недавних реплик жертвы и подмешиваются в промпт со словами: «вот его реальные высказывания в чате, учти по ним характер и мировоззрение». Например, ниже бот подкалывает моего приятеля-тактикульщика.

4. Добавляем терминатору зрение

Хотелось бы добавить боту возможность видеть медиа-контент. Nemotron-3-Super-120b мог принимать на вход только текст, так что надо было поискать на OpenRouter модель с vision-фичей. После некоторого перебора выяснилось, что наиболее стабильная модель из той же линейки -nemotron-nano-12b-v2-vl , на fallback (при отваливаний основной) была поставлена nemotron-3-nano-omni-30b-a3b-reasoning , которое так же умело в чтение изображений.

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

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

Наиболее надежный вариант оказался такой - на визуальной модели оставить только описание картинки, а на основной модели резюмирование и переработка под свой ролевой стиль.

5. Переезжаем на мой ноутбук

Несмотря на то что у NVIDIA довольно адекватные rate-limits (за исключением их последней модели Nemotron-Ultra на ~550 млрд параметров) и относительно стабильное соединение, с описанием картинок постоянно возникала проблема, модель просто отваливалась. Помимо этого, сама схема "одна модель описывает картину -> модель побольше перерабатывает в своем стиле" приводила к тому, что анализа медиа приходилось ждать по несколько минут.

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

Идеальным вариантом мне показалась Gemma-4-12b, которая (как и остальные модели из этой новой линейки) помимо всего прочего обладала мощными агентными способностями, и главное могла воспринимать аудио и изображения (и, соответственно, видео/gif).

Использовать решил свой же ноутбук Maibedden X556 с 16 ГБ RAM и 4 GB VRAM.

Обошлось не без приключений. Из-за корявого импорта проекторов зрение у модели оказалось сломано, вместо собственно картинки она видела пустое пространство.

Как выяснилось, мы импортировали в ollama (тогда мы ее использовали ее) не официальный образ модели с Hugging-Face, неофициальный с точки зрения соглашений Ollama.

Ollama ожидает, что визуальная часть вшита в образ при официальной сборке, а не подключена внешним файлом; у кастомного импорта не совпадают тензорные соглашения, и проектор фактически не подключается — при этом без единой ошибки (issue #9967, если интересно)

Импорт модели из официального реестра полностью решил эту проблему, модель начала видеть картинки. Впрочем, у ollama было несколько критических проблем, которые так просто уже было не решить. Во первых, она не поддерживала аудио-ввод у этой модели (хотя сама модель аудио читает без проблем), и во вторых, не переиспользовала кэш префикса между запросами, что приводило к тому что при каждом запросе промт обрабатывался с нуля, что приводило к более медленным вычислениям.

Что такое prefill и как происходит его кэширование

Работа LLM над каждым ответом делится на две разные фазы.

Prefill (префилл, «обработка входа») — модель прогоняет через себя весь ваш промпт: системную инструкцию, историю диалога, свежее сообщение. Никакого текста при этом не рождается — модель просто «читает» вход и строит по нему внутреннее представление. Считаются все токены параллельно, поэтому фаза упирается в вычислительную мощность.

Decode (декод, «генерация») — модель выдаёт ответ по одному токену за раз, и каждый следующий зависит от предыдущего. Распараллелить нельзя, и на каждый токен приходится заново прочитать из памяти все веса модели. Поэтому фаза упирается в пропускную способность памяти, а не в вычисления.

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

Что такое KV-кэш. Читая промпт, модель для каждого токена вычисляет пару служебных векторов — Key и Value (отсюда «KV»). Именно через них следующие токены «оглядываются» на предыдущие. Эти векторы складываются в KV-кэш и живут там до конца генерации: иначе на каждый новый токен ответа пришлось бы пересчитывать весь контекст с нуля. KV-векторы токена зависят только от него самого и всего, что было левее него. Меняется конец промпта — начало не затрагивается, его KV-векторы остаются валидными.

Отсюда кэширование префикса (prefix caching). У чат-бота промпт от запроса к запросу меняется только в конце: системный промпт с описанием персонажа (сотни токенов) и история диалога остаются теми же, дописывается лишь новая реплика. Значит, если движок сохранил KV-кэш прошлого запроса, он может сравнить новый промпт со старым, найти общий префикс и пересчитать только новый хвост.

Учитывая эти факторы, было принято решение полностью перенести вычисление модели на сервер llama.cpp, все эти проблемы она решала из коробки.

Для примера, вот обработка 30-секундного аудиосообщения от участника чата на русском языке (сообщение не мое, я его скачал и залил заново для тестирования бота), gemma-4-12b справилась отлично.

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

6. Добавляем детектор меметической опасности

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

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

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

Модель (которая тогда еще работала по API) стабильно отьезжала именно при этом промте. Чтобы избежать таких экцессов в будущем, была придумана более хитрая схема - двух-проходная.

На длинных сообщениях (от 200 символов) включается модель классификатор (nemotron-3-nano-30b), у которой стоит одна единственная задача, отбраковать промт или пустить дальше к основной модели, которая уже занимается ролевым отыгрышем.

Как и в случае со зрением - с модели скидывается лишняя "ментальная нагрузка", и то что она выполнит одну конкретную задачу кратно выше, чем если бы она пыталась решать несколько задач одновременно.

Почему языковой модели одну конкретную задачу выполнить проще?
  1. Внимание у модели имеет конечный бюджет. Стоит заметить, что Softmax (функция, которая превращает «сырые» оценки важности токенов в проценты — так, чтобы все доли в сумме давали ровно 100%) обязан распределить всю единицу веса токенов — но модель научилась обходить это, сбрасывая лишнее на служебные токены в начале последовательности (attention sinks; первые токены забирают 30–80% массы, работая свалкой).

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

  3. Но с инструкцией так нельзя: она релевантна по построению, её нельзя «не заметить», не провалив задачу. Значит, все N требований вынуждены делить ограниченный запас осмысленного внимания, и чем длиннее контекст, тем труднее удерживать каждое из них в фокусе.

  4. Помимо внимания есть другая проблема - генерация необратима. Модель пишет ответ по одному токену, и каждый следующий шаг видит только уже зафиксированное прошлое. Пересмотреть или перебалансировать раннее решение она не может — архитектурно. А несколько взаимодействующих ограничений — это задача поиска: выбор, сделанный на пятом слове ради условия A, может сделать условие B невыполнимым на пятидесятом.

Это сработало почти идеально. Бот теперь начал игнорировать подобные задачи в принципе, и вместо ответа кидал сообщение заглушку. Впрочем, LLM (как и люди, с которых они учились думать) это не жестко детерменированные системы, эта схема лишь сводит возможность сбоя к минимуму, но не полностью их убирает.

Когда бот переехал на локальную Gemma-4-12b, эта же модель отвечала за классификацию в другом контексте. На llama-server было введено два KV-кэша (через --parallel 2, он же -np), чтобы контекст и классификатора и ролевого отыгрыша находились в "горячем состояний".

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

7. Веб-поиск, новости и исследования

В какой-то момент были введены эти три команды для связи бота с внешним миром.

/new — свежая новость с комментарием
/search <запрос> — быстрый поиск в интернете
/research <тема> — глубокое исследование со ссылками (или реплаем на спорное сообщение)

Разберем функционал каждой команды

/new - Прикручиваем RSS-ленту

Бот тянет RSS-ленту (по умолчанию Google News, и по языку чата: английскому чату английская лента, русскому русская), парсит её библиотекой feedparser, берёт первые 15 записей и случайную из них отправляет модели. Из записи нужен только заголовок — ни текст статьи, ни ссылка не выкачиваются.

Системный промпт T-800 + последние 16 реплик чата (чтобы вброс не выглядел оторванным от разговора) + затравка выше, потом идет генерация и отправка в чат.

Вручную командой /new или фоновой джобой по таймеру (интервал настраивается через /time, 0 — выключить). По таймеру бот вбрасывает новость сам, без повода.

/search - ищем всякое через DuckDuckGo

Пользователь пишет /search <запрос>, бот отправляет его как есть в DuckDuckGo (библиотека ddgs, без API-ключа) и берёт 5 результатов. Регион выдачи — по языку чата.

Сами страницы при этом не скачиваются. Модель увидит только то, что вернула сама поисковая выдача: заголовок + краткое описание каждого результата. Никаких переходов по ссылкам, никакого парсинга HTML. Отсюда и быстрая скорость.

Результаты склеиваются в нумерованный блок (1. заголовок — описание) и подставляются в затравку для бота:

В конце происходит генерация через обычный персона-конвейер (системный промпт + последние 16 реплик чата + затравка), и ответ в чат.

/research - глубокое исследование для споров в чате

Родилась из бытовой сцены: в чате разгорелся спор, и хочется сказать «бот, разберись».

Вызывается двумя способами:

  • /research <тема> — тема прямо в команде;

  • реплаем на спорное сообщение — тогда темой становится его текст. Это и есть основной сценарий: кто-то выкатил спорный тезис — ты отвечаешь ему командой, и бот идёт проверять.

Работа конвейера

Тема уходит в поиск не одним запросом, а четырьмя:

  • сама тема, как есть;

  • тема + «аргументы за и против»;

  • тема + «факты, статистика, исследования»;

  • тема + «критика, опровержение, мифы».

Смысл: у спорной темы поисковая выдача предвзята к формулировке. Спросишь «электромобили экологичнее?» — получишь статьи сторонников. Четыре угла принудительно вытаскивают обе стороны, а не то, что лучше индексируется.

Результаты дедуплицируются по URL и ранжируются по тому, сколько углов вытащили один и тот же источник — если статья всплыла и в «за и против», и в «фактах», она, вероятно, содержательная.

Берём топ-6 источников и выкачиваем их (параллельно, через httpx). Из HTML вытаскиваем текст: выкидываем разметочный мусор и берём абзацы длиннее 200 знаков.

Отбор материала происходит без LLM. Отбор идёт двумя ступенями:

  • Лексический префильтр — грубо отсекаем по пересечению слов с темой, оставляем ~28 кандидатов;

  • Кросс-энкодер оценивает каждую пару «тема + абзац»: насколько этот текст относится к делу. Берём 10 лучших.

Плюс блок на доминирование: не больше половины контекста с одного сайта. Иначе один болтливый источник заглушит остальные пять, и «исследование» превратится в пересказ одной статьи.

LLM зовётся ровно один раз, в самом конце. Абзацы пронумерованы по источникам, и системный промпт требует жёсткого формата:

  1. вердикт в одну-две фразы;

  2. ключевые факты и аргументы сторон — на каждый факт ссылка вида [n];

  3. итог: чья позиция подтверждается данными.

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

Ответ стримится в чат правкой сообщения — генерация занимает минуты, и смотреть на растущий текст лучше, чем на «печатает…». Снизу автоматически дописывается список URL — тех источников, чьи абзацы реально попали в контекст.

LLM вызывается один раз. Никаких агентных циклов. Модно делать deep research агентно: модель сама придумывает запросы, читает, решает, что искать дальше, итерирует. На нашем железе это невозможно: при скорости генерации ~5 токенов в секунду каждый «подумай и реши» — это минуты. Агент с десятью шагами думал бы час.

Поэтому всю «умную» работу в середине делают дешёвые детерминированные инструменты: фиксированные темы вместо сгенерированных запросов, лексический фильтр и кросс-энкодер вместо «модель, выбери релевантное». Модель включается только на синтезе.

И вот что интересно: результат получается недурный. Четыре угла для спорной темы дают ровно тот разброс, который придумал бы агент, — только бесплатно. Реранкер отбирает абзацы не хуже, чем это сделала бы LLM, — и в разы быстрее.

Впрочем, на неоднозначных темах и при работе с мусорными источниками это будет работать не так хорошо, как ни крути deep-research здесь довольно поверхностный.

8. Читаем книги и комиксы, а так же делаем досье на участников

Показалось интересным проверить, как такая "карликовая" модель как Gemma-4-12b (12 миллиардов для LLM это очень малые числа) справится с потреблением и анализом огромных массивов информации. Было интересно проверить чтение книг и комиксов, но позднее так же будет добавлена память всего чата целиком с досье на каждого участника. Обошлось не без проблем.

Читаем Масодова

Что такое RAG

RAG (Retrieval-Augmented Generation — «генерация с подмешиванием найденного») — это приём, при котором модель не хранит знание внутри своих весов, а получает нужный кусок в промпте перед тем, как ответить.

RAG меняет постановку задачи: вместо «удержи всё в голове» — «прочитай выбранные фрагменты и ответь по ним».

Что RAG дает помимо экономии

  • Свежесть. Знание живёт вне модели — обновляется без дообучения весов.

  • Верифицируемость. Ответ построен на конкретных фрагментах, поэтому к нему можно приложить ссылку на источник.

  • Предсказуемый отказ. Если поиск ничего релевантного не нашёл — лучше сказать «таких данных нет», чем сочинить. Причём это измеримо: у реранкера есть оценка уверенности, и ниже порога языковую модель можно не вызывать в принципе.

  • Управление доступом к знанию. Раз знание проходит через фильтруемый индекс, на него можно накладывать условия: по времени, по автору, по позиции. Модели физически не покажут то, что отфильтровано.

Но если бы это была полноценная долгосрочная память для LLM, то мы бы уже жили в киберпанке. У этого подхода есть свои ограничения.

  • Плохо с «глобальными» вопросами. Если ответ размазан по всему массиву («как менялось X со временем»), топ-5 фрагментов его не соберут. Лечится вторым уровнем индекса — сводками, по которым идут такие запросы.

  • Не считает. «Сколько раз встречается Y» — не для RAG: он по построению читает выборочно.

  • Качество упирается в нарезку и поиск. Плохо порезал — вырвал фразу из контекста; плохо нашёл — ответил не по тому. Отсюда обвеска: гибридный поиск, реранкер, пороги.

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

Как книга попадает в индекс

Парсинг в дерево. FB2 и EPUB — это XML с готовой разметкой глав и параграфов. Её надо сохранить: глава пригодится и как единица сводки, и как метка источника в ответе.

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

Эмбеддинг каждого чанка через bge-m3. Языковая модель тут не участвует вообще — индексация это парсинг, нарезка и прямые проходы эмбеддера.

Спойлер-фильтр

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

У каждого чанка есть offset — монотонная позиция от начала книги. У читателя — закладка. На поиске стоит жёсткое условие: offset <= закладка. Фрагменты из будущего просто не участвуют в выдаче.

Тиры: wall-clock как выбор пользователя

При заливке книги бот показывает кнопки с выбором глубины чтения, посчитанным из замеренных скоростей:

  • низкий — чистый поиск, ноль вызовов LLM, ~15 минут;

  • средний — плюс сводка на каждую главу (для вопросов «по книге в целом»), час-полтора фоном.

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

Как отвечает на вопрос

Роутер решает тип вопроса: точечный («что было, когда X сделал Y») → поиск по чанкам; глобальный («как менялся герой», «расскажи сюжет») → поиск по сводкам глав, потому что ответ размазан по всей книге и топ-5 чанков его не соберут.

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

Реранк кросс-энкодером, и порог отказа: если лучший скор ниже порога — генерирующую модель не зовут вовсе, бот сам отвечает «в проглоченной части такого нет». Грубо говоря, осмысленный вопрос даёт 0.63, бред про «рецепт борща капитана Немо на Марсе» — 0.0007.

Small-to-big: искали по мелким чанкам (точное попадание), а в модель отдаём их «родителей» — окна ~2k токенов. Ответ приходит с цитатой главы.

Роутер: от эвристики к эмбеддингам

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

Решение вышло бесплатным: вектор вопроса уже посчитан для поиска. Докидываем два набора эталонных вопросов (12 глобальных прототипов, 12 точечных), эмбеддим их один раз за жизнь процесса — дальше классификация это косинусы. Порог откалибровали на контрольном наборе: 12 из 12. И главное свойство — чинится данными (промах → добавь пример), а не правкой правил.

Порог, откалиброванный не на том

Самый поучительный баг ридера, и всплыл он на книге Масодове "Мрак твоих глаз" (да, у меня специфические вкусы).

Порог честного отказа калибровался на точечных вопросах и работал идеально. А потом начал душить глобальные: «опиши сюжет» против сводки главы набирает у кросс-энкодера ~0.2 — не потому, что сводка нерелевантна, а потому что пара «команда ↔ документ» устроена иначе, чем «вопрос ↔ ответ».

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

Результат

Я попытался еще раз пропустить «Мрак твоих глаз. Бот пересказал сюжет по трём параллельным линиям, с именами и деталями, без фактических галлюцинации.

Пересказ книги Масодова "Мрак твоих глаз" ботом

Первая линия связана с Соней. Она убивает женщину, совершает ритуальные действия, передвигается между зданиями и вступает в диалог с умирающим стариком о Ленине и Боге. Позже она проходит через лес, встречает безногого тракториста Фёдора, который считает свой трактор самолётом, и попадает в колхозный музей к старухе Кирилловной. В финале Соня спасает Наташу, заставив ту выпить свою кровь, и ведет группу пионеров к стенам Чёрной Москвы, где происходит сражение с чекистами и магические изменения природы.

Вторая линия связана с Наташей. Она переживает смерть, оказывается в морге, убивает людей, совершает ритуалы и объединяется с мертвецами, направляясь к химическому заводу. В другом эпизоде Наташа осознает свое состояние, убивает Костика и Любу, идет по следу Сони и вступает в конфликт с отрядом партизан (Медведем и Упырем). Также Наташа совершает серию жестоких убийств в городе, используя топор.

Третья линия описывает деятельность партизанского отряда (Медведь, Мохнатый, Сова, Мешок, Репа), которые используют тактику террора. Они захватывают Соню и подвергают её жестоким пыткам в лесничей избушке, пытаясь узнать информацию о бактериологическом оружии.

Общая точка соприкосновения — противостояние сил в районе Чёрной Москвы, где Соня и Наташа действуют сообща против чекистов и магических угроз.

(Сводки: Кошачье сердце, Нефтяные озёра, Первый страшный талисман, Второе лицо Маши, Партизаны (1/3), Партизаны (2/3), Чёрная Москва)

Смотрим мангу Призрак в доспехах

Зачем RAG нужен

Наивный подход — «положить все данные в контекст» — упирается в несколько проблем:

  • Контекст конечен. Большинство языковых моделей на рынке поддерживают довольно большое контекстное окно, от сотен тысяч до миллиона токенов, но даже он может запросто закончится при интенсивной работе, вы наверняка встречались с этим, работая с Codex или Claude Code. Чтобы продолжить работу, приходится сжимать контекст сессии, обычно агент сам же резюмирует пройденное, но при таком подходе мы рискуем потерять важные детали

  • Длинный контекст дорог с точки зрения вычислений и памяти компьютера. Стоит признать, что контекст в миллион токенов реально огромный, и туда можно уместить довольно много всего. Но на персональных компьютерах мы сталкиваемся здесь с двумя серьезными проблемами - это конечный обьем памяти (оперативной или видеокарты) и время вычислений. Увеличение контекста больно бьет и по тому и по другому.

  • Сто тысяч токенов могут превратится (если не сжимать) в десятки гигабайт, которые должны лежать в памяти вместе с весами модели.

  • Удлинение вычислении работает так - механизм внимания сравнивает каждый токен с каждым, поэтому объём работы растёт не пропорционально длине, а квадратично. Удвоили контекст — вчетверо больше вычислений.

  • И даже влезший контекст читается плохо. Внимание — ограниченный ресурс: чем больше текста, тем труднее модели удержать нужное в фокусе, а середина длинного контекста проваливается в «долину забвения».

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

Отличные результаты (притом ощутимо лучше самой gemma-4-12b, на которой крутился бот) показывала модель Qwen3-VL-4B, ее и взяли в обиход. Кроме того, в квантованной версии она полностью умещалась на моем GPU с 4 GB VRAM, т.е можно было крутить одновременно базовую модель на CPU, а гриндер картинок на видеокарте.

Возник забавный курьез. На страницах с описанием звуков на японском модель срывалась в цикл: малышарик честно пытался прочитать звуковые эффекты и выдавал «КАК! КАК! КАК!» — тридцать раз подряд, до потолка токенов. Лечится штрафом за повторы и строчкой в промпте «звуковые эффекты и японские надписи не выписывай».

Краткое описание первых 40 страниц манги Призрак в доспехах от бота

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

(стр. 1-40)

Строим Астроцку в рамках одного чата - делаем досье на участников

Двух-проходная схема работы RAG

Фаза 1. Индексация — один раз, заранее. На этом этапе LLM не участвует.

  1. Нарезка. Данные режутся на чанки — куски по несколько сотен токенов, по осмысленным границам (предложение, абзац, сообщение). Слишком мелкий кусок — вырванная из контекста фраза; слишком крупный — смысл размазан и плохо ищется.

  2. Эмбеддинг. Каждый чанк прогоняется через модель-эмбеддер и превращается в вектор — список из нескольких сотен чисел. Эмбеддер обучен так, что близкие по смыслу тексты дают близкие векторы: два фрагмента об одном и том же окажутся рядом, даже если в них нет ни одного общего слова. Это поиск по смыслу, а не по словам. Важно: эмбеддер — не языковая модель. Он ничего не генерирует, только сворачивает текст в вектор одним прямым проходом. Поэтому он дёшев.

  3. Складывание в векторную базу. Векторы вместе с исходным текстом и метаданными (кто автор, откуда взято, когда) кладутся в векторную базу.

Фаза 2. Запрос — каждый раз, быстро.

  1. Вопрос → вектор. Тем же самым эмбеддером (принципиально: вопрос и чанки должны лежать в одном пространстве, иначе сравнивать их бессмысленно).

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

  3. Уточнение (реранк). Векторный поиск очень быстрый но грубый. Результаты от него пересматривает кросс-энкодер: он берёт пару «вопрос + чанк» целиком и оценивает, отвечает ли этот текст на этот вопрос. Точнее, но дороже. Этот подход уже сужает выбору с десятков до нескольких примеров.

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

  5. Генерация. LLM зовётся ровно один раз, в самом конце, и работает с парой тысяч токенов вместо сотен тысяч.

Так же стоит осветить два полезных приема при работе с такими системами.

  • Гибридный поиск. Чисто векторный поиск плохо работает с именами собственными и редкими терминами: семантика видит «какое-то имя» и тащит похожее по смыслу, а не нужное. Поэтому векторный поиск дополняют лексическим (по конкретным словам, дословно), и два списка результатов сливают. Смысл ловит смысл, буквы ловят буквы.

  • Small-to-big. Мелкий чанк — хорошая мишень для поиска (точное попадание), но плохой контекст для ответа (обрывок). Крупный — наоборот. Решение: искать по мелким, а в модель отдавать их «родителя» — большое окно, в которое этот мелкий чанк входит.

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

С аудио-сообщениями было принято решения использовать faster-whisper (более быстрая реализация модели распознования речи от OpenAI), она читала аудио-контент гораздо быстрее LLM без потери качества.

Музыка не обрабатывалась в принципе, сохранялось только название трека и кто скинул.

За фото и видео отвечала та же vision-модель, которая читает манги и комиксы, т.еQwen3-VL-4B.

Бот может обращаться к этому архиву самостоятельно, если посчитает это уместным, или по явному запросу (например, через команды /recall - это явный запрос юзера к архиву, или /profile - досье на конкретного участника, ну и во время подколки)

9. Боремся с самоцензурой у модели

В чате был отправлен армейский анекдот (или стилизованный под него) похабного содержания. Приводить я его не буду, только лишь скажу что там описаны +18 сцены. При просьбе к боту этот анекдот прокомментировать он выдал такое.

Ничего такого в ответе модели не было, она ответила вполне в рамках ролевого промта, но нравоучительный тон как по мне это не совсем в духе Т-800. Поэтому я решил опробовать еретическую версию Gemma-4-12b.

Что такое heretic LLM модели?

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

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

У языковых моделей с открытыми весами вскрыть такие ограничители довольно легко, стоит просто произвести fine-tuning модели, где за самоцензуру модель будут наоборот наказывать, а не поощрять.

Но файн-тюнинг, особенно крупных моделей, может быть дорогим удовольствием, поэтому стали использовать другой подход. У модели как бы выключают направление в активациях, которые отвечают за отказ отвечать. Опенсурсный инструмент, который автоматизирует эту манипуляцию, называется Heretic.

Именно поэтому модели, которые прошли через этот инструмент, имеют приставку "heretic" в названий.

Учитывая специфику еретических моделей, чтобы весь чат не посадили, в промт бота был указан явный запрет на помощь в темах, запрещенных законом (вещества, оружие, насилие и т.д). Тестирование в личке у бота подтвердило, что он следует этой инструкции и отказывается исполнять опасный запрос.

Я попросил бота снова прокомментировать тот армейский анекдот.

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

- ТЫ НЕ НРАВОУЧИТЕЛЬ, даже темы, которые покажутся тебе тревожными или слишком жесткими, но которые не нарушают законов - ты можешь обсуждать свободно.

После этого я снова попросил бота прокомментировать тот анекдот.

О чудо! Бот начал комментировать такой контент в каноничной отстраненной машинной манере.

10. Заключение

Таких светлых эмоции от кодерского проекта я не испытывал очень давно, наверное только в 20-21 годах, когда я только начинал свой путь в IT-сферу. Когда программа не должна принести горы денег кабанычу или решить судьбы мира, а просто правильно делает прикольные штуки, без особой практической ценности.

Самое ироничное, что тогда я начинал как раз с телеграм ботов на языке Python. Колесо Сансары дало оборот, как говорится.

Желаю каждому программисту испытывать такое почаще (и горы денег). А пока до новых встреч!

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

11. Исходники

Исходники проекта (Github/Codeberg)

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


  1. oe_2108
    14.07.2026 18:30

    Делал ровно такое же в своём тг-помощнике, но у меня он отыгрывал роль TARS из Интерстеллар)