Проблема: модель без памяти

Представь, что после каждого предложения разговора твой собеседник полностью забывает всё, что было сказано. Ровно так работает 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 по дизайну

Stateless API — не баг, а фича. Это позволяет масштабировать модель горизонтально, не хранить состояние на стороне провайдера, гарантировать воспроизводимость. Задача управления памятью полностью лежит на разработчике агента — это даёт полный контроль над тем, что помнит агент.

Контекстное окно как память

Context window — это весь текст, который модель «видит» за один вызов: системный промпт, история сообщений, текущий запрос, доступные инструменты. Всё это измеряется в токенах — примерно 3/4 слова или 4 символа на токен.

Концептуально context window — это и есть краткосрочная память агента. Всё, что находится в окне, модель «помнит» и учитывает при генерации ответа. Всё, что за его пределами — не существует.

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
① Накопление context window system 50 tok user M1 12 tok asst M1 340 tok user M2 asst M2 user M3 ... asst M3 ... ⚠ переполнение ~8 000 tok ~8 000 / 8 000 токенов контекст заполнен сумма- ризация ② После сжатия context window system 50 tok РЕЗЮМЕ (M1 – M6) Пользователь обсудил X, решили Y... ~120 tok user M7 asst M7 user M8 (текущий) свободно ~5 800 tok ~2 200 / 8 000 токенов 75% окна свободно диалог продолжается ③ Следующий цикл context window system 50 tok РЕЗЮМЕ (M1 – M12) Расширенный контекст: задача, решения, артефакты... ~180 tok user M13 asst M13 user M14 (текущий) свободно ~5 400 tok ~2 600 / 8 000 токенов паттерн стабилизировался системный промпт резюме user-сообщение ответ ассистента свободное место

Размеры контекстных окон

У разных моделей — разные лимиты. При выборе стратегии важно знать, с каким бюджетом работаешь:

Модель 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. Системный промпт передаётся отдельным параметром. Каждый новый запрос включает всю предыдущую историю — так модель «помнит» контекст диалога.

Вот как выглядит история из трёх ходов перед четвёртым запросом:

system Ты — ассистент для написания кода. Пиши на Python, кратко объясняй решения. ~22 tok
user Как реализовать LRU-кэш на Python? ~10 tok
assistant Используй functools.lru_cache или OrderedDict вручную. Вот пример... ~380 tok
user Как установить максимальный размер кэша? ~8 tok
assistant В lru_cache параметр maxsize=128. Например: @lru_cache(maxsize=256)... ~120 tok
user ← отправляем сейчас: А как сбросить кэш? ~7 tok

При четвёртом запросе в 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 токенов
💡 Быстрая оценка без API-вызова

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 токенов:

Скользящее окно (последние 6 сообщений)
sys
M10–M15 (6 сообщ.)
свободно
Гибрид: резюме M1–M10 + последние 4 сообщения
sys
резюме
M12–M15 (4 сообщ.)
свободно
Без управления (все 15 сообщений)
sys
M1–M15 (все сообщения)
системный промпт
резюме
история
свободно

Реализация: 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() в цикле = доп. расходы

Каждый вызов 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 ходов диалога:

Ходы M1 – M6
[system]  50 tok
user M1 12 tok
asst M1 340 tok
user M2 8 tok
asst M2 280 tok
user M3 15 tok
asst M3 400 tok
Ходы M7 – M8 → суммаризация
[system]  50 tok
РЕЗЮМЕ M1–M6 ~120 tok
[ack резюме] ~10 tok
user M7 18 tok
asst M7 350 tok
user M8 11 tok
asst M8 290 tok
Ходы M13 – M14 → 2-я суммаризация
[system]  50 tok
РЕЗЮМЕ M1–M12 ~160 tok
[ack резюме] ~10 tok
user M13 9 tok
asst M13 310 tok
user M14 13 tok

Теперь — код:

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 последних сообщений нетронутыми — это гарантирует точность ближайшего контекста.
Слишком агрессивная суммаризация
Порог слишком мал — суммаризация запускается после каждых 2–3 ходов. Результат: постоянные доп. вызовы LLM, рост latency, и резюме теряет детали из-за многократного сжатия.
Устанавливай порог 30–40% от лимита контекста. Суммаризация должна запускаться не чаще, чем раз в 10–15 ходов.
Потеря резюме между сессиями
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

Практические задания

  1. Измерь рост истории. Запусти run_chat() и проведи 20 ходов диалога. Выводи число токенов перед каждым запросом. Построй график роста: os x — номер хода, os y — токены. Когда история начинает становиться проблемой при стандартных длинах ответов?
  2. Реализуй все три стратегии. Добавь в ConversationMemory параметр strategy: "window" | "trim" | "summarize". Проведи одинаковый 30-ходовой диалог с каждой стратегией. Сравни: итоговый размер контекста, стоимость (токены × тариф), качество ответов модели (помнит ли детали из первых ходов?).
  3. Персистентная память. Добавь в ConversationMemory методы save(path) и load(path) — сохранение в JSON-файл. При старте агента загружай сохранённую память. Убедись, что после перезапуска агент помнит детали из предыдущей сессии (имя пользователя, задачи, которые обсуждались).