Промпт-инжиниринг для кода: схема ответа, а не красивая формулировка

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

Лечится это не более убедительной формулировкой. В приложении промпт живёт не один, а в паре со схемой ответа: модель отдаёт объект по Pydantic-схеме, а текст остаётся только там, где текст и нужен. Ниже — как это устроено в трёх реальных проектах: классификатор товаров с динамическим enum, конструктор коммерческих предложений и разметка обучающего датасета силами LLM.

Отдельно оговорюсь про соседнюю тему. Про то, чем наполнять контекстное окно — историей, найденными документами, фактами о пользователе — у меня есть разбор context engineering для ИИ-агента. Здесь речь про другое: как сформулировать задачу так, чтобы ответ был машинно-пригодным и воспроизводимым от вызова к вызову.

Как эти приёмы складываются в рабочий сервис, а не в набор советов, разбираем на курсе «ИИ для 1С».

Почему промпт, который работает в чате, ломается в проде?

Три режима отказа, и все три не про «модель глупая».

Формат плавает. Вы попросили вернуть JSON — модель вернула JSON, обёрнутый в markdown-блок кода, с вежливым вступлением перед ним. В следующий раз без обёртки. В третий — с полем, которого вы не просили. Пока ответ читает человек, это незаметно; когда ответ читает json.loads, это падение раз в сотню вызовов.

Словарь плавает. Модель классифицирует товар «Стартер 21214» как «Стартеры», а через двадцать позиций «Стартер ЛАРГУС» — как «Стартер в сборе». По отдельности оба ответа разумные. Вместе — мусорный справочник, который потом никто не разгребёт.

Ответ невоспроизводим. Тот же вход, тот же промпт, другой ответ. Для генерации текста это норма, для классификации — дефект, из-за которого нельзя ни повторить прогон, ни сравнить два промпта между собой.

Ни один из трёх режимов не чинится добавлением фразы «отвечай строго по формату». Он чинится тем, что формат перестаёт быть просьбой и становится контрактом.

Схема ответа — вторая половина промпта

Structured output — это не «попросить JSON», а передать модели JSON Schema отдельным каналом, вне текста. Дальше рантайм (Ollama, OpenAI, GigaChat — у всех это есть) ограничивает генерацию так, чтобы результат схеме соответствовал. В LangChain это одна строка llm.with_structured_output(schema=..., method="json_schema"), а сама схема описывается Pydantic-моделью.

Важная деталь, которую упускают: описания полей — тоже промпт. Модель видит их ровно в тот момент, когда заполняет конкретное поле, и это работает точнее, чем общая инструкция в системном тексте. Вот схема из проекта канонизации справочника категорий:

class CategoryCanonical(BaseModel):
    """Одна строка маппинга оригинальной категории на каноническую."""

    original: str = Field(..., description=(
        "Исходное название категории из входа, символ в символ. "
        "Если изменишь регистр или пробелы — строка не сматчится."
    ))
    canonical: str = Field(..., description=(
        "Каноническое название категории: существительное во множественном "
        "числе, именительный падеж, 1–3 слова, без брендов и знаков препинания. "
        "Если категория уже хороша — повтори её в этом поле без изменений."
    ))

Заметьте формулировку в original: там не «пожалуйста, не меняй строку», а объяснение последствия — «не сматчится». Модель, которая понимает, зачем ограничение, соблюдает его заметно охотнее, чем модель, которой просто запретили.

Откуда берётся список допустимых значений?

Самый полезный приём из всей этой практики: enum в схеме собирается из данных, а не пишется руками. В классификаторе товаров поле category имеет тип Literal[tuple(known_categories)], где known_categories — список категорий, найденных на предыдущих итерациях того же прогона:

