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

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 History struct {
    mu       sync.Mutex
    messages []llm.Message
}

func (h *History) Append(msg llm.Message) {
    h.mu.Lock()
    defer h.mu.Unlock()
    h.messages = append(h.messages, msg)
}

// Messages возвращает копию истории — её отдаём в запрос к LLM.
func (h *History) Messages() []llm.Message {
    h.mu.Lock()
    defer h.mu.Unlock()
    out := make([]llm.Message, len(h.messages))
    copy(out, h.messages)
    return out
}

// Replace — единственный способ изменить уже записанное.
// Нужен только для condense — больше нигде историю не переписываем.
func (h *History) Replace(msgs []llm.Message) {
    h.mu.Lock()
    defer h.mu.Unlock()
    h.messages = append(h.messages[:0], msgs...)
}

// Snapshot/Restore — чекпоинт, аудит, откат неудачного condense.
func (h *History) Snapshot() []llm.Message  { return h.Messages() }
func (h *History) Restore(snap []llm.Message) { h.Replace(snap) }

Всё. Replace нужен только для condense — больше нигде историю не переписываем. Санитизация (пустые assistant, orphan tool-result, неразрывность пар tool_call ↔ tool_result) встроена в саму историю — та же History, что в Главе 12.

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

type Run struct {
    mem           *History
    contextWindow int     // лимит модели, например 128_000
    condenseAt    float64 // один настраиваемый Threshold ∈ (0,1); типовое значение ~0.85
    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 {
            // CondenseErrorEvent: событие цикла, не крах — ход продолжается
            // с несжатой историей. Реактивно разберётся Шаг 3.
            r.condenseDone = true
            return nil
        }
        r.condenseDone = true
    }
    return nil
}

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

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

resp, err := client.Complete(ctx, Request{Messages: r.mem.Messages()})
if isContextOverflow(err) {
    if r.condenseDone {
        return r.wrapUp(ctx) // condense уже был — graceful save
    }
    if err := r.condense(ctx); err != nil {
        // Ошибка конденсации — событие цикла (CondenseErrorEvent), не крах Run:
        // продолжаем ход с несжатой историей. Попытка израсходована —
        // повторных конденсаций не будет.
        r.condenseDone = true
        return nil // следующая итерация идёт с несжатой историей
    }
    r.condenseDone = true
    resp, err = client.Complete(ctx, Request{Messages: r.mem.Messages()}) // повтор
}

Триггер всего два: threshold (proactive) и overflow (reactive). Действие одно: condense. Лимит один: 1 раз за Run. При повторном overflow после успешного condense — wrapUp (см. ниже), не повторный condense. Если condense упал — Run не падает: ход продолжается с несжатой историей; когда следующая итерация снова упрётся в overflow, condense уже израсходован — единственный путь wrapUp. Бисекций («а может, сжать только четверть?») нет.

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

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

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

    // Запрос суммаризации несёт ТОТ ЖЕ кэшируемый префикс (system + tools),
    // поэтому head читается из прогретого кэша, а не платится по полной.
    summary, err := r.summarizeWithLLM(ctx, head)
    if err != nil {
        return err // оригинал не тронут — см. обработку в Шаге 3
    }

    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.Replace(next)
    return nil
}

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

  1. Полная замена истории, а не «обрезание середины». Так чище и предсказуемее.
  2. Summary кладём как user, не как assistant. Модель тогда воспринимает его как «вот контекст, продолжай», а не как свой собственный ответ, который можно противоречить.
  3. Хвост (последние 3-5 сообщений) — нетронут. Это страховка от того, что в summary потерялось что-то важное и непосредственно нужное модели прямо сейчас.
  4. Суммаризация читает историю из прогретого кэша. LLM-вызов для summary несёт тот же префикс system + tools, что и обычные итерации, — head не платится по полной цене, condense стоит заметно дешевле, чем кажется.

