← max_tokens

Промпт — это 5% работы: контекст-инжиниринг LLM-агента в проде

Однажды мой агент взял свежий список кампаний и сообщил пользователю, что трафик упал. Данные были в порядке. Упал не трафик, а лимит: результат инструмента не влез в бюджет контекста, код молча обрезал список, и модель добросовестно приняла дыру за факт. Я долго искал проблему в промпте. Промпт был ни при чём — сломан был контекст.

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

Я строю PRIQO — AI-аналитика для маркетинга: пользователь на любом языке спрашивает про данные из GA4, Google Ads, Search Console, Merchant Center, Meta и LinkedIn, а чат-агент отвечает текстом, графиками и отчётами в реальном времени. Под капотом — ReAct-цикл с сорока инструментами, роутинг моделей между провайдерами и потоковая выдача через SSE. Этот пост — про то, как у такого агента устроен контекст. Промпт в нём — верхний слой, процентов пять. Остальное — архитектура: что модель видит, когда видит, сколько это стоит и чего не видит никогда.

1. Системный промпт — это слоёная архитектура кеша

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

  • Слой 1 — ядро. Глобальные правила поведения, общие для всех пользователей. Меняется релизами. Кешируется у провайдера один раз на всех.
  • Слой 2 — справочники источников. Документация по каждому подключённому типу источника: как называются поля GA4, какие шаблоны запросов нужны Ads, как выбирать визуализацию. Загружается по типу источника, а не по аккаунту — поэтому кеш переживает любых клиентов с одинаковым набором подключений.
  • Слой 3 — снимок памяти проекта. Что агент знает об этом проекте из прошлых разговоров. Данные снимка обновляются раз в 24 часа, а сам блок собирается детерминированно: между обновлениями одни и те же данные дают байт-в-байт одинаковый блок — иначе кеш не сработает ни разу.
  • Изменчивый хвост. Всё, что живёт один ход: привязки аккаунтов и валют, подгруженные скиллы, few-shot примеры, результаты префетча URL и языковой блок. Языковой блок стоит последним сознательно: у моделей есть recency bias, и инструкция в конце промпта держится надёжнее всего.

В коде это буквально список блоков с флагом кешируемости:

def build_prompt(ctx) -> list[Block]:
    return [
        Block(core_rules(),                  cacheable=True),   # один на всех
        Block(source_docs(ctx.source_types), cacheable=True),   # по ТИПУ источника
        Block(project_memory(ctx.project),   cacheable=True),   # суточный снимок
        Block(bindings(ctx) + skills(ctx) + examples(ctx),
                                             cacheable=False),  # живёт один ход
        Block(language_rule(ctx.lang),       cacheable=False),  # последним: recency bias
    ]

У кеширования есть неочевидная ловушка: запись в кеш стоит дороже обычного инференса (примерно ×1.25), а чтение — на порядок дешевле (×0.1). Кешировать мелкие блоки — значит доплачивать почти впустую, поэтому блок меньше ~2000 токенов в кеш у меня не идёт.

Эта конструкция — не теория: за последние два месяца 63% всех входных токенов агента пришли из кеша (у отдельных провайдеров — до 65–70%). То есть две трети контекста, который видит модель, я не оплачиваю по полной ставке и не жду, пока он прожуётся заново. Сразу оговорка про выборку: она относится ко всем цифрам в этом посте. Прод пока скромный: 135 агентных ходов за 60 дней. Мало для статистики, достаточно, чтобы увидеть, как механика ведёт себя на живых пользователях.

Системный промпт как слоёный кеш: кешируемые ядро, справочники и снимок памяти, изменчивый хвост с языковым блоком в конце

Вывод: проектируйте промпт как схему кеша. Стабильное — вперёд и крупными блоками, изменчивое — в хвост, и считайте экономику записи, а не только чтения.

2. Классификатор — это диспетчер бюджета, а не формальность

Прежде чем дорогая модель увидит запрос, он проходит два уровня. Уровень 0 — регулярки, ноль токенов: попытки вытащить системный промпт ловятся набором паттернов на семи языках и получают заготовленный вежливый отказ; вопросы «а что ты умеешь?» — готовый ответ на языке пользователя, LLM не вызывается вообще. Уровень 1 — лёгкая модель-классификатор, которая возвращает строгий JSON: интент, нужный источник, язык и сложность. Выход ограничен 256 токенами — классификатору не положено рассуждать вслух.