literal_type = Literal[tuple(known_categories)]  # type: ignore[valid-type]
return create_model(
    "GroupsData",
    __doc__="Структурированный ответ классификатора товаров",
    category=(
        Optional[literal_type],
        Field(default=None, description=category_desc),
    ),
    new_category=(
        Optional[str],
        Field(default=None, description=new_category_desc),
    ),
)

Что это даёт. Модель физически не может вернуть категорию вне списка — не «не должна», а не может: декодирование ограничено enum'ом из JSON Schema. Придумать новое значение она может только через отдельное поле new_category, и это уже совсем другой путь: предложение проходит через нормализацию (схлопывание пробелов, регистронезависимая проверка на дубль среди известных) и только после этого попадает в enum следующего вызова.

На самом первом вызове список пуст, и схема собирается иначе: category становится Optional[str] с описанием «список пуст — всегда оставляй null». Модель обязана предложить название. Дальше множество растёт монотонно и управляемо: каждая новая категория — осознанное расширение справочника, а не случайная формулировка.

Ровно поэтому в системном промпте появилось правило «не плоди дубли: прежде чем предложить новое название, мысленно проверь, нет ли в списке похожей по смыслу категории». Оно бессмысленно без enum'а и работает вместе с ним.

Почему системный промпт не должен меняться между вызовами?

Соблазн очевидный: раз список категорий динамический, впишем его прямо в текст системного промпта. Так делать нельзя — и не из соображений красоты.

Провайдеры кэшируют префикс запроса: Ollama держит KV-кэш, у облачных моделей это prompt caching со скидкой на кэшированные входные токены. Кэш работает, пока начало запроса совпадает байт в байт. Стоит подставить в системный текст меняющийся список — и на каждом вызове префикс новый, кэш промахивается, а вы платите полную цену за одни и те же две тысячи токенов инструкции, помноженные на число товаров.

Поэтому в пайплайне системный текст читается один раз до цикла и больше не трогается, а переменная часть уезжает двумя путями: короткое пользовательское сообщение и — через format= в JSON Schema — сам enum:

system_text = read_prompt_from_file(cfg.system_prompt_file)  # читаем один раз
system_msg = SystemMessage(content=system_text)              # и не пересобираем

for idx, product in enumerate(products, 1):
    # схема пересобирается под текущий список категорий
    schema_cls = make_groups_schema(known)
    structured_llm = llm.with_structured_output(schema=schema_cls, method="json_schema")

    user_msg = HumanMessage(content=user_template.format(name=name))
    result = await structured_llm.ainvoke([system_msg, user_msg])

    chosen = getattr(result, "category", None)        # выбрана из known
    proposed = getattr(result, "new_category", None)  # предложена новая

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

Разбор промптов и схем под конкретные задачи — часть курса «ИИ для 1С: разработка ИИ-агентов, RAG и MCP-серверов», там мы собираем такие пайплайны целиком.

Промпт, который готовит данные для другой модели

Промпт — не обязательно про диалог. В проекте с NER-моделью на spaCy LLM вообще не участвует в проде: её работа — один раз разметить обучающий датасет, чтобы дальше товары размечала быстрая локальная модель без всяких вызовов к API.

Промпт там короткий: «извлеки одну ключевую характеристику товара PRODUCT_TYPE из его названия», плюс схема с полями label, text, start, end. И вот здесь всплывает ограничение, о котором стоит знать заранее: позиции символов модель считает плохо. Она уверенно возвращает start и end, и они регулярно не совпадают с реальным вхождением. Лечится не промптом, а кодом:

def _correct_label_indices(self, item):
    text = item["text"].lower()
    corrected = []
    for label in item["labels"]:
        start = text.find(label["text"].lower())
        if start == -1:          # модель вернула фрагмент, которого в тексте нет
            print(f"Не удалось найти '{label['text']}' в '{text}'")
            continue             # такую метку выбрасываем целиком
        label["start"] = start
        label["end"] = start + len(label["text"])
        corrected.append(label)
    item["labels"] = corrected
    return item

