Продакшн-разработка ИИ-агентов · Модуль 1 · Урок 1.2

Глава 2. Context engineering

Цели главы

  • Относиться к контекстному окну (context window) как к бюджету и осознанно распределять его между system/tools/history/RAG.
  • Запускать компакцию и суммаризацию истории по триггерам (порог токенов), а не наугад.
  • Правильно ставить точки prompt caching и понимать, что инвалидирует кэш.
  • Понимать context rot / «lost in the middle» и размещать важное в начале/конце контекста.
  • Перейти от «дать всё сразу» к just-in-time retrieval — выборочной подаче контекста.

Что нового (дельта к базовому курсу)

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

Здесь мы углубляем ту же тему до инженерной дисциплины — context engineering:

  • от «обрезать, когда не влезает» к бюджетированию окна по зонам и компакции по триггерам;
  • добавляем prompt caching и понимание KV-кэша на уровне инференса — то, чего в базовом курсе не было вовсе;
  • объясняем почему длинный контекст вредит качеству (context rot), а не только тем, что он «дорогой»;
  • меняем стратегию подачи: just-in-time вместо «загрузить весь возможный контекст заранее».

Контекстное окно — это бюджет, а не свалка

Контекст (context) — это набор токенов, который модель видит на данном шаге инференса. Окно конечно, и каждый токен «конкурирует за внимание» с остальными. Поэтому окно полезно представлять как бюджет, разбитый на зоны:

  • system — инструкции, роль, политика. Должно быть стабильным (об этом ниже — это основа кэша).
  • tools — спецификации инструментов. Тоже относительно стабильны.
  • history — диалог и результаты вызовов инструментов. Растёт быстрее всего и быстрее всего «протухает».
  • RAG / retrieved — подтянутые документы. Самая «шумная» зона: легко переполнить нерелевантным.

Мысленная модель: не «сколько влезет», а «какая конфигурация контекста с наибольшей вероятностью даст нужное поведение». Лишний релевантно выглядящий, но ненужный токен — не бесплатен: он размывает внимание и повышает context rot.

Context rot и «lost in the middle»

Увеличение окна не значит линейного роста качества. У моделей ограниченное внимание: чем больше токенов, тем труднее удерживать фокус и точно вспоминать детали. Это явление называют context rot — деградация качества по мере роста контекста.

Связанный эффект — «lost in the middle»: информация в середине длинного контекста извлекается хуже, чем в его начале и конце. Модель надёжнее «видит» края.

Практические выводы:

  • Кладите самое важное (ключевая инструкция, актуальный вопрос, критичные факты) в начало (system) и конец (последнее сообщение) контекста.
  • Боритесь с раздуванием history: компактируйте старое, не тащите полные сырые результаты инструментов вечно.
  • Меньше, но релевантнее — обычно лучше, чем «всё на всякий случай».

Just-in-time retrieval вместо «всё сразу»

Соблазн RAG-агента — заранее «напихать» в контекст все потенциально полезные документы. Это бьёт по бюджету и по вниманию (context rot) сразу.

Альтернатива — just-in-time retrieval: давать модели не сами данные, а инструменты и ссылки для их получения, и подтягивать конкретный фрагмент только когда он реально понадобился на конкретном шаге. Так контекст остаётся компактным и релевантным, а решение «что подтянуть» принимается по ходу задачи, а не угадывается заранее.

Это перекликается с главой 1: оркестратор может хранить артефакты вне окна (файлы, БД) и подавать их выборочно, а не держать всё в messages.

Prompt caching и KV-кэш: почему важен стабильный префикс

На уровне инференса модель считает внутреннее представление токенов — KV-кэш (ключи/значения механизма внимания). Для префикса, который не менялся между запросами, это представление можно переиспользовать вместо пересчёта. Это и есть физическая причина, почему стабильный префикс ускоряет и удешевляет инференс.

