Разработка ИИ-агентов · Модуль 2 · Урок 2.2

Управление контекстом: окно, обрезка, суммаризация, внешнее состояние

Контекстное окно — ограниченный и платный ресурс

У модели есть контекстное окно — максимум токенов, которые она видит за один запрос (история + системный промпт + схемы инструментов + текущий ввод). Окно конечно, и за каждый токен вы платите при каждом вызове. В агенте история растёт с каждым оборотом цикла, поэтому без управления контекстом агент рано или поздно упрётся в лимит или станет неоправданно дорогим и медленным.

Кроме того, есть эффект «потери в середине» (lost in the middle): чем длиннее контекст, тем хуже модель замечает детали в его середине. Больше контекста ≠ лучше. Цель — держать в окне только то, что реально нужно для следующего шага.

Три приёма: обрезка, суммаризация, внешнее состояние

  • Обрезка (truncation) — самый простой приём: оставляем системный промпт и последние N сообщений, старое отбрасываем. Подходит, когда давняя история не важна. Минус — теряются факты из начала диалога.
  • Суммаризация — когда история разрослась, просим модель сжать её в краткое резюме и заменяем им старые сообщения. Факты сохраняются, токены экономятся; минус — резюме может потерять нюанс, и это лишний вызов модели.
  • Внешнее состояние — выносим данные из диалога наружу: в переменные, файлы, БД. В контекст кладём не полный текст, а ссылку/идентификатор («заказ #4471 сохранён»), а полный объект достаём инструментом по запросу. Это самый масштабируемый приём.

На практике их комбинируют: свежие сообщения — как есть, давние — в резюме, крупные данные — во внешнем хранилище.

Считать токены и кешировать

Чтобы управлять контекстом, его надо измерять. Грубая прикидка: токен ≈ несколько символов (для русского — заметно меньше, чем для английского), но для точности используйте токенайзер/счётчик провайдера. Заведите бюджет: «не больше X токенов истории» — и применяйте обрезку/суммаризацию при его превышении.

Отдельный рычаг — prompt caching: если большой стабильный кусок (системный промпт, длинная инструкция, справочные данные) повторяется из запроса в запрос, его можно пометить как кэшируемый, и провайдер не будет тарифицировать его заново по полной. Держите стабильную часть в начале — так она лучше кешируется. (Детали и заголовки кеша версионно-зависимы — сверяйтесь с доками провайдера.)

Обрезка истории: всегда сохраняем system, оставляем последние N сообщений
// trimHistory держит системные сообщения и последние maxTail реплик.
func trimHistory(messages []Message, maxTail int) []Message {
    var system, rest []Message
    for _, m := range messages {
        if m.Role == "system" {
            system = append(system, m)
        } else {
            rest = append(rest, m)
        }
    }
    if len(rest) > maxTail {
        rest = rest[len(rest)-maxTail:] // оставляем хвост
    }
    return append(system, rest...)
}

// Замечание: при обрезке нельзя «разрывать» пару assistant(tool_calls) → tool.
// Обрезайте по границам завершённых обменов, иначе провайдер вернёт ошибку.
Суммаризация: сжать давнюю историю в одно сообщение
// summarizeOld заменяет старую часть диалога кратким резюме от модели.
func summarizeOld(messages []Message, keepTail int) ([]Message, error) {
    if len(messages) <= keepTail+1 {
        return messages, nil
    }
    head := messages[:len(messages)-keepTail] // то, что сжимаем
    tail := messages[len(messages)-keepTail:]  // свежее оставляем как есть

    summary, err := callLLM([]Message{
        {Role: "system", Content: "Сожми диалог в 5–7 пунктов: факты, решения, открытые вопросы."},
        {Role: "user", Content: renderDialog(head)},
    }, nil)
    if err != nil {
        return messages, err
    }
    sumMsg := Message{Role: "system", Content: "Резюме предыдущего диалога:\n" + summary.Content}
    return append([]Message{sumMsg}, tail...), nil
}

Anti-patterns

Анти-паттернПочему плохоКак правильно
Складывать всю историю без ограниченийУпор в лимит окна, рост цены и задержкиБюджет токенов + обрезка/суммаризация по мере роста
«Больше контекста — лучше»Эффект lost in the middle, шум, ценаВ окне — только нужное для следующего шага
Обрезать посреди пары assistant→toolНевалидная история — ошибка провайдераРезать по границам завершённых обменов
Держать крупные данные прямо в диалогеДорого и нечитаемоВнешнее состояние: в контексте — ссылка/id, данные — по инструменту

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

  • Добавьте в агент подсчёт примерного размера истории и установите бюджет токенов.
  • Реализуйте обрезку, сохраняющую system и не разрывающую пары assistant(tool_calls) → tool.
  • Реализуйте суммаризацию давней части истории и подставьте резюме вместо старых сообщений.
  • Перенесите один крупный результат инструмента во внешнее состояние, а в контекст положите только id.
  • Если провайдер поддерживает prompt caching — пометьте стабильный системный промпт как кэшируемый и сравните стоимость.

Проверка знаний

Почему «затолкать в контекст как можно больше» — плохая стратегия?

  • A Контекст вообще нельзя расширять
  • B Окно конечно и платно, а длинный контекст ухудшает внимание модели (lost in the middle)
  • C Модель читает только первое сообщение
  • D Так требует Go

В чём смысл приёма «внешнее состояние»?

  • A Хранить весь диалог на диске вместо памяти
  • B Выносить крупные данные наружу, а в контекст класть только ссылку/идентификатор и доставать данные инструментом по запросу
  • C Отключить контекстное окно
  • D Шифровать историю диалога

Что опасно сделать при обрезке истории?

  • A Сохранить системное сообщение
  • B Разорвать пару «ход ассистента с tool_calls» и соответствующие ему tool-результаты
  • C Оставить последние сообщения
  • D Посчитать размер истории