Перейти к содержанию

Память чат-бота

Модель не помнит предыдущие сообщения, а контекстное окно конечно. Память в protoprompt решает две задачи:

  1. Хранить факты и куски диалога, чтобы их можно было найти по смыслу.
  2. Ужимать длинную переписку, чтобы она влезала в окно модели.

Для этого есть два механизма, которые можно использовать вместе или по отдельности:

Механизм Где живёт Что делает
Хранилище store Векторный поиск: положил текст с эмбеддингом — нашёл по смыслу
Память сессии session Сжимает старые сообщения в короткие блоки-выжимки

Ещё есть LRU-кэш эмбеддингов (cache) — он не хранит память, а ускоряет повторные запросы. Про него — отдельная страница.

Хранилище: store

Хранилище работает как векторная база: вы кладёте текст вместе с его эмбеддингом, а потом ищете по смыслу — ближайшие по косинусной близости записи.

Как устроено

Всё хранилище описывается одним интерфейсом StoreProtocol с четырьмя методами:

store.add(doc_id, chunks, embeddings, metadata)   # положить документ
store.query(embedding, top_k=5, where=None, score_threshold=None)  # найти похожее
store.delete(doc_id)                               # удалить документ
store.count()                                      # сколько всего чанков

Чуть подробнее о методах:

  • add — кладёт документ. doc_id — имя документа, chunks — список кусков текста, embeddings — эмбеддинги этих кусков, metadata — опциональные поля-ярлыки (например, {"chat_id": "c1"}).
  • query — возвращает top_k самых похожих чанков. Каждый результат — словарь с полями document (текст), score (близость 0–1), metadata.
  • where — фильтр по метаданным. Два вида:
  • точное равенство: {"chat_id": "c1"};
  • вхождение в список: {"chat_id": {"$in": ["c1", "c2"]}}.
  • score_threshold — отбрасывает записи с близостью ниже порога.

Какие есть реализации

Класс Где Особенность
InMemStore protoprompt.store Всё в оперативке. Быстро, теряется при перезапуске
SqliteStore protoprompt.store В файле SQLite, без внешних сервисов. Переживает перезапуск
AsyncInMemStore protoprompt.store То же, что InMemStore, но с async-методами
AsyncStoreWrapper protoprompt.store Любой синхронный стор превращает в async (работа в потоках)

Выбор простой:

  • для тестов и прототипов — InMemStore;
  • для прода без внешних зависимостей — SqliteStore;
  • в asyncio-приложении с тяжёлым бэкендом — оберните любой стор через as_async(store), чтобы блокирующий код не тормозил цикл событий.

Пример

from protoprompt import InMemStore

store = InMemStore()

# 1. Эмбеддинги получаем у LLM-клиента (см. LRU-кэш, чтобы не повторять работу)
embedding = [0.12, 0.34, 0.56, ...]  # упрощённо

# 2. Кладём документ
store.add(
    "doc-1",
    ["Париж — столица Франции.", "Берлин — столица Германии."],
    [emb1, emb2],
    metadata={"topic": "география"},
)

# 3. Ищем похожее по смыслу
results = store.query(
    embedding_of("Какая столица у Франции?"),
    top_k=1,
    where={"topic": "география"},
)
print(results[0]["document"])  # "Париж — столица Франции."

С SqliteStore всё то же самое, только адресом станет файл:

from protoprompt import SqliteStore

store = SqliteStore("chat.db")          # файл, переживает перезапуск
store.add("doc-1", ["Привет!"], [emb], {"chat_id": "c1"})

Повторное добавление

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

Память сессии: session

Хранилище даёт поиск по смыслу, но само по себе не решает главную проблему: между 10 и 500 сообщений диалога растёт в размере, и рано или поздно перестаёт влезать в окно модели. Для этого есть сжатие сессии.

Как устроено

Вся механика живёт в Pipeline:

  1. Раз в compress_every_n сообщений он запускает стратегию сжатия.
  2. Стратегия превращает длинную переписку в несколько коротких блоков (CompressedBlock).
  3. Блоки с эмбеддингами кладутся в стор под именем session_{chat_id}.
  4. Дальше ContextBuilder находит их тем же векторным поиском и вставляет в промпт как «память диалога».

Пример

import asyncio
from protoprompt import Pipeline, Session, HeuristicStrategy, InMemStore

class MyLLM:
    async def chat(self, messages, model="", **options):
        return "ок"
    async def embed(self, texts, model=""):
        return [[0.1] * 384 for _ in texts]

async def main():
    store = InMemStore()
    llm = MyLLM()

    pipeline = Pipeline(
        store, llm,
        strategy=HeuristicStrategy(),
        compress_every_n=10,
    )

    session = Session(chat_id="c1", messages=[
        {"role": "user", "content": "Меня зовут Илья."},
        {"role": "assistant", "content": "Привет, Илья!"},
        # ... ещё 8+ сообщений ...
    ])

    if pipeline.should_compress(len(session.messages)):
        blocks = await pipeline.compress_and_store(session)
        print(f"сжато в {len(blocks)} блоков")

asyncio.run(main())

Стратегии сжатия

HeuristicStrategy — чистый Python, без вызовов LLM. Делит диалог на три области:

  • head — первые head_count сообщений (начало разговора);
  • tail — последние tail_count сообщений (недавний контекст);
  • important — реплики из середины длиннее min_length символов, содержащие ключевое слово.
strat = HeuristicStrategy(head_count=3, tail_count=5, min_length=80)

LLMSummaryStrategy — зовёт LLM, чтобы тот сам написал выжимку каждого окна из window_size сообщений.

from protoprompt import LLMSummaryStrategy

strat = LLMSummaryStrategy(model="llama3.1", window_size=8, language="ru")

Откат при ошибке

Если LLM упала (тайм-аут, битый ответ), LLMSummaryStrategy сама переключится на fallback (по умолчанию — HeuristicStrategy). Чат не должен ломаться из-за модели.

Как выбрать compress_every_n

Длина сессии Рекомендация
до 20 ходов не сжимать
20–60 ходов 10
60+ ходов 8
code-review-бот 6

Меньше число — чаще сжатие (дороже, но плотнее контекст). Больше — дешевле, но выше риск переполнения.

Память в одном предложении

  • Хранилище (store) отвечает на вопрос «где лежит факт?» — векторный поиск по смыслу.
  • Сжатие (session) отвечает на вопрос «как ужать историю?» — Pipeline + стратегия.
  • Кэш (cache) отвечает на вопрос «как не эмбеддить одно и то же дважды?» — подробности на следующей странице.