Перейти к содержанию

13. Context Engineering

Зачем это нужно?

Контекстное окно конечно. В долгом Run агент рано или поздно упрётся в лимит. Эта глава — про то, как с этим жить.

Главная мысль в одной строке: простая память — закончилась, сжимаем. Всё остальное — стабильный system prompt, аккуратные prompt'ы к tools и неподдельные числа токенов.

Если вы пришли сюда из учебников, где «context engineering» — это слои с фиксированными долями, скоринг важности сообщений, BudgetTracker с warning'ами в system prompt и три стратегии сжатия на выбор, — выдохните. В реальном агенте всё это не нужно. Дальше расскажу, почему.

Реальный кейс

Ситуация: Агент идёт уже 25 итераций. На последнем ответе провайдер сказал: usage.prompt_tokens = 102_400 (модель — 128K). Следующий tool result добавит ещё пару тысяч токенов. Дальше — overflow.

Проблема:

  • Если ничего не делать — следующий запрос упадёт.
  • Если выкинуть «менее важные» сообщения — порвёте tool_call ↔ tool_result, и провайдер вернёт ошибку валидации.
  • Если перестроить контекст «по слоям с долями» — сбросите prompt cache, и каждый следующий запрос станет в 5-10 раз дороже.

Решение: один раз за Run сжимаем старую часть истории через LLM, оставляем последние N сообщений нетронутыми, продолжаем работать. Это и есть condense. Один порог запуска, один лимит, никаких страусов.

Главная мысль (с опытом)

С опытом понимаешь: реальный продакшен-агент состоит из четырёх простых вещей.

  1. Простой loop. while True: ответ = llm.Chat(messages); если есть tool_calls — выполнить и положить результаты в messages; иначе — вернуть ответ. Глава 04. Автономность и циклы.
  2. Простая память. Один линейный массив []Message. Растёт строго от старого к новому. Ничего не переставляется, ничего не удаляется в середине. Глава 12. Память.
  3. Сжатие при переполнении. Условие срабатывания — одно, действие — одно (см. ниже).
  4. Prompt engineering для tools. Хорошее description, явные требования к параметрам, понятные сообщения об ошибках. Это даёт куда больше, чем любая «хитрая сборка контекста».

Всё, что сложнее, — обычно лечит не ту болезнь. Если возникает желание добавить «приоритизацию», «слои с долями» или «динамический system prompt» — почти всегда это сигнал, что у задачи плохая архитектура (нужно разбить на под-Run, или вынести знания во внешний retrieval), а не что мало weights в формуле.

Теория простыми словами

Из чего состоит контекст

Контекст для одного запроса — это messages[], который вы отправляете провайдеру. В нём живут четыре слоя — но как ментальная модель, а не как четыре отдельных system-сообщения в коде.

Слой Что туда кладём Где физически
System Роль, стиль, скилы, правила (стабильно внутри Run) messages[0], тип system
История Все user/assistant/tool сообщения messages[1..N-1], в порядке появления
Факты из долгой памяти Релевантные факты, выбранные на старте Run В первое user-сообщение Run, не в system
Live state Прогресс задачи, прочитанные файлы, план В tool results, в Notes, в последнее user-сообщение фрейма

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

Anchoring bias: осторожно с фактами

Если в «факты» попадают предпочтения пользователя («пользователь думает, что проблема в БД») или гипотезы, модель воспринимает их как истину и подстраивает ответ под них — даже когда данные говорят обратное.

Лечение простое: называйте вещи своими именами в самом тексте. Не «факт», а «гипотеза пользователя» или «предположение, требует проверки». Никакого хитрого фильтра по Type == "hypothesis" в коде — модель прекрасно понимает русский/английский префикс.

[факт] Сервер web-01 не отвечает на ping с 14:32 UTC.
[гипотеза пользователя] Пользователь предполагает, что проблема в БД (не подтверждено).
[ограничение] Не трогать prod-кластер до 18:00 UTC.

