Context Engineering: как кормить ИИ-агента контекстом, а не только промптом

Context engineering — это дисциплина о том, что именно видит модель на каждом вызове. Не «написать один умный системный промпт», а постоянно управлять ограниченным контекстным окном по мере того, как диалог с ИИ-агентом растёт. Когда я собираю агента для 1С на LangGraph, с памятью и поиском по базе знаний, 80% работы это не формулировка промпта, а решение: какую часть истории, какие найденные документы и какие факты о пользователе положить в окно прямо сейчас, а что оставить за бортом. Термин новый, спрос на него пока в основном справочный, поэтому начну с определения и разберу три рабочие техники с кодом.

Сразу разведём два понятия, которые часто путают. Промпт-инжиниринг оптимизирует, как вы спрашиваете: формулировку, роль, примеры, формат ответа. Context engineering оптимизирует, что стоит перед моделью в момент вопроса: найденные документы, история диалога, долговременные факты о пользователе, результаты инструментов. Идеальный промпт с контекстным окном, забитым нерелевантной историей, всё равно даёт плохой ответ. Один настроен один раз; второй пересобирается на каждом шаге.

Дальше разберу три техники, которые использую в боевых агентах: суммаризация истории, долговременная семантическая память и переформулировка запроса перед поиском. Под каждую даю реальный код на LangGraph и langmem.

Почему нельзя просто «положить всё в промпт»

Наивный подход — на каждый вызов агента отправлять модели всю историю целиком: системный промпт, все реплики, все результаты инструментов, все найденные документы. Он ломается по двум причинам.

Первая — контекстное окно конечно, а токены стоят денег. Даже если у модели окно на 200К токенов, вы платите за каждый входной токен на каждом вызове. Длинный диалог, который тащит за собой всю историю, к сотому сообщению превращается в дорогой запрос, который вы гоняете снова и снова. На локальной модели ограничение жёстче: окно упирается в VRAM, и «просто добавить всё» физически не влезает.

Вторая причина тоньше и важнее: нерелевантный контекст ухудшает ответ, а не только удлиняет его. Модель отвлекается на устаревшие или посторонние реплики, теряет фокус, начинает отвечать на давно закрытый вопрос. Это не проблема «обрезали слишком рано». Это проблема «положили лишнее». Поэтому context engineering — дисциплина, а не «купите окно побольше»: большое окно только отодвигает момент, когда качество поплывёт.

Простой пример из практики. Клиент в чат-боте магазина сначала подбирал вентиляторы, уточнял модель, спрашивал наличие: набралось двадцать реплик. Потом переключился на телевизоры. Если тащить в окно всю историю, модель на вопрос «а какая гарантия?» начинает путаться: гарантия на что, на вентилятор из начала диалога или на телевизор сейчас? Лишний контекст тут не помог. Он сбил фокус. Дисциплина здесь в том, чтобы старое про вентиляторы свернуть в одну строку summary, а в окно положить только актуальную ветку про телевизоры.

Поиграйте с бюджетом окна: включайте и выключайте источники контекста и смотрите, как заполняется окно и растёт цена вызова.

Вывод из этой картинки простой: задача не «впихнуть всё», а решить, что модель реально должна видеть на этом шаге. Дальше покажу три способа наполнять окно порционно.

Техника 1: суммаризация истории вместо полного накопления

Первое, что упирается в окно, — история диалога. Она растёт линейно с каждой репликой, и именно её надо сжимать в первую очередь. Штатный инструмент для этого в экосистеме LangGraph — SummarizationNode из langmem. Он ставится отдельным узлом графа перед узлом с моделью:

from langmem.short_term import SummarizationNode
from langchain_core.messages import AnyMessage
from langgraph.graph import StateGraph, START, MessagesState
from typing import Any, TypedDict

summarization_node = SummarizationNode(
    model=llm.bind(max_tokens=128),
    max_tokens=256,                 # целевой размер окна для модели
    max_tokens_before_summary=1024, # порог, после которого запускается сжатие
    max_summary_tokens=128,         # предел размера самого summary
)

class State(MessagesState):
    context: dict[str, Any]

builder = StateGraph(State)
builder.add_node("summarize_node", summarization_node)
builder.add_node("call_model", call_model)
builder.set_entry_point("summarize_node")
builder.add_edge("summarize_node", "call_model")

Ключевой механизм здесь легко пропустить, и именно он и есть context engineering. Узел с моделью читает другой ключ состояния, не тот, куда пишется полный лог:

class LLMInputState(TypedDict):
    summarized_messages: list[AnyMessage]  # сюда SummarizationNode кладёт summary
    context: dict[str, Any]

def call_model(state: LLMInputState):
    # модель видит только summarized_messages, а не весь messages
    response = llm.bind_tools(tools).invoke(state["summarized_messages"])
    return {"messages": [response]}

Полный messages продолжает копиться: для аудита, отладки, повтора прогонов. Но в окно модели уходит только скользящее summary плюс последние реплики. Разделение «полный лог против того, что видит модель» — и есть сам приём. Ниже видно, как это работает вживую: добавляйте реплики и сравните две колонки.

