AI-агенты - это цикл обмена сообщениями между пользователем и языковой моделью (LLM), где для ответа пользователю модель может обратиться к доступным ей напрямую инструментам (tools) или через настроенные для неё MCP. Каждое такое взаимодействие дописывает историю диалога. Для простоты часто считают, что вся эта история и уходит в любой следующий вызов модели - иначе она «не вспомнит», о чём шла речь, и криво соберёт ответ.

История «не резиновая» - у современных моделей контекстное окно может быть огромным, но умение работать с длинным логом сильно зависит от того, насколько он структурирован. Плюс накопленная история разговора и реальный контекст, который модель видит на очередном ходе, не всегда одно и то же. В последнее время это один из фокусов развития агентских систем в рамках context engineering: что сжать, что оставить снаружи, что подтянуть инструментами только когда нужно.

В этой статье хочу рассказать про эволюцию подхода работы с длинной историей и где мы находимся сейчас. Примеры будут на LangChain - на открытом стеке легко посмотреть реализацию, которая в готовых продуктах часто спрятана. При этом LangChain достаточно популярный, развивающийся фреймворк - остальные либо делают похожие вещи, либо сами опираются на него как на базу.

Почему история агента вообще становится проблемой

Мозг агента - большая языковая модель (LLM). Текст режется на токены, модель предсказывает следующий токен, из цепочки токенов снова собирают текст. У модели есть контекстное окно: сколько информации она может учесть, когда делает очередное предсказание.

Часто на учебных слайдах рисуют «весь вход → модель → весь ответ» и контекстным окном называют «весь вход», но генерация считает окно иначе: каждый новый токен предсказывается уже с учётом предыдущих. Для последнего токена в полном ответе контекстом будет весь вход + опционально большой блок thinking/reasoning (в UI его может и не быть) + все уже сгенерированные токены ответа, кроме самого последнего, который как раз сейчас предсказывают. Т.е. ответ сам увеличивает требования к размеру контекстного окна.

Размеры контекстных окон за последние годы сильно выросли. Когда-то нормой были тысячи токенов, потом десятки тысяч, сейчас они могут считаться в миллионах. Гонка лимитов не отменила вторую проблему: большой контекст не значит, что конкретный факт в нем так же легко найти, как в маленьком. Три чётких факта в коротком окне модель вытащит увереннее, чем их же среди тысячи похожих записей в длинном контексте. Более того, качество зависит от места данных внутри контекста. Классическая работа Lost in the Middle показывает U-образную кривую: начало и конец длинного входа используются лучше, середина - хуже.

Отсюда две причины работать над сокращением истории агента - вернее, того, что попадает в контекст. Либо окно физически кончается, и его надо как-то сжать. Либо, даже при огромном теоретическом размере контекста, много разнородной информации в контексте часто ухудшает работу с конкретными фактами из истории.

Практический вывод, который современные harness’ы уже усвоили: один бесконечный неструктурированный диалог «обо всём» - плохой режим. Удобнее держать контекст вокруг текущей задачи, а не копить годы переписки в одном потоке сообщений (messages) - даже если для пользователя снаружи это всё ещё один чат.

Пробежимся по истории эволюции подходов.

Обрезка хвоста: ещё не суммаризация, но близка по идее

Самый простой способ уменьшить контекст - «помнить только недавнее» т.е. оставить последние N сообщений, а остальное выбросить. Это как «память золотой рыбки», которая просто забывает всё, что было за пределами последних 3 секунд (чтобы не оскорбить золотых рыбок, биологи давно доказали, что память у них на самом деле гораздо лучше).

Обрезка хвоста: keep last N
Обрезка хвоста: keep last N

В экосистеме LangChain чистый rolling window для всей истории агента - обрезка списка сообщений trim_messages:

from langchain_core.messages.utils import trim_messages

trimmed = trim_messages(
    messages,                    # исходный список сообщений истории
    max_tokens=4000,             # бюджет: сколько токенов максимум оставить
    strategy="last",             # брать с конца (свежий хвост), старое отбросить
    token_counter="approximate", # как считать токены; есть несколько подходов с разным балансом скорости и точности
)

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

Вместо хвоста - блок сводки (summary)

Следующий ход очевидный - старое не просто удалить, а сжать отдельным запросом к модели и положить в историю короткий блок сводки (summary). Диалог продолжается «примерно помня», что было. Типичная форма после срабатывания:

[сводка старого] + [свежий хвост сообщений]

Сводка старого + свежий хвост
Сводка старого + свежий хвост

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

В LangChain этот паттерн ближе всего к SummarizationMiddleware у create_agent: при пороге (trigger) по числу сообщений, токенов или доле окна старая часть уходит в вызов суммаризатора, хвост задаётся политикой keep.

from langchain.agents import create_agent
from langchain.agents.middleware import SummarizationMiddleware

agent = create_agent(
    model=model,   # основная модель агента
    tools=tools,   # инструменты агента
    middleware=[
        SummarizationMiddleware(       # middleware сжатия истории через сводку
            model=model,               # может быть отдельная (часто более дешёвая) модель для сводки
            # summary_prompt=...,      # свой промпт суммаризатора; если не указать - дефолт из middleware
            trigger=("tokens", 4000),  # когда сжимать: порог по токенам / messages / fraction
            keep=("messages", 20),     # сколько свежего хвоста оставить после сводки
        ),
    ],
)

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

