Структурированные системные промпты для ИИ-агентов на Rust: уровни, кэширование, проверка отклонений.
Создайте многоуровневый системный промпт для агента на Rust: идентификатор, инструкции, правила вывода, примеры и свежий контекст, плюс границы кэширования и отпечаток для CI.
Источники: Building AI Agents in Rust — part 2
Первый агент обычно справляется с задачей при помощи системного промпта, представляющего собой одну строковую литералу длиной около шестидесяти слов: отвечать на вопросы о проекте Rust и вызывать функцию read_file, когда необходимо просмотреть содержимое файла. Это работает потому, что существует ровно один инструмент, один пользователь и нет никаких правил относительно того, какими должны быть ответы. Но затем ситуация усложняется: появление второго инструмента заставляет модель понимать, когда нужно перечислять файлы, а когда их читать; появление третьего — требует определения порядка действий. Правило вывода вроде «цитируйте прочитанный файл» нуждается в месте для реализации. Тест на регрессию, проверяющий, остаётся ли промпт таким же и даёт ли он одинаковый ответ при фиксированном входе, требует способа обнаружения случайных изменений промпта. Кэширование промптов заставляет учитывать, какие части меняются с каждым ходом, а какие — нет. Простая строковая литерала не справляется со всем этим гибко.
Далее приведен структурированный запрос для агента по имени Юджин, который умеет с этим справляться: четыре уровня, инструмент для формирования текста, контекст для каждого запроса, один пример в формате нескольких примеров, хэш для выявления отклонений, а также статическая/динамическая граница, позволяющая кэшу запросов Anthropic заплатить за использование данного персонажа один раз, а не при каждом ответе. Цикл работы агента остается прежним; меняется только формат системного сообщения.
Почему структура помогает
Запрос — это своего рода код, поскольку он определяет поведение, и каждое предложение в нем конкурирует за внимание модели. Длинный блок без четкой структуры будет отчасти проигнорирован, тогда как небольшой набор размеченных секций будет внимательно прочитан.
Это может показаться суеверием, но это наблюдаемое поведение. Claude и его аналоги обучаются на инструкциях в знакомых форматах: структурированные системные промпты, запросы пользователя с простой формулировкой и ответы ассистента в таком же простом формате. Если следовать этим форматам, модель меньше сомневается в значении отдельных элементов, что снижает количество галлюцинаций, делает ответы более точными и улучшает выбор инструментов.
Четыре уровня, описанные ниже, — не единственная возможная структура. Это самая простая структура, которая выдерживает изменения, с которыми сталкивается реальный агент. Почти такая же структура встречается в системных промптах, которые Claude Code CLI создает для себя, и это не случайность: агенты, способные работать с реальными пользователями, как правило, приходят к одинаковой структуре.
Уровни
Идентичность — это абзац, описывающий того, кто говорит, что определяет тон, уровень экспертизы и позицию. Пример: Юджин — тщательный исследовательский ассистент в проекте Rust, который предпочитает читать исходный код, а не догадываться. В него не должны входить никакие правила и факты.
Инструкции — это правила поведения, каждое из которых представляет собой отдельное проверяемое утверждение: используйте list_files, чтобы выяснить, что содержится в проекте, прежде чем его читать, или не пытайтесь снова выполнить вызов, который вернул ошибку. Они накапливаются по мере развития агента. Храните их в виде списка, поскольку пункты списка легче добавлять и удалять, чем абзацы.
Ограничения на вывод определяют форму ответа, а не его содержание: простой прозаический текст без использования markdown, ссылка в скобках на прочитанный файл, ответы длиной до 200 слов. Они применяются независимо от того, о чем спросил пользователь. Разделение их от инструкций позволяет изменять формат, не влияя на поведение агента, и наоборот.
Примеры демонстрируют, как должен вести себя агент. Пример с несколькими примерами выполняет за одну строку то, что для инструкции требуется три строки, поскольку он показывает хороший результат вместе с правильным стилем цитирования. Примеры являются частью образа агента, поэтому они размещаются в статическом префиксе рядом с информацией об идентичности и инструкциями.
Контекст хранит всё то, что меняется при каждом запуске: дату, региональные настройки пользователя, корневую директорию проекта, навыки, которые сейчас доступны. Поскольку он меняется, его нельзя хранить в статической строке. Он находится в собственном слое под границей кэша и пересоздается при каждом запросе.
Строитель в Rust
В Rust эта структура превращается в строитель, где каждый метод соответствует определённому слою. Он отслеживает, какие слои являются статическими, а какие — динамическими, и формирует границу между ними.
let prompt = SystemPromptBuilder::new()
.identity("You are Eugene, a careful research assistant who answers \
questions about a Rust project. You prefer reading the source \
over guessing.")
.instruction("Use `list_files` to discover what is in the project before reading.")
.instruction("Use `read_file` to inspect a specific file. Do not call it on \
paths you have not seen listed.")
.instruction("If a tool returns an error, do not retry the same call.")
.output_constraints("Answer in plain prose. Cite the file you read in parentheses, \
for example: (src/main.rs).")
.example("What edition does Cargo.toml use?",
"I'll check Cargo.toml directly. (Cargo.toml) The project uses Rust edition 2024.")
.context(format!("<env>\ntoday: {today}\nproject_root: {sandbox}\n</env>"))
.build();
Компилятор не может много проверить в этой части, но читаемость значительно улучшается. Вы можете просмотреть цепочку и понять, что увидит агент. Первые пять методов формируют статический префикс, а последний — динамический суффикс. Отображаемый префикс выглядит так:
## Identity
You are Eugene, a careful research assistant ...
## Instructions
- Use `list_files` to discover what is in the project before reading.
- Use `read_file` to inspect a specific file. Do not call it on paths ...
- If a tool returns an error, do not retry the same call.
## Output
Answer in plain prose. Cite the file you read in parentheses ...
## Examples
Example 1:
User: What edition does Cargo.toml use?
Assistant: I'll check Cargo.toml directly. (Cargo.toml) ...
Суффикс короче и полностью динамичен:
## Context
<env>
today: 2026-05-22
project_root: /Users/me/code/eugene
</env>
Заголовки несут смысл. «## Инструкции» сообщают модели, что пункты ниже — это правила, а «## Контекст» указывает, что строки ниже — это факты. Без них эти два уровня смешиваются, и модель может воспринимать факты как команды или наоборот. Благодаря заголовкам выбор инструментов становится заметно лучше.
Теги в стиле XML, окружающие информацию об среде, работают аналогичным образом. Оболочка <env> является маркером, с которым модель сталкивалась тысячи раз во время обучения, поэтому она воспринимает содержимое как текущее состояние системы, а не как руководство. Claude Code CLI передает свой текущий каталог, состояние git, операционную систему и идентификатор модели в свой собственный промпт именно таким образом, и это правило стоит перенять.
Идентичность даёт голос, а не истину
Именно этот слой чаще всего переписывают. Новички заполняют его утверждениями вроде «я — опытный инженер на языке Rust с двадцатью годами опыта в крупных технологических компаниях». Сообщение модели о том, что она эксперт, не делает её таковой. Идентичность влияет на стиль общения: тщательный исследовательский ассистент сомневается перед тем, как говорить, быстрый и прямолинейный инженер отвечает кратко, а терпеливый учитель объясняет подробно.
Он также определяет отношение к неопределенности. Приказ агенту предпочитать чтение исходного текста догадкам — это реальный инструмент воздействия, и модель действительно чаще вызывает функцию read_file, когда такая фраза присутствует, поскольку это влияет на то, какой ответ будет соответствовать её характеру. Рассматривайте идентичность как ориентир для действий персонажа, а не как резюме.
Инструкции представляют собой список
Каждая инструкция должна сама по себе либо выполняться, либо не выполняться. Условие «Используйте list_files перед чтением» можно проверить, поскольку модель либо вызывает его в первую очередь, либо нет. Условие «Будьте полезны пользователю» невозможно проверить, поэтому его следует убрать.
При коллизии правил важен порядок, так как модель читает их сверху вниз: более ранние правила задают основу, а позднейшие уточняют её. Если два правила действительно противоречат друг другу, запрос содержит ошибку, и модель будет выбирать случайным образом. Необходимо исправить противоречие в запросе, а не во время обработки.
Для небольшого агента подходит три-четыре правила. Десять — уже слишком много. Двадцать — это само по себе проблема, так как модель не может удержать их все и тихо игнорирует некоторые. Длинный список обычно означает отсутствие ограничений на вывод или фактов контекста, которые сделали бы несколько правил ненужными.
Ограничения вывода разделяют формат и поведение
Возьмем пример запроса, в котором требуется ответить вежливо простым прозаическим языком, указать источник в скобках и ограничиться 200 словами. В нем сочетаются три аспекта: вежливость относится к стилю общения, поэтому входит в состав идентичности ответа; указание источника и длина текста относятся к самому результату. Когда кто-то просит изменить формат цитирования, следует отредактировать лишь одну строку в соответствующем слое, не перечитывая всё остальное.
Именно здесь также применяется структурированный формат вывода. Если последующая система требует JSON, слой вывода указывает на необходимость ответа в формате JSON, соответствующем предоставленной схеме. Если пользователь просит перечислить шаги, слой указывает на необходимость пронумерования каждого из них. Поведение системы остается прежним, в то время как формат адаптируется.
Примеры показывают, а не объясняют
Один пример часто заменяет несколько инструкций:
.example(
"What edition does Cargo.toml use?",
"I'll check Cargo.toml directly. (Cargo.toml) The project uses Rust edition 2024.",
)
Этот единственный пример демонстрирует формат цитирования, показывает, что достаточно краткого упоминания, определяет ожидаемую длину и иллюстрирует связь между вызовом инструмента и цитатой. Ничего из этого не указано в инструкциях, и этого не требуется.
Риск заключается в чрезмерной адаптации модели. Если все примеры касаются Cargo.toml, модель начинает чрезмерно сосредотачиваться на нем. Используйте один-два примера, охватывающих реальный диапазон входных данных. Если вы не можете составить репрезентативный пример, скорее всего, что-то ещё в запросе описано недостаточно подробно.
Контекст воссоздается при каждом запросе
Сначала все забывают об этом аспекте. У модели есть предел знаний: она не знает сегодняшнюю дату, название вашего проекта или какие файлы существуют. Она будет делать предположения, и эти предположения кажутся правдоподобными, хотя на самом деле ошибочны. Фраза «вероятно, середина 2024 года» не является полезной датой.
Решение простое: каждый раз передавайте необходимые факты.
let today = OffsetDateTime::now_utc()
.date()
.format(&Iso8601::DATE)
.unwrap_or_else(|_| "unknown".into());
let sandbox = sandbox_root()?.display().to_string();
let prompt = system_prompt(&today, &sandbox);
Два факта, примерно полкилобайта токенов — и агент теперь знает, когда и где он находится. Затраты незначительны, а точность при вопросах, чувствительных к дате или пути, значительно повышается.
Уровень контекста настоящего агента быстро растет: имена пользователей и проектов, активные навыки, страница, которую просматривают, последние сообщения об ошибках, хеш последней коммит-записи. Ничто из этого не является мнением или инструкцией. Это данные, связанные с одним запросом и предоставляемые кодом.
Границы кэша
Именно это отличает агента-хобби от того, который можно экономично запускать. API Anthropic предоставляет функцию кэширования промптов: передавайте поле system в виде массива текстовых блоков вместо одной строки, пометьте блок тегом cache_control: {"type": "ephemeral"}, и весь контент этого блока будет кэшироваться на пять минут. Любой последующий запрос с идентичным префиксом будет обрабатываться из кэша, при этом стоимость использованных токенов будет примерно в десять раз ниже обычной.
Для агента, работающего в цикле из шести шагов с персонажем, состоящим из тысячи токенов, это означает снижение стоимости вводных данных примерно в 5 раз после первого шага, плюс сокращение задержек. Экономия увеличивается в зависимости от стабильности промпта. Персонаж почти всегда остается стабильным, в то время как сегодняшняя дата — нет.
let blocks = prompt.into_system_blocks();
// blocks[0] = { type: "text", text: <static prefix>, cache_control: {type: "ephemeral"} }
// blocks[1] = { type: "text", text: <dynamic suffix> } // no cache_control
Структура конструктора разделяется естественным образом. Идентичность, инструкции, ограничения вывода и примеры являются статическими, контекст — динамическим, причем каждая половина отображается отдельно со своими заголовками. Только первая содержит маркер кэша, поэтому вторая перетокенизируется при каждом запросе, в то время как персона остается в кэше.
Именно по этой причине в исходном коде Claude Code присутствует буквальный маркер SYSTEM_PROMPT_DYNAMIC_BOUNDARY, по которому работает логика кэширования. Все, что меняется между запросами, такое как соединения MCP в ходе сессии, изменения параметров модели или предпочтения языка, находится ниже этой линии. Статическая персона над ней может кэшироваться между пользователями, если это разрешено структурой конструктора.
Два правила обеспечивают активность кэша. Префикс должен быть полностью идентичным во всех запросах: даже один лишний пробел, изменение порядка элементов или другой формат даты меняют хэш, и вам придётся заново создавать кэш. Кроме того, префикс должен содержать 1024 токена, прежде чем Anthropic его закэширует. Короткие персоны не получают никакой выгоды, тогда как длинные — значительную. Если ваш префикс слишком короткий, добавьте содержимое, которое заслуживает своего места, а не просто воду.
В ответе приводится квитанция:
[turn 0] in=4 cache_read=0 cache_create=1247 out=89
[turn 1] in=3 cache_read=1247 cache_create=0 out=42
На первом шаге создается кэш, и на всех последующих шагах данные читаются из него. Во время разработки выводите статистику использования, поскольку кэш остается невидимым до момента его измерения.
Хранение в кэше динамических разделов
Границы кэша позволяют сохранять токены. Меньшая версия данных внутри процесса экономит вычислительные ресурсы. Некоторые динамические элементы требуют значительных усилий для создания, например сканирование файловой системы, загрузка снимка памяти или вызов удаленного хранилища настроек. Повторение этих действий на каждом шаге тратит время и усложняет анализ логов.
Сохраняйте каждый динамический раздел по имени и пересчитывайте его только при явном аннулировании, например при новом запросе пользователя, команде /clear или /compact. В исходном коде Claude Code разделы, которые могут храниться в кэше (systemPromptSection), отделены от нестабильных (DANGEROUS_uncachedSystemPromptSection); для последних требуется аргумент reason, поэтому автор должен обосновать необходимость разрушения кэша. Такое громкое название оправдано: один нестабильный раздел, меняющийся при каждом шаге, аннулирует всё, что находится после него в рамках данного запроса.
В агенте на Rust эквивалентом является реестр записей Section { name, compute_fn, cache_break }, которые генерируются при формировании запроса, причем каждый результат сопровождается идентификатором хода. Пока что библиотека eugene-prompt остается проще и пересчитывает каждый динамический раздел при каждой сборке. Если это занимает много ресурсов, оберните его в собственную кэш-систему и передавайте полученные значения, поскольку задача библиотеки — форматирование, а не хранение результатов в кэше.
Версионирование и проверка на регрессию
Самая сложная ошибка в запросах — это непреднамеренная. Кто-то изменяет предложение, чтобы исправить другую проблему, поведение меняется и нарушается работа сценария, за которым никто не следил, при этом ни один тест не срабатывает из-за отсутствия таковых.
Для решения этой проблемы помогает «отпечаток». Механизм формирования запроса генерирует хэш отрендеренного текста и предоставляет его дважды: prefix_fingerprint — только для префикса, который используется в ключах кэша, и fingerprint — для всего запроса целиком.
const EXPECTED_PROMPT_FINGERPRINT: u64 = 0; // set after first successful run
if EXPECTED_PROMPT_FINGERPRINT != 0
&& prompt.fingerprint() != EXPECTED_PROMPT_FINGERPRINT
{
eprintln!(
"warning: system prompt fingerprint drifted (was {EXPECTED_PROMPT_FINGERPRINT}, \
is {}). Update the constant if the change was intentional.",
prompt.fingerprint()
);
}
В CI сравнивается отпечаток префикса с константой. Когда они отличаются, CI выдает предупреждение, и вам нужно либо принять изменение (обновить константу и объяснить команде причину), либо отклонить его (вернуться к предыдущему состоянию). В любом случае это становится видно. Тот же отпечаток, который предотвращает регрессии, также показывает точное время изменения префикса кэша, когда следующий запрос не может воспользоваться кэшем.
Отпечаток необходим, но недостаточен. Вторая половина — это тестирование поведения: фиксированные входные данные, фиксированное ожидаемое поведение и механизм сравнения результата с ожиданиями. Отпечаток позволяет выявить 90% случаев незначительных отклонений.
Результат в Eugene
Цикл остался нетронутым. Изменился структурированный запрос и способ его передачи:
let prompt = system_prompt(&today, &sandbox);
let system_blocks = prompt.into_system_blocks();
let response = send(&http, &api_key, &system_blocks, &tools, &messages).await?;
Благодаря наличию функций list_files и read_file инструкции и пример показывают, как связать их между собой. При запросе о содержимом папки src агент перечисляет файлы в этой папке, выбирает подходящий файл, читает его и отвечает с цитатой. При запросе о версии в файле Cargo.toml агент сначала говорит, что откроет этот манифест, затем указывает версию (2024) в скобках вместе с именем файла. Размещение цитаты — задача слоя вывода, проверка вместо угадывания соответствует инструкциям, а стиль цитирования определяется примером. Расходы составляют лишь часть того, что было бы без ограничений кэша.
Выводы
Жестко заданный текст работает нормально, пока это так. Когда у вас есть несколько инструментов, несколько форматов вывода, несколько инженеров, редактирующих запрос, или какие-то бюджетные ограничения, структура оказывается крайне полезной. Эти уровни не являются произвольными: идентичность — это кто, инструкции — что делать, результат — какой он будет, а контекст — текущая ситуация. Если что-то не соответствует одному из этих элементов, причина в нечеткости требований к агенту, а не в отсутствии структуры.
Границы кэша превращают структуру в источник экономии ресурсов. Инструмент типирования — это форма, предоставляемая Rust бесплатно, а «отпечаток» — дешевый способ тестирования на изменения. Вместе они позволяют создать системный запрос, который можно редактировать в пятницу днем без чувства тревоги.
Эти два инструмента по-прежнему являются специальными функциями внутри оператора match, не имеющими общего интерфейса, возможности генерации схемы, механизмов повторных попыток, а также способа для третьих сторон предоставить инструмент, который агент мог бы использовать. Естественным следующим шагом является введение трейта Skill — реестра, управляющего таблицей распределения задач и структурами schemars для генерации JSON Schema из структур Rust, что позволит агенту развиваться за счёт комбинирования компонентов, а не простого накопления функций.