Галлюцинации — это не баг, а фича? Нет, это баг.
Когда твой RAG-пайплайн начинает рассказывать, что "кофеин содержится в дистиллированной воде", хочется выкинуть ноутбук в окно. Я видел десятки RAG-систем, которые на 80% выдают правду, а потом внезапно несут чушь, потому что LLM «дополнила» ответ из своего тренировочного набора. Стандартные промпты в стиле «отвечай только по контексту» ломаются при первом же сложном запросе. Почему? Потому что ты даёшь модели свободу выбора формата ответа, а свобода в мире LLM — это прямой билет к галлюцинациям.
В этой статье мы наденем на модель смирительную рубашку — типизированный контракт ответа (Typed Answer Contract). Не очередной промпт, а строгая схема, которую LLM обязана соблюдать. Я покажу код на Python с Pydantic, разберу типичные ошибки и докажу, что без контракта любой RAG — это рулетка.
Анатомия галлюцинации: почему мягкие методы не работают
Ты пишешь в system prompt: "Если в контексте нет ответа, скажи 'я не знаю'". Модель кивает, но на выходе выдаёт "по моим данным..." и придумывает факт. Это не злой умысел, а архитектура: трансформеры учатся заполнять пробелы правдоподобно. Чем больше свободы в структуре ответа, тем выше шанс, что модель использует свои внутренние знания, а не переданный контекст.
В предыдущем разборе RAG я уже касался проблемы: модель сложно заставить честно признаться в незнании. Но есть более радикальный подход — не просить, а требовать.
Идея контракта: перестань просить, начни диктовать
Типизированный контракт ответа — это JSON-схема или Pydantic-модель, которая описывает не только формат, но и обязательные поля: answer (сам ответ), confidence (уверенность), source_chunks (какие куски контекста использованы), is_supported (флаг, подтверждён ли ответ контекстом). Модель вынуждена заполнить все поля. Если она не может найти ответ в контексте, она выставит is_supported: false и может вообще отказаться от ответа. Это не «пожалуйста, не выдумывай», а «ты обязана заполнить эту структуру, иначе я не приму вывод». LLM понимает, что нарушение контракта = ошибка.
Важное отличие от простого JSON mode: контракт требует обязательных полей, валидации типов и логических связей. Например, если is_supported: true, то confidence должен быть выше 0.7. Если контекст пустой или нерелевантный — весь блок ответа может быть отклонён.
Связь с агентным RAG прямая: контракт — это первый шаг к контролируемому циклу генерации. Модель больше не вольна выбирать форму ответа, она подчиняется схеме.
Реализация на Python: от промпта к контракту
Пойдём от противного: сначала покажу, как НЕ надо делать, а потом — правильный вариант с Pydantic. Предположим, у нас есть RAG-система с ретривером (например, Chroma). Мы получаем контекст и генерируем ответ.
1 Типичная ошибка: строковый ответ без контроля
import openai
def generate(context: str, query: str) -> str:
prompt = f"""Ответь на вопрос только на основе контекста.
Если в контексте нет данных, скажи "не знаю".
Контекст: {context}
Вопрос: {query}
Ответ:"""
response = openai.chat.completions.create(
model="gpt-4o", # или gpt-5 на 2026
messages=[{"role": "user", "content": prompt}]
)
return response.choices[0].message.contentЧто произойдёт? Модель может ответить красиво, но если контекст пустой — она всё равно что-то напишет, потому что промпт не запрещает прямо, а просто «просит». Теперь добавим контракт.
2 Правильная реализация с Pydantic и structured output
from pydantic import BaseModel, Field
from typing import List, Optional
import openai
class AnswerContract(BaseModel):
answer: Optional[str] = Field(
default=None,
description="Финальный ответ на вопрос. Если ответ не подтверждён контекстом, значение None."
)
is_supported: bool = Field(
description="True, если ответ полностью подтверждён предоставленными чанками."
)
confidence: float = Field(
ge=0.0, le=1.0,
description="Уверенность в ответе на основе контекста (0-1)."
)
used_chunks: List[int] = Field(
default_factory=list,
description="Индексы чанков (из переданного списка), которые использованы для ответа."
)
def generate_with_contract(query: str, chunks: List[str]) -> AnswerContract:
# ВАЖНО: передаём контекст в формате, который модель может проиндексировать
chunk_text = "\n---\n".join([f"[{i}] {chunk}" for i, chunk in enumerate(chunks)])
system_prompt = """Ты — RAG-ассистент. Твоя задача — ответить на вопрос,
используя предоставленные фрагменты текста. Ты ОБЯЗАН заполнить структуру AnswerContract.
Если ни один фрагмент не содержит ответа, установи is_supported=False,
confidence=0.0, answer=None. Не придумывай информацию."""
user_prompt = f"Вопрос: {query}\n\nКонтекст:\n{chunk_text}"
response = openai.beta.chat.completions.parse(
model="gpt-4o", # уточни версию по документации 2026
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
response_format=AnswerContract
)
return response.choices[0].message.parsedЗаметили response_format=AnswerContract? Это структурированный вывод (structured output), доступный в OpenAI и других провайдерах (Anthropic, Gemini). Модель не просто генерирует JSON, а следует схеме: все обязательные поля, типы, ограничения. Если модель попытается заполнить confidence строкой или пропустит is_supported — вызов упадёт с ошибкой. На этапе разработки это сразу выявляет галлюцинации.
pydantic.v1 для старых моделей или instructor библиотеку — она поддерживает несколько LLM и патчит вызовы. Кстати, подход с контрактом отлично сочетается с структурированным парсингом вопросов — на входе тоже контракт, на выходе контракт.Арбитр как второй контур: когда контракт не сработал
Даже с контрактом остаётся риск: модель может вернуть is_supported: true, хотя на самом деле ответ не подтверждён. Это редкий сценарий, но он есть. Тут в игру вступает второй LLM — арбитр. Он валидирует выполнение контракта постфактум. В статье самовосстанавливающийся RAG мы делали похожий цикл. Сейчас арбитр проверяет, что confidence адекватен и used_chunks действительно содержат ответ. Если арбитр обнаруживает несоответствие — он перезапускает генерацию с уточнённым промптом.
Типичные ошибки при внедрении контракта
Вот что я встречал в своих проектах и в коде коллег.
- Слишком сложная схема. Если в контракте 15 полей, модель начинает выдумывать значения. Держите 3-5 полей, остальное — опционально.
- Несоответствие типов. Например, поле
confidenceтипаfloatс ограничением 0-1, но модель иногда выдаёт 0.9999 — это ок. Но если она выдаёт 2 — ошибка. Включайтеstrict=Trueв Pydantic. - Забыли про None. Обязательно разрешайте
answer=Noneпри отсутствии данных. Иначе модель вынуждена врать. - Игнорирование
used_chunks. Если модель указывает чанки, нужно проверять, что в них действительно содержится ответ. Это уже задача арбитра, но можно делать простую проверку: совпадает ли ответ с текстом чанков.
Кстати, в OCC-RAG используется другой подход — компактные модели, обученные не галлюцинировать. Но контракт с ними тоже работает: вы просто добавляете response_format к вызову локальной модели, если она поддерживает structured output (например, через llama.cpp или vLLM).
FAQ: частые вопросы
| Вопрос | Ответ |
|---|---|
| Не увеличит ли контракт latency? | На 10-20% из-за структурированного вывода, но это окупается качеством. Можно кэшировать схемы. |
Какие модели поддерживают response_format? | OpenAI gpt-4o-2024-08-06+, Gemini 1.5 Pro, Claude 3 Sonnet (через instructor). На 2026 почти все мейнстрим-модели. |
Что делать, если модель постоянно возвращает is_supported=False? | Проверьте ретривер. Возможно, контекст нерелевантный. Используйте better chunking или гибридный поиск. |
Вместо финала: контракт — это не панацея, но фундамент
Типизированный контракт не решит всех проблем RAG сам по себе. Он не заменит качественный ретривер, чистые данные и нормальную оценку. Но он даёт тебе контроль там, где раньше была анархия. Когда модель обязана заполнить поле confidence, ты сразу видишь, что она сомневается. И можешь решить: довериться или запросить уточнение.
Следующий шаг — внедрение контрактов в CI/CD для RAG. Мы уже тестируем это в продакшене: пайплайн генерирует ответ, арбитр проверяет, если контракт не пройден — ответ не отправляется пользователю, а логируется для анализа. Это превращает галлюцинации из проблемы в метрику.
Неочевидный совет: попробуй добавить в контракт поле ambiguous_interpretations — список альтернативных трактовок вопроса. Если модель понимает, что вопрос двусмыслен, она может явно указать это, а не выбирать одну версию наугад. Так ты превращаешь слабость (неоднозначность) в преимущество.