Как это устроено в реальном SDK. Память цикла — линейная History (New/Append/Replace/Snapshot/Restore). В конфиге один Threshold ∈ (0,1), типовое значение ~0.85; condense — максимум 1 раз за Run. Ошибка конденсации эмитится как событие EvCondenseErr: Run продолжается с несжатой историей, инвариант «condense ≤ 1/Run» не нарушается. Кэш-метрики (CacheRead / CacheCreation) парсятся из Usage ответа.

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

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

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

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

// Самый частый паттерн: после каждого Chat-ответа сохраняем число токенов.
resp, err := client.Complete(ctx, Request{Messages: 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 (выкинуть сообщения из середины, оставив голову и хвост) в цикле агента с prompt cache не нужен: середина меняется → кэш сбрасывается на каждой итерации, а разорванная пара tool_call ↔ tool_result — это 400 Bad Request. Он остаётся инструментом для single-shot запросов и провайдеров без кэша. Если condense уже отработал (1/Run), а контекст всё ещё переполнен — не truncate, а wrapUp.

Вес инструментов и tool-результатов

Контекст платят не только system prompt и история. Два постоянных источника роста, о которых часто забывают:

1. Блок tools платится в каждом запросе. Схема каждого инструмента (name, description, параметры) уезжает провайдеру на каждой итерации — 4 типовых инструмента это уже ~742 токена overhead на каждый запрос Run'а. Поэтому descriptions должны быть короткими и точными — это не место для эссе. Как писать хорошие схемы — Глава 03: Tools & Function Calling.

2. Tool-результаты — главный рычаг экономии контекста: FilteredContent. Результат инструмента существует в двух версиях: полная — для UI и аудита (её видит человек), отфильтрованная — для модели (её видит LLM). Классика: полный вывод go test (десятки килобайт) — в лог/UI человеку; модели — только список упавших тестов с последними строками стектрейса. Модель почти никогда не нужны сырые десятки KB, но почти всегда нужен вывод «что сломалось и где».

Плюс два жёстких бюджета, которые стоит зашить в цикл:

  • Бюджет на один tool-результат: ~128 KB. Что не влезло — усекается с маркером ... truncated, full output in artifact; полный вывод пишется в артефакт вне контекста (Глава 11: State Management).
  • Лимит картинок в сессии. Изображения дороги в токенах и не сжимаются condense'ом (они в сообщениях); держите счётчик и ограничивайте число картинок в одной сессии.

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 токенов). Это эвристика-ориентир, а не правило: точная доля зависит от задачи, и ничего не «ломается» при 18% или 30%. Если разрастается — режьте базовую версию на старте, а не «между итерациями». 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.Complete(ctx, Request{Messages: messages})
    if isContextOverflow(err) {
        messages = condense(ctx, messages)
        continue
    }
}

// GOOD: максимум 1 раз за Run, дальше — wrapUp
condenseDone := false
for {
    resp, err := llm.Complete(ctx, Request{Messages: 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{"glm-5.3": 128000, ...} не обновили под новую модель, и она получила дефолтное значение 4096.

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

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

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

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

type Run struct {
    contextWindow int
    lastTokens    int
    condenseDone  bool
}

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

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

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

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

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

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

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

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

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

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

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

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

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

✅ Сдано:

  • Понимаете 4 слоя контекста как ментальную модель, а не как 4 отдельных system-сообщения
  • Берёте usage.PromptTokens от провайдера как первичный источник числа токенов
  • Лимиты моделей живут в одном месте (каталог/конфиг), а не разбросаны по коду
  • Сжатие — один настраиваемый порог (типовое значение ~0.85, threshold или overflow), один лимит (1 раз за Run)
  • Condense — полная замена истории, summary как user, хвост из последних 3-5 сообщений нетронут
  • Знаете, что блок tools платится в каждом запросе, а у tool-результата есть полная версия (UI/аудит) и отфильтрованная (для модели)
  • Ошибка конденсации — событие CondenseErrorEvent, Run продолжается с несжатой историей
  • 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 85% → wrap-up at 95%». На практике с условием usage.PromptTokens >= contextWindow * 0.85 один порог покрывает все случаи, потому что:

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

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

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

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

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

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

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

Что дальше?

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


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