Суммаризация и компрессия контекста без потери смысла
Урок 4: Суммаризация и компрессия контекста без потери смысла
Приветствую, будущий архитектор ИИ-решений. Сегодня мы затронем одну из самых болезненных тем в разработке LLM-приложений — управление памятью. Даже с учетом того, что современные модели Gemini обладают огромным контекстным окном (до миллионов токенов), бездумное скармливание всей истории диалога модели — это путь к медленным, дорогим и, как ни странно, глупым архитектурным решениям.
В этом уроке мы разберем, как превратить гигабайты текста в компактные смысловые единицы, не потеряв при этом суть. Мы отойдем от примитивного «обрезания» старых сообщений и перейдем к интеллектуальной компрессии.
Почему контекст — это ресурс, а не просто лимит?
Долгое время разработчики боролись с жесткими ограничениями (4k, 8k токенов). С появлением Gemini 1.5 и грядущих версий, проблема «нехватки места» сменилась проблемой «шума в сигнале».
- Стоимость (Cost): Каждый токен входного контекста стоит денег. Передавать 500 страниц документации в каждом запросе чат-бота — экономическое самоубийство.
- Задержка (Latency): Чем больше контекст, тем дольше модель его обрабатывает до начала генерации первого токена (TTFT — Time To First Token).
- Точность (Accuracy): Существует феномен «Lost in the Middle». Даже мощные модели могут упускать детали, находящиеся в середине огромного массива данных, уделяя больше внимания началу и концу промпта.
Наша цель — научиться сжимать информацию так, чтобы для модели она оставалась столь же информативной, как и исходник, но занимала в 10–100 раз меньше токенов.
Стратегии компрессии: От простого к сложному
Рассмотрим основные подходы, которые применяются в профессиональной разработке.
1. Скользящее окно (Rolling Window)
Самый примитивный метод. Мы храним только последние N сообщений.
Минус: Катастрофическая забывчивость. Если пользователь упомянул свое имя в начале диалога, а сейчас идет 50-е сообщение, бот его забудет.
2. Суммаризация предыдущего контекста
Когда контекст заполняется, мы просим саму модель: «Сократи этот диалог до одного абзаца, сохранив ключевые факты». Затем этот абзац подается как системное сообщение для следующего этапа.
3. Структурированная экстракция (Entity Extraction)
Вместо пересказа текста мы извлекаем факты в JSON. Например, мы не храним текст «Меня зовут Иван, я живу в Москве», мы храним объект {"name": "Ivan", "city": "Moscow"}. Это наиболее экономный способ.
4. Итеративное уплотнение (Chain of Density)
Продвинутая техника, предложенная исследователями из MIT и Salesforce. Идея заключается в том, чтобы генерировать саммари, а затем просить модель добавить пропущенные сущности в то же количество слов, повышая плотность информации шаг за шагом.
import google.generativeai as genai
from dataclasses import dataclass
from typing import List, Dict
# Инициализация модели (предполагаем наличие API ключа в переменных среды)
model = genai.GenerativeModel('gemini-1.5-pro-latest')
@dataclass
class Message:
role: str
content: str
def naive_summarization(history: List[Message]) -> str:
"""
Базовый подход: просим модель сжать историю в один нарратив.
"""
conversation_text = "\n".join([f"{msg.role}: {msg.content}" for msg in history])
prompt = f"""
Проанализируй следующий диалог между пользователем и AI.
Создай краткое резюме (summary), которое содержит все ключевые факты,
принятые решения и контекст, необходимый для продолжения разговора.
Игнорируй приветствия и светскую беседу.
ДИАЛОГ:
{conversation_text}
РЕЗЮМЕ:
"""
response = model.generate_content(prompt)
return response.text
# Пример использования
history = [
Message("user", "Привет, я хочу написать код на Python для анализа данных."),
Message("model", "Привет! Отличный выбор. Какие библиотеки планируешь использовать? Pandas, NumPy?"),
Message("user", "Да, Pandas точно. И мне нужно загружать данные из CSV файлов весом по 5 гигабайт."),
Message("model", "Для таких объемов чистый Pandas может быть медленным. Рассмотри использование Dask или Polars."),
Message("user", "О, Polars звучит интересно. Давай попробуем его.")
]
summary = naive_summarization(history)
print(f"Сжатый контекст:\n{summary}")
Техника «Chain of Density» (Цепочка плотности)
Давайте углубимся в более сложный, но крайне эффективный метод. Проблема обычной суммаризации в том, что модели любят «воду». Они часто пишут общие фразы вроде «Пользователь и Ассистент обсудили библиотеки Python», упуская, что именно Polars был выбран из-за файлов в 5 ГБ.
Метод Chain of Density (CoD) заставляет модель циклически переписывать саммари, добавляя всё больше сущностей (entities) без увеличения длины текста. Это создает сверхплотный контекст, идеальный для системного промпта.
Алгоритм CoD:
- Сгенерируй начальное саммари.
- Выдели ключевые сущности из исходного текста, которые пропущены в саммари.
- Перепиши саммари той же длины, включив эти пропущенные сущности.
- Повтори N раз.
def chain_of_density_summarization(text_to_compress: str, iterations: int = 3) -> str:
"""
Реализация упрощенной логики Chain of Density для Gemini.
"""
cod_prompt = f"""
Я предоставлю тебе текст разговора. Ты должен сгенерировать все более краткое и информационно плотное резюме (summary).
Повтори следующий процесс {iterations} раза:
1. Идентифицируй 1-3 ключевые сущности (Entities) из исходного текста, которые отсутствуют в предыдущем резюме.
2. Напиши НОВОЕ резюме той же длины или короче, которое включает все предыдущие сущности ПЛЮС новые.
3. Резюме должно быть очень сухим, фактологическим, без лишних слов.
ИСХОДНЫЙ ТЕКСТ:
{text_to_compress}
"""
response = model.generate_content(cod_prompt)
return response.text
# В реальной системе мы бы парсили ответ, чтобы взять только финальную итерацию.
# Gemini часто хорошо справляется, если попросить его вывести только финальный результат в конце.
print("Запуск Chain of Density...")
# Представим, что text_to_compress - это длинная стенограмма встречи
Гибридный подход: Краткосрочная и Долгосрочная память
Самая надежная архитектура для сложных ботов (например, AI-менторов или персонажей ролевых игр) использует комбинацию подходов. Это напоминает устройство человеческой памяти.
- Буфер (Short-term memory): Последние 3-5 сообщений в сыром виде. Это позволяет поддерживать живой стиль общения, реагировать на "ага", "нет", "почему?".
- Активная суммаризация (Working memory): Сжатый пересказ текущей сессии разговора. Обновляется каждые N сообщений.
- База знаний (Long-term memory): Векторная база данных (Vector Store), куда мы сохраняем важные факты и извлекаем их через RAG (Retrieval Augmented Generation), когда они становятся релевантными.
Ниже представлен архитектурный паттерн управления таким контекстом.
class ContextManager:
def __init__(self, model, max_tokens=1000):
self.model = model
self.raw_buffer = [] # Хранит объекты Message
self.summary = "" # Текущее сжатое состояние
self.max_tokens = max_tokens
def add_message(self, role, content):
self.raw_buffer.append(Message(role, content))
# Простая эвристика проверки переполнения (в проде нужно считать токены)
if len(self.raw_buffer) > 5:
self._compress_context()
def _compress_context(self):
"""
Переносит старые сообщения из буфера в саммари.
"""
# Берем старые сообщения, оставляя 2 последних для связности
to_summarize = self.raw_buffer[:-2]
remaining = self.raw_buffer[-2:]
text_block = "\n".join([f"{m.role}: {m.content}" for m in to_summarize])
prompt = f"""
Текущее знание о диалоге: {self.summary}
Новые сообщения:
{text_block}
Обнови текущее знание, интегрировав информацию из новых сообщений.
Удали устаревшую информацию. Сохрани имена, даты, предпочтения пользователя.
Вывод должен быть только обновленным текстом саммари.
"""
resp = self.model.generate_content(prompt)
self.summary = resp.text.strip()
self.raw_buffer = remaining
print(f"[System] Контекст сжат. Новое саммари: {self.summary[:50]}...")
def get_full_context(self) -> str:
"""
Формирует промпт для модели
"""
buffer_text = "\n".join([f"{m.role}: {m.content}" for m in self.raw_buffer])
return f"Контекст прошлого: {self.summary}\n\nТекущий диалог:\n{buffer_text}"
# Имитация работы
manager = ContextManager(model)
manager.add_message("user", "Я живу в Берлине.")
manager.add_message("model", "Прекрасный город! Давно там?")
manager.add_message("user", "Переехал 2 года назад. Работаю инженером.")
# ... добавляем сообщения ...
# При превышении лимита сработает _compress_context
Особенности Gemini 1.5/3 и Context Caching
В экосистеме Google Gemini есть уникальная функция, которая меняет правила игры — Context Caching. Если ваш системный промпт или массив документов огромен (например, целая книга или кодовая база), и вы делаете к нему много запросов, вы можете закешировать этот контекст.
Это означает, что вам не нужно каждый раз передавать и (главное!) оплачивать обработку входных токенов огромного контекста. Вы платите за хранение кеша (что дешевле) и за новые токены запроса.
Когда использовать компрессию, а когда кеширование?
- Используйте компрессию для динамического диалога, который постоянно меняется и растет (чат с пользователем).
- Используйте кеширование для статических данных (документация проекта, правила компании, содержание книги), к которым обращается бот.
Напишите функцию `extract_profile_json(dialogue_history)`, которая принимает список сообщений и использует Gemini для извлечения профиля пользователя в формате JSON. Профиль должен содержать поля: `name`, `tech_stack`, `project_goal`. Если информация отсутствует, поля должны быть null. Функция должна возвращать чистый JSON-объект (dict), а не строку.
Какой метод управления контекстом лучше всего подходит, если нам критически важно сохранить точные формулировки и детали из начала очень длинного разговора (например, юридические условия), но мы ограничены по токенам?