Промпт-инжиниринг как инженерная дисциплина
Как вести промпт месяцами, а не один вечер — версии и шаблоны в git, тест-набор и грейдеры, разбор ошибок по категориям, решение «выпускать ли новую версию» по доверительным интервалам, миграция на другую модель и маршрут по официальным гайдам Anthropic, OpenAI, Google, DeepSeek и Qwen.
Зачем это нужно
Заголовок раздела «Зачем это нужно»В уроке 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 hashlibimport jsonfrom 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 f508cd348b2 сообщения<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. Для промпта добавьте два правила:
- Каждый исправленный баг становится примером. Жалоба про «ошибку 401» → строка в наборе с верной меткой «доступ». Это регрессионный тестрегрессионный тестEN regression testТест, написанный на уже исправленный баг, чтобы он не вернулся незаметно.: баг не вернётся незаметно.
- Примеры 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"] == gold | 35/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: если версия верно ответила на долю из примеров, 95%-й доверительный интервалдоверительный интервалEN confidence intervalДиапазон вокруг измеренного значения метрики, построенный так, что при многократном повторении эксперимента он накрывает истинное значение с заданной частотой (обычно 95%). Для доли верных ответов ≈ p ± 1.96·√(p(1−p)/n).
Корень — это стандартная ошибкастандартная ошибкаEN standard errorСтандартное отклонение оценки метрики из-за конечного числа вопросов. Для доли — √(p(1−p)/n).: насколько доля «гуляет» из-за того, что примеров конечное число. Для A: , стандартная ошибка , интервал . Для B: . Интервалы перекрываются, и кажется, что сказать ничего нельзя.
Парное сравнение
Заголовок раздела «Парное сравнение»Но обе версии отвечали на одни и те же 60 обращений, поэтому нужно парное сравнениепарное сравнение (парный тест)EN paired testСравнение двух вариантов на одних и тех же примерах через разность по каждому примеру (например, парный бутстрэп). Убирает общий шум лёгких и трудных вопросов, поэтому точнее сравнения двух независимых оценок. из урока 1.3.4. Разложим ответы в таблицу 2×2:
| B права | B ошиблась | |
|---|---|---|
| A права | 38 | 3 |
| A ошиблась | 13 | 6 |
38 обращений обе версии решили, 6 обе провалили — о разнице между версиями они ничего не говорят. Вся информация — в несогласованных парах: 13 раз права только B и 3 раза только A. Разница точностей . Каждый пример даёт разность , и стандартная ошибка их среднего:
где и — несогласованные пары. Словами: дисперсия разностей — это доля несогласованных пар минус
квадрат средней разности, а делим на , потому что усредняем разностей. Подставим:
, интервал . Он целиком
выше нуля: 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% случаев: p-valuep-value (p-значение)EN p-valueКак часто случай дал бы результат не менее экстремальный, чем наблюдаемый, если бы эффекта не было. Не вероятность того, что эффекта нет, и не размер эффекта. Порог «значимости» по соглашению — 0.05. . В уроке 1.3.4 вы получали такое число симуляцией монеток, здесь — точной формулой. Вы реализуете обе проверки в практике.
Правило выпуска
Заголовок раздела «Правило выпуска»- Интервал разницы по главной метрике целиком выше нуля.
- Защитные метрики не ухудшились: доля ответов в правильном формате, длина, стоимость, задержка. Версия, которая на 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, 20true_acc = 0.80 # честно: все 20 вариантов промпта ОДИНАКОВО хорошиdev = rng.random((n_candidates, n_dev)) < true_acc # True — вариант ответил верно на пример devtest = rng.random((n_candidates, n_test)) < true_acc
dev_acc = dev.mean(axis=1)best = int(dev_acc.argmax()) # «оптимизатор» выбирает лучший вариант по devprint(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 для парного сравнения: , где — доля несогласованных пар, а — точная дисперсия парной разности (в 1.3.4 её приближали самой долей расхождений). Для нашего случая (, ) выходит около 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-набор, ставя нужный документ на первое, среднее и последнее место. Если точность сильно зависит от позиции, переставляйте документы по релевантности, сокращайте их число или берите модель с лучшей работой с длинным контекстом.
Structured outputs и prefill
Заголовок раздела «Structured outputs и prefill»Три уровня структурированного выводаструктурированный вывод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 — раздел о генерации промптов. Решение о выпуске всё равно принимает ваш набор, а не модель-автор.
Маршрут по официальным гайдам
Заголовок раздела «Маршрут по официальным гайдам»Лаборатории, которые делают модели, лучше всех знают их привычки. Их гайды — первоисточник. Рекомендуемый порядок:
| Шаг | Материал | Что взять | Время |
|---|---|---|---|
| 1 | Anthropic Prompt Engineering Interactive Tutorial | 9 глав с упражнениями: от структуры промпта до борьбы с галлюцинациями; главы 8–9 и приложение (цепочки промптов, tool use) | 6–10 ч |
| 2 | Anthropic: Prompting best practices | живой справочник: длинный контекст, примеры, prefill и миграция между поколениями | 1–2 ч |
| 3 | Anthropic: Define success criteria and build evaluations | критерии успеха, тест-кейсы и грейдеры; совет брать для оценки другую модель, чем для генерации | 1 ч |
| 4 | OpenAI: Prompt engineering | роли и иерархия сообщений, промпты как код, снимки моделей, отличия reasoning-моделей | 1 ч |
| 5 | Google: Prompt design strategies | few-shot, префиксы, разбиение задачи, стратегии итераций | 1 ч |
| 6 | Карточка и документация вашей модели: README DeepSeek-R1, README Qwen3, DeepSeek API | температура, системный промпт, режимы мышления, JSON Output, prefix completion | 30 мин |
Упражнения туториала 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 промптов: грейдеры, точность с доверительным интервалом и решение о выпуске.
Классификатор обращений в поддержку «Панда-Облака» (вымышленный провайдер из ДЗ 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)
Проверь себя
Заголовок раздела «Проверь себя»- Промпт — это код: шаблон, примеры, модель, параметры и схема ответа хранятся вместе, у версии есть отпечаток, он пишется в лог каждого запроса, рядом лежат тест-набор и журнал изменений.
- Рабочий цикл: набор → прогон → грейдеры → разбор ошибок по категориям → одна правка → регрессионный прогон. Грейдеры тоже бывают с багами, поэтому ответы читают глазами.
- Решение о выпуске — по интервалу разницы на одних и тех же примерах (несогласованные пары, тест Макнемара), с защитными метриками и подтверждением на замороженном test: перебор вариантов на dev завышает результат.
- Смена модели ломает формат, разбор «мыслей», параметры, токены и провайдерские приёмы вроде prefill. Мигрируйте через полный прогон набора и разбор ошибок.
- Официальные гайды Anthropic, OpenAI, Google, DeepSeek и Qwen сходятся в главном и расходятся в деталях. Гайд даёт гипотезу, а решает ваш набор.
Дальше: урок 8.4.2 — eval-driven development и наблюдаемость. Тест-набор начнём собирать из логов продакшена, научимся калибровать LLM-судью, трейсить вызовы и ловить регрессию по метрикам.