Обновлено: 12 мая 2026 г.

 Roman Panarin.

Роман Панарин ML-инженер

Машинное обучение

[SnowBall: итеративная обработка контекста, который не влезает в окно LLM]

Анализ с помощью ИИ

Получите аналитику на основе ИИ для этой технической статьи Enji:

Читать с Claude Читать с ChatGPT

Откуда берётся проблема

У нас в Enji.ai есть агентский пайплайн из 35 узлов: router_agent, planning_agent, language_detection_agent, agent_choice_agent и еще 31 штука. Каждый узел тянет данные через text2sql или RAG собирает результаты, и всё это летит в LLM как единый контекст. На маленьких командах это работает нормально, но когда подключается режим Global Access – c доступом ко всем проектам компании сразу – контекст раздувается.

Конкретно на проекте одного из наших клиентов с командой в 46+ человек за одну рабочую неделю набирается столько данных из трекеров и Git-а, что контекст перешагивает за 1500K токенов. А мы используем qwen3-32b через Groq, где потолок – 131,072 токена. Запрос просто падает с ошибкой, если с этим ничего не делать.

Но даже если бы окно было больше, есть вторая проблема. Исследование "Lost in the Middle" (Stanford, опубликовано в TACL 2024) показало, что у моделей есть характерная U-образная кривая внимания: они хорошо работают с информацией в начале и в конце контекста, но теряют до 30% точности на данных из середины. С нашими SQL-результатами, где в середине могут оказаться ворклоги ключевого разработчика, это вполне реальная потеря качества ответа.

The idea: roll out context in portions

Идея: накатывать контекст порциями

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

По сути это тот же паттерн, который в LangChain называется Refine – итеративное уточнение. Разница в том, что у нас это не отдельный вызов в цепочке, а прозрачная обертка над ainvoke(). Разработчик вызывает модель как обычно, а система сама решает, нужно ли дробить контекст.

Вот как выглядит точка входа в LLMGenerator:

# llm_generator.py

async def ainvoke(self, messages, *args, **kwargs):
    system_msg, user_msg = self._decompose_messages(messages)
    user_msg_tokens = self.llm_client.get_num_tokens(user_msg)
    system_msg_tokens = self.llm_client.get_num_tokens(system_msg)
    
    if user_msg_tokens + system_msg_tokens < self.tokens_limit:
        return await self.llm_client.ainvoke(messages, *args, **kwargs)
    
    # контекст не влезает – переключаемся на итеративную обработку
    return await self.snowball_ainvoke(system_msg, user_msg, *args, **kwargs)

Проверка такая: считаем токены через get_num_tokens, сравниваем с лимитом из конфига модели (131,072 для qwen3-32b). Если влезает – обычный вызов. Если нет – запускаем SnowBall.

Как работает snowball_ainvoke

Алгоритм состоит из двух фаз. Сначала разрезаем user_message на чанки, потом прогоняем их последовательно, накапливая саммари:

async def snowball_ainvoke(self, system_message, user_message, *args, **kwargs):
    chunk_size = self.tokens_limit - int(self.tokens_limit * 0.2)  # 80% от лимита
    chunks = self.get_chunks(user_message, chunk_size)
    
    # первый чанк обрабатываем вместе с системным промптом
    initial_prompt = system_message + "\n\n" + chunks[0]
    snowball_summary = await self.llm_client.ainvoke(initial_prompt)
    
    # каждый следующий чанк обогащает предыдущее саммари
    for chunk in chunks[1:]:
        messages = snowball_prompt.format_messages(
            chunk=chunk,
            system_message=system_message,
            summary=snowball_summary.content
        )
        snowball_summary = await self.llm_client.ainvoke(messages)
    
    return snowball_summary

20% резерва от лимита – это запас на системный промпт, на сам snowball_prompt и на оверхед сериализации. При лимите в 131K получается чанк около 105K токенов. Для нарезки используется CharacterTextSplitter.from_tiktoken_encoder из LangChain – он считает размер чанка в токенах, а не в символах, что для точного подсчета принципиально. Оверлап между чанками – 20 токенов, чтобы не терять контекст на границах.

На практике для типичного запроса Global Access за неделю по нашему клиенту получается 2-3 чанка. Это 2-3 последовательных вызова LLM вместо одного упавшего.

Два класса для разных сценариев

В обычном режиме (system + user message) все просто – режем user_message на чанки по токенам. Но когда LLM использует тулы (SQL-запросы, RAG), контекст устроен сложнее: там не только текст, а цепочка из SystemMessage, HumanMessage, AIMessage с tool_calls и ToolMessage с результатами. Резать такую цепочку по токенам нельзя – теряется связь между вызовом тула и его ответом.

