Перейти к содержимому
Ступень 88.4 LLM-приложение в продакшене

Промпт-инжиниринг как инженерная дисциплина

Как вести промпт месяцами, а не один вечер — версии и шаблоны в git, тест-набор и грейдеры, разбор ошибок по категориям, решение «выпускать ли новую версию» по доверительным интервалам, миграция на другую модель и маршрут по официальным гайдам Anthropic, OpenAI, Google, DeepSeek и Qwen.

УРОКstage-8/8-4-production/01-prompt-engineering-discipline.mdx2 ч 30 минУрок 1 из 3
После урока вы сможете
  • Хранить промпт как код — шаблон с переменными, отпечаток версии, журнал изменений и тест-набор рядом
  • Проходить рабочий цикл «набор → прогон → разбор ошибок по категориям → одна правка → регрессионный прогон»
  • Писать грейдеры и решать, выпускать ли новую версию промпта, по точности с доверительным интервалом и парному сравнению
  • Предсказывать, что сломается при смене модели или провайдера, и пользоваться официальными гайдами лабораторий как картой

В уроке 8.1.2 вы научились писать хороший промпт: роль, задача, данные в тегах, формат, примеры. Это навык одного вечера. В продакшене промпт живёт месяцами, и происходит вот что. Пришла жалоба: бот отнёс «ошибку 401» к багам, а это проблема доступа. Кто-то дописал в промпт строчку и сломал десять других случаев, о которых никто не узнал. Через месяц провайдер выпустил новую модель, вы переключились, и JSON начал приходить в обёртке из трёх обратных кавычек. Парсер падает, пользователи видят ошибку.

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

Ответ модели зависит не только от текста. Тот же текст с другой температурой, другими примерами или на другой модели — это другая программа. Поэтому версия промпта — всё, что влияет на ответ:

  • текст системного промпта и шаблоншаблон промптаEN prompt templateТекст промпта с переменными-«дырками» ({ticket}, {docs}), которые заполняются на каждом запросе. Хранится в репозитории как код. сообщения пользователя с переменными;
  • примеры few-shot;
  • модель — точное имя снимка, а не «последняя»;
  • параметры: температуратемпература (сэмплирования)EN temperatureЛогиты делятся на T перед softmax. T < 1 — распределение острее, T > 1 — площе, T → 0 — greedy., max_tokens, схема ответа.

Удобно держать всё это одним объектом в отдельном файле репозитория, а не размазывать по коду склейкой строк. Тогда изменение промпта видно в git diff и проходит ревью, как любой код. Так советует и OpenAI в своём руководстве: хранить промпты как часть кода приложения, в именованных модулях, и проверять правки через pull request.

import hashlib
import json
from dataclasses import asdict, dataclass, field, replace
@dataclass(frozen=True)
class PromptSpec:
"""Всё, что влияет на ответ модели, — в одном месте. Это и есть «версия промпта»."""
name: str
model: str
temperature: float
system: str
template: str # шаблон сообщения user с переменными {ticket}
examples: tuple = field(default_factory=tuple) # few-shot: пары (вход, ответ)
def render(self, **variables):
"""Собирает messages для Chat Completions (урок 8.1.1)."""
msgs = [{"role": "system", "content": self.system}]
for inp, out in self.examples:
msgs += [{"role": "user", "content": self.template.format(ticket=inp)},
{"role": "assistant", "content": out}]
msgs.append({"role": "user", "content": self.template.format(**variables)})
return msgs
def fingerprint(self):
"""Короткий «отпечаток»: меняется при ЛЮБОЙ правке — текста, примеров, модели, температуры."""
blob = json.dumps(asdict(self), ensure_ascii=False, sort_keys=True)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:10]
v1 = PromptSpec(
name="ticket-classifier", model="qwen3:8b", temperature=0.0,
system='Ты классифицируешь обращения в поддержку. Ответь только JSON: {"category": "..."}, '
"где category — одно из: оплата, доступ, ошибка, другое.",
template="<ticket>\n{ticket}\n</ticket>",
)
v1_hot = replace(v1, temperature=0.7) # копия, в которой изменена одна настройка
print(v1.fingerprint(), v1_hot.fingerprint())
print(len(v1.render(ticket="Списали деньги дважды")), "сообщения")
print(v1.render(ticket="Списали деньги дважды")[-1]["content"])
8344cb3034 f508cd348b
2 сообщения
<ticket>
Списали деньги дважды
</ticket>

@dataclass(frozen=True) (урок 0.2.3) запрещает менять объект после создания: новая версия — новый объект. replace делает копию с изменёнными полями. Отпечаток — первые 10 символов хэша SHA-256 от всех полей: хэш-функция превращает любой текст в строку фиксированной длины, и малейшая правка даёт совсем другой результат. Температура изменилась — отпечаток другой. Этот отпечаток пишут в лог каждого запроса вместе с ответом. Тогда через неделю по логу видно, какая версия что ответила. Это основа наблюдаемости из следующего урока.

Рядом с промптом в репозитории лежат ещё два файла: тест-набор (о нём ниже) и журнал изменений — по строке на версию: что поменяли, зачем и какие числа получили на наборе. Через полгода этот журнал отвечает на вопрос «почему здесь такая странная фраза?» лучше любой памяти.

flowchart TD
A["Тест-набор (dev)"] --> B["Прогон версии"]
B --> C["Грейдеры: формат, ответ"]
C --> D["Разбор ошибок по категориям"]
D --> E["Одна правка = новая версия"]
E --> F["Регрессионный прогон<br/>на том же наборе"]
F --> G{"Лучше и ничего<br/>не сломали?"}
G -->|нет| D
G -->|да| H["Проверка на test и выпуск"]
H -->|"новые ошибки из логов"| A
Цикл промпт-инженера: каждая правка проверяется на том же наборе, что и предыдущая