Отсюда практическое правило: не просите у модели то, что дешевле и надёжнее вычислить кодом. Модель отвечает за смысловую часть — «какой фрагмент названия является типом товара». Арифметика по строке — работа str.find. Заодно эта же функция ловит галлюцинацию: если фрагмента в тексте нет вовсе, метка не чинится, а выбрасывается.

Дальше размеченный датасет проходит валидацию через offsets_to_biluo_tags спейси, и примеры, где разметка не сходится в BILOU-теги, отсеиваются автоматически. Тоже характерный приём: между «LLM разметила» и «датасет пошёл в обучение» стоит машинная проверка, а не доверие.

Как заставить модель выбирать из справочника, а не из головы?

Второй проект — конструктор коммерческих предложений. Пользователь описывает проект текстом, модель подбирает под него модули из каталога и сразу набрасывает структуру КП с уточняющими вопросами по каждому разделу.

Ключевой риск понятен: модель придумает модуль, которого в каталоге нет. Защита двухслойная. Первый слой — каталог целиком уезжает в пользовательское сообщение плоскими строками с идентификаторами:

catalog_text = "\n".join(
    f'- id:{m.id} [{m.category}] {m.title}: {m.description or ""} ({m.hours_typical}ч)'
    for m in all_modules
)
user_content = f"Описание проекта: {description}\n\nДоступные модули:\n{catalog_text}"

Второй слой — ответ модели никогда не берётся на веру. Из ответа используется только id и текстовое обоснование reason, а всё остальное (название, категория, часы) подтягивается из базы:

module_map = {str(m.id): m for m in all_modules}
for sug in data.get("modules", []):
    mod = module_map.get(sug.get("id"))
    if mod:                      # id, которого нет в каталоге, молча отбрасывается
        enriched_modules.append({
            "id": str(mod.id),
            "title": mod.title,
            "hours_typical": mod.hours_typical,
            "reason": sug.get("reason", ""),
        })

Модель здесь работает как маршрутизатор: сопоставляет свободный текст с закрытым множеством и объясняет выбор. Цифры, названия и часы она не порождает — их порождает база. Это тот же принцип, что и с динамическим enum, только реализованный на уровне приложения, потому что каталог слишком велик, чтобы становиться enum'ом.

Как понять, что новый промпт лучше старого?

Никак, если у вас нет датасета с эталонами. «Покрутил формулировку, вроде стало лучше» — это не измерение, а самоощущение, и на третьей итерации вы уже не помните, какая правка что дала.

Рабочая схема простая: набор входов с правильными ответами, функция оценки, прогон. В проекте с Langfuse это выглядит так — одна Pydantic-схема, один системный промпт из файла и словарь моделей, по которым гоняется один и тот же эксперимент:

class QuestionClassification(BaseModel):
    """Классификация вопросов пользователей"""
    category_id: int = Field(description="Категория запроса пользователя (1, 2 или 3)")

models = {
    "GigaChat-2-Lite": {"model": giga_llm_lite,    "use_structured_output": True},
    "ChatGPT-4o-mini": {"model": openai_llm_4_mini, "use_structured_output": True},
    "Qwen-2.5-14B":    {"model": qwen25_14b,        "use_structured_output": False},
}

Флаг use_structured_output — не украшение. Часть локальных моделей схему не держит, и для них ответ приходится вытаскивать из текста числом; в датасете это сразу видно по просевшей точности. Оценка при этом тупая до неприличия — точное совпадение с эталоном даёт 1.0, несовпадение 0.0, — и для классификации этого достаточно.

Главный вывод из таких прогонов: промпт нельзя переносить между моделями молча. Тот же текст и та же схема на другой модели дают другую точность, иногда драматически. Про то, как устроен сам стенд — датасеты, трейсы, сравнение прогонов — я писал отдельно в разборе тестирования и отладки LLM-приложений через Langfuse.