Сложность конвертируется в ресурсы дальше по конвейеру: простые и средние запросы идут в пул быстрых моделей, сложные — в мощный, и потолок итераций агентного цикла растёт вместе с уровнем (4/6/8). В проде это распределение выглядит так: 78% ходов остаются в быстром пуле, и только каждый пятый запрос реально требует тяжёлой модели. При этом медиана — 2 итерации цикла на обоих уровнях: большинство вопросов закрывается за «сходил за данными → ответил», а запас итераций работает страховкой для хвоста, а не нормой. Если же по интенту подгружается экспертный скилл (режим «советника по GA4»), уровень принудительно повышается: совет от слабой модели хуже, чем чуть более дорогой, но полезный ответ.

Сюда же относится язык. Все мои промпты, включая few-shot примеры, написаны только по-английски — фронтирные модели хорошо переносят такие примеры между языками, и английский пример «Generate a new variant» корректно срабатывает на «сгенерируй вариант» и «اصنع متغيرًا», так что дублировать примеры на N языках — раздувать контекст без роста точности. А язык ответа контролируется отдельным механизмом: классификатор определяет язык последнего сообщения (отличая украинский от русского по уникальным буквам ї, є, ґ), и блок принудительного языка в конце промпта требует единый язык во всём ответе вплоть до подписей на графиках.

Вывод: решение «сколько модели дать думать» должно приниматься до дорогого вызова, дешёвым механизмом. (Как устроен сам роутер пулов — тема отдельного поста.)

3. Агент видит не все инструменты — и не все результаты целиком

У агента больше сорока инструментов, но в конкретном разговоре он видит только инструменты подключённых источников плюс универсальные. Конструктор кросс-канальных отчётов появляется в списке, только когда подключено два и более источников — зачем модели инструмент, которым невозможно воспользоваться? Справочные инструменты, например схемы метрик, исчезают из списка после первого вызова в ходе: повторно спрашивать схему незачем.

Два механизма защищают цикл от раздувания. Первый — дедуп-кеш вызовов инструментов: ключ — имя инструмента плюс канонизированные параметры; если модель просит те же данные второй раз, она получает кешированный результат мгновенно и бесплатно. В проде так обслуживается примерно каждый десятый вызов инструмента — модели правда спрашивают одно и то же дважды. Кешируются только успехи: ошибки имеют право на повтор. А 16% ходов агент закрывает вообще без единого вызова инструмента — ответ уже есть в контексте разговора, и дисциплина «не ходи за тем, что знаешь» тоже экономит цикл.

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

def to_observation(result, max_chars=32_000) -> str:
    text = compact_json(result)
    if len(text) <= max_chars:
        return text
    items, key = largest_list(result)   # rows, campaigns, audiences…
    lo, hi = 1, len(items)
    while lo < hi:                      # бинарный поиск: сколько ЦЕЛЫХ
        mid = (lo + hi + 1) // 2        # элементов влезает в бюджет
        if fits(result, key, items[:mid], max_chars):
            lo = mid
        else:
            hi = mid - 1
    return compact_json(replace(result, key, items[:lo])) + (
        f"\n(showing {lo} of {len(items)} items — "
        "data is COMPLETE from API, only truncated for display)"
    )

Но сама техника обрезки — только полдела. Главное — последняя строка, фраза, которой обрезка сопровождается. Без неё модель интерпретировала усечённый список как маленький — и «трафик падал». С ней — понимает, что смотрит в окно, а не на весь мир.

Вывод: список инструментов — часть контекста, управляйте им так же активно, как промптом. А любое усечение данных должно быть прокомментировано для модели, иначе она прочитает дыру как факт.

4. Числа не должны проходить через пересказ

Самая дорогая галлюцинация в аналитике — цифра. Пользователь простит кривую формулировку, но не выдуманную выручку. Защита многослойна:

  • В ядре промпта — жёсткое правило: каждое число, URL и название в ответе должно приходить из результата инструмента. Никаких оценок, экстраполяций и «примерно». Бенчмарки — только из подгруженного справочника советника.
  • Markdown-таблицы агенту запрещены: вместо пересказа данных текстом он отдаёт блок CHART_DATA — чистый JSON, который фронтенд рендерит через ECharts. Модель решает, какой график построить; раскладку и отрисовку делает код.
  • Результаты инструментов считаются недоверенными данными: если внутри ответа API встречается текст, похожий на инструкцию, агент обязан игнорировать его. Это защита от инъекций через данные.