Операции с контекстом

В реальной жизни их всего две:

  1. Append — добавили новое сообщение в конец messages[].
  2. Condense — один раз за Run заменили старую часть истории на summary.

Всё. Никаких Select / Extract / Layer / Reorder.

Как это работает (пошагово)

Шаг 1: Линейная память

type Memory struct {
    messages []llm.Message
}

func (m *Memory) Append(msg llm.Message) {
    m.messages = append(m.messages, msg)
}

func (m *Memory) Snapshot() []llm.Message {
    out := make([]llm.Message, len(m.messages))
    copy(out, m.messages)
    return out
}

func (m *Memory) Reset(msgs []llm.Message) {
    m.messages = msgs
}

Всё. Reset нужен только для condense — больше нигде историю не переписываем.

Шаг 2: Один порог, одно действие

type Run struct {
    mem           *Memory
    contextWindow int     // лимит модели, например 128_000
    condenseAt    float64 // 0.80 — порог запуска condense
    condenseDone  bool    // лимит: condense максимум 1 раз за Run
    lastTokens    int     // usage.PromptTokens из последнего ответа провайдера
}

// Вызывается перед каждым следующим Chat-запросом.
func (r *Run) BeforeNextRequest(ctx context.Context) error {
    used := float64(r.lastTokens) / float64(r.contextWindow)
    if used >= r.condenseAt && !r.condenseDone {
        if err := r.condense(ctx); err != nil {
            return err
        }
        r.condenseDone = true
    }
    return nil
}

Шаг 3: Реакция на overflow от провайдера

Иногда оценка по lastTokens не успевает: следующий tool result оказался жирнее, чем ожидалось, и провайдер вернул ContextOverflowError. Делаем то же самое, но реактивно.

resp, err := client.Chat(ctx, r.mem.Snapshot())
if isContextOverflow(err) {
    if r.condenseDone {
        return r.wrapUp(ctx) // condense уже был — graceful save
    }
    if err := r.condense(ctx); err != nil {
        return err
    }
    r.condenseDone = true
    resp, err = client.Chat(ctx, r.mem.Snapshot()) // повтор
}

Триггер всего два: threshold (proactive) и overflow (reactive). Действие одно: condense. Лимит один: 1 раз за Run. При повторном overflow — wrapUp (см. ниже), не повторный condense.

Шаг 4: Condense — что внутри

func (r *Run) condense(ctx context.Context) error {
    msgs := r.mem.Snapshot()
    if len(msgs) < 6 {
        return nil // нечего сжимать
    }

    head := msgs[1 : len(msgs)-4] // всё кроме system и последних 4
    tail := msgs[len(msgs)-4:]
    system := msgs[0]

    summary, err := r.summarizeWithLLM(ctx, head)
    if err != nil {
        return err
    }

    next := make([]llm.Message, 0, 2+len(tail))
    next = append(next, system)
    next = append(next, llm.Message{
        Role:    "user",
        Content: "Контекст предыдущей работы:\n\n" + summary,
    })
    next = append(next, tail...)
    r.mem.Reset(next)
    return nil
}

Три ключевых момента:

  1. Полная замена истории, а не «обрезание середины». Так чище и предсказуемее.
  2. Summary кладём как user, не как assistant. Модель тогда воспринимает его как «вот контекст, продолжай», а не как свой собственный ответ, который можно противоречить.
  3. Хвост (последние 3-5 сообщений) — нетронут. Это страховка от того, что в summary потерялось что-то важное и непосредственно нужное модели прямо сейчас.

Шаг 5: Wrap-up при повторном overflow

Если condense уже был — больше не сжимаем (повторное сжатие катастрофически теряет детали). Вместо этого: сохраняем прогресс, выдаём пользователю частичный результат, выходим из Run.

