Структуровані системні запити для AI-агентів на 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, рівень вихідних даних пропонує відповідати у цьому форматі, відповідаючи наданій схемі. Якщо користувач просить навести кроки, рівень вимагає пронумерувати їх. Поведінка залишається незмінною, тоді як формат адаптується.
Приклади показують, а не пояснюють
Один приклад часто замінює кілька інструкцій:
.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) з назвою файлу в дужках. Розміщення посилання — це завдання рівня виведення даних; перевірка замість здогадок є частиною інструкцій, а стиль посилань визначається прикладом. Витрати становлять лише частину тих, які б були без обмежень кешу.
Висновки
Жорстко закодований текст підходить, доки це так. Коли у вас є кілька інструментів, кілька форматів вихідних даних, кілька інженерів, які редагують запит, або певні бюджетні очікування, структура виявляється корисною. Ці елементи не є довільними: ідентичність — це хто, інструкції — що робити, результат — яким він має бути, а контекст — це поточна ситуація. Якщо щось не підходить до одного з цих елементів, вимоги агента залишаються нечіткими, і це не вина структури.
Межі кешу перетворюють структуру на економію ресурсів. Інструмент типу typed builder — це формат, який Rust пропонує безкоштовно, а фінгерпринт — це дешевий спосіб тестування на зміни. Разом вони дають вам системний запит, який можна редагувати у п’ятницю після обіду без страху.
Ці два інструменти залишаються спеціальними функціями в операторі match, які не мають спільного інтерфейсу, не генерують схеми, не передбачають можливості повторних спроб та не дозволяють третім сторонам надавати інструменти, які агент може використовувати. Наступним логічним кроком є використання трейту Skill — реєстру, який керує таблицею диспетчеризації та schemars для отримання JSON Schema зі структури Rust, щоб агент розвивався шляхом комбінування елементів, а не простим накопиченням функцій.