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. Один порог запуска, один лимит, никаких страусов.
Главная мысль (с опытом)¶
С опытом понимаешь: реальный продакшен-агент состоит из четырёх простых вещей.
- Простой loop.
while True: ответ = llm.Chat(messages); если есть tool_calls — выполнить и положить результаты в messages; иначе — вернуть ответ. Глава 04. Автономность и циклы. - Простая память. Один линейный массив
[]Message. Растёт строго от старого к новому. Ничего не переставляется, ничего не удаляется в середине. Глава 12. Память. - Сжатие при переполнении. Условие срабатывания — одно, действие — одно (см. ниже).
- 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.
Операции с контекстом¶
В реальной жизни их всего две:
- Append — добавили новое сообщение в конец
messages[]. - 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
}
Три ключевых момента:
- Полная замена истории, а не «обрезание середины». Так чище и предсказуемее.
- Summary кладём как
user, не какassistant. Модель тогда воспринимает его как «вот контекст, продолжай», а не как свой собственный ответ, который можно противоречить. - Хвост (последние 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.
Считаем токены правильно¶
Иерархия источников — от лучшего к худшему:
usage.PromptTokensиз ответа провайдера. Точное число, по которому вам выставили счёт. Берёте из последнего ответа модели и используете для решения «не пора ли condense». Это первичный источник, не выдумывайте свой счётчик, если есть этот.- Токенизатор модели (например,
tiktokenдля OpenAI,anthropic.count_tokensдля Anthropic). Нужен в одном случае: вы хотите оценить вес ещё-не-отправленного сообщения, чтобы решить «отправлять или сначала сжать». - Приближение по словам/символам — последнее средство. Подходит для черновой оценки, когда (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.
Ожидаемый результат:
- 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 работает в своём контексте, итог сохраняется в долговременную память или в файл, верхний уровень потом собирает результаты. Так делают все продакшен-агенты, способные работать с большими кодовыми базами.
Связь с другими главами¶
- Глава 04: Автономность и циклы — простой loop, в который встроены
BeforeNextRequestи реакция на overflow - Глава 11: State Management —
wrapUpсохраняет состояние через checkpoint - Глава 12: Системы Памяти Агента — линейная память, факты в user, live state без мутации system, recall для блочной памяти
- Глава 20: Cost & Latency Engineering — стабильность префикса, prompt cache, стоимость condense
Важно: Context Engineering — это про сборку контекста для одного запроса. Хранение знаний между сессиями описано в Memory; постоянные данные (схемы, политики) — в State Management; внешний поиск — в RAG.
Что дальше?¶
После освоения context engineering переходите к:
- 14. Экосистема и Фреймворки — обзор популярных фреймворков для агентов и где они помогают, а где мешают.
Навигация: ← Глава 12: Память | Оглавление | Глава 14: Экосистема →