func (r *Run) wrapUp(ctx context.Context) error {
    snapshot := r.mem.Snapshot()
    if err := r.checkpoint.Save(snapshot); err != nil {
        return err
    }
    return ErrRunWrappedUp // верхний уровень это ловит и показывает пользователю
}

Чек-поинт нужен, чтобы пользователь мог нажать «Continue» и продолжить с другой моделью / другим контекстом. См. Главу 11: State Management.

Считаем токены правильно

Иерархия источников — от лучшего к худшему:

  1. usage.PromptTokens из ответа провайдера. Точное число, по которому вам выставили счёт. Берёте из последнего ответа модели и используете для решения «не пора ли condense». Это первичный источник, не выдумывайте свой счётчик, если есть этот.
  2. Токенизатор модели (например, tiktoken для OpenAI, anthropic.count_tokens для Anthropic). Нужен в одном случае: вы хотите оценить вес ещё-не-отправленного сообщения, чтобы решить «отправлять или сначала сжать».
  3. Приближение по словам/символам — последнее средство. Подходит для черновой оценки, когда (1) и (2) недоступны.

Подробнее про опасность char-based оценки см. Главу 12, Ошибка 7.

// Самый частый паттерн: после каждого Chat-ответа сохраняем число токенов.
resp, err := client.Chat(ctx, msgs)
if err == nil {
    r.lastTokens = resp.Usage.PromptTokens
}

Никаких WordBasedCounter со ставкой TokensPerWord = 2.0 для русского. Никакого подсчёта len(content)/3. Провайдер уже посчитал — берите готовое.

Лимиты моделей

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

Минимальная структура и место хранения:

type Model struct {
    ID            string
    ContextWindow int // максимальный вход + выход
    MaxOutput     int // обычно меньше ContextWindow
}

func SafeBudget(m Model, reserveOutput int) int {
    if reserveOutput == 0 {
        reserveOutput = m.MaxOutput
    }
    return m.ContextWindow - reserveOutput
}

Где брать Model:

  • из каталога моделей вашего LLM SDK (предпочтительно);
  • из конфига сервиса (если SDK не предоставляет каталог);
  • из переменных окружения для частных деплоев.

Главное — в одном месте, не размазывайте _ = 128000 по 15 файлам.

Условия для truncate

truncate (выкинуть лишние сообщения из середины и оставить голову+хвост) — отдельный инструмент от condense. Он применим в трёх случаях:

  • Single-shot запросы без многошагового цикла (агентный loop тут вообще ни при чём).
  • Провайдеры без prompt cache — экономить нечего, остаётся только укладываться в лимит.
  • Аварийный fallback, когда condense уже отработал свой лимит 1/Run и контекст всё ещё переполнен — проще урезать, чем падать (но проще — отдать wrapUp и сохранить прогресс).

В цикле агента с prompt cache truncate использовать не нужно — он сбрасывает кэш на каждой итерации, потому что middle меняется. Если очень хочется — пишите аккуратно, сохраняя пары tool_call ↔ tool_result целыми (рваная пара — это 400 Bad Request от провайдера).

Condensation Prompt

Качество condense на 80% определяется промптом. Голое «суммаризируй» даёт бесполезный пересказ. Хороший промпт работает по принципу «передаю задачу коллеге, который сейчас её подхватит».

You are summarizing a multi-turn agent run for a teammate
who will continue the work. Be specific and operational.

Required sections:
1. Goal — what the user wants
2. Key Findings — important discoveries (with file:line if relevant)
3. Resources Examined — files read, commands run
4. Decisions Made — choices and their rationale
5. Work Completed — what's done
6. Pending Items — what's left
7. Current State — where we stopped
8. Next Steps — what to do next

Be SPECIFIC:
- "Add JWT middleware to internal/auth/middleware.go:45" — GOOD
- "implement authentication" — BAD
- "Found memory leak in worker pool at pkg/pool/pool.go:120" — GOOD
- "found a bug" — BAD