Как собрать золотой наборзолотой набор (тест-набор)EN golden datasetВерсионированный набор входов с эталонными ответами или способом проверки, на котором прогоняют каждую версию промпта или модели., вы знаете из урока 7.3.2: 50–200 реальных входов, трудные края, разделение на dev и замороженный test. Для промпта добавьте два правила:

  1. Каждый исправленный баг становится примером. Жалоба про «ошибку 401» → строка в наборе с верной меткой «доступ». Это регрессионный тестрегрессионный тестEN regression testТест, написанный на уже исправленный баг, чтобы он не вернулся незаметно.: баг не вернётся незаметно.
  2. Примеры few-shot не берутся из test. Иначе вы проверяете модель на ответах, которые сами ей подсказали. Это та же утечка данныхутечка данныхEN data leakage, что в классическом ML (урок 2.1.2).

Грейдергрейдер (проверяющая функция)EN graderФункция, которая ставит оценку ответу модели. Бывает кодовой (JSON разобрался, поле совпало), модельной (LLM-судья) и человеческой. — функция, которая по ответу модели ставит оценку. В статье Anthropic «Demystifying evals for AI agents» их делят на три вида, и это деление удобно запомнить:

ГрейдерПримерыПлюсыМинусы
КодовыйJSON разбирается, поле совпало с эталоном, есть ссылка [1], длина в пределахбыстрый, дешёвый, воспроизводимыйне понимает перефразирования
МодельныйLLM-судьяLLM-судья (LLM-as-a-judge)EN LLM-as-a-judgeСильная модель оценивает ответы другой модели по рубрике или сравнивает пару ответов. Дёшево и быстро, но со смещениями. с рубрикой (урок 7.3.2)понимает смысл, годится для открытых ответовнедетерминирован, требует калибровки
Человекразметка выборки ответовзолотой стандартдорого и медленно

Правило: всё, что проверяется кодом, проверяйте кодом. Судья — для того, что кодом не проверить.

Грейдер — тоже код, и в нём бывают баги. Опасные баги — те, что меняют вывод. В практике этого урока есть наглядный пример. Новый промпт B с примерами заставил модель заворачивать JSON в обёртку ```json … ```. «Наивный» грейдер вызывает json.loads(ответ) прямо на сыром тексте и считает такой ответ ошибкой:

ГрейдерТочность AТочность BВывод
наивный: json.loads(raw)["category"] == gold35/60 = 58%8/60 = 13%«B катастрофа, выпускаем A»
правильный: снять обёртку, нормализовать метку41/60 = 68%51/60 = 85%B лучше

Тот же набор, те же ответы — противоположное решение. Поэтому первые прогоны новой версии всегда читайте глазами: 10–20 ответов целиком, вместе с оценкой грейдера. Anthropic формулирует это так: вы не узнаете, хорошо ли работают грейдеры, пока не прочитаете ответы и оценки из многих прогонов.

Число «68%» говорит, что плохо, но не говорит, что чинить. Это говорит разбор ошибоканализ ошибокEN error analysisРучной разбор неудачных ответов с разметкой причин. Подсказывает, что чинить, лучше любой средней метрики.. Порядок простой: выпишите все неверные ответы, к каждому — короткую пометку «что пошло не так», затем сгруппируйте пометки в категории и посчитайте. Для промпта A из практики получается:

КатегорияСколькоЧто делать
«доступ» принят за «ошибку» (коды 401, 403, invalid_api_key)6определения категорий с примерами кодов
ответ не JSON («Категория: оплата»)6пример формата, structured outputs
метка не из списка («биллинг», «аккаунт»)2перечислить метки, enum в схеме
«ошибка» принята за «доступ»2то же, что первая строка
«другое» принято за конкретную категорию3определение «другого» и пример

Две верхние строки дают 12 из 19 ошибок. Правка «определения + примеры» бьёт сразу в обе. Иногда разбор находит ошибку в эталоне: человек, размечавший набор, сам ошибся. Тогда правят набор и повышают его версию (eval_v2): старые числа с новыми напрямую не сравнивают.

И главное правило цикла: одна правка за раз. Если поменять три вещи сразу и точность выросла, вы не знаете, какая из трёх помогла, а какая навредила.

Выпускать ли новую версию: статистика на 60 примерах

Заголовок раздела «Выпускать ли новую версию: статистика на 60 примерах»

Промпт B дал 85% против 68% у A. Разница 17 процентных пунктов (п. п.) — это улучшение или шум? На 60 примерах вопрос не праздный.

Напомню формулу из урока 1.3.4: если версия верно ответила на долю pp из nn примеров, 95%-й доверительный интервалдоверительный интервалEN confidence intervalДиапазон вокруг измеренного значения метрики, построенный так, что при многократном повторении эксперимента он накрывает истинное значение с заданной частотой (обычно 95%). Для доли верных ответов ≈ p ± 1.96·√(p(1−p)/n).

p±1.96p(1−p)n.p \pm 1.96\sqrt{\frac{p(1-p)}{n}}.

Корень — это стандартная ошибкастандартная ошибкаEN standard errorСтандартное отклонение оценки метрики из-за конечного числа вопросов. Для доли — √(p(1−p)/n).: насколько доля «гуляет» из-за того, что примеров конечное число. Для A: p=41/60≈0.683p = 41/60 \approx 0.683, стандартная ошибка 0.683⋅0.317/60≈0.060\sqrt{0.683 \cdot 0.317 / 60} \approx 0.060, интервал [0.566; 0.801][0.566;\ 0.801]. Для B: [0.760; 0.940][0.760;\ 0.940]. Интервалы перекрываются, и кажется, что сказать ничего нельзя.