Prompt caching в API — это управляемая версия идеи: вы помечаете границы блоков cache_control, и при следующем запросе совпадающий префикс (system + tools + начало истории) читается из кэша дешевле и быстрее, а не обрабатывается заново.

Ключевые правила, вытекающие из природы префиксного кэша:

  • Кэш работает только на общий префикс. Любое изменение раньше точки кэша инвалидирует всё, что после.
  • Поэтому стабильное (system, спецификации tools) ставьте в начало, а изменчивое (свежие сообщения) — в конец.
  • Не вставляйте динамику (таймстемпы, счётчики) в начало system — каждый такой токен ломает кэш для всего запроса.
  • Точки кэша ставьте на границах стабильных крупных блоков: конец system, конец tools, конец «замороженной» части истории.

Компакция и суммаризация по триггерам

Компакция (compaction) — это сжатие истории так, чтобы освободить бюджет, не потеряв смысл. Запускать её стоит по триггеру, а не на каждом шаге:

  • порог токенов — history превысила X% окна (например 70%);
  • давность — сообщения старше N шагов сворачиваем в краткое резюме;
  • объём результата инструмента — большой сырой ответ заменяем на выжимку + ссылку на полные данные (just-in-time).

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

Практика на Go: cache_control и компакция

Ниже — работа с github.com/anthropics/anthropic-sdk-go (v1.x; сверьтесь с актуальными доками по точным именам типов CacheControl и слагам моделей). Сначала — расстановка точек кэша на стабильном префиксе, затем — компакция истории по порогу токенов.

Точки prompt cache на стабильном префиксе (system + tools)
package contexteng

import "github.com/anthropics/anthropic-sdk-go"

// buildStableParams кладёт стабильное в начало и помечает точки кэша.
// Идея: кэшируем общий ПРЕФИКС, который не меняется между запросами.
func buildStableParams(systemText string, tools []anthropic.ToolParam,
    history []anthropic.MessageParam) anthropic.MessageNewParams {

    // system — самый стабильный блок: ставим cache_control в КОНЦЕ system.
    // Всё, что раньше этой точки и совпадает с прошлым запросом, читается из кэша.
    system := []anthropic.TextBlockParam{{
        Text:         systemText,
        CacheControl: anthropic.NewCacheControlEphemeralParam(),
    }}

    // tools тоже стабильны: помечаем последний инструмент как границу кэша,
    // чтобы префикс system+tools кэшировался целиком.
    if n := len(tools); n > 0 {
        tools[n-1].CacheControl = anthropic.NewCacheControlEphemeralParam()
    }

    return anthropic.MessageNewParams{
        Model:     anthropic.ModelClaudeSonnet4_5, // сверьтесь с актуальным слагом
        MaxTokens: 1024,
        System:    system,
        Tools:     toUnion(tools),
        Messages:  history, // изменчивое — В КОНЦЕ, после точек кэша
    }
}
Компакция истории по порогу токенов (с сохранением краёв)
// compactByThreshold сворачивает СЕРЕДИНУ истории при превышении порога.
// Бережём начало и конец: там модель «видит» лучше (lost in the middle).
func compactByThreshold(ctx context.Context, cl anthropic.Client,
    history []anthropic.MessageParam, usedTokens, windowLimit int,
    summarize SummarizeFn) ([]anthropic.MessageParam, bool, error) {

    // Триггер: компактируем только когда реально близко к лимиту.
    if usedTokens < windowLimit*70/100 {
        return history, false, nil // кэш не трогаем — выгоднее не дёргать
    }
    if len(history) < 6 {
        return history, false, nil // нечего сворачивать
    }

    // Оставляем "голову" (контекст задачи) и "хвост" (свежие шаги) как есть,
    // а середину заменяем кратким резюме.
    head := history[:2]
    tail := history[len(history)-2:]
    middle := history[2 : len(history)-2]

    summary, err := summarize(ctx, cl, middle) // одна выжимка вместо N сообщений
    if err != nil {
        return history, false, err
    }
    summaryMsg := anthropic.NewUserMessage(
        anthropic.NewTextBlock("Резюме предыдущих шагов: " + summary),
    )

    out := make([]anthropic.MessageParam, 0, len(head)+1+len(tail))
    out = append(out, head...)
    out = append(out, summaryMsg)
    out = append(out, tail...)
    // Внимание: префикс изменился -> prompt cache инвалидирован.
    // Поэтому компактируем РЕДКО и крупно, по триггеру, а не каждый шаг.
    return out, true, nil
}

