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

Кэш, ограничения и цена запроса

Одинаковые вопросы могут повторяться, а необязательная модель добавляет время и нагрузку. Кэш позволяет повторно использовать результат, но у помощника по документам это опаснее, чем просто запомнить строку. Источник изменился, стал служебным или был удалён — прежняя цитата больше не должна возвращаться. Роль редактора тоже не может делиться ответом с публичным читателем. Введём небольшой памятьный кэш и разберём, какие свойства запроса определяют его допустимость.

Состояние lesson-15 добавляет policy.py. Кэш хранит не более шестидесяти четырёх записей и использует срок жизни шестьдесят секунд. Это локальная демонстрация контракта, не распределённый сервис. При CLI-запуске процесс быстро завершается, поэтому кэш практически полезен лишь для живого веб-сервера. Данные не записываются в Git или SQLite. Модель, запросы и измерения стоимости при подготовке не запускались, поэтому урок не обещает фактическое ускорение в определённое число раз.

Из чего складывается ключ

Ключ включает вопрос после удаления внешних пробелов, доверенную роль, ревизию разрешённых фрагментов, имя модели и версию политики. Этот набор сериализуется как JSON и хэшируется. Использование JSON важно: простое склеивание строк без разделителей может сделать разные наборы параметров одинаковыми. Hash здесь служит компактным ключом словаря, а не скрывает вопрос криптографически от атакующего. Если пространство вопросов известно, его можно перебирать. Не следует называть хэширование заменой политики хранения пользовательских данных.

Вопрос сохраняет внутренний регистр и пунктуацию. Мы не приводим его целиком к термам поиска, потому что одна и та же совокупность слов может выражать разные условия. Например, «можно ли» и «нельзя ли» после удаления служебных слов иногда становятся похожими, хотя пользователю важен оттенок. Для извлекающего поиска это уже ограничение, но кэш не должен дополнительно смешивать представления. Удаление внешних пробелов полезно и понятно; более агрессивная нормализация потребует отдельного обоснования и контрольных случаев.

Роль является отдельной частью ключа даже тогда, когда два текущих вопроса получают одинаковые публичные цитаты. Сегодня editor не использовал staff, а завтра такой источник появится. Политика кэша не должна зависеть от случайного совпадения нынешнего ответа. Лучше разделить полномочия заранее. В реальном многопользовательском сервисе одной роли недостаточно: нужны организация, разрешённые коллекции или другой устойчивый контекст прав. Иначе редакторы двух компаний смогут получить общий результат, несмотря на разные закрытые источники.

Ревизия разрешённого набора

Функция revision сериализует текущие разрешённые фрагменты с упорядоченными ключами и вычисляет SHA-256. Изменение текста, URL, видимости или состава влияет на payload. При закрытии публичного документа reader-набор изменится, и прежний ключ больше не будет найден. Это простой способ продемонстрировать инвалидацию. Однако вычисление ревизии читает весь набор и не масштабируется бесконечно. Для большого индекса лучше иметь согласованную generation в метаданных транзакции, а не пересчитывать отпечаток всех фрагментов при каждом вопросе.

В ключе есть строка context-v1-answer-v1. Она напоминает, что данные — не единственная причина изменения ответа. Если изменилось разбиение, бюджет, порог или выбор предложения, старый кэш может остаться формально совпадающим по корпусу. Политику нужно версионировать и обновлять при смысловом изменении алгоритма. Сама надпись v1 не обнаруживает изменение автоматически: это дисциплина оператора или схема, которую надо встроить в процесс выпуска. Без такой дисциплины «версионированный кэш» останется декоративным обещанием.

Имя модели также входит в ключ, но этого недостаточно для строгого воспроизведения весов. Один tag может позже указывать на другой digest. Наша политика вообще не кэширует generated_unverified, поэтому этот недостаток меньше влияет на текущий результат, но остаётся важным при расширении. Если захотите сохранять генерацию, включите digest, параметры, prompt-version и доступ, а затем определите, какой срок допустим для такого текста. Нельзя бессрочно хранить ответ лишь потому, что модель стоила времени при первом запросе.

Когда сохраняется результат

В кэш попадают извлекающие результаты без model_error. Сгенерированный текст не сохраняется, поскольку его семантика ещё не проверена. Ошибка локальной модели также не кэшируется: иначе краткий сбой endpoint станет причиной одинакового сообщения ещё минуту после восстановления. Статус insufficient в извлекающем режиме можно сохранить, потому что он относится к текущей ревизии и политике. Если корпус обновился, новый ключ даст возможность ответить по добавленному документу, не ожидая истечения старого отказа.

Для времени используется time.monotonic. Оно подходит для длительности внутри процесса и не зависит от изменения часов системы. В кэше нет абсолютной пользовательской даты; срок сравнивается с текущим монотонным значением. При рестарте все записи исчезают, поэтому перенос такого числа между процессами не требуется. Если заменить память внешним хранилищем, временной контракт нужно переосмыслить: монотонные часы одного процесса не являются общим календарём распределённой системы.

Когда записей стало слишком много, словарь очищается целиком. Это простой предел памяти, а не LRU. Популярный ответ может исчезнуть вместе с редким, и несколько параллельных запросов могут одновременно вычислить одно основание. Текущий словарь не реализует single-flight, блокировки или распределённую координацию. Для локального прототипа это допустимая граница. При публикации нужны ограничения параллельных модельных задач, ожидание активного вычисления и поведение при отмене; один cache dictionary не решает нагрузку автоматически.

Цена запроса и наблюдение

Без внешней модели основной бюджет — чтение SQLite, расчёт BM25, упаковка и HTML. При Ollama добавляются память весов, загрузка модели и время генерации. Мы не указываем выдуманную цену в рублях или число токенов: они зависят от выбранного оборудования и рантайма. Полезно измерять длительность по слоям и счётчики фактического использования, оставляя вопросы и тексты вне обычного журнала. В локальном исходнике пока есть только cached, чтобы видеть, какой путь использован при повторном запросе.

Полный policy.py ниже описывает ожидаемое поведение. В живом самостоятельном сервере одинаковый публичный вопрос в пределах срока должен получить cached true после первого вычисления. После обновления индекса ключ должен измениться. Сценарии не выполнялись. Не забывайте, что sync выполняется при старте сервера, а не на каждую HTTP-форму: изменение Markdown само по себе ещё не меняет живую базу. Последний урок объединит этот порядок с выпуском и сопровождением локального помощника, сохранив честные границы версии.

Полный файл policy.py из состояния lesson-15:

import hashlib
import json
import os
import time
from storage import revision

CACHE = {}

def cached(question, role, build):
    now = time.monotonic()
    key = hashlib.sha256(json.dumps([question.strip(), role, revision(role), os.getenv("OLLAMA_MODEL"),
                                    "context-v1-answer-v1"], ensure_ascii=False).encode()).hexdigest()
    if key in CACHE and CACHE[key][0] > now:
        return {**CACHE[key][1], "cached": True}
    value = build()
    if value["status"] != "generated_unverified" and not value.get("model_error"):
        if len(CACHE) >= 64:
            CACHE.clear()
        CACHE[key] = (now + 60, value)
    return {**value, "cached": False}

Оглавление серии · Исходники всех шагов · Учебные маршруты

Пример остаётся локальным черновиком. При подготовке он не выполнялся; все описанные эффекты являются ожидаемыми, а не результатами запущенных проверок.