Это же работает и на агентском цикле. У ReAct-агента граф гоняет петлю «модель → инструмент → модель», и каждый результат инструмента дописывается в messages. Если узел суммаризации стоит перед моделью, а из узла инструментов ребро возвращается обратно к нему, сжатие происходит на каждом витке, и раздутые ответы инструментов (сырой JSON от поиска, выгрузка из 1С) тоже попадают под свёртку, а не копятся в окне. Полная история при этом цела в messages благодаря чекпойнтеру: он сохраняет состояние после каждого узла, так что диалог можно поднять с любой точки.

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

Для сравнения вот минимальный чекпойнтер без всякой суммаризации, ровно как он устроен в нашем агенте для 1С на LangGraph:

checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)

await graph.astream(
    {"messages": [HumanMessage(content=user_message)]},
    config={"configurable": {"thread_id": thread_id}},
)

thread_id — это и есть весь механизм короткой памяти: по нему чекпойнтер поднимает историю нужного диалога, LangGraph сам подгружает предыдущие сообщения. Но InMemorySaver ничего не сжимает: история растёт, пока жив процесс, и рано или поздно упрётся в бюджет окна из первого раздела. SummarizationNode выше — это то, чего в таком простом агенте ещё нет и что нужно добавить, как только диалоги перестанут умещаться в пару обменов репликами.

Техника 2: долговременная память, не только текущий диалог

Суммаризация сжимает текущий диалог. Но агенту часто нужно помнить факты из других диалогов: что пользователь — 1С-разработчик, что он предпочитает тёмную тему, какое решение приняли на прошлой неделе. Это уже не обрезка окна, а прицельная подстановка. В langmem для этого есть готовые инструменты памяти поверх векторного хранилища (store):

from langmem import create_manage_memory_tool, create_search_memory_tool

tools = [
    create_manage_memory_tool(namespace=("memories", "{user_id}")),
    create_search_memory_tool(namespace=("memories", "{user_id}")),
]

namespace с user_id изолирует память по пользователям: каждый видит только свои факты. Дальше в промпт-функции агента вы ищете релевантные воспоминания по последней реплике и вставляете их отдельным блоком:

from langgraph.utils.config import get_store

def prompt(state):
    store = get_store()
    memories = store.search(
        ("memories",),
        query=state["messages"][-1].content,
    )
    system_msg = f"""Вы полезный помощник.
## Память
<memories>
{memories}
</memories>
"""
    return [{"role": "system", "content": system_msg}, *state["messages"]]

Разница с Техникой 1 принципиальная: суммаризация режет историю, а память добавляет структурированный контекст блоком <memories>. Два разных слоя: короткий (этот диалог) и длинный (факты навсегда). Переключите вкладки и сравните область действия, срок жизни и механизм каждого:

Есть два продвинутых варианта, которые стоит знать, но не обязательно внедрять сразу.

Фоновая экстракция фактов. Отдельный асинхронный процесс (create_memory_store_manager плюс шаг рефлексии) вытаскивает устойчивые факты после завершения диалога, а не во время него. Так «горячий путь» ответа остаётся быстрым: агент не тратит вызов на анализ памяти прямо в реплике. Это по-настоящему продвинутая штука, легко переусложнить, поэтому упоминаю одним абзацем.

Профиль вместо списка. Иногда вам нужна не «история событий», а «текущее состояние пользователя»: имя, роль, предпочтения. Тогда лучше обновлять один структурированный объект на месте, а не растить бесконечный список воспоминаний:

from langmem import create_memory_manager
from pydantic import BaseModel, Field
from typing import Optional

class UserProfile(BaseModel):
    name: Optional[str] = Field(default="", description="Имя пользователя")
    role: Optional[str] = Field(default="", description="Кем работает")

manager = create_memory_manager(
    llm,
    schemas=[UserProfile],
    instructions="Извлекай информацию из профиля пользователя",
    enable_inserts=False,  # профиль обновляется на месте, а не дописывается
)

enable_inserts=False — это и есть выбор «обновить один объект», а не «добавить ещё запись». Полезно, когда вы храните состояние пользователя, а не летопись событий.

Чтобы стало предметно, вот что реально извлекает менеджер памяти из живого диалога. Пользователь пишет: «Я работаю в команде 1С», потом «в основном интеграция 1С с сайтами и CRM». На выходе — не дословные реплики, а компактные факты: «работает в команде 1С», «специализация — интеграция 1С с сайтами и CRM-системами». Именно эти строки потом всплывут в блоке <memories> в следующем диалоге, когда пользователь спросит что-то смежное. Память хранит смысл, а не стенограмму. Поэтому она компактная и её дёшево держать в окне.

Техника 3: переформулировка запроса перед поиском (для RAG)

Первые две техники управляют тем, что видит модель. Но у агента с базой знаний есть ещё один слой, где context engineering решает не меньше: сам поиск. Сырой уточняющий вопрос вроде «а сроки?» или «то есть 902?» для векторного поиска не значит ничего: он осмыслен только вместе с предыдущими репликами. Прежде чем идти в базу, вопрос нужно переписать в самостоятельный.

