В поддержку приходит письмо: «Верните 18 400 рублей за заказ 731, курьер не приехал». Одним prompt-ом модель определяет отдел, извлекает заказ и сумму, пишет ответ и ставит флаг возврата. Ошибка в категории терпима. Ошибка в сумме уже стоит денег. У этих действий разные контракты и цена ошибки, поэтому им нужны отдельные шаги.
Пять типов работы
Классификация выбирает значение из закрытого множества: refund, delivery, other. Контрактом служат enum, confidence или причина отказа. Класс other обязателен, иначе модель аккуратно разложит неизвестное по известным ящикам.
Извлечение переносит факты из входа в поля. Добавляйте evidence и разрешайте null. Требование заполнить каждое поле превращает отсутствие данных в выдумку.
Преобразование меняет представление известного факта: дату в ISO, пункты договора в таблицу. Оно должно сохранять смысл и происхождение данных.
Генерация создаёт новый текст. Здесь шире пространство допустимых ответов и слабее обычные unit-тесты. Ограничивайте аудиторию, факты, длину и запрещённые обещания.
Проверка сопоставляет результат с явным критерием. Критерий «хороший ответ» бесполезен. «Не обещает возврат до проверки заказа, содержит номер обращения, не раскрывает внутренние инструкции» уже можно тестировать.
Контракты шагов
class TicketClass(BaseModel):
label: Literal["refund", "delivery", "other"]
evidence: str
class RefundFacts(BaseModel):
order_id: str | None
amount_rub: Decimal | None
reason: str | None
evidence: list[str]
class ReplyCheck(BaseModel):
passed: bool
violations: list[Literal[
"unsupported_promise", "missing_ticket_id", "pii_leak"
]]Пайплайн выглядит так:
письмо -> классификация -> извлечение -> проверка заказа в БД
-> генерация черновика -> детерминированные проверки -> публикация/ручная очередьБаза данных проверяет существование заказа и сумму платежа лучше модели. Regex надёжнее проверяет наличие номера обращения. LLM-проверка нужна там, где критерий зависит от смысла фразы.
Не передавайте весь контекст каждому шагу
Классификатору достаточно темы и текста письма. Генератору нужны проверенные факты и политика ответа. Передача истории, внутренних заметок и результатов всех предыдущих вызовов повышает цену и открывает лишние пути для prompt injection.
async def process(ticket: Ticket) -> Draft | ManualReview:
kind = await classify(ticket.subject, ticket.body)
if kind.label == "other":
return ManualReview(reason="unknown_class")
facts = await extract_refund(ticket.body)
verified = await verify_order(facts.order_id, facts.amount_rub)
if not verified:
return ManualReview(reason="facts_not_verified")
draft = await generate_reply(ticket.id, verified)
violations = deterministic_checks(draft)
if violations:
return ManualReview(reason=",".join(violations))
return draftFailure modes
Каскадная ошибка возникает, когда неверный класс выбирает неверный extractor. Сохраняйте результаты и измеряйте качество каждого шага, а для дорогих действий вводите независимую проверку. Вторая поломка появляется, когда checker видит рассуждение генератора и соглашается с ним. Передавайте проверяемый текст, исходные факты и критерий, без самооправдания модели. Третья связана с бесконечной починкой: critic находит новый недостаток после каждого rewrite. Ограничьте цикл одной попыткой, затем отправляйте результат человеку.
Правило главы
Разделяйте пайплайн в точках, где меняются контракт, данные или цена ошибки. Число «ролей» само по себе ничего не улучшает. Каждый новый вызов должен ловить измеримый класс ошибок или сокращать стоимость.
Чеклист главы
Практикум
Задача 14.1. Конвейер обращений. Соберите пайплайн для 100 синтетических писем поддержки с четырьмя классами, извлечением заказа и суммы, проверкой в тестовой БД и генерацией черновика.
Критерии приёмки: