Память чат-бота: как хранить историю диалога и не платить за неё дважды
Модель не помнит предыдущих реплик: историю отправляете вы при каждом запросе. Разбираем, как растёт цена диалога и чем её сдерживать.
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) токенов: промпт и новая реплика плюс вся накопленная история.
| Ходов в диалоге | Входных токенов за весь диалог |
|---|---|
| 10 | 24 000 |
| 20 | 88 000 |
| 40 | 336 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 реплик.
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. Суммаризация старой части
Когда история превышает порог, старые реплики сворачивают в краткую выжимку, а в запрос идут выжимка плюс последние реплики.
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 добавит его сам):
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.
Похожие статьи
- LangChain text splitters: как резать текст на чанки правильноКак подобрать размер чанка и перекрытие в RecursiveCharacterTextSplitter, почему считать нужно в токенах и как сверить подсчёт с точным количеством от шлюза.
- LangChain RAG: рабочий пример с моделью через шлюзСтроим простой RAG на LangChain: документы, чанки, векторный поиск и ответ модели через шлюз. Считаем, из чего складывается цена вопроса, и режем лишние токены.
- Агенты opencode: как настроить роли, модели и инструментыСвой агент в opencode — это роль, модель и список разрешений. Показываем конфиг и пример, где рутину делает дешёвая модель, а код правит сильная.
- Plan mode в Claude Code: сначала план, потом правки в кодеВ plan mode Claude Code читает код и предлагает план, но ничего не правит, пока вы не согласитесь. Как включить, использовать и во сколько это обходится.