В боевом RAG я делаю это кастомным ретривером, который сначала собирает из истории полноценный запрос, а потом уже ищет и переранжирует:

from langchain_core.retrievers import BaseRetriever
from langchain_core.documents import Document

class ConversationalRetriever(BaseRetriever):
    def _get_relevant_documents(self, query, *, run_manager) -> list[Document]:
        # 1. из "902" + истории собираем "вентилятор настольный 902"
        standalone_queries = self._rewrite_with_history(query, self.chat_history)
        target = standalone_queries[-1]
        # 2. только теперь ищем в векторном хранилище
        candidates = self.vector_store.similarity_search(target, k=10)
        # 3. и переранжируем найденное под уточнённый запрос
        return self._rerank(candidates, target)

Переформулировку делает отдельный промпт к модели: он получает историю пользовательских реплик и текущий обрывок, а возвращает список законченных запросов. На практике это ловит два кейса: «уточнение к прошлому товару» («вентиляторы» → «настольные» → «900») и «новый товар» («…а ещё телевизор»), когда прошлый запрос надо закрыть и начать новый.

В нашем RAG Studio за индексом стоит конкретный конвейер: PDF рендерится в PNG, страницы прогоняются через vision-модель, которая возвращает Markdown, а уже он режется на чанки и кладётся в Qdrant с эмбеддингами qwen3-embedding. Индекс можно сделать сколь угодно точным, но плохой запрос на входе всё равно вернёт мусор. Переписывание стоит именно до Qdrant, а не после, потому что чинить нерелевантную выдачу постфактум дороже, чем не допустить плохой запрос.

Разница видна на входе поиска. Без переписывания в векторное хранилище уходит голое «900», и оно матчится с чем угодно, где встречается это число: артикулы, годы, цены. После переписывания туда уходит «вентилятор настольный 900», и поиск возвращает ровно ту позицию. Один и тот же индекс, одна и та же модель эмбеддингов; разница только в том, что мы решили подать на вход. В переформулировке я передаю в промпт только пользовательские реплики, без ответов ассистента: намерение пользователя живёт в его словах, а ответы бота — это шум, который сбивает сборку самостоятельного запроса.

Смысл тот же, что в Техниках 1–2, только на один слой раньше: вы решаете, что видит ретривер, а не только модель. Плохо переписанный запрос вернёт нерелевантные документы, и они займут место в окне, отвлекут модель и испортят ответ. Ошибка на этапе поиска дальше по конвейеру уже не лечится. Подробнее про двухпроходный поиск и переранжирование я разбирал в статье про агента на LangGraph для 1С.

MCP и context engineering — два разных способа дать агенту контекст

Здесь легко смешать два понятия, которые работают на разных уровнях. MCP (Model Context Protocol) отвечает на вопрос, к каким инструментам и источникам данных агент вообще может дотянуться: базе 1С, файловой системе, внешнему API. Context engineering — про то, какое подмножество этого реально попадёт в окно модели на конкретном вызове.

Это взаимодополняющие вещи. Агент с идеальным MCP-доступом ко всей 1С, но без дисциплины контекста будет тонуть на длинных диалогах: он может достать что угодно, но тащит в окно слишком много и отвлекается. И наоборот: аккуратное управление контекстом бесполезно, если агенту нечего в это окно положить. MCP даёт доступ, context engineering — отбор. Когда за одну задачу берутся несколько агентов, отбор контекста для каждого становится ещё важнее. Об этом отдельно в статье про мультиагентные системы.

Что запомнить

Context engineering — это про управление контекстным окном как ограниченным ресурсом. Три техники закрывают три слоя:

  • Суммаризация сжимает текущий диалог, чтобы история не переполнила окно.
  • Долговременная память подставляет факты из прошлых диалогов блоком <memories>.
  • Переформулировка запроса решает, что увидит ретривер, ещё до генерации ответа.

Ни одна из них не «настройка один раз». Все три работают на каждом вызове агента. Именно поэтому это отдельная дисциплина, а не подпункт промпт-инжиниринга.

Порядок внедрения я советую такой. Если сейчас у вас просто список сообщений в промпте, начните с суммаризации: она даёт больше всего эффекта при минимуме кода и не требует отдельного хранилища. Долговременную память подключайте, когда агент должен узнавать пользователя между сессиями: под неё уже нужен store с векторным индексом. Переформулировку запроса добавляйте, если у агента есть RAG и пользователи задают уточняющие вопросы обрывками. Не тащите все три сразу: каждая техника решает свою боль, и внедрять их стоит по мере того, как эта боль появляется.

Суммаризацию, память и RAG с учётом контекста мы разбираем на практике, с рабочим кодом на LangChain и LangGraph, в курсе «ИИ для 1С». Если вы уже строите агента и упёрлись в раздувающееся контекстное окно, начните с Техники 1: она даёт максимум эффекта при минимуме кода.

Частые вопросы