Вывод: разделите роли — модель выбирает и объясняет, код доставляет и рисует. Чем короче путь числа от API до экрана, тем меньше мест, где оно может «поплыть».

5. Отложенный контекст: не платить за правила в каждой итерации

ReAct-цикл — это несколько обращений к модели подряд, и каждый лишний килобайт системного промпта оплачивается в каждой итерации. Поэтому часть контекста у меня отложенная: подробные правила построения графиков (выбор типа графика, лимиты top-N, формат когорт) не присутствуют в промпте, пока агент вызывает инструменты, — они подгружаются один раз, прямо перед финальной генерацией ответа, когда только и нужны.

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

6. Память — три горизонта вместо бесконечной истории

Бесконечно растущая история чата — самый ленивый и самый дорогой способ «помнить». У меня память разнесена на три горизонта:

  1. Внутри сессии: старые сообщения сжимаются в прогрессивную сводку, которая подкладывается как блок «контекст предыдущего разговора»; свежие ходы идут как есть, обогащённые компактной выжимкой прошлых вызовов инструментов (что вызывали, с какими параметрами, что получили — без полных данных).
  2. Между сессиями: снимок памяти проекта — извлечённые сущности и связи из прошлых разговоров, пересобирается раз в сутки и попадает в кешируемый слой промпта.
  3. На текущий ход: точечное извлечение — релевантные текущему вопросу находки из памяти, маленьким изменчивым блоком.

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

7. Что модель вообще не должна генерировать

Пока агент работает, пользователь видит живой прогресс: статусы, плашки вызовов инструментов, поток токенов. Соблазн — попросить модель самой комментировать свой прогресс. Я сделал наоборот: интерфейсный поток собирается из 36 типов доменных событий, и статусные тексты приходят из кода и базы, а не из модели. Модель не может «приукрасить» прогресс, потому что не она его рассказывает.

Это часть более общего правила, которое я вывел болезненно: рассказ агента — не ground truth. Модель может убедительно написать «я построила график по реальным данным» — верить можно только вызовам инструментов и логам исполнения. Всё, что критично для доверия пользователя, должно генерироваться кодом из фактов, а не текстом из модели.

Вывод: каждый раз, когда хочется поручить модели сообщить о состоянии системы, спросите себя: а знает ли она его на самом деле?

Чеклист

  • Системный промпт — слоёная схема кеша: стабильное вперёд, изменчивое в хвост, мелкое не кешировать (запись дороже чтения).
  • Дешёвый диспетчер до дорогой модели: regex → лёгкий классификатор → уровень, лимит итераций и набор инструментов по сложности.
  • Агент видит только применимые инструменты; дубль вызова — кеш, не повторный вызов.
  • Любая обрезка данных комментируется для модели, иначе дыра станет «фактом».
  • Числа и графики — JSON-блоками в код, не пересказом; данные инструментов — недоверенные.
  • Контекст бывает отложенным: правила финального ответа не возят в каждой итерации.
  • Память — три горизонта (сводка сессии / суточный снимок / retrieval на текущий ход), а не бесконечная история.
  • Статусы и прогресс генерирует код из событий, а не модель из фантазий.
  • Промпты на английском; язык ответа — отдельным блоком принудительного языка в конце.

Ничего из этого не решается «хорошим промптом». Решается архитектурой: что модель видит, когда видит, сколько это стоит и чего не видит никогда. Это и есть context engineering.

<|endoftext|> · 3 872 tok · finish_reason: stop

// top_k · nearest neighbors

  1. [0] 0.734 Не каждый запрос заслуживает агента
  2. [1] 0.712 Turn count как продуктовая метрика
  3. [2] 0.676 Graphify и MemPalace: агенту нужна не «память», а карта проекта и история решений

cosine of embeddings · scale 0–1 absolute · computed at build

integrity: sha256 0c14f06d…

tokens · o200k_base