Проблема: модель без памяти
Представь, что после каждого предложения разговора твой собеседник полностью забывает всё, что было сказано. Ровно так работает LLM «из коробки»: каждый запрос к API независим, модель не хранит состояние между вызовами.
Попробуй без управления историей:
import anthropic
client = anthropic.Anthropic()
# Вызов 1
r1 = client.messages.create(
model="claude-opus-4-6", max_tokens=64,
messages=[{"role": "user", "content": "Меня зовут Иван."}]
)
print(r1.content[0].text) # «Привет, Иван! Рад познакомиться.»
# Вызов 2 — модель ничего не помнит
r2 = client.messages.create(
model="claude-opus-4-6", max_tokens=64,
messages=[{"role": "user", "content": "Как меня зовут?"}]
)
print(r2.content[0].text) # «Не знаю, вы не представились.»
Проблема: каждый вызов messages.create() — чистый лист.
Второй запрос не знает о первом, потому что мы передали только одно сообщение,
а не всю историю. Память — это то, что мы сами вкладываем в параметр
messages.
Stateless API — не баг, а фича. Это позволяет масштабировать модель горизонтально, не хранить состояние на стороне провайдера, гарантировать воспроизводимость. Задача управления памятью полностью лежит на разработчике агента — это даёт полный контроль над тем, что помнит агент.
Контекстное окно как память
Context window — это весь текст, который модель «видит» за один вызов: системный промпт, история сообщений, текущий запрос, доступные инструменты. Всё это измеряется в токенах — примерно 3/4 слова или 4 символа на токен.
Концептуально context window — это и есть краткосрочная память агента. Всё, что находится в окне, модель «помнит» и учитывает при генерации ответа. Всё, что за его пределами — не существует.
Размеры контекстных окон
У разных моделей — разные лимиты. При выборе стратегии важно знать, с каким бюджетом работаешь:
| Модель | Context window | Практический лимит истории | Стратегия |
|---|---|---|---|
| Claude (Opus/Sonnet/Haiku) | 200 000 tok | ~150 000 tok под историю | Суммаризация при длинных сессиях |
| GPT-4o | 128 000 tok | ~100 000 tok под историю | Суммаризация при многочасовых сессиях |
| GPT-4o mini | 128 000 tok | ~100 000 tok под историю | Sliding window или суммаризация |
| Llama 3 (70B) | 8 000 tok | ~5 000 tok под историю | Агрессивная суммаризация обязательна |
Input-токены тарифицируются при каждом вызове. Если история в 10 000 токенов накопилась и не управляется — каждый новый запрос стоит в 10 раз дороже первого. При ставке $3 / 1M токенов и 1 000 сообщений в день это $30/день только на историю. Управление памятью — это и вопрос качества, и вопрос экономии.
История сообщений и базовый цикл
История сообщений в Anthropic API — это список объектов с полями
role и content. Роли: user и
assistant. Системный промпт передаётся отдельным параметром.
Каждый новый запрос включает всю предыдущую историю — так модель «помнит»
контекст диалога.
Вот как выглядит история из трёх ходов перед четвёртым запросом:
При четвёртом запросе в API летят все шесть сообщений разом. Модель видит полный контекст — поэтому понимает, что «кэш» относится к LRU-кэшу из первого вопроса.
Базовый цикл диалога
from anthropic import Anthropic
client = Anthropic()
SYSTEM = "Ты — ассистент для написания кода на Python."
def run_chat():
"""Простой диалоговый цикл с накоплением истории."""
messages: list[dict] = []
while True:
user_input = input("Вы: ").strip()
if not user_input or user_input.lower() in ("выход", "exit", "quit"):
break
# Добавляем сообщение пользователя в историю
messages.append({"role": "user", "content": user_input})
# Отправляем ВСЕЮ историю в API
response = client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
system=SYSTEM,
messages=messages # ← вся история
)
assistant_text = response.content[0].text
print(f"Агент: {assistant_text}\n")
# Сохраняем ответ ассистента — он войдёт в следующий запрос
messages.append({"role": "assistant", "content": assistant_text})
if __name__ == "__main__":
run_chat()
Anthropic API требует строгого чередования ролей: user → assistant → user → assistant.
Нельзя поставить два user-сообщения подряд — API вернёт ошибку.
При сборке истории следи за этим инвариантом. Первое сообщение всегда должно быть
user. Системный промпт передаётся отдельным параметром system=,
а не в массиве messages.
Токены: считаем и контролируем
Проблема неуправляемой истории проявляется постепенно. Сначала всё работает, потом запросы дорожают, а на N-ном шаге API возвращает ошибку о превышении лимита. Нужно считать токены до отправки.
Anthropic SDK предоставляет метод count_tokens() — точный подсчёт
токенов без реального вызова модели (billing-точный, как если бы запрос был отправлен):
def count_tokens(messages: list[dict], system: str = "") -> int:
"""Считает input-токены для списка сообщений."""
response = client.messages.count_tokens(
model="claude-opus-4-6",
system=system,
messages=messages
)
return response.input_tokens
# Пример использования
messages = [
{"role": "user", "content": "Привет!"},
{"role": "assistant", "content": "Привет! Как могу помочь?"},
{"role": "user", "content": "Расскажи про Python."},
]
tokens = count_tokens(messages, system="Ты — ассистент.")
print(f"История занимает {tokens} токенов")
# → История занимает 47 токенов
count_tokens() делает лёгкий API-запрос (без генерации).
Для грубой оценки можно считать символы: ~4 символа на токен.
Это быстрее, но менее точно, особенно для текстов с числами, кодом и не-ASCII символами.
Для критичного управления лимитами — используй count_tokens().
Теперь добавим контроль токенов в цикл диалога — и увидим, как растёт история:
MAX_CONTEXT = 8_000 # ограничение для примера (модели поддерживают до 200 000)
SYSTEM = "Ты — ассистент для написания кода на Python."
def chat_with_metrics():
"""Диалог с отображением роста истории."""
messages: list[dict] = []
while True:
user_input = input("Вы: ").strip()
if not user_input:
break
messages.append({"role": "user", "content": user_input})
# Считаем токены ПЕРЕД отправкой
current_tokens = count_tokens(messages, system=SYSTEM)
fill_pct = current_tokens / MAX_CONTEXT * 100
print(f" [tokens: {current_tokens}/{MAX_CONTEXT} = {fill_pct:.0f}%]")
if current_tokens > MAX_CONTEXT * 0.9:
print(" [⚠ контекст заполнен на 90%+ — нужно управление памятью]")
response = client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
system=SYSTEM,
messages=messages
)
text = response.content[0].text
print(f"Агент: {text}\n")
messages.append({"role": "assistant", "content": text})
Четыре стратегии управления историей
Когда история начинает угрожать лимиту, нужна одна из четырёх стратегий. Выбор зависит от требований к качеству, latency и стоимости:
Оставляем только последние N сообщений. Старые удаляются целиком.
- Просто реализовать
- Предсказуемый размер
- Нулевые доп. расходы
- Теряет ранний контекст
- Не работает при длинных сессиях
Удаляем старые пары сообщений, пока общий объём не войдёт в лимит.
- Точный контроль бюджета
- Гибкость по размеру
- Также теряет ранний контекст
- Требует подсчёта токенов
LLM сжимает старые сообщения в краткое резюме. Резюме остаётся, оригиналы удаляются.
- Сохраняет ранний контекст
- Сжатие ~10–20× по токенам
- Доп. вызов LLM = задержка
- Риск потери деталей
Резюме старых сообщений + полный текст последних N ходов. Баланс между контекстом и точностью.
- Ранний контекст через резюме
- Точный недавний контекст
- Сложнее реализовать
- Доп. вызов LLM
Визуально — что находится в context window для каждой стратегии при 15 ходах диалога, бюджет 8 000 токенов:
Реализация: sliding window и token trimming
Sliding window
Самая простая стратегия — оставлять только последние N сообщений.
Важная деталь: список должен начинаться с user-сообщения,
поэтому после обрезки проверяем роль первого элемента.
def sliding_window(
messages: list[dict],
max_messages: int = 20
) -> list[dict]:
"""
Оставляет последние max_messages сообщений.
Гарантирует, что первое сообщение — user (инвариант API).
"""
if len(messages) <= max_messages:
return messages
trimmed = messages[-max_messages:]
# Если после обрезки первым оказался assistant — убираем его
if trimmed and trimmed[0]["role"] == "assistant":
trimmed = trimmed[1:]
return trimmed
# Применение в цикле
messages = sliding_window(messages, max_messages=20)
response = client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
system=SYSTEM,
messages=messages
)
Token trimming
Вместо фиксированного числа сообщений — контроль по токенам.
Удаляем самую старую пару user + assistant, пока не уложимся в лимит:
def trim_to_tokens(
messages: list[dict],
max_tokens: int = 50_000,
system: str = ""
) -> list[dict]:
"""
Удаляет старые сообщения, пока объём не войдёт в max_tokens.
Удаляет парами (user + assistant), чтобы не нарушить инвариант чередования.
Останавливается при <= 2 сообщениях (минимально нужное).
"""
messages = list(messages) # не мутируем оригинал
while len(messages) > 2:
current = count_tokens(messages, system=system)
if current <= max_tokens:
break
# Удаляем первую пару user + assistant
if len(messages) >= 2 and messages[0]["role"] == "user":
messages = messages[2:]
else:
messages = messages[1:]
# Убеждаемся, что после удаления первым идёт user
if messages and messages[0]["role"] == "assistant":
messages = messages[1:]
return messages
Каждый вызов count_tokens() — это API-запрос (без генерации, но не бесплатный
по времени). В trim_to_tokens цикл может вызвать его несколько раз.
Для высоконагруженных систем кэшируй результат или используй оценку по символам
(len(text) / 4) как первый фильтр, а точный подсчёт — только перед финальной отправкой.
Паттерн скользящей суммаризации
Суммаризация — наиболее умная стратегия: вместо удаления истории мы её сжимаем. LLM (обычно быстрая и дешёвая модель) превращает 10–20 сообщений в 3–5 предложений. Резюме сохраняет суть диалога при многократно меньшем числе токенов.
Ключевые параметры суммаризатора:
- Модель — для суммаризации достаточно
claude-haiku-4-5-20251001: он быстрее и дешевле, а качество резюме не уступает более крупным моделям - Промпт — явно указываем, что сохранять: факты, имена, числа, решения
- Порог — при каком объёме запускать суммаризацию (80–90% лимита)
- Сколько сохранять «живыми» — последние 4–6 сообщений оставляем нетронутыми для точности самого недавнего контекста
SUMMARIZE_PROMPT = """Сожми переписку в краткое резюме (3-5 предложений).
Обязательно сохрани:
- Ключевые факты и цифры
- Принятые решения
- Имена, названия, технологии
- Незакрытые вопросы и задачи
Пиши в прошедшем времени, сжато. Не добавляй мнений."""
def summarize(messages_to_compress: list[dict]) -> str:
"""
Суммаризирует список сообщений в один текстовый блок.
Использует быструю и дешёвую модель.
"""
# Форматируем переписку как текст
conversation = "\n".join(
f"{m['role'].upper()}: {m['content']}"
for m in messages_to_compress
)
response = client.messages.create(
model="claude-haiku-4-5-20251001", # дешевле для вспомогательных задач
max_tokens=400,
messages=[{
"role": "user",
"content": f"{SUMMARIZE_PROMPT}\n\n---\n{conversation}"
}]
)
return response.content[0].text.strip()
def maybe_summarize(
messages: list[dict],
system: str,
token_threshold: int = 6_000,
keep_recent: int = 4
) -> tuple[list[dict], str]:
"""
Проверяет объём истории и при превышении порога суммаризирует старые сообщения.
Возвращает:
messages — обновлённая история (только «живые» сообщения)
summary — текущее резюме (пустая строка если суммаризации не было)
"""
if count_tokens(messages, system=system) <= token_threshold:
return messages, ""
# Делим историю: что сжимаем и что оставляем нетронутым
to_compress = messages[:-keep_recent]
recent = messages[-keep_recent:]
if not to_compress:
return messages, ""
summary_text = summarize(to_compress)
return recent, summary_text
ConversationMemory: полная реализация
Соберём всё вместе в класс ConversationMemory, который:
- Хранит историю и накопленное резюме
- Автоматически суммаризирует при превышении порога
- Мёрджит новое резюме с предыдущим (если оно уже есть)
- Возвращает готовый список сообщений для API — с резюме как первым блоком
Сначала посмотрим, как меняется состояние памяти на протяжении 14 ходов диалога:
Теперь — код:
from dataclasses import dataclass, field
import anthropic
client = anthropic.Anthropic()
SYSTEM = "Ты — полезный ассистент."
@dataclass
class ConversationMemory:
"""
Краткосрочная память агента с автоматической суммаризацией.
messages — «живые» сообщения (недавние, полный текст)
summary — сжатый контекст всего предыдущего диалога
token_threshold — при превышении этого порога запускается суммаризация
keep_recent — сколько последних сообщений оставлять нетронутыми
"""
messages: list[dict] = field(default_factory=list)
summary: str = ""
token_threshold: int = 6_000
keep_recent: int = 6
def add(self, role: str, content: str) -> None:
"""Добавляет сообщение и при необходимости суммаризирует историю."""
self.messages.append({"role": role, "content": content})
self._maybe_compress()
def get_messages(self) -> list[dict]:
"""
Возвращает список сообщений для отправки в API.
Если есть резюме — вставляет его как первый блок (user + ack).
"""
if not self.summary:
return list(self.messages)
# Резюме оборачиваем в user/assistant пару,
# чтобы не нарушать инвариант чередования
return [
{
"role": "user",
"content": f"[Резюме предыдущего диалога]\n{self.summary}"
},
{
"role": "assistant",
"content": "Понял, учту контекст."
},
*self.messages
]
def _maybe_compress(self) -> None:
"""Суммаризирует старые сообщения при превышении порога токенов."""
context = self.get_messages()
if count_tokens(context, system=SYSTEM) <= self.token_threshold:
return
# Делим историю: что сжимаем и что оставляем
to_compress = self.messages[:-self.keep_recent]
recent = self.messages[-self.keep_recent:]
if not to_compress:
return # нечего сжимать — слишком мало сообщений
new_summary = summarize(to_compress)
# Мёрджим с предыдущим резюме, если оно уже есть
if self.summary:
merged_input = (
f"Предыдущее резюме:\n{self.summary}\n\n"
f"Новые события для добавления:\n{new_summary}"
)
self.summary = summarize([
{"role": "user", "content": merged_input}
])
else:
self.summary = new_summary
self.messages = list(recent)
print(f" [memory] суммаризация: {len(to_compress)} сообщ. → резюме")
Агентский цикл с памятью
def run_agent_with_memory():
"""Агент с управляемой краткосрочной памятью."""
memory = ConversationMemory(
token_threshold=6_000, # суммаризировать при ~6к токенов
keep_recent=6 # последние 3 хода всегда «живые»
)
while True:
user_input = input("Вы: ").strip()
if not user_input:
break
# Добавляем сообщение пользователя
memory.add("user", user_input)
# Получаем контекст для API (с резюме если есть)
api_messages = memory.get_messages()
response = client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
system=SYSTEM,
messages=api_messages
)
assistant_text = response.content[0].text
print(f"Агент: {assistant_text}\n")
# Сохраняем ответ ассистента
memory.add("assistant", assistant_text)
if __name__ == "__main__":
run_agent_with_memory()
Хорошее эмпирическое правило: устанавливай token_threshold в 30–40%
от лимита контекстного окна. Для Claude с 200 000 токенов — это 60 000–80 000 токенов.
Суммаризация запускается редко, зато резюме умещается в оставшиеся 60–70% окна.
Слишком низкий порог = частые суммаризации = больше накладных расходов.
Типичные ошибки
assistant,
или два user-сообщения идут подряд. API возвращает ошибку
валидации. Это незаметно до первого «пограничного» запроса.
user, роли чередуются.trim_to_tokens изменяют входной список напрямую —
это приводит к непредсказуемому поведению при повторных вызовах или
совместном использовании ссылки на список.
messages = list(messages) или messages.copy() в начале функции.keep_recent=4–6 последних сообщений нетронутыми — это гарантирует точность ближайшего контекста.ConversationMemory живёт в памяти процесса. При перезапуске агента
история и резюме исчезают. Это нормально для одной сессии, но не для агентов
с долгосрочным контекстом (пользователь возвращается завтра).
memory.summary и memory.messages в БД или файл. Долгосрочная память — тема следующего урока.Шпаргалка
- Память LLM — это то, что ты кладёшь в
messages=при каждом вызове client.messages.count_tokens()→ точный подсчёт input-токенов без генерации- Инвариант: первое сообщение —
user, роли строго чередуются - Системный промпт → параметр
system=, не в массивmessages - Sliding window:
messages[-N:]→ простейшая защита от переполнения - Token trimming: удаляй парами user+assistant, пока не уложишься в лимит
- Суммаризатор: быстрая модель (
haiku), промпт «сохрани факты и решения» - Гибрид:
[system] + [summary_pair] + [last 4–6 messages] - Порог суммаризации = 30–40% лимита;
keep_recent=4–6сообщений «живыми» - Мёрдж резюме:
старое резюме + новые события → суммаризатор → объединённое резюме ConversationMemory.get_messages()возвращает готовый список для API
Практические задания
-
Измерь рост истории.
Запусти
run_chat()и проведи 20 ходов диалога. Выводи число токенов перед каждым запросом. Построй график роста: os x — номер хода, os y — токены. Когда история начинает становиться проблемой при стандартных длинах ответов? -
Реализуй все три стратегии.
Добавь в
ConversationMemoryпараметрstrategy:"window"|"trim"|"summarize". Проведи одинаковый 30-ходовой диалог с каждой стратегией. Сравни: итоговый размер контекста, стоимость (токены × тариф), качество ответов модели (помнит ли детали из первых ходов?). -
Персистентная память.
Добавь в
ConversationMemoryметодыsave(path)иload(path)— сохранение в JSON-файл. При старте агента загружай сохранённую память. Убедись, что после перезапуска агент помнит детали из предыдущей сессии (имя пользователя, задачи, которые обсуждались).