Но обе версии отвечали на одни и те же 60 обращений, поэтому нужно парное сравнениепарное сравнение (парный тест)EN paired testСравнение двух вариантов на одних и тех же примерах через разность по каждому примеру (например, парный бутстрэп). Убирает общий шум лёгких и трудных вопросов, поэтому точнее сравнения двух независимых оценок. из урока 1.3.4. Разложим ответы в таблицу 2×2:

B праваB ошиблась
A права383
A ошиблась136

38 обращений обе версии решили, 6 обе провалили — о разнице между версиями они ничего не говорят. Вся информация — в несогласованных парах: 13 раз права только B и 3 раза только A. Разница точностей d=(13−3)/60≈0.167d = (13 - 3)/60 \approx 0.167. Каждый пример даёт разность di∈{−1,0,1}d_i \in \{-1, 0, 1\}, и стандартная ошибка их среднего:

se(d)=1n(nA+nBn−d2),\mathrm{se}(d) = \sqrt{\frac{1}{n}\left(\frac{n_A + n_B}{n} - d^2\right)},

где nA=3n_A = 3 и nB=13n_B = 13 — несогласованные пары. Словами: дисперсия разностей — это доля несогласованных пар минус квадрат средней разности, а делим на nn, потому что усредняем nn разностей. Подставим: (16/60−0.1672)/60≈0.063\sqrt{(16/60 - 0.167^2)/60} \approx 0.063, интервал 0.167±1.96⋅0.063=[0.043; 0.290]0.167 \pm 1.96 \cdot 0.063 = [0.043;\ 0.290]. Он целиком выше нуля: B лучше. (В уроке 1.3.4 ту же величину считали как d.std(ddof=1) / √n; на 60 примерах разница — в третьем знаке.)

Второй взгляд — тест Макнемаратест МакнемараEN McNemar's testПарный тест для двух классификаторов на одних и тех же примерах. Смотрит только на несогласованные пары; если версии равны, каждая такая пара — «монетка» 50/50. в точной форме. Если версии на самом деле одинаковы, каждая несогласованная пара — подбрасывание монеты: с вероятностью 1/2 права A, с вероятностью 1/2 права B. Насколько вероятен перекос 13 : 3 или сильнее у 16 монет? Сумма биномиальных вероятностей 2⋅∑k=03(16k)/216=2⋅697/65 536≈0.0212 \cdot \sum_{k=0}^{3} \binom{16}{k} / 2^{16} = 2 \cdot 697 / 65\,536 \approx 0.021. Такой перекос «случайно» получается примерно в 2% случаев: p-valuep-value (p-значение)EN p-valueКак часто случай дал бы результат не менее экстремальный, чем наблюдаемый, если бы эффекта не было. Не вероятность того, что эффекта нет, и не размер эффекта. Порог «значимости» по соглашению — 0.05. ≈0.021\approx 0.021. В уроке 1.3.4 вы получали такое число симуляцией монеток, здесь — точной формулой. Вы реализуете обе проверки в практике.

  1. Интервал разницы по главной метрике целиком выше нуля.
  2. Защитные метрики не ухудшились: доля ответов в правильном формате, длина, стоимость, задержка. Версия, которая на 3 п. п. точнее, но вдвое дороже, — это решение бизнеса, а не автоматический выпуск.
  3. Улучшение подтвердилось на замороженном test, а не только на dev, где вы итерировали.

Почему третий пункт обязателен? В уроке 1.3.4 вы видели проклятие победителя: лучший из одинаковых промптов выглядит лучше истины. Доведём до конца — проверим «победителя» на свежем test. Двадцать вариантов промпта, все одинаково хороши (настоящая точность 0.80), выбираем лучший по 60 примерам dev:

import numpy as np
rng = np.random.default_rng(0)
n_dev, n_test, n_candidates = 60, 500, 20
true_acc = 0.80 # честно: все 20 вариантов промпта ОДИНАКОВО хороши
dev = rng.random((n_candidates, n_dev)) < true_acc # True — вариант ответил верно на пример dev
test = rng.random((n_candidates, n_test)) < true_acc
dev_acc = dev.mean(axis=1)
best = int(dev_acc.argmax()) # «оптимизатор» выбирает лучший вариант по dev
print(f"точность на dev: от {dev_acc.min():.3f} до {dev_acc.max():.3f}")
print(f"победитель №{best}: dev {dev_acc[best]:.3f}, свежий test {test[best].mean():.3f}")
точность на dev: от 0.683 до 0.883
победитель №10: dev 0.883, свежий test 0.816

«Победитель» показал на dev 88%, хотя он ничем не лучше остальных: просто ему повезло с выборкой. На свежих данных он вернулся к 0.82 — около настоящих 0.80. Это множественные сравнениямножественные сравненияEN multiple comparisonsЧем больше проверок, тем больше ложных «открытий»: среди 20 бесполезных идей хотя бы одна окажется «значимой» на уровне 5% с вероятностью ≈ 64%. Лечат заранее зафиксированным протоколом, перепроверкой и поправками (Бонферрони). в чистом виде: чем больше вариантов вы перебираете на одном наборе, тем сильнее завышен результат лучшего.

