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

Урок 3: LRU-кэш — не эмбеддить одно и то же дважды

В уроке 2 каждый запуск сжатия заново переводил одни и те же тексты в эмбеддинги. Настоящая модель берёт за это время и деньги. Научимся запоминать результат.

Концепция: почему эмбеддинг дорогой

embed — это такой же запрос к модели, как и chat. Пользователь спрашивает «что я решил насчёт хранилища?», через минуту перефразирует, а бот в это время ищет ответ, пере-эмбеддя старые факты.

Повторяющиеся тексты — это выброшенные вызовы. Решение простое: запомнить вектор каждого текста и при повторной встрече отдать его из памяти.

Концепция: что такое LRU

LRU (Least Recently Used) — «выкидываем самое давно использованное». Кэш имеет ограниченный размер (capacity):

  • текст запросили — вектор кладётся в кэш и считается свежим;
  • текст снова запросили — он перемещается в конец очереди (свежий);
  • кэш переполнен — выкидывается самая «протухшая» запись.

Проще говоря: бот держит в памяти то, что спрашивал недавно.

Практика: читаем проект

Откройте examples/tutorials/03_embedding_cache/main.py.

Шаг 1. Заглушка со счётчиком

class FakeLLM:
    def __init__(self):
        self.embed_calls = 0      # сколько раз вызвали embed
        self.embedded_texts = 0   # сколько всего текстов заэмбедчили

Заглушка из урока 1, только теперь она считает, сколько её звали. На эти счётчики мы и будем смотреть.

Шаг 2. Оборачиваем клиента в кэш

llm = CachedLLMClient(raw, InMemoryEmbeddingCache(capacity=128))

CachedLLMClient — обёртка:

  • embed — кэширует: вектор текста запоминается по ключу «модель + текст»;
  • chat — проходит насквозь, кэш его не трогает.

InMemoryEmbeddingCache(capacity=128) — сам кэш в оперативке, максимум 128 записей.

Шаг 3. Наблюдаем за вызовами

v1 = await llm.embed(texts)              # модель: 2 текста
v2 = await llm.embed(texts)              # кэш: 0 вызовов
v3 = await llm.embed(["старый", "новый"])  # модель: 1 вызов, только за новый

Вывод программы:

1. вызовов модели: 1
2. вызовов модели: 1 (не изменилось)
3. вызовов модели: 2 (+1 только за новый текст)

И главное — векторы из кэша совпадают с теми, что вернула модель:

Векторы из кэша совпадают с исходными: True

Частичное попадание

embed принимает список. Кэш проверяет каждый текст по отдельности: что нашлось — берёт из памяти, что нет — отправляет в модель одним батчем, сохраняя порядок. Смесь попаданий и промахов не превращается в десяток мелких вызовов.

Где это применяется

В каждом реальном боте:

from protoprompt import CachedLLMClient, InMemoryEmbeddingCache

llm = CachedLLMClient(raw_llm, InMemoryEmbeddingCache(capacity=512))

Пока бот строит контекст — эмбеддит документы, историю, вопросы. Без кэша каждая сборка оплачивает всё заново. С кэшем повторы отдаются из памяти.

Задание

  1. Уменьшите capacity до 1. Теперь текстов больше, чем помещается, — при третьем embed первый текст уже вытеснен. Убедитесь по счётчику.
  2. Проверьте chat: вызовите await llm.chat([...]) и убедитесь, что embed_calls не изменился.
  3. Напишите свой кэш на 20 строках: класс с методами get(key) и put(key, vectors), хранящий данные в словаре. Подставьте его вместо InMemoryEmbeddingCache — интерфейс один и тот же.
  4. Подключите настоящий клиент (Ollama) и посмотрите, сколько вызовов embed экономит кэш за одну сборку контекста.

Дальше

Мы научились хранить факты, сжимать диалог и не платить за повторы. Всё это про чат-ботов. Но есть другой тип приложений — код-агенты, которые сами выполняют десятки шагов. У них своя проблема: недавнее — не значит важное. Урок 4 — про рабочую память агента.