Память чат-бота: как хранить историю диалога и не платить за неё дважды

Модель не помнит предыдущих реплик: историю отправляете вы при каждом запросе. Разбираем, как растёт цена диалога и чем её сдерживать.

6 мин · · разработка · экономия

Если вы делаете чат-бота на основе GPT или другой модели, быстро выясняется неприятная вещь: память чат-бота в API не существует. Модель не хранит ничего между запросами, и «помнит» бот ровно то, что вы снова отправили в этом вызове. Отсюда два следствия: историей нужно управлять самому, а цена диалога растёт с каждой репликой. Разберём способы хранить историю и посчитаем, сколько они стоят.

Примеры на Python через OpenAI-совместимый API: base_url="https://api.zukko.pro/v1", ключ fetch_.... Поля запроса и ответа, включая usage, перечислены в справочнике chat completions.

Как «запоминает» модель

Каждый запрос к чат-API содержит весь список сообщений: системный промпт, прошлые реплики пользователя и ответы, новую реплику. Модель читает этот список с нуля. Вся «память» — это массив messages, который вы собираете и храните сами (в списке в памяти процесса, в базе данных, в Redis).

Контекстное окно LLM — это максимальный объём, который влезает в один запрос. У большинства моделей из каталога Zukko он составляет 1M токенов, у GLM — 128K, у Grok — 256K (список и цены: GET https://api.zukko.pro/v1/models). Но предел окна — не главная проблема диалога. Главная проблема — деньги: вы платите за каждый токен входа в каждом запросе.

Как растёт стоимость длинного диалога

Допустим, системный промпт занимает 500 токенов, реплика пользователя — 100, ответ бота — 300. На ход номер n вход равен 600 + 400 × (n − 1) токенов: промпт и новая реплика плюс вся накопленная история.

Ходов в диалогеВходных токенов за весь диалог
1024 000
2088 000
40336 000

Вход растёт квадратично: диалог вдвое длиннее обходится почти вчетверо дороже по входу. Для 20 ходов на gpt-6.1-sol (цены на 18 октября 2026: $0.27 за 1M входных, $1.32 за 1M выходных):

вход: 88 000 × 0.27 / 1e6 = $0.02376 выход: 6 000 × 1.32 / 1e6 = $0.00792 (20 ходов × 300 токенов) итого: $0.03168

Копейки на один диалог, но у бота с тысячами пользователей цена складывается из таких диалогов, а вход составляет большую часть счёта. Как устроена цена запроса в целом, написано в статье Как считается цена запроса.

Способ 1. Скользящее окно

Самое простое: отправлять только последние N реплик.

bash
from openai import OpenAI

client = OpenAI(base_url="https://api.zukko.pro/v1", api_key="fetch_...")
SYSTEM = {"role": "system", "content": "Ты вежливый помощник магазина."}
KEEP = 7  # новая реплика + 3 прошлых хода (по 2 сообщения)

def answer(history: list, user_text: str) -> str:
    history.append({"role": "user", "content": user_text})
    resp = client.chat.completions.create(
        model="gpt-6.1-sol",
        messages=[SYSTEM] + history[-KEEP:],
    )
    text = resp.choices[0].message.content
    history.append({"role": "assistant", "content": text})
    return text

Пересчёт для тех же 20 ходов: на ходе n в окне не больше трёх прошлых ходов, то есть вход равен 600 + 400 × min(n − 1, 3). Итого 33 600 входных токенов вместо 88 000. Стоимость на gpt-6.1-sol: 33 600 × 0.27 / 1e6 + 0.00792 = $0.01699, против $0.03168.

Цена такого подхода: бот забывает всё, что старше окна, включая имя пользователя и договорённости. Поэтому окно почти всегда сочетают со вторым способом.

Способ 2. Суммаризация старой части

Когда история превышает порог, старые реплики сворачивают в краткую выжимку, а в запрос идут выжимка плюс последние реплики.

bash
def compress(history: list, summary: str) -> tuple[list, str]:
    old, recent = history[:-4], history[-4:]
    if not old:
        return history, summary
    text = "\n".join(f"{m['role']}: {m['content']}" for m in old)
    resp = client.chat.completions.create(
        model="deepseek-v4.1-flash",
        messages=[
            {"role": "system", "content": "Сожми диалог в заметки на 150 слов: факты о пользователе, договорённости, открытые вопросы. Ничего не выдумывай."},
            {"role": "user", "content": f"Прежние заметки: {summary}\n\nНовые реплики:\n{text}"},
        ],
    )
    return recent, resp.choices[0].message.content

Выжимку подмешивайте в системное сообщение: "Заметки о диалоге: ...". Сам вызов суммаризации стоит немного: 3000 токенов входа и 300 выхода на deepseek-v4.1-flash — 3000 × 0.013 / 1e6 + 300 × 0.038 / 1e6 ≈ $0.00005. Минус — потери: выжимка теряет детали, а ошибка в ней закрепляется. Для важных фактов (имя, номер заказа, ограничения) надёжнее хранить их отдельно структурой в базе и подставлять в промпт.

Способ 3. Кэш промпта

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

Формулу расчёта для Claude вы найдёте в статье Кэширование промптов: чтение и запись; в двух словах: чтение кэша стоит заметно дешевле входа, запись — дороже. Важно, через какой эндпоинт идёт бот на Claude: только в /v1/messages шлюз получает разбивку на чтение и запись кэша и считает их по своим ценам, а через /v1/chat/completions вся история Claude оплачивается как обычный вход.

Пример для claude-sonnet-5-5 (вход 0.4, выход 2, чтение кэша 0.05, запись 0.5), те же 20 ходов. В каждом запросе, кроме первого, кэшируется всё, что было в предыдущем (чтение), а новые 400 токенов (прошлый ответ и новая реплика) записываются в кэш:

чтение: 79 800 × 0.05 / 1e6 = $0.00399 запись: 8 200 × 0.5 / 1e6 = $0.0041 выход: 6 000 × 2 / 1e6 = $0.012 итого: $0.02009 без кэша: 88 000 × 0.4 / 1e6 + 0.012 = $0.0472

Код на официальном Anthropic SDK (адрес без /v1, SDK добавит его сам):

bash
import anthropic

client = anthropic.Anthropic(base_url="https://api.zukko.pro", api_key="fetch_...")

def answer(history: list, user_text: str) -> str:
    history.append({"role": "user", "content": user_text})
    # метка кэша только на последней реплике: у запроса ограничено число меток
    messages = history[:-1] + [{"role": "user", "content": [
        {"type": "text", "text": user_text, "cache_control": {"type": "ephemeral"}}
    ]}]
    resp = client.messages.create(
        model="claude-sonnet-5-5",
        max_tokens=1024,
        system="Ты вежливый помощник магазина.",
        messages=messages,
    )
    u = resp.usage
    print(u.input_tokens, u.cache_read_input_tokens, u.cache_creation_input_tokens)
    text = resp.content[0].text
    history.append({"role": "assistant", "content": text})
    return text

Метка cache_control на последней реплике просит закэшировать запрос до этого места; ставьте её только на последнюю реплику, число меток в запросе ограничено. У кэша есть минимальная длина префикса и ограниченное время жизни (у Anthropic по умолчанию порядка пяти минут), поэтому в коротких диалогах и при долгих паузах выигрыш меньше расчётного. Сверьтесь с актуальной документацией Anthropic по кэшированию.

У GPT-моделей кэш виден в обоих форматах: прочитанные из кэша токены приходят в usage.prompt_tokens_details.cached_tokens, а отдельной платы за запись нет. Если кэш сработал на всей накопленной части, для gpt-6.1-sol (чтение 0.013) расчёт для 20 ходов даёт 79 800 × 0.013 / 1e6 + 8 200 × 0.27 / 1e6 + 0.00792 ≈ $0.01117. Это верхняя граница выигрыша: сработает ли кэш и на какой доле запроса, определяет провайдер, поэтому смотрите на реальные значения cached_tokens.

Что выбрать

ЗадачаКомбинация
Короткие диалоги из поддержкискользящее окно, кэш не нужен
Длинный диалог, нужны деталиокно + выжимка + структурированные факты в базе
Одинаковый большой системный промпт у всехкэш промпта на системной части
Ассистент с долгой историей у одного пользователявсё вместе

Перед тем как внедрять сложную схему, измеряйте: посмотрите в кабинете расход по ключу за день и сравните с числом диалогов. Размер запроса можно узнать заранее через эндпоинт подсчёта токенов. Если вы выбираете модель под бота, пригодится статья Как выбрать модель для чат-бота.

Чтобы попробовать, создайте ключ в кабинете, подставьте его в пример выше и выведите usage после каждого хода: график роста входа вы увидите сами.

Частые вопросы

Почему модель «забыла», что я говорил в прошлый раз?

Потому что в этот запрос история не попала. Между запросами модель ничего не хранит. Если пользователь вернулся на следующий день, историю нужно достать из вашей базы и снова отправить или передать выжимку.

Влезет ли весь диалог в контекстное окно?

Для большинства моделей с окном 1M токенов да, и очень надолго. Но влезть и быть дешёвым — разные вещи: чем длиннее история, тем больше платите за вход в каждом запросе.

Надо ли хранить историю в базе данных?

Если бот живёт дольше одного процесса или обслуживает много пользователей, да. Подойдёт любая база с ключом по идентификатору диалога. Храните исходные реплики, а выжимку считайте как производную.

Можно ли сочетать суммаризацию и кэш?

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

zukko

Платите в 25×
меньше

за Claude Code & ChatGPT

Один ключ fetch_* и base URL api.zukko.pro — в Cursor, Claude Code, Codex и любом SDK.