Сколько нужно примеров? Формула мощностимощность экспериментаEN statistical powerВероятность, что эксперимент обнаружит эффект, если он действительно есть. Обычно планируют 80%; для этого считают нужный размер выборки до эксперимента. из урока 1.3.4 для парного сравнения: n≈(1.96+0.84)2⋅(pнес−d2)/d2n \approx (1.96 + 0.84)^2 \cdot (p_\text{нес} - d^2) / d^2, где pнесp_\text{нес} — доля несогласованных пар, а pнес−d2p_\text{нес} - d^2 — точная дисперсия парной разности (в 1.3.4 её приближали самой долей расхождений). Для нашего случая (d=0.167d = 0.167, pнес=0.267p_\text{нес} = 0.267) выходит около 67 примеров — 60 было впритык. Чтобы уверенно увидеть улучшение на 5 п. п. при 20% несогласованных пар, нужно уже около 620 примеров. Мелкие правки на маленьком наборе статистически невидимы: копите набор.

Части промпт-конфигурации: инженерный взгляд

Заголовок раздела «Части промпт-конфигурации: инженерный взгляд»

Приёмы из урока 8.1.2 остаются в силе. Здесь — то, что важно, когда промпт поддерживают долго.

Системный промптсистемный промптEN system promptСообщение с ролью system — правила для модели (роль, тон, формат). Технически это просто первый блок текста в chat-шаблоне. — место для того, что не меняется от запроса к запросу: роль, правила, формат. Изменчивые данные (обращение, найденные документы) — в сообщениях user, ближе к концу. Это работает и на качество, и на деньги: неизменное начало попадает в кэш контекста (урок 8.1.1). Помните и про иерархию: правила системного промпта для модели весомее, чем текст пользователя и тем более вывод инструментов (урок 8.3.4).

Полезная привычка из гайда Anthropic — объяснять причину правила. Вместо «НИКОГДА не используй многоточие» — «ответ прочитает синтезатор речи, а он не умеет произносить многоточие». Модель обобщает объяснение на похожие случаи, о которых вы не подумали.

Few-shot-примеры — часть версии промпта, их подбирают по разбору ошибок. Добавили пример «ошибка 401 → доступ», потому что модель путала категории, — запишите это в журнал. Гайды здесь расходятся, и это поучительно:

  • Anthropic советует 3–5 разнообразных примеров в тегах <example>;
  • Google в «Prompt design strategies» рекомендует добавлять примеры всегда;
  • авторы DeepSeek-R1 пишут, что few-shot ухудшает результаты их reasoning-модели (урок 8.1.2).

Вывод один: рекомендация гайда — гипотеза, а проверяет её ваш набор на вашей модели. Продвинутый вариант — динамические примеры: для каждого входа поиском (как в RAG, модуль 8.2) выбирают 3 самых похожих примера из банка размеченных. Тогда банк можно пополнять, не раздувая промпт.

Правило «длинные данные сверху, вопрос в конце» из урока 8.1.2 — не вкусовщина. В гайде Anthropic сказано, что вопрос в конце улучшал качество ответов в тестах до 30%, особенно на сложных входах из нескольких документов. Эффект Lost in the Middle (8.1.2) превращается в инженерную проверку: прогоните RAG-набор, ставя нужный документ на первое, среднее и последнее место. Если точность сильно зависит от позиции, переставляйте документы по релевантности, сокращайте их число или берите модель с лучшей работой с длинным контекстом.

Три уровня структурированного выводаструктурированный выводEN structured outputОтвет модели в машиночитаемом формате (обычно JSON по схеме), который код может разобрать и проверить. вы знаете: просьба, JSON mode, JSON Schema с ограниченным декодированиемограниченное декодированиеEN constrained decoding / guided decodingНа каждом шаге сэмплирования запрещаются токены, нарушающие грамматику (например, JSON-схему), — их логиты заменяются на минус бесконечность, и после softmax вероятность таких токенов равна нулю.. Для продакшена добавьте:

  • Схема — часть версии. Поменяли поле — новая версия промпта, прогон набора.
  • Выход для «не знаю». Если в enum нет варианта «другое» или «нет ответа», ограниченное декодирование заставит модель выбрать неверную категорию: формат идеален, смысл нет.
  • Порядок полей. Модель пишет слева направо. Поле "reasoning" перед "category" даёт ей место подумать до ответа (chain-of-thought внутри JSON). Поле после ответа — уже только оправдание готового решения.