Hard limit: {maxWords} words.

Что важно:

  • Чёткие секции. Иначе модель пишет эссе, и из summary невозможно вытащить «что осталось сделать».
  • Требование конкретики с примерами. Без этого получите воду «реализована аутентификация».
  • Жёсткий лимит слов. Если не указать — summary раздуется почти до размера оригинала.

System Prompt: стабильность важнее экономии

System prompt занимает токены на каждой итерации loop'а. Возникает соблазн «оптимизировать»: показать длинные инструкции на первой итерации и убрать часть на последующих. На бумаге — экономия. На практике — почти всегда дороже.

Почему «адаптивный system prompt» проигрывает

Современные провайдеры кэшируют префикс запроса. Кэшированный токен сильно дешевле обычного:

Провайдер Скидка за cache hit
OpenAI ~50% от input-цены
Anthropic ~90% от input-цены
Z.AI / GLM до ~80% (зависит от модели)

Если system prompt меняется между итерациями — кэш сбрасывается на всём, что после точки изменения, и вы платите полную цену не только за изменившиеся секции, но и за всю историю сообщений.

Простая арифметика на типичном Run (system 25K, история к 5-й итерации ~30K, всего 8 итераций):

Стратегия Input cost за Run Cache hit ratio
Стабильный system ~$0.04 (Anthropic) ~85%
«Адаптивный»: убрали 1500 токенов на итерациях 2-8 ~$0.18 (Anthropic) ~5%

Экономия 1500 input-токенов оборачивается потерей кэш-хитов на 25-30K префиксных токенов на каждой из 7 последующих итераций. В 4-5 раз дороже.

Что делать вместо

1. System prompt стабилен внутри Run. Все динамические данные — в tool results, в Notes, или в первое user-сообщение фрейма. Подробно — Глава 12: Live state без мутации system prompt.

2. Стабильные включения фиксируйте один раз. Дата, рабочая директория, режим (debug / prod) — записываете в system prompt при старте Run и больше не трогаете.

3. Хочется «warning» при переполнении — кладите в user, не в system. Если очень хочется намекнуть модели «начинай закругляться», добавьте короткое предложение в последнее user-сообщение очередного фрейма, не мутируйте system. Cache не пострадает.

4. Условные «знания» — лучше через tool, чем через условный prompt. Если хочется на этапе анализа добавить SOP, а на этапе действия — не показывать, оформите SOP как tool (get_sop("incident_diagnosis")) и пусть модель сама вызывает. Tool result добавляется в хвост — кэш не страдает.

Правило 20-25%

System prompt не должен превышать ~20-25% контекстного окна. Для 128K — ~25-32K токенов. Если разрастается — режьте базовую версию на старте, а не «между итерациями». Live state не кладите вообще никогда — это Ошибка 6 из Главы 12.

Типовые ошибки

Ошибка 1: Динамический system prompt

Симптом: Стоимость Run в 4-5 раз выше ожидаемой; cache hit ratio в логах провайдера около 5%.

Причина: System prompt пересобирается на каждой итерации (включается current_time, текущий план, прочитанные файлы, динамические секции по итерации).

Решение:

// BAD: cache miss каждой итерации
sys := fmt.Sprintf(`You are an agent. Current time: %s. Files read: %v. Iteration: %d`,
    time.Now(), filesRead, iteration)

// GOOD: стабильный префикс
sys := `You are an agent. Use tools to read files when needed.`
// А current_time / filesRead / iteration агенту покажет следующее user-сообщение или tool result.

Ошибка 2: Live state в system prompt

Симптом: Те же 4-5x перерасхода + при каждом новом tool call cache hit падает до нуля.

Причина: Прогресс задачи (Read files: a.go, b.go, c.go) обновляется в system prompt после каждого tool call.

