Память чат-бота¶
Модель не помнит предыдущие сообщения, а контекстное окно конечно. Память
в protoprompt решает две задачи:
- Хранить факты и куски диалога, чтобы их можно было найти по смыслу.
- Ужимать длинную переписку, чтобы она влезала в окно модели.
Для этого есть два механизма, которые можно использовать вместе или по отдельности:
| Механизм | Где живёт | Что делает |
|---|---|---|
| Хранилище | 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:
- Раз в
compress_every_nсообщений он запускает стратегию сжатия. - Стратегия превращает длинную переписку в несколько коротких блоков
(
CompressedBlock). - Блоки с эмбеддингами кладутся в стор под именем
session_{chat_id}. - Дальше
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символов, содержащие ключевое слово.
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) отвечает на вопрос «как не эмбеддить одно и то же дважды?» — подробности на следующей странице.