Prefill ответаprefill ответа (начало ответа за модель)EN response prefillРазработчик сам пишет начало сообщения ассистента, а модель продолжает. Есть в vLLM (continue_final_message) и у DeepSeek (бета, prefix: true); в новых моделях Claude не поддерживается. — приём, когда вы сами пишете начало ответа ассистента, а модель продолжает. Например, начало {"category": " почти гарантирует JSON. В открытых моделях это естественно: чат-шаблон (урок 8.1.1) — просто текст, и vLLM умеет продолжать последнее сообщение ассистента (параметр continue_final_message). У DeepSeek есть бета-функция Chat Prefix Completion («продолжение диалога по префиксу»): последнему сообщению ассистента ставят "prefix": true и обращаются к адресу https://api.deepseek.com/beta:

messages = [
{"role": "user", "content": "Классифицируй обращение: «Списали деньги дважды»"},
{"role": "assistant", "content": '{"category": "', "prefix": True}, # модель продолжит отсюда
]
# client = OpenAI(base_url="https://api.deepseek.com/beta", api_key=...)
# client.chat.completions.create(model=..., messages=messages, stop=["\"}"])

А в гайде Anthropic сказано обратное: начиная с моделей Claude 4.6, prefill последнего сообщения ассистента не поддерживается, и такой запрос получает ошибку 400. Вместо него гайд предлагает structured outputs или инструмент с enum для классификации. Мораль для инженера: приём, который работает у одного провайдера, может не существовать у другого. Держите такие вещи за тонким слоем своего кода и проверяйте регрессионным набором.

Смена модели или провайдера: что ломается

Заголовок раздела «Смена модели или провайдера: что ломается»

Переход на новую модель — самая частая причина тихих поломок. Типичные места:

Что меняетсяСимптомКак ловить
Привычки форматированияJSON в ```json, вступление «Конечно! Вот ответ:»грейдер формата, structured outputs
Чат-шаблон и «мысли»в ответе блок <think>…</think> (Qwen3), поле reasoning_content (DeepSeek)разбор ответа в одном месте кода, тест на сырой ответ
Параметры по умолчаниюу Anthropic-совместимого API Moonshot температура умножается на 0.6 (урок 8.1.1); рекомендованные температуры разныепараметры явно в версии промпта
Токенизатордругое число токенов: стоимость, обрезка по max_tokens (finish_reason: length)следить за usage и finish_reason
Поддержка функцийprefill, JSON Schema, формат tool callingрегрессионный набор, адаптер на провайдера
Буквальность и инициативамодель делает больше или меньше, чем просилиразбор ошибок на наборе
Чувствительность к примерамfew-shot помогает одной модели и вредит другойпрогон с примерами и без

Последние две строки особенно коварны. В разделе о миграции гайда Anthropic есть совет «убавить» настойчивые формулировки («ОБЯЗАТЕЛЬНО используй инструменты»), которые были нужны старым моделям: новые и так проактивнее и начинают перестараться. А общий принцип там же: приём, измеренный на одной модели, перепроверяйте своими эвалами, прежде чем переносить на другую.

Порядок миграции: прогнать весь набор на новой модели со старым промптом → разбор ошибок → адаптация промпта (новая версия) → парное сравнение со старой связкой → выпуск на часть трафика с наблюдением за онлайн-метриками (урок 8.4.2).

Автоматическая оптимизация промптов — обзорно

Заголовок раздела «Автоматическая оптимизация промптов — обзорно»

Если есть набор и метрика, почему бы не поручить подбор промпта программе? Это идея автоматической оптимизации промптовавтоматическая оптимизация промптовEN automatic prompt optimizationПрограмма перебирает формулировки инструкций и примеры, чтобы поднять метрику на наборе (DSPy, GEPA). Риск — переобучение на dev-наборе.. Самый известный проект — DSPy (Stanford, arXiv:2310.03714). В нём вы описываете не текст промпта, а сигнатуру задачи («текст обращения → категория») и метрику. Оптимизатор сам перебирает формулировки инструкций и подбирает примеры из обучающих данных так, чтобы метрика на наборе выросла. Промпт становится параметром, метрика — функцией потерь, dev-набор — обучающими данными. Из этой аналогии сразу следуют и риски:

  • оптимизатор переобучается на dev — ровно проклятие победителя из симуляции выше; нужен отдельный test;
  • он оптимизирует вашу метрику, а не ваше намерение: плохой грейдер даст промпт, который «обманывает» грейдер;
  • результат бывает странным текстом, который трудно поддерживать человеку.

Попроще и ближе к ручной работе — мета-промптинг: сильная модель пишет черновик промпта или предлагает правки по списку ошибок. У Anthropic для этого есть ноутбук с metaprompt, у OpenAI — раздел о генерации промптов. Решение о выпуске всё равно принимает ваш набор, а не модель-автор.

Лаборатории, которые делают модели, лучше всех знают их привычки. Их гайды — первоисточник. Рекомендуемый порядок:

ШагМатериалЧто взятьВремя
1Anthropic Prompt Engineering Interactive Tutorial9 глав с упражнениями: от структуры промпта до борьбы с галлюцинациями; главы 8–9 и приложение (цепочки промптов, tool use)6–10 ч
2Anthropic: Prompting best practicesживой справочник: длинный контекст, примеры, prefill и миграция между поколениями1–2 ч
3Anthropic: Define success criteria and build evaluationsкритерии успеха, тест-кейсы и грейдеры; совет брать для оценки другую модель, чем для генерации1 ч
4OpenAI: Prompt engineeringроли и иерархия сообщений, промпты как код, снимки моделей, отличия reasoning-моделей1 ч
5Google: Prompt design strategiesfew-shot, префиксы, разбиение задачи, стратегии итераций1 ч
6Карточка и документация вашей модели: README DeepSeek-R1, README Qwen3, DeepSeek APIтемпература, системный промпт, режимы мышления, JSON Output, prefix completion30 мин

Упражнения туториала Anthropic рассчитаны на платный ключ API, но приёмы переносятся на Qwen и DeepSeek через клиент из урока 8.1.1. Плотный обзор одним документом — whitepaper Google «Prompt Engineering» (Ли Бунстра) из курса Kaggle 5-Day Gen AI Intensive.

Что у всех общего: ясная задача и явный формат; данные отделены от инструкций (XML-теги, Markdown, разделители); примеры как самый надёжный способ показать формат; итерации против набора тестов; перепроверка при смене модели. Где расходятся: нужны ли примеры reasoning-моделям, поддерживается ли prefill, использовать ли системный промпт (DeepSeek-R1 советовал обходиться без него), какие температуры брать. Поэтому гайд — карта, а маршрут проверяет ваш набор.

Полный цикл на записанных ответах. Есть 60 обращений в поддержку «Панда-Облака» (вымышленного провайдера из ДЗ s8-hw01) с верными категориями и сырые ответы модели на два промпта: короткий A и B с определениями и примерами. Напишите грейдеры, разбор ошибок, точность с интервалом, парное сравнение и правило выпуска. Модель не нужна: ответы уже записаны, как в настоящем A/B-эксперименте по логам.

A/B промптов: грейдеры, интервалы и решение о выпуске8 тестов
"""A/B промптов: грейдеры, точность с доверительным интервалом и решение о выпуске.

Классификатор обращений в поддержку «Панда-Облака» (вымышленный провайдер из ДЗ s8-hw01) должен вернуть
JSON {"category": "<одна из CATEGORIES>"}. Промпт A — короткая инструкция. Промпт B — та же инструкция,
определения категорий и три примера. Ниже, в DATA, — 60 обращений с верной категорией и ЗАПИСАННЫЕ сырые
ответы модели на оба промпта: модель не нужна, задача — честно их оценить.
"""
import json
import math
import re

CATEGORIES = ("оплата", "доступ", "ошибка", "другое")


def normalize_label(text):
    """Канонический вид метки: без пробелов по краям, нижний регистр, «ё» → «е»."""
    return text.strip().lower().replace("ё", "е")


def sign_test_p(only_a, only_b):
    """Точный двусторонний тест знаков по несогласованным парам (точная форма теста Макнемара).

    Если версии на самом деле равны, каждая несогласованная пара — «монетка»: с вероятностью 1/2 права A,
    с вероятностью 1/2 права B. p-value — вероятность получить перекос не меньше наблюдаемого.
    """
    m = only_a + only_b
    if m == 0:
        return 1.0
    k = min(only_a, only_b)
    tail = sum(math.comb(m, i) for i in range(k + 1)) / 2 ** m
    return min(1.0, 2 * tail)


def parse_answer(raw):
    """Достаёт JSON-объект из сырого ответа модели.

    - Если ответ обёрнут в ```json ... ``` или ``` ... ```, берём то, что внутри.
    - Текст до первой «{» и после последней «}» отбрасываем («Вот классификация:», «Ответ:»).
    - Возвращаем dict. Если фигурных скобок нет, JSON битый или это не объект (например, список) — None.

    >>> parse_answer('Вот:\\n```json\\n{"category": "оплата"}\\n```')
    {'category': 'оплата'}
    >>> parse_answer('Категория: оплата') is None
    True
    """
    # TODO: re.search(r"```(?:json)?\s*(.*?)\s*```", raw, re.S), затем find("{") / rfind("}") и json.loads
    raise NotImplementedError


def grade(raw, gold):
    """Возвращает {"format_ok": bool, "correct": bool, "pred": str | None}.

    pred — нормализованное (normalize_label) значение ключа "category", если ответ разобрался и там строка;
    иначе None. format_ok — pred входит в CATEGORIES. correct — format_ok и pred совпал с нормализованным gold.
    """
    # TODO
    raise NotImplementedError


def error_buckets(golds, grades):
    """Разбор ошибок: словарь {причина: сколько раз}, только для неверных ответов.

    Причины: "не JSON" (pred is None), "метка не из списка" (pred есть, но не из CATEGORIES),
    иначе "<эталон> → <ответ>", например "доступ → ошибка". Порядок: по убыванию числа, при равенстве по алфавиту.
    """
    # TODO
    raise NotImplementedError


def accuracy_ci(flags, z=1.96):
    """(p, lo, hi): доля True в flags и интервал p ± z·√(p(1−p)/n), обрезанный до [0, 1].

    Пустой список — ValueError.
    """
    # TODO
    raise NotImplementedError


def paired_compare(a, b, z=1.96):
    """Парное сравнение: a[i], b[i] — верно ли версии A и B ответили на i-й пример.

    Вернуть словарь с ключами: n, acc_a, acc_b, only_a (A права, B нет), only_b (B права, A нет),
    diff = acc_b − acc_a, ci = (diff − z·se, diff + z·se), где se = √(((only_a + only_b)/n − diff²)/n),
    p_value = sign_test_p(only_a, only_b). Разная длина a и b или пустые списки — ValueError.
    """
    # TODO
    raise NotImplementedError


def decide(report):
    """По отчёту paired_compare: "B", если ci целиком выше нуля; "A", если целиком ниже; иначе "неясно"."""
    # TODO
    raise NotImplementedError


# (номер, текст обращения, верная категория, сырой ответ на промпт A, сырой ответ на промпт B)
DATA = [
    (1, 'Как сменить карту для автоплатежа?', 'оплата', 'Это вопрос про оплату.', '```json\n{"category": "оплата"}\n```'),
    (2, 'Function calling возвращает аргументы не по схеме', 'ошибка', '{"category": "ошибка"}', '```json\n{"category": "ошибка"}\n```'),
    (3, 'Хочу возврат за ошибочно купленный пакет', 'оплата', '{"category": "оплата"}', '```json\n{"category": "возврат"}\n```'),
    (4, 'Эмбеддинги возвращают NaN', 'ошибка', '{"category": "ошибка"}', '```json\n{"category": "ошибка"}\n```'),
    (5, 'Есть ли у вас скидки для университетов?', 'другое', '{"category": "оплата"}', '```json\n{"category": "оплата"}\n```'),
    (6, 'Где документация по лимитам контекста?', 'другое', '{"category": "ошибка"}', '```json\n{"category": "ошибка"}\n```'),
    (7, 'Не могу принять приглашение в команду', 'доступ', '{"category": "доступ"}', '{"category": "доступ"}'),
    (8, 'Почему с баланса ночью ушло 300 рублей?', 'оплата', '{"category": "оплата"}', '```json\n{"category": "оплата"}\n```'),
    (9, 'Списали деньги дважды за один месяц', 'оплата', '{"category": "Оплата"}', '```json\n{"category": "оплата"}\n```'),
    (10, 'Ошибка 401 при каждом запросе, ключ новый', 'доступ', '{"category": "ошибка"}', 'Вот классификация:\n```json\n{"category": "доступ"}\n```'),
    (11, 'Заблокировали аккаунт без объяснения причин', 'доступ', '{"category": "аккаунт"}', '```json\n{"category": "доступ"}\n```'),
    (12, 'Списание прошло, а чека на почте нет', 'оплата', '{"category": "оплата"}', '```\n{"category": "оплата"}\n```'),
    (13, 'Можно оплатить через СБП?', 'оплата', '{"category": "оплата"}', '```json\n{"category": "оплата"}\n```'),
    (14, 'Как удалить старый ключ API?', 'доступ', 'Категория: доступ', '{"category": "доступ"}'),
    (15, 'Где почитать условия обработки данных?', 'другое', '{"category": "доступ"}', '```json\n{"category": "доступ"}\n```'),
    (16, 'Планируете открыть серверы в Казахстане?', 'другое', '{"category": "Другое"}', '```json\n{"category": "другое"}\n```'),
    (17, 'Не приходит код двухфакторной аутентификации', 'доступ', '{"category": "ошибка"}', 'Вот классификация:\n```json\n{"category": "доступ"}\n```'),
    (18, 'Ошибка 429, хотя мы шлём 5 запросов в минуту', 'ошибка', '{"category": "доступ"}', '```json\n{"category": "доступ"}\n```'),
    (19, 'Ключ API утёк в публичный репозиторий, что делать?', 'доступ', '{"category": "доступ"}', '```\n{"category": "доступ"}\n```'),
    (20, 'Модель отвечает по-английски на русский вопрос', 'ошибка', '{"category": "ошибка"}', '```json\n{"category": "ошибка"}\n```'),
    (21, 'Не могу войти в личный кабинет, пароль не подходит', 'доступ', '{"category": " доступ "}', '{"category": "доступ"}'),
    (22, 'Вход через корпоративный SSO не пускает', 'доступ', '{"category": "ошибка"}', '```json\n{"category": "ошибка"}\n```'),
    (23, 'invalid_api_key, хотя ключ скопирован полностью', 'доступ', '{"category": "ошибка"}', '```json\n{"category": "доступ"}\n```'),
    (24, 'Кириллица в ответе приходит кракозябрами', 'ошибка', '{"category": "ошибка"}', 'Вот классификация:\n```json\n{"category": "ошибка"}\n```'),
    (25, 'Ошибка 413 при загрузке PDF на 5 МБ', 'ошибка', '{"category": "ошибка"}', '```json\n{"category": "ошибка"}\n```'),
    (26, 'Есть ли вакансии в вашей команде?', 'другое', '{"category": "другое"}', '```json\n{"category": "другое"}\n```'),
    (27, 'Как связаться с вами по телефону?', 'другое', 'Категория: другое', '```json\n{"category": "другое"}\n```'),
    (28, 'Куски ответа дублируются в потоке SSE', 'ошибка', '{"category": "ошибка"}', '{"category": "ошибка"}'),
    (29, 'Промокод не применился при оплате', 'оплата', '{"category": "оплата"}', '```json\n{"category": "оплата"}\n```'),
    (30, 'Не проходит оплата картой, пишет «отказ банка»', 'оплата', '{"category": "оплата", "confidence": 0.9}', '```json\n{"category": "оплата"}\n```'),
    (31, 'SDK падает с JSONDecodeError на ответе сервера', 'ошибка', '{"category": "ошибка"}', 'Вот классификация:\n```json\n{"category": "ошибка"}\n```'),
    (32, 'Ответ обрывается на середине предложения', 'ошибка', '{"category": "ОШИБКА"}', '```json\n{"category": "ошибка"}\n```'),
    (33, 'Спасибо, всё заработало!', 'другое', '{"category": "другое"}', '```json\n{"category": "другое"}\n```'),
    (34, 'Модель возвращает пустой content', 'ошибка', '{"category": "ошибка"}', '```json\n{"category": "ошибка"}\n```'),
    (35, 'Выставили счёт за модель, которую мы не вызывали', 'оплата', '{"category": "оплата"}', '{"category": "оплата"}'),
    (36, 'Деньги списались, а тариф не продлился', 'оплата', '{"category": "оплата"}', '```json\n{"category": "оплата"}\n```'),
    (37, 'Загрузка файла падает с upload_failed', 'ошибка', '{"category": "ошибка"', '```json\n{"category": "ошибка"}\n```'),
    (38, 'Сервер отвечает 400 на корректный JSON', 'ошибка', '{"category": "доступ"}', 'Вот классификация:\n```json\n{"category": "ошибка"}\n```'),
    (39, 'Стриминг зависает после первых токенов', 'ошибка', 'Категория: ошибка', '```json\n{"category": "ошибка"}\n```'),
    (40, 'Задержка выросла с 1 до 8 секунд со вчерашнего дня', 'ошибка', '{"category": "ошибка"}', '```json\n{"category": "ошибка"}\n```'),
    (41, 'Отключите автопродление подписки', 'оплата', '{"category": "оплата"}', '```json\n{"category": "оплата"}\n```'),
    (42, 'Хотим предложить партнёрство', 'другое', '{"category": "другое"}', '{"category": "другое"}'),
    (43, 'Ключ перестал работать после смены пароля', 'доступ', '{"category": "ошибка"}', '```json\n{"category": "ошибка"}\n```'),
    (44, 'Забыл почту, на которую регистрировался', 'доступ', '{"category": "доступ"}', '```json\n{"category": "доступ"}\n```'),
    (45, 'Почему счёт за октябрь вдвое больше обычного?', 'оплата', '{"category": "оплата"}', 'Вот классификация:\n```json\n{"category": "оплата"}\n```'),
    (46, 'Где скачать закрывающие документы за сентябрь?', 'оплата', 'Ответ: {"category": "оплата"}', '```json\n{"category": "оплата"}\n```'),
    (47, 'Сколько стоит миллион входных токенов?', 'оплата', '{"category": "оплата"}', '```json\n{"category": "оплата"}\n```'),
    (48, 'Письмо для сброса пароля не приходит', 'доступ', '{"category": "доступ"}', '```json\n{"category": "доступ"}\n```'),
    (49, 'Поддерживают ли эмбеддинги китайский язык?', 'другое', '{"category": "другое"}', '{"category": "другое"}'),
    (50, 'Таймаут на запросах длиннее 30 тысяч токенов', 'ошибка', '{"category": "ошибка"}', '```json\n{"category": "ошибка"}\n```'),
    (51, 'Получаю 403 Forbidden на эндпоинте эмбеддингов', 'доступ', '{"category": "ошибка"}', '```json\n{"category": "доступ"}\n```'),
    (52, 'Нужен счёт на юрлицо с НДС', 'оплата', '{"category": "биллинг"}', '```json\n{"category": "оплата"}\n```'),
    (53, '502 Bad Gateway уже полчаса', 'ошибка', '{"category": "ошибка", "confidence": 0.8}', '```json\n{"category": "ошибка"}\n```'),
    (54, 'Можно ли дообучить модель на наших данных?', 'другое', '{"category": "другое"}', '```json\n{"category": "другое"}\n```'),
    (55, 'Как перейти с помесячной оплаты на годовую?', 'оплата', 'Категория: оплата', '```json\n{"category": "оплата"}\n```'),
    (56, 'Пополнил баланс, но деньги не появились', 'оплата', '{"category": "оплата"}', '{"category": "оплата"}'),
    (57, '500 Internal Server Error при вызове инструментов', 'ошибка', '{"category": "ошибка"}', '```json\n{"category": "ошибка",}\n```'),
    (58, 'Как дать сотруднику доступ только на чтение?', 'доступ', '{"category": "доступ"}', '```json\n{"category": "доступ"}\n```'),
    (59, 'Верните деньги за неиспользованные токены', 'оплата', '{"category": "оплата"}', '```json\n{"category": "возврат"}\n```'),
    (60, 'Как выпустить второй API-ключ для коллеги?', 'доступ', 'Ответ: {"category": "доступ"}', '```json\n{"category": "доступ"}\n```'),
]


def demo():
    golds = [row[2] for row in DATA]
    grades_a = [grade(row[3], row[2]) for row in DATA]
    grades_b = [grade(row[4], row[2]) for row in DATA]
    for name, gr in (("A", grades_a), ("B", grades_b)):
        p, lo, hi = accuracy_ci([g["correct"] for g in gr])
        fmt = sum(g["format_ok"] for g in gr) / len(gr)
        print(f"Промпт {name}: точность {p:.3f} [{lo:.3f}; {hi:.3f}], формат в порядке {fmt:.0%}")
    print("Ошибки A:", error_buckets(golds, grades_a))
    print("Ошибки B:", error_buckets(golds, grades_b))
    rep = paired_compare([g["correct"] for g in grades_a], [g["correct"] for g in grades_b])
    print(f"Только A права: {rep['only_a']}, только B права: {rep['only_b']}, "
          f"разница {rep['diff']:+.3f}, 95% ДИ [{rep['ci'][0]:+.3f}; {rep['ci'][1]:+.3f}], p = {rep['p_value']:.4f}")
    print("Выпускаем:", decide(rep))


try:
    demo()
except Exception as e:  # пока функции не дописаны
    print("Допишите функции:", type(e).__name__, e)
Проверь себя6 вопросов
1 Вы поменяли в промпте только температуру с 0 до 0.7. Нужно ли считать это новой версией и прогонять набор?
2 Промпт A верен на 41 из 60 примеров, B — на 51. Несогласованных пар: «права только A» — 3, «права только B» — 13. Чему равна разница точностей dd? Округлите до трёх знаков.
3 Какие утверждения о сравнении двух версий промпта на одном наборе верны?Можно выбрать несколько ответов
4 Новый промпт дал JSON в обёртке ``json, а грейдер вызывает json.loads` прямо на сыром ответе. Что произойдёт?
5 Вы переходите на модель Claude 4.6 с кодом, который заставлял прошлую модель писать JSON через prefill (начало ответа ассистента). Что вероятнее всего случится?
6 Что главный риск автоматической оптимизации промпта (в духе DSPy) по метрике на dev-наборе?
  • Промпт — это код: шаблон, примеры, модель, параметры и схема ответа хранятся вместе, у версии есть отпечаток, он пишется в лог каждого запроса, рядом лежат тест-набор и журнал изменений.
  • Рабочий цикл: набор → прогон → грейдеры → разбор ошибок по категориям → одна правка → регрессионный прогон. Грейдеры тоже бывают с багами, поэтому ответы читают глазами.
  • Решение о выпуске — по интервалу разницы на одних и тех же примерах (несогласованные пары, тест Макнемара), с защитными метриками и подтверждением на замороженном test: перебор вариантов на dev завышает результат.
  • Смена модели ломает формат, разбор «мыслей», параметры, токены и провайдерские приёмы вроде prefill. Мигрируйте через полный прогон набора и разбор ошибок.
  • Официальные гайды Anthropic, OpenAI, Google, DeepSeek и Qwen сходятся в главном и расходятся в деталях. Гайд даёт гипотезу, а решает ваш набор.

Дальше: урок 8.4.2 — eval-driven development и наблюдаемость. Тест-набор начнём собирать из логов продакшена, научимся калибровать LLM-судью, трейсить вызовы и ловить регрессию по метрикам.