Решение: Live state живёт в tool results / в последнем user-сообщении / в Notes — то есть в хвосте истории. Префикс не трогаем. Подробный разбор — Глава 12: Live state без мутации system prompt.

Ошибка 3: Скоринг важности и переупорядочивание

Симптом: Провайдер возвращает 400 Bad Request: tool_use without matching tool_result (или зеркальную ошибку).

Причина: «Умный» алгоритм оценил часть tool сообщений как «неважные» и выкинул их, оставив assistant с висящими tool_calls.

Решение: Не делайте скоринг сообщений и переупорядочивание. Используйте condense — он заменяет всю старую часть на текстовый summary, и проблема разрыва пар tool_call ↔ tool_result исчезает по построению.

Ошибка 4: Повторная конденсация уже сжатого контекста

Симптом: После 2-3 condense агент забывает цель задачи, путает файлы, повторяет действия.

Причина: Condense вызывается каждый раз при overflow, без лимита.

Решение:

// BAD: бесконечная конденсация
for {
    resp, err := llm.Chat(ctx, messages)
    if isContextOverflow(err) {
        messages = condense(ctx, messages)
        continue
    }
}

// GOOD: максимум 1 раз за Run, дальше — wrapUp
condenseDone := false
for {
    resp, err := llm.Chat(ctx, messages)
    if isContextOverflow(err) {
        if condenseDone {
            return wrapUpAndSaveProgress()
        }
        messages = condense(ctx, messages)
        condenseDone = true
        continue
    }
}

Ошибка 5: Подсчёт токенов через len(content)/3

Симптом: Condense не запускается до самого overflow, или наоборот — запускается, когда контекст занят на 30%.

Причина: Используете char-based оценку («3 символа = 1 токен»). Для русского реальный коэффициент — 1.5-2x от этой оценки, для кода — 0.5x. Ошибка ±50%.

Решение: Берите usage.PromptTokens из ответа провайдера на предыдущий запрос. Это факт, посчитанный самим провайдером, и достаётся бесплатно. Подробнее — Глава 12, Ошибка 7.

Ошибка 6: Хардкод-словарь моделей в коде

Симптом: При смене модели агент работает «как-то не так» — упирается в overflow раньше, чем должен, или поздно начинает condense.

Причина: Словарь типа var ModelLimits = map[string]int{"gpt-4o": 128000, ...} не обновили под новую модель, и она получила дефолтное значение 4096.

Решение: Берите лимит из каталога моделей вашего SDK (см. раздел Лимиты моделей выше). В одном месте, не размазывайте по коду.

Мини-упражнения

Упражнение 1: BeforeNextRequest

Реализуйте функцию, которая решает, нужно ли запускать condense до очередного запроса.

type Run struct {
    contextWindow int
    lastTokens    int
    condenseDone  bool
}

func (r *Run) ShouldCondense() bool {
    // ваш код
}

Ожидаемый результат:

  • Возвращает true, если lastTokens / contextWindow >= 0.80 и condense ещё не был.
  • Возвращает false во всех остальных случаях.
  • Никаких счётчиков сообщений, никаких слоёв.

Упражнение 2: Реактивный condense на overflow

Допишите цикл, чтобы при ContextOverflowError он один раз делал condense и повторял запрос, а во второй раз — выходил с wrapUp.

for {
    resp, err := client.Chat(ctx, mem.Snapshot())
    // ваш код
}

Ожидаемый результат:

  • Condense вызывается максимум 1 раз за Run.
  • При повторном overflow — wrapUp, не повторный condense.
  • При не-overflow ошибках — пробрасывается выше, не «лечится» condense'ом.

Упражнение 3: Перенос фактов из system в user

Дано: код, где факты из долгой памяти добавлялись отдельным system-сообщением и переписывались на каждой итерации. Задача: перенести так, чтобы факты попадали в первое user-сообщение Run и больше не менялись (если набор фактов меняется внутри Run — это сигнал, что вам нужен recall-tool, а не мутация контекста).