Кстати, то же правило работает и когда модель пишет код: критерий приёмки должен быть машинным. В вайбкодинге им становятся тесты, здесь — датасет с эталонами. Без него любой промптинг превращается в спор о вкусах.

Что выносить в схему, а что оставлять текстом

Разделение, к которому я пришёл на этих проектах:

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

В текст уходят правила выбора и разграничения, которые схемой не выразить. Пример из канонизации справочника: «не сливай товарно-разные категории, даже если они похожи по словам: „Накладки порогов“ и „Накладки на двери“ — РАЗНЫЕ, „Радиаторы кондиционера“ и „Радиаторы печки“ — РАЗНЫЕ». Такие пары-контрпримеры дают больше, чем абзац общих рассуждений о том, что такое хорошая категория.

Не дублируйте формат ответа в тексте, если он уже задан схемой — два источника истины со временем разъезжаются, и вы получаете промпт, который требует одного, а схема разрешает другое. Единственное исключение — требования, которые схемой не выражаются: например, «в поле items должно быть РОВНО столько объектов, сколько строк во входе». Схема гарантирует тип списка, но не его длину, поэтому такое требование живёт в тексте и проверяется кодом после ответа.

Когда дробить задачу на два вызова?

Когда у подзадач разные единицы контекста. В классификаторе товар обрабатывается по одному за вызов — модели незачем видеть соседние позиции, зато так работает кэш префикса и растёт enum. А вот склейка синонимов в справочнике устроена наоборот: решение «„Резонаторы глушителя“ и „Резонаторы глушителей“ — одно и то же» требует видеть список целиком, поэтому категории уходят батчами.

И тут же вылезает следствие батчей: синонимы, попавшие в разные батчи, друг о друге не знают. Отсюда второй раунд — тот же самый прогон, но уже по сводному списку канонических имён, с другим системным промптом. Итоговое отображение считается композицией: final[original] = round2[round1[original]].

Ещё одна мелочь, которая экономит нервы: батчи формируются не случайно, а сортировкой по убыванию числа товаров — крупные категории-якоря распределяются по всем батчам, и мелким дублям есть к чему прилипнуть. И на любой сбой действует одно правило по умолчанию:

missing = inputs_exact - mapping.keys()
for m in missing:
    mapping[m] = m     # что модель пропустила — оставляем как было

Пропущенная моделью строка не роняет прогон и не теряется — она просто остаётся собой. Для пайплайна, который час гоняет тысячи позиций, «частичный успех» ценнее, чем «всё или ничего».

Температура — часть контракта, а не настройка на вкус

В классификации и канонизации стоит temperature=0.0, в генерации текста разделов КП — 0.3 и 0.4. Это не подбор по ощущениям, а прямое следствие задачи. Если задача — воспроизводимое отображение входа в конечное множество, любая температура выше нуля означает, что один и тот же товар в двух прогонах попадёт в разные категории; тогда ни повторить прогон, ни сравнить два промпта вы уже не сможете. Если задача — написать связный абзац для документа, нулевая температура даёт сухой шаблонный текст, и небольшой разброс идёт на пользу.

Так же и с форматом: json_mode/json_schema — не «режим для аккуратности», а объявление того, что ответ пойдёт в код, а не человеку. Оба параметра стоит фиксировать в конфиге рядом с промптом и менять осознанно, а не подкручивать между запусками.

Где проходит граница

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

За кадром намеренно осталось то, что определяет качество на конкретной задаче: полные тексты промптов этих проектов с их эволюцией, разбор режимов отказа (модель игнорирует enum, возвращает список не той длины, зацикливается на одной категории), подбор модели под задачу и бюджет, устройство оценочных датасетов. Это разбирается на курсе «ИИ для 1С: разработка ИИ-агентов, RAG и MCP-серверов» — там мы собираем пайплайн целиком, от промпта и схемы до прогона на датасете и сравнения моделей.

Частые вопросы