Промпты как код: шаблоны и переменные
История, случающаяся с каждым первым агентом. Бот-ревьюер работает неделю, и в пятницу кто-то замечает: он перестал замечать пустые except-блоки. Открываете код, находите функцию, внутри которой слеплена f-строка на тридцать строк: половина текста — инструкция, половина — подстановки из локальных переменных. Где-то в середине кто-то «чуть поправил формулировку» — случайно затёр строку про except. Git diff не помогает: промпт менялся вместе с кодом, в коммите «fix», среди прочего.
Диагноз простой: промпт жили в коде как мусор — без своего места, версии и ответственности. Лечится тоже просто: промпт — это исходник. У него есть шаблон (неизменная часть), данные (то, что подставляется) и история изменений в git. Это занятие — о том, как разложить промпт по этим полочкам.
Два слоя любого промпта
Разберите на части промпт ревьюера. Слой первый — постоянный: «ты ревьюер, вот правила, вот формат ответа». Он один и тот же на каждом PR, его хочется кэшировать (помните про 10% от цены входа?) и версионировать. Слой второй — переменный: сам дифф, список изменённых файлов, может, название ветки. Он каждый раз новый.
Смешение слоёв — источник бед из первого абзаца. Разделение даёт три выигрыша сразу: постоянный префикс ложится в кэш провайдера, изменение правил видно в git отдельным коммитом, а тест может проверить сборку промпта, не запуская модель.
Прежде чем показать механику, одно отступление о том, куда промпт кладётся в запросе.
Роли: инструкции отдельно от задачи
В первом модуле наш LLMClient отправлял одно сообщение с ролью user. Промптов побольше — и появляется вторая роль: системные инструкции — сообщение с ролью «система» (у части провайдеров она называется developer), где живёт постоянный слой: кто ты, какими правилами руководствуешься, в каком формате отвечаешь. Пользовательское сообщение — переменный слой: конкретный дифф, конкретный вопрос.
messages = [
{"role": "system", "content": SYSTEM_PROMPT}, # постоянный слой
{"role": "user", "content": user_prompt}, # переменный слой
]Системная роль стоит первой в запросе — и отлично ложится на кэш: она одинакова от запроса к запросу. Провайдеры по-разному взвешивают «приказы» из системного сообщения и из пользовательского (системное обычно приоритетнее), но для нас важно другое: это внятное место для правил. Правило, вписанное в системный промпт, — правило в рамке; правило, вписанное в середину f-строки, — примечание на полях.
Шаблон: string.Template и почему не format
Переменный слой собирается подстановкой. В питоне для этого есть f-строки, str.format и string.Template — и для промптов правильный выбор последний. Покажу почему.
Естественное желание — написать шаблон с фигурными скобками и собрать его format-ом:
PROMPT = """Ты — ревьюер. Правила:
{rules}
Дифф:
{diff}
Ответ дай в формате:
{"comments": [{"file": "...", "line": 1, "severity": "warning"}]}"""
PROMPT.format(rules=rules_text, diff=diff)Этот код падает при первом же запуске:
KeyError: '"comments"'str.format разбирает ВСЕ фигурные скобки как свои поля подстановки — включая скобки JSON-примера, который вы показали модели. Мало того: если дифф в этот шаблон попадает через конкатенацию (или вы позже решите подставлять его в другой {}-шаблон), сломаются и скобки из кода диффа. В промптах фигурные скобки — рабочая ткань: JSON, dict-литералы, f-строки в чужом коде. Шаблонизатор, который считает {} своим, не подходит.
string.Template использует $-плейсхолдеры, и скобки его не волнуют:
from string import Template
REVIEW_TEMPLATE = Template("""Ты — ревьюер. Правила:
$rules
Дифф:
$diff
Ответ дай строго в формате JSON:
{"comments": [{"file": "...", "line": 1, "severity": "warning", "message": "..."}]}
Если замечаний нет — отдай {"comments": []}.""")
def build_review_prompt(diff: str, rules: list[str]) -> str:
rules_text = "\n".join(f"- {rule}" for rule in rules)
return REVIEW_TEMPLATE.substitute(rules=rules_text, diff=diff)Симметричная ловушка тоже существует: $ в самом шаблоне ($PATH в примере команды из диффа, если он попал в шаблон) уронит substitute с KeyError: 'PATH'. Важно понять границы: и format, и Template парсят только шаблон, значения они не трогают. Дифф с любыми скобками и долларами, подставленный как значение, пройдёт насквозь без единой царапины. Правило простое: шаблон — ваш, значения — чужие; следите за спецсимволами только в шаблоне.
Почему не f-строка? Она склеивает шаблон и данные в момент написания кода: чтобы «увидеть» промпт целиком, надо исполнить функцию, а изменение формулировки — это правка кода. Template позволяет хранить шаблон константой (а позже — файлом в prompts/), тестировать сборку отдельно и менять текст, не прикасаясь к логике.
Правила ревью — данные, а не проза
Последний штрих: список правил тоже выносится из текста. Правила как питоновский список (в третьем модуле это будет yaml-файл) превращаются в подстановку $rules одной стройкой, как в build_review_prompt выше. Выигрыш не в красоте: набор правил можно фильтровать по репозиторию, включать и выключать флагами и — снова — менять коммитом «rules: +пустые except», а не «fix».
Few-shot: один пример стоит десяти инструкций
Есть приём, экономящий и токены, и нервы: few-shot — когда в промпт кладётся короткий пример «вход → правильный ответ». Модели подражают примеру точнее, чем выполняют абзац описаний. Вместо трёх предложений о том, каким должен быть формат замечания, — один образец:
Пример ответа:
{"comments": [{"file": "pay.py", "line": 14, "severity": "critical",
"message": "сумма без проверки на отрицательные значения"}]}Пример тоже часть шаблона: он постоянен, версионен и попадает под кэш. А следующее занятие объяснит, почему пример — это только половина защиты формата: вторая половина — жёсткий парсинг того, что модель на самом деле прислала.
Что запомнить
Промпт — исходник: шаблон отдельно, данные отдельно, история в git. f-строка на тридцать строк — антипаттерн, из которого растут «боты, которые сами изменились».
Постоянный слой — системное сообщение, первым в запросе: приоритет инструкций и кэшируемый префикс. Переменный — пользовательское.
str.formatломается о фигурные скобки JSON-примеров (KeyError),string.Template— о$в шаблоне. Значения безопасны в обоих случаях; следите за шаблоном.Правила — данные: список, потом yaml. Изменение правил — отдельный коммит.
Few-shot пример стабилизирует формат ответа дешевле и надёжнее длинных описаний.
Дополнительно
string.Template — документация Python:
$-плейсхолдеры,substituteиsafe_substitute.
Как устроены LLM: токены, контекст, цена
10 мин
Домашка: токен-калькулятор
4 мин
Квиз: Как устроены LLM: токены, контекст, цена
8 мин
Промпты как код: шаблоны и переменные
8 мин
Домашка: промпт ревьюера
3 мин
Квиз: Промпты как код: шаблоны и переменные
7 мин
Структурированный вывод: JSON вместо прозы
6 мин
Домашка: парсер ревью
3 мин
Квиз: Структурированный вывод: JSON вместо прозы
7 мин
Выбор модели: GPT, Claude, открытые
7 мин
Домашка: бенчмарк вслепую
4 мин
Квиз: Выбор модели: GPT, Claude, открытые
7 мин
HARD-задача: смета агента
3 мин
Локальная модель: Ollama по-настоящему
8 мин
Квиз: Локальная модель: Ollama по-настоящему
6 мин