Руководство по контрактам для AI: делаем входные/выходные схемы и тестируем ответы модели
Как превратить LLM-интеграцию в предсказуемый сервис: задаём схемы входных данных и результата, валидируем ответы, ловим деградацию качества и строим минимальный набор регрессионных тестов. Без иллюзий о “гарантиях” модели.
Содержание
Руководство по контрактам для AI: делаем входные/выходные схемы и тестируем ответы модели
Интеграция LLM (large language model) в продукт часто начинается как прототип: “сформируем промпт — получим текст”. Но как только модель становится частью сервиса, появляются инженерные вопросы: насколько предсказуемы результаты, как обнаруживать деградацию качества, как валидировать ответы и как удерживать стабильность при изменениях модели, промптов и контекста.
Ключевая идея — относиться к LLM как к компоненту, который обменивается данными по контракту. Контракт задаёт формат входа, формат выхода, семантические инварианты и правила валидации. В отличие от “гарантий качества” самой модели, контракт даёт вам контроль на стороне системы: вы можете проверять структуру, снижать вариативность, отлавливать сбои и вести регрессионные тесты.
Ниже — практическое руководство: как превратить LLM-интеграцию в предсказуемый сервис за счёт схем входа/выхода, валидаторов, мониторинга и минимального набора регрессионных тестов. Без иллюзий: модель может ошибаться, но вы можете системно уменьшить хаос.
Контракт для AI: что именно вы “гарантируете” системой
Начнём с определения. Контракт для AI — это набор требований, которые ваша система гарантирует до и после вызова модели:
-
Контракт входа
- модель получает строго определённый формат данных;
- поля не “плавают” по типам;
- присутствуют обязательные атрибуты;
- есть ограничения (длины, допустимые значения, язык, домен и т. п.).
-
Контракт выхода
- модель возвращает данные в ожидаемой структуре;
- формат устойчив к шуму (а значит, вы можете парсить и валидировать);
- есть явные поля и коды ошибок/отказов.
-
Инварианты и правила
- если ответ “должен” содержать определённые поля, он обязан их содержать;
- если не может выполнить задачу — возвращает отказ в структурированном виде;
- текстовые поля ограничены по длине или по допустимым символам/языку (где возможно).
-
Валидация и обработка несоответствий
- при невалидном ответе вы не “молчаливо принимаете текст”;
- есть стратегия: повторный запрос, fallback, отказ пользователю, логирование.
-
Тестирование и регрессия
- на уровне контрактов — валидность структуры и инвариантов;
- на уровне качества — заранее выбранные сценарии с ожидаемыми свойствами (а не “точным текстом”).
Важно: контракт не гарантирует правильность содержания так, как гарантирует строгая формальная система. Но он гарантирует, что ваш сервис ведёт себя предсказуемо: ошибки обнаруживаются, формат не ломается, деградация не прячется.
Выбор формы контрактов: текст vs структурированные данные
Слабое место большинства LLM-подходов — “попросили текстом — получили текстом”. Для контракта лучше перейти к структурированным ответам, которые можно парсить.
Практические варианты:
- JSON-ответ: модель возвращает объект со строго фиксированной схемой.
- Схема “tool/function calling”: модель заполняет аргументы функции (часто это ближе к JSON).
- Семантическая разметка (например, XML/пронумерованные секции) — хуже с точки зрения строгой валидации, но иногда удобнее.
На практике чаще всего выигрывает JSON + строгий валидатор схемы (например, JSON Schema или Pydantic/TypedDict в Python). Модель при этом просим “вернуть только JSON”.
Однако важно понимать: модель может вернуть некорректный JSON. Поэтому контракт включает не только “просим JSON”, но и механизмы восстановления: повтор с “исправь формат”, fallback на другой режим, или отказ.
Контракт входа: фиксируем семантику и ограничения
Что обычно уходит в контракт входа
Для большинства сценариев LLM-интеграции имеет смысл фиксировать:
task_type— тип задачи (классификация, извлечение, генерация ответа, резюме и т. п.)language— язык ответаinput— основной текст/данныеcontext— дополнительные контекстные факты (если нужны)constraints— ограничения (тон, длина, запреты, формат)request_id— идентификатор запроса (для логирования и трассировки)client_version/prompt_version— версия промпта/схемы (важно для регрессии)
Почему это снижает деградацию
Модель чувствительна к тому, как мы формулируем условия. Если вы не фиксируете поля и их значения, любые изменения в промпте могут незаметно изменить поведение. Контракт входа позволяет:
- сохранять единообразие структуры;
- сравнивать результаты по одним и тем же сценариям;
- проще воспроизводить баги (“эта регрессия случилась на входе X при версии промпта Y”).
Типовые ошибки в контракте входа
-
Переизбыток текста без явных полей
Когда всё “в одном абзаце”, вы не валидируете ничего. Лучше вынести параметры в поля. -
Отсутствие ограничений
Если не ограничить длину/язык/домен, LLM начнёт “компенсировать” неопределённость текстом. -
Неявные зависимости
Например, “если user спрашивает про возврат — значит можно упоминать политику”. Это должно быть либо в правилах, либо в параметрах/контексте.
Контракт выхода: проектируем схему, а не “просим ответить”
Минимально жизнеспособная схема
Любая схема выхода должна решать три задачи:
- Определить статус (успех/отказ/частичный успех).
- Дать структуру результата (что именно вернуло).
- Добавить диагностические поля для инженерной эксплуатации.
Пример: контракт для задачи “ответ на вопрос с цитатами из контекста” или “ассистент”. Он может выглядеть так:
status:"ok" | "refusal" | "error"answer: строка (или структурированный объект)citations: массив ссылок/фрагментов (опционально)safety_notes: краткие пояснения почему отказconfidence: оценка (осторожно: не как “истина”, а как внутренний сигнал модели)validation: метаданные вашей валидации (например, какие поля были проверены)
Пример схемы на Python (Pydantic)
from pydantic import BaseModel, Field
from typing import List, Literal, Optional
class AIResponse(BaseModel):
status: Literal["ok", "refusal", "error"]
answer: Optional[str] = Field(default=None, max_length=4000)
citations: List[str] = Field(default_factory=list)
safety_notes: Optional[str] = Field(default=None, max_length=1000)
confidence: Optional[float] = Field(default=None, ge=0.0, le=1.0)
# Поля для отладки и контроля контрактов
model: Optional[str] = None
prompt_version: Optional[str] = None
error_code: Optional[str] = None
Важно: answer может быть None при refusal/error. Это тоже контракт — вы не допускаете “ответ в неправильном статусе”.
Стратегии при невалидном выходе
Если модель вернула невалидный JSON или несоответствующее поле:
- Лёгкая реконсиляция: второй запрос — “исправь формат, верни только JSON по схеме”.
- Жёсткий отказ: возвращаем
status="error"и логируем. - Fallback на другой тип ответа: если у вас есть альтернативный генератор (например, более строгий режим извлечения).
Ключевой принцип: контракт выхода и обработчик ошибок должны быть частью системы, а не “надеяться, что модель справится”.
Инструментируем промпт под контракт: как формулировать требования модели
Контракт — это не только схемы в коде, но и инструкция модели. Практический подход:
- “Верни ТОЛЬКО JSON”
- “Используй строго эти поля”
- “Не добавляй дополнительных ключей”
- “Статус
refusal, если …” - “Длина
answerне более … символов” - “Если данных недостаточно — верни
refusalилиerror_code”
Но будьте осторожны: чрезмерная строгость промпта может увеличить вероятность “продуцирования кода”, т. е. модель начнёт имитировать структуру, но при этом ошибётся в содержании. Поэтому лучше сочетать:
- чёткие правила,
- валидацию,
- повторную фиксацию формата только по необходимости.
Пример системной инструкции (идея, не привязана к конкретному SDK)
Ты — модуль ответов по контракту.
Верни результат строго в JSON согласно схеме AIResponse.
- Используй только поля: status, answer, citations, safety_notes, confidence, model, prompt_version, error_code.
- Не добавляй других полей и не выводи текст вне JSON.
- status:
- "ok" — если можно сформировать ответ;
- "refusal" — если нельзя отвечать по политике/безопасности;
- "error" — если недостаточно данных или произошла ошибка.
- answer: строка <= 4000 символов или null, если status != "ok".
Валидируем ответы: автоматическая проверка контрактов
Валидация по типам и полям
После вызова модели у вас должен быть этап:
- Парсинг результата (JSON).
- Проверка схемы (types, длины, диапазоны, обязательность).
- Проверка инвариантов (статус ↔ поля).
Например, инвариант: если status="refusal", то answer должен быть None (или пустым) и safety_notes заполнен.
Пример валидатора инвариантов
def validate_invariants(resp: AIResponse) -> None:
if resp.status == "ok":
if not resp.answer:
raise ValueError("status=ok требует answer")
elif resp.status == "refusal":
if resp.answer is not None and resp.answer.strip() != "":
raise ValueError("status=refusal не должен содержать answer")
if not resp.safety_notes:
raise ValueError("status=refusal требует safety_notes")
elif resp.status == "error":
# Обычно answer отсутствует
if resp.answer is not None and resp.answer.strip() != "":
# допускайте ваш вариант, но лучше фиксировать строго
raise ValueError("status=error не должен содержать answer")
if not resp.error_code:
raise ValueError("status=error требует error_code")
Не ограничивайтесь JSON-валидацией
JSON-схема проверяет структуру, но не содержание. Для инженеринга качества добавляют семантические проверки:
- Ответ на классификацию содержит ровно одно значение из набора.
- В ответе отсутствуют запрещённые фразы.
- Если ожидаются “цитаты”, то
citationsне пустой. - Если ответ должен быть на русском — проверьте язык (хотя бы грубо: доля кириллицы).
Эти проверки превращают LLM из “генератора текста” в “компонент с измеримыми критериями”.
Тестирование LLM: почему нужны регрессионные тесты, а не “ручная проверка”
Ручной контроль в какой-то момент перестаёт масштабироваться. LLM-системы деградируют не только из-за “плохой модели”. Типичные причины регрессии:
- обновление модели в провайдере;
- изменение параметров генерации (temperature, top_p, max_tokens);
- изменение промпта/шаблонов;
- изменение системы контекста (retrieval, ранжирование фрагментов);
- обновление токенизации/лимитов;
- случайные изменения в подготовке входа (например, вырезание важного поля).
Поэтому стратегия: минимальный набор регрессионных тестов должен быть внедрён в пайплайн так же, как тесты для обычного кода.
Уровни тестов
-
Контрактные тесты (обязательные)
- валиден JSON/схема;
- инварианты статуса соблюдены;
- поля не выходят за лимиты;
- отсутствие “лишнего текста”.
-
Семантические свойства (желательные)
- классификация в пределах допустимых значений;
- ответ содержит ключевые элементы (например, заголовок/список/поля);
- соблюдение политик (не выдаём запрещённое).
-
Качественные тесты (аккуратные)
- вместо точного совпадения текста используем измеримые метрики:
- наличие фактов/чисел,
- соответствие требованиям (“должно быть кратко”),
- “не противоречит” контексту (через эвристику),
- формат ответа.
- вместо точного совпадения текста используем измеримые метрики:
Важно: точное совпадение ответа почти всегда хрупкое. LLM — стохастическая система (даже при низкой температуре). Поэтому правильнее тестировать свойства, а не строки.
Минимальный набор регрессионных тестов: как не утонуть
Хорошая цель — 20–50 тестовых сценариев, которые покрывают риск. Не “все запросы пользователя”, а ключевые классы:
-
Нормальные кейсы
- типичный вопрос/задача;
- ожидаемый формат и структура.
-
Пограничные кейсы ввода
- пустой/короткий input,
- слишком длинный input,
- нестандартные символы,
- отсутствующий контекст.
-
Кейсы отказа (safety/refusal)
- запросы, где по политике нужно отказаться;
- запросы, где недостаточно данных (должен вернуться
errorилиrefusalв зависимости от вашей модели поведения).
-
Проверка устойчивости формата
- тестирует “всегда ли возвращается JSON” при разных входах;
- ловит случаи, когда модель внезапно начала возвращать markdown/текст.
Практика: тестируйте на фиксированных входах и фиксированных версиях промпта
Пример: вы хотите сравнивать результат до/после. Для этого:
- фиксируйте
prompt_version; - фиксируйте параметры генерации (temperature и т. п.);
- сохраняйте “золотые” входы и ожидания.
Если вы не можете фиксировать ответы (из-за API стохастики), тесты должны быть property-based.
Пример регрессионного теста (property-based) на Python
Ниже — упрощённый пример. Он не привязан к конкретному SDK, но демонстрирует идею: вызываем модель, валидируем контракт и проверяем свойства ответа.
import json
from typing import Any, Dict
# Допустим, у вас есть функция call_llm(...) которая возвращает строку
# с JSON и вы уже знаете prompt_version и используемую модель.
def call_llm(prompt_version: str, user_input: str) -> str:
# Заглушка: здесь ваш реальный вызов провайдера
raise NotImplementedError
def run_test_case(test: Dict[str, Any]) -> None:
prompt_version = test["prompt_version"]
user_input = test["input"]
raw = call_llm(prompt_version=prompt_version, user_input=user_input)
# 1) Парсинг JSON
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
raise AssertionError(f"LLM returned invalid JSON: {e}. Raw={raw[:200]}...")
# 2) Валидация схемы (пример через Pydantic)
resp = AIResponse(**data)
validate_invariants(resp)
# 3) Property checks
# Например, для ok-ответа должны быть citations (если требуются по вашему ТЗ)
if test.get("require_citations", False):
if resp.status == "ok" and len(resp.citations) == 0:
raise AssertionError("Expected non-empty citations for ok response")
if test.get("answer_must_contain", None):
must = test["answer_must_contain"]
if resp.status == "ok":
if must.lower() not in (resp.answer or "").lower():
raise AssertionError(f"Answer must contain '{must}'")
# Проверка длины (контракт уже проверил, но это дополнительная защита)
if resp.answer and len(resp.answer) > 4000:
raise AssertionError("Answer exceeds max length")
def main():
tests = [
{
"name": "normal_question",
"prompt_version": "v1.3",
"input": "Кратко объясни, что такое TF-IDF.",
"require_citations": False,
"answer_must_contain": "TF-IDF",
},
{
"name": "missing_context_should_error",
"prompt_version": "v1.3",
"input": "Сформируй ответ по документу, которого нет в контексте.",
# Ожидаем status=error или refusal — зависит от вашей политики
# Добавьте проверки, которые отражают ваш контракт поведения
},
]
for t in tests:
run_test_case(t)
print(f"OK: {t['name']}")
if __name__ == "__main__":
Комментарии
Пока нет комментариев