Anti-patterns

Типичные грабли context engineering
ГрабляПочему плохоКак избегать
«Дать всё сразу»: напихать весь RAG в контекстБьёт по бюджету и по вниманию (context rot); качество падаетJust-in-time retrieval: давать инструменты для выборки, подтягивать фрагмент по необходимости
Динамика (таймстемп, счётчик) в начале systemМеняющийся префикс инвалидирует prompt cache на каждом запросеСтабильное — в начало; динамику — в конец, после точек кэша
Компактировать историю каждый шагПостоянно переписывает префикс и сбрасывает кэш — экономия теряетсяКомпакция по триггеру (порог токенов), редко и крупными порциями
Важное прячется в середину длинного контекстаЭффект lost in the middle: модель хуже извлекает серединуКлючевые инструкции и вопрос — в начало (system) и в конец (последнее сообщение)
Тащить полные сырые результаты инструментов вечноHistory раздувается, ускоряя context rot и расход токеновЗаменять большие результаты выжимкой + ссылкой на полные данные вне окна
Считать, что большее окно само по себе лучшеДеградация (context rot) растёт с длиной; «больше токенов» != «лучше»Курировать контекст: минимум релевантного, компакция, выборочная подача

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

  • Разметьте бюджет окна своего агента по зонам (system/tools/history/RAG) и зафиксируйте целевые доли в процентах.
  • Вынесите всё стабильное (system, спецификации tools) в начало и поставьте точки cache_control на их границах.
  • Замерьте cache_read/cache_creation токены в usage до и после и подтвердите, что префикс реально кэшируется.
  • Внесите динамику (таймстемп) в начало system и убедитесь по метрикам, что кэш сломался — затем верните на место.
  • Реализуйте компакцию по триггеру (порог токенов) с сохранением головы и хвоста истории; убедитесь, что середина сворачивается в резюме.
  • Переведите один тяжёлый источник данных на just-in-time: вместо вставки документа дайте инструмент его выборки и подтягивайте фрагмент по запросу модели.

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

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

Что произошло и как чинить?

  • A Ничего: дата в system безвредна, рост случаен
  • B Меняющийся таймстемп в начале system инвалидирует prompt cache на каждом запросе; динамику надо убрать из стабильного префикса
  • C Нужно увеличить MaxTokens, чтобы вернуть экономию
  • D Проблема в модели; смените слаг на более дешёвую

Почему «lost in the middle» и context rot заставляют класть важное в начало и конец контекста?

  • A Потому что API физически обрезает середину запроса
  • B Потому что у модели ограниченное внимание: с ростом контекста качество падает (context rot), а середину длинного контекста модель извлекает хуже краёв
  • C Потому что середину нельзя кэшировать
  • D Потому что токены в середине стоят дороже

Чтобы держать историю компактной, инженер запускает суммаризацию середины истории на каждом шаге агента. Качество приемлемо, но кэш-метрики показывают почти нулевой cache_read.

В чём корень проблемы и как её решить?

  • A Суммаризация на каждом шаге переписывает префикс и инвалидирует prompt cache; компактировать надо по триггеру, редко и крупно
  • B Нужно вообще отключить компакцию
  • C Кэш не работает с историей в принципе
  • D Проблема в слишком большом MaxTokens