Ожидаемый результат:

  • В коде один system-message за Run, неизменный.
  • Факты добавляются ровно в первое user-сообщение Run.
  • Cache hit ratio в логах провайдера растёт.

Критерии сдачи / Чек-лист

Сдано:

  • Понимаете 4 слоя контекста как ментальную модель, а не как 4 отдельных system-сообщения
  • Берёте usage.PromptTokens от провайдера как первичный источник числа токенов
  • Лимиты моделей живут в одном месте (каталог/конфиг), а не разбросаны по коду
  • Сжатие — один порог запуска (threshold или overflow), один лимит (1 раз за Run)
  • Condense — полная замена истории, summary как user, хвост из последних 3-5 сообщений нетронут
  • System prompt стабилен внутри Run (live state — вне system, см. Глава 12)
  • Понимаете trade-off: «адаптивный prompt» против prompt cache почти всегда проигрывает по деньгам
  • Знаете, что при повторном overflow делается wrapUp, а не повторный condense

Не сдано:

  • Контекст растёт бесконечно — нет condense по threshold
  • Condense вызывается повторно на уже сжатом контексте (детали теряются экспоненциально)
  • Динамический system prompt меняется на каждой итерации (cache miss)
  • Live state (прогресс, прочитанные файлы, текущая дата на каждой итерации) лежит в system
  • Скоринг важности и переупорядочивание сообщений — рвёт цепочки tool_call ↔ tool_result
  • Подсчёт токенов через len(content)/3 вместо usage.PromptTokens
  • Хардкод-словарь моделей в коде — устаревает быстрее, чем код обновляют
  • «Слои с долями» (SystemRatio: 0.10, FactsRatio: 0.10 ...) — это иллюзия контроля, а не контроль

Для любопытных

Этот раздел — для тех, кому всё-таки хочется глубже. Можно пропустить.

Почему один порог, а не два

В литературе часто встречается каскад «warning at 75% → condense at 80% → wrap-up at 90%». На практике с условием usage.PromptTokens >= contextWindow * 0.80 один порог покрывает все случаи, потому что:

  • К моменту, когда вы достигли 80%, у вас уже точная информация о потреблении (из последнего usage). Никакой неопределённости, для которой нужен «warning» как отдельный уровень.
  • Warning через мутацию system prompt — антипаттерн (cache miss). Через мутацию assistant — тоже плохая идея (модель видит чужой «голос»). Остаётся вставлять в user — и тогда это уже не отдельный уровень, а просто «добавили строчку в очередное user-сообщение».
  • Wrap-up — это не уровень сжатия, а исход. Он не уменьшает контекст, он сохраняет состояние и завершает Run. Логически он стоит после condense, не параллельно ему.

Что насчёт block-памяти и recall

Если у вас агент с REPL-подобным взаимодействием (один сложный запрос → один большой результат → следующий запрос), может быть полезна блочная память с tool recall: история каталогизируется по «блокам», в активный контекст идут summary, а полное содержимое блока подгружается по запросу модели. Это уже не базовый случай; разбор — в Главе 12: Block Memory.

Когда контекст реально не помещается

Если задача такая большая, что 128K не хватает даже после condense, — это сигнал не «оптимизировать сжатие», а разбить задачу на под-Run (см. Главу 09: Architecture). Каждый под-Run работает в своём контексте, итог сохраняется в долговременную память или в файл, верхний уровень потом собирает результаты. Так делают все продакшен-агенты, способные работать с большими кодовыми базами.

Связь с другими главами

Важно: Context Engineering — это про сборку контекста для одного запроса. Хранение знаний между сессиями описано в Memory; постоянные данные (схемы, политики) — в State Management; внешний поиск — в RAG.

Что дальше?

После освоения context engineering переходите к:


Навигация: ← Глава 12: Память | Оглавление | Глава 14: Экосистема →