Жать середину, беречь начало и хвост

Когда разобрались, что модели хуже достают факты из середины длинного входа (Lost in the Middle), следующим логичным шагом для агентов стало оставить завязку диалога и свежий хвост, а серединку сжать. Для агентских сценариев это часто даже важнее, чем в общих LLM-кейсах из той работы: в начале обычно лежит постановка задачи и ключевые ограничения, а середина - длинное обсуждение деталей, которое как раз удобнее свернуть.

Форма:

[начало] + [сводка середины] + [свежий хвост]

Начало + сводка середины + хвост
Начало + сводка середины + хвост

По сути это тот же приём «сжать старое в summary», только в «старое» не пускают первые N сообщений: их явно защищают. Тогда режется именно середина между защищённым началом и хвостом.

Штатный SummarizationMiddleware в LangChain так не умеет: у него есть keep (свежий хвост), но нет параметра «оставь ещё и первые N». Если такая защита нужна, её несложно дописать.

Сам подход живой, например в Hermes Agent как раз protect_first_n + защита хвоста и LLM-сводка куска между ними - сборка head / summary / tail. У OpenRouter близкий по мотиву middle-out / context compression: края берегут, середину ужимают, чтобы влезть в окно; там чаще truncate/сжатие середины, а не обязательно отдельный вызов суммаризатора - но форма «режем середину, края важнее» та же.

Новый подход: жать контекст, но не уничтожать историю

У всех вариантов со сводкой (summary) вместо оригинальной истории одна общая проблема - любое сжатие через LLM это сжатие с потерями (lossy). Модель-суммаризатор решает, что будет важно для продолжения разговора. Она может угадать, и всё будет хорошо, может не угадать и выкинуть нужное, а может перефразировать историю так, что для будущего хода смысл просто поменяется. Ведь сводка - это просто применение определённого промпта к истории. Часть проблем снимается, если агент специализированный под конкретные задачи и вы сами в промпте суммаризатора прописали, что важно оставлять, а что нет, но даже это не всегда спасает.

Без сводки длинный агентский разговор может упереться в окно. Со сводкой он продолжает работать, но уже по «пересказу исходного диалога». Если ваш диалог - каталог артикулов, «сжать без потери смысла» почти невозможно: смысл и есть перечень.

Отсюда развилка. Можно считать, что история сообщений и есть единственная память, и продолжать её переписывать всё более умными сводками. Можно развести два понятия: накопленная информация и рабочий контекст следующего вызова модели (некий view на накопленное).

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

На суммаризации истории это выглядит так.

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

  1. Выбирает кусок, который пора сжать в сводку - например всё старше последних N сообщений.

  2. Строит сводку по этому куску и кладёт её в состояние агента (это ещё не контекст вызова, а заготовка для view).

  3. Сам выбранный кусок (или полный лог) сохраняет во внешнее хранилище: файл на backend / виртуальной FS агента - чтобы сырьё не исчезло.

  4. В следующий вызов модели собирает view: свежий хвост (и иногда начало) + сводка вместо вытесненного куска. Полная история формально никуда не делась; в окно идёт собранный срез.

  5. Агенту оставляют инструменты вроде read_file / grep, чтобы при необходимости открыть файл полной истории и достать деталь, которой нет в текущем контексте.

Новый подход к с выгрузкой полной истории на диск
Новый подход к с выгрузкой полной истории на диск

В прошлом году LangChain выпустил дополнительную библиотеку Deep Agents (расширение поверх core функционала). Именно такой подход реализован в классе SummarizationMiddleware: вытесненное дописывается, например, в /conversation_history/{thread_id}.md, а канонический state["messages"] может оставаться непереписанным - меняется то, что уходит в model call. Точные дефолты порогов зависят от версии и профиля модели; смысл паттерна стабильнее имён параметров.

Забавный факт: в базовом LangChain и в Deep Agents слой часто называется одинаково - SummarizationMiddleware, но то что они делают внутри, это вообще абсолютно разные подходы, импорт из другой библиотеки и ваш код работает уже иначе, что не очень хорошо. Знакомые убили несколько часов на дебаг этой проблемы.

Снаружи одно имя класса, внутри разная сложность
Снаружи одно имя класса, внутри разная сложность

Именно сюда эволюция суммаризации свернула после активного развития идеи context engeneering - сводка (summary) перестаёт быть единственной правдой о прошлом. Она становится рабочим сжатием для окна, а сырая история может оставаться отдельным артефактом, куда агент может заглянуть с помощью доступных ему инструментов.

В завершении

Жать активный контекст - уже обязательная часть современных агентов: без неё сложно держать длинные диалоги (хотя авторы обычно учат пользователей не решать все вопросы в одном чате, чтобы прибегать к ней меньше). Суммаризация - это часть более широкого тренда по управлению контекстом: разделять что агент делает и знает (полная история, большие результаты tools, skills, субагенты со своим контекстом), и что реально кладёт в контекст следующего вызова. Для пользователя это часто неочевидно - в UI видна вся переписка, а в таком виде она попадает в model call обычно только в начале разговора.