Для этого есть отдельный класс BoundLLMGenerator. Его метод build_message_batches группирует сообщения целиком, стараясь не разрывать пары tool_call / tool_result. Только если одно сообщение само по себе превышает лимит (бывает, когда SQL вернул огромную таблицу), оно режется на чанки.

LLMGenerator                          BoundLLMGenerator
─────────────                         ──────────────────
ainvoke(messages)                      ainvoke(messages)
  ├─ обычный вызов                       ├─ считает токены ВСЕХ сообщений
  └─ snowball_ainvoke()                  └─ _snowball_ainvoke()
       └─ чанки из user_message               └─ батчи из целых сообщений

Разделение на два класса позволяет не тащить логику tool-батчинга в базовый код и наоборот. LLMGenerator.bind_tools(tools) возвращает BoundLLMGenerator, так что переключение между режимами происходит автоматически.

Что стоит учитывать

Задержка растет линейно с количеством чанков. Контекст на 200K токенов – это 5-6 последовательных вызовов. Для аналитических запросов менеджера, где ответ нужен не за миллисекунды, а за секунды или минуты, это приемлемо. Для чата в реальном времени – уже нет.

Есть потеря информации между итерациями. Каждый шаг суммаризации что-то теряет, и на длинных цепочках (5+ чанков) это накапливается. Мы пока не столкнулись с критичной деградацией на наших данных, но для задач, где нужна точная числовая агрегация (суммы часов, количество задач), это потенциальная проблема. В таких случаях лучше работает иерархическая суммаризация, где агрегации считаются на каждом уровне отдельно – об этом хорошо написано в исследовании CoTHSSum (Springer, 2025).

Еще один момент – постепенная деградация, она же graceful degradation. Если один чанк упал с ошибкой (таймаут Groq, невалидный JSON в ответе), цикл продолжается с предыдущим саммари. Мы теряем информацию из этого чанка, но не теряем весь ответ.

Почему не LLMLingua и не Map-Reduce

Мы рассматривали альтернативы. LLMLingua от Microsoft сжимает промпт, удаляя несущественные токены через маленькую модель-компрессор (GPT2-small или LLaMA-7B). На текстах с "водой" работает отлично – до 20x сжатия. Но наши данные – это SQL-результаты: таблицы с полями employee name, detail, hours. Там каждый токен несёт смысловую нагрузку, и агрессивное сжатие вырезает важную информацию, которую мы не готовы терять.

Map-Reduce мог бы помочь с параллелизацией – обработать чанки одновременно, потом смёржить результаты. Но наш контекст не раскладывается на независимые куски. Ворклоги одного разработчика могут быть в первом чанке, а связанные с ними задачи – во втором. Map-Reduce потеряет эту связь, а Refine/SnowBall ее сохранит, потому что саммари накапливается.

Gisting – красивая идея (26x сжатие, 40% экономии FLOPs), но требует дообучения модели на наших данных. Для стартапа, который итерирует продукт каждую неделю и меняет промпты, это пока не вариант. Но мы в целом думаем о своей модели Enji LLM и возможно там применим Gisting.

Конфигурация в продакшн

Все open-source модели в пайплайне Enji работают через Groq с qwen3-32b. Groq на сегодня – единственный провайдер инференсы, который поддерживает полное окно 131K для этой модели (сама модель нативно работает на 32K и расширяется до 131K через YaRN).

router_model = { model = "qwen/qwen3-32b", temperature = 0.1, tokens_limit = 131072 }

При этом tokens_limit – это не просто ограничение API, а порог для включения SnowBall. Если поменять провайдера на того, у которого окно больше, SnowBall будет включаться реже или не включаться вовсе. Код менять не надо.

Что дальше

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

В MIT недавно предложили Recursive Language Models – подход, где модель может рекурсивно обращаться к полному неcжатому контексту вместо суммаризации. На бенчмарках показывают 91% accuracy на 10M+ токенов. Когда это станет доступно в продакшн инференсе, SnowBall, скорее всего, станет не нужен. Но пока контекстные окна конечны, а данных все больше – итеративная обработка работает.

Читайте также:

ML

[Как переходить с SOTA LLM на локальные OSS LLM]

Как переписать агентный пайплайн с SOTA-моделей на локальные OSS LLM. Разбираем выбор модели, декомпозицию на узлы и работу с Qwen3 внутри закрытого периметра.

ML

[Как развивать промпты узлов на OSS-моделях через GEPA]

Как автоматизировать промпт-оптимизацию для локальных OSS LLM через GEPA. Практический гайд с датасетом, примерами промптов и реальными метриками Enji.