Промпты как код: шаблоны и переменные

История, случающаяся с каждым первым агентом. Бот-ревьюер работает неделю, и в пятницу кто-то замечает: он перестал замечать пустые except-блоки. Открываете код, находите функцию, внутри которой слеплена f-строка на тридцать строк: половина текста — инструкция, половина — подстановки из локальных переменных. Где-то в середине кто-то «чуть поправил формулировку» — случайно затёр строку про except. Git diff не помогает: промпт менялся вместе с кодом, в коммите «fix», среди прочего.

Диагноз простой: промпт жили в коде как мусор — без своего места, версии и ответственности. Лечится тоже просто: промпт — это исходник. У него есть шаблон (неизменная часть), данные (то, что подставляется) и история изменений в git. Это занятие — о том, как разложить промпт по этим полочкам.

Два слоя любого промпта

Разберите на части промпт ревьюера. Слой первый — постоянный: «ты ревьюер, вот правила, вот формат ответа». Он один и тот же на каждом PR, его хочется кэшировать (помните про 10% от цены входа?) и версионировать. Слой второй — переменный: сам дифф, список изменённых файлов, может, название ветки. Он каждый раз новый.

Смешение слоёв — источник бед из первого абзаца. Разделение даёт три выигрыша сразу: постоянный префикс ложится в кэш провайдера, изменение правил видно в git отдельным коммитом, а тест может проверить сборку промпта, не запуская модель.

Прежде чем показать механику, одно отступление о том, куда промпт кладётся в запросе.

Роли: инструкции отдельно от задачи

В первом модуле наш LLMClient отправлял одно сообщение с ролью user. Промптов побольше — и появляется вторая роль: системные инструкции — сообщение с ролью «система» (у части провайдеров она называется developer), где живёт постоянный слой: кто ты, какими правилами руководствуешься, в каком формате отвечаешь. Пользовательское сообщение — переменный слой: конкретный дифф, конкретный вопрос.

python
messages = [
    {"role": "system", "content": SYSTEM_PROMPT},   # постоянный слой
    {"role": "user", "content": user_prompt},       # переменный слой
]

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

Шаблон: string.Template и почему не format

Переменный слой собирается подстановкой. В питоне для этого есть f-строки, str.format и string.Template — и для промптов правильный выбор последний. Покажу почему.

Естественное желание — написать шаблон с фигурными скобками и собрать его format-ом:

python
PROMPT = """Ты — ревьюер. Правила:
{rules}

Дифф:
{diff}

Ответ дай в формате:
{"comments": [{"file": "...", "line": 1, "severity": "warning"}]}"""

PROMPT.format(rules=rules_text, diff=diff)

Этот код падает при первом же запуске:

java
KeyError: '"comments"'

str.format разбирает ВСЕ фигурные скобки как свои поля подстановки — включая скобки JSON-примера, который вы показали модели. Мало того: если дифф в этот шаблон попадает через конкатенацию (или вы позже решите подставлять его в другой {}-шаблон), сломаются и скобки из кода диффа. В промптах фигурные скобки — рабочая ткань: JSON, dict-литералы, f-строки в чужом коде. Шаблонизатор, который считает {} своим, не подходит.

string.Template использует $-плейсхолдеры, и скобки его не волнуют:

python
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 — когда в промпт кладётся короткий пример «вход → правильный ответ». Модели подражают примеру точнее, чем выполняют абзац описаний. Вместо трёх предложений о том, каким должен быть формат замечания, — один образец:

java
Пример ответа:
{"comments": [{"file": "pay.py", "line": 14, "severity": "critical",
               "message": "сумма без проверки на отрицательные значения"}]}

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

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

  • Промпт — исходник: шаблон отдельно, данные отдельно, история в git. f-строка на тридцать строк — антипаттерн, из которого растут «боты, которые сами изменились».

  • Постоянный слой — системное сообщение, первым в запросе: приоритет инструкций и кэшируемый префикс. Переменный — пользовательское.

  • str.format ломается о фигурные скобки JSON-примеров (KeyError), string.Template — о $ в шаблоне. Значения безопасны в обоих случаях; следите за шаблоном.

  • Правила — данные: список, потом yaml. Изменение правил — отдельный коммит.

  • Few-shot пример стабилизирует формат ответа дешевле и надёжнее длинных описаний.

Дополнительно

  • theory icon

    Как устроены LLM: токены, контекст, цена

    10 мин

  • homework icon

    Домашка: токен-калькулятор

    4 мин

  • quiz icon

    Квиз: Как устроены LLM: токены, контекст, цена

    8 мин

  • theory icon

    Промпты как код: шаблоны и переменные

    8 мин

  • homework icon

    Домашка: промпт ревьюера

    3 мин

  • quiz icon

    Квиз: Промпты как код: шаблоны и переменные

    7 мин

  • theory icon

    Структурированный вывод: JSON вместо прозы

    6 мин

  • homework icon

    Домашка: парсер ревью

    3 мин

  • quiz icon

    Квиз: Структурированный вывод: JSON вместо прозы

    7 мин

  • theory icon

    Выбор модели: GPT, Claude, открытые

    7 мин

  • homework icon

    Домашка: бенчмарк вслепую

    4 мин

  • quiz icon

    Квиз: Выбор модели: GPT, Claude, открытые

    7 мин

  • homework icon

    HARD-задача: смета агента

    3 мин

  • theory icon

    Локальная модель: Ollama по-настоящему

    8 мин

  • quiz icon

    Квиз: Локальная модель: Ollama по-настоящему

    6 мин

🎯
Тренажёр собеседованияЗакрепите знания перед интервью