Проблема: тесты, которые рождаются в муках
Каждый, кто хоть раз составлял корпоративный экзамен, знает эту боль. Ты садишься, открываешь Google Docs и пытаешься придумать 30 вопросов по микросервисам. Через час у тебя 12 вопросов, три из которых повторяют друг друга. Через неделю экзамен проводят снова, и половина сотрудников просто заучила ответы. Это не проверка знаний, это цирк.
А теперь представьте, что каждый экзамен генерируется автоматически, под конкретную должность, уровень и стек. Без галлюцинаций, с привязкой к вашим внутренним документам. И всё это работает для 50 разных отделов, у каждого свои правила, свои темы, своя изоляция данных. Это — задача, которую мы разберём по косточкам.
Архитектура — не MVC, а DDD с bounded context
Забудьте про монолит, где все таблицы свалены в одну базу. Корпоративная платформа для аттестации — это как минимум четыре домена: Управление тенантами, Банк вопросов и ответов, Экзаменационный runtime и AI-генератор. Каждый из них живёт своей жизнью, может масшабироваться независимо и говорит с другими через события.
Я выбрал Domain-Driven Design (DDD) не ради модного словца. Он даёт чёткие границы: если баг в генерации вопросов — не падает экзамен. Если лаг в Redis при сохранении ответа — не пересоздаётся банк. Каждый bounded context — отдельный microservice. Связь — через RabbitMQ или Kafka (в зависимости от нагрузки).
⚠️ Распространённая ошибка: класть всё в один сервис FastAPI, а потом удивляться, что при пересчёте статистики упали сессии экзаменов. Нет, друзья, asyncio.gather не спасёт от проблем с изоляцией.
Генерация вопросов через LLM — от хаоса к валидности
Вот где начинается самое интересное. Вы кидаете в промпт тему (например, «Kubernetes Pod Lifecycle») и ожидаете 5 вопросов с вариантами ответов. Но LLM может:
- придумать несуществующий флаг
—mount; - дать правильный ответ, который противоречит вашим внутренним регламентам;
- сгенерировать вопрос, где все варианты неверны.
Решение — гибридный пайплайн.
Сначала LLM генерирует сырые вопросы (мы используем Claude 4.5 Opus на собственных серверах через деплой LLM on-premise для экономии). Затем идёт пост-обработка: детерминированный валидатор проверяет синтаксис, отсекает дубликаты по дайджесту вопроса, верифицирует ответы через формальную проверку (например, для кода — запуск юнит-теста на сгенерированном ответе).
Этот подход перекликается с тем, что описано в статье «Гибридный AI: как объединить детерминированный анализ и LLM для точных результатов в Enterprise» — только там было про анализ контрактов, а у нас — про экзамены.
1Промпт-инжиниринг с контекстом
Как заставить LLM генерировать вопросы строго по вашим материалам? Добавить RAG. Мы подгружаем из корпоративного wiki релевантные абзацы и оборачиваем их в контекст вместе с инструкцией. Без этого LLM начинает фантазировать. Вот реальный пример ошибки: RAG-система извлекает правильные данные, но даёт неверный ответ — читали? Та же история, только вместо ответов — вопросы.
# Промпт для генерации вопроса (упрощён)
prompt = f"""
Ты — эксперт по теме {topic}.
Используй следующий корпоративный материал:
{context}
Сгенерируй вопрос с четырьмя вариантами ответа, один верный.
Вопрос должен проверять понимание, а не память.
Ответь строго в формате JSON: {{'question': ..., 'options': [...], 'correct_index': 0}}
"""
Multi-tenant — изоляция данных без боли
У нас 50 отделов. У каждого своя база вопросов, свои курсы и своя статистика. Если сделать общую таблицу с tenant_id — рано или поздно кто-то из соседнего отдела увидит чужие вопросы (баг в одном JOIN — и данные утекли).
Мы выбрали изоляцию на уровне схемы PostgreSQL. Каждый tenant — отдельная схема (public_tenantN). Это даёт чёткие границы: запросы к questions касаются только своего отдела. Административные операции (создание регламентов) живут в центральной таблице tenants.
Важный нюанс: для миграций используем Alembic с динамическим target_metadata. Сначала выполняем миграцию для общей схемы, потом проходим циклом по всем tenant-ам.
Минусы — сложнее сделать кросс-тенантный поиск или агрегированную аналитику. Но в такой системе это и не нужно. Если потребуется — можно выгружать данные через Data Lake. См. статью «Как выбрать стратегию развёртывания LLM» — там описывается похожий компромисс между изоляцией и стоимостью.
Stateful runtime экзамена — почему без состояния не обойтись
Экзамен — это не CRUD. Пользователь начинает сессию, система по одному выдаёт вопросы, фиксирует время на каждый, сохраняет черновики ответов. Если пользователь случайно закрыл браузер — нужно восстановить ровно то же состояние, с тем же вопросом и оставшимся временем.
Stateful runtime мы реализуем через Redis с TTL и событийную модель. Каждый экзамен — это агрегат, который проходит через ряд состояний: Created, InProgress, Paused, Completed. Команды (StartExam, AnswerQuestion, FinishExam) обрабатываются сервисом ExamEngine. События публикуются в Kafka, а их синхронизация в PostgreSQL — через CQRS.
# Модель экзамена в SQLAlchemy (агрегат)
class Exam(Base):
__tablename__ = 'exams'
id = Column(UUID, primary_key=True)
tenant_id = Column(Integer, nullable=False)
user_id = Column(UUID, nullable=False)
status = Column(Enum(ExamStatus), default=ExamStatus.CREATED)
started_at = Column(DateTime(timezone=True), nullable=True)
expires_at = Column(DateTime(timezone=True), nullable=False)
current_question_index = Column(Integer, default=0)
total_questions = Column(Integer, nullable=False)
answers: list[Answer] = relationship('Answer', back_populates='exam', cascade='all, delete-orphan')
def start(self) -> None:
if self.status != ExamStatus.CREATED:
raise DomainException('Exam already started')
self.status = ExamStatus.IN_PROGRESS
self.started_at = datetime.now(timezone.utc)
Зачем так сложно? Затем, что параллельные запросы из UI (пользователь мог случайно два раза ткнуть «Начать») не должны создать два экземпляра. Статус блокирует дублирование.
Стек технологий — компромиссы и почему не всё идеально
| Компонент | Выбор | Причина |
|---|---|---|
| API | FastAPI | Асинхронность, интеграция с SQLAlchemy async |
| ORM | SQLAlchemy 2.0 async | Поддержка multi-tenant схем, хорошая типизация |
| База данных | PostgreSQL 17 + Citus | Multi-tenant на уровне схем, шардирование по tenant |
| LLM inference | vLLM + Anthropic Claude 4.5 (on-premise) | Низкая задержка, контроль данных, деплой через Локальный ИИ за бетонной стеной |
| Очереди | Kafka + Celery | События экзамена — Kafka, генерация вопросов (долгая) — Celery |
| State | Redis Cluster | TTL, быстрый доступ, поддержка pub/sub для уведомлений |
Тестирование генерации — отдельный цирк с LLM
Когда модель недетерминирована, обычные юнит-тесты не работают. Нельзя написать assert generated_question == expected. Вместо этого мы используем property-based testing и валидацию выходной структуры. Подробнее — в статье «Тестируем недетерминированные LLM: как написать тесты для вызова функций и не сойти с ума». Основные проверки:
- структура JSON валидна (pydantic модель);
- все варианты ответов уникальны и не пусты;
- правильный ответ действительно корректен с точки зрения формальной логики (если вопрос про код — выполнить код);
- нет слов-затычек («вариант А», «вероятно»).
Также мы настраиваем автоматическое ревью: каждую партию сгенерированных вопросов отправляем эксперту (человеку) на утверждение в UI. Если три вопроса подряд отклонены — модель считается нестабильной, и включается fallback на статический банк.
Ошибки, которые мы встретили на продакшене
❌ Генерация вопросов в одном потоке — при тестировании всё ок, но под нагрузкой 100 одновременных запросов к LLM таймауты пачками. Решение: batching — накапливаем задания на 10 секунд, отправляем батчем через vLLM с аргументом max_num_batched_tokens.
❌ Отсутствие rate-limiter на генерацию — один пользователь запустил скрипт, спамивший API, и мы упёрлись в квоту по токенам. С тех пор multi-tenant rate limiting на уровне gateway (per tenant, per user).
Не делайте так, если не хотите получить счёт на $10k за ночь экспериментов с LLM.
FAQ — ответы на то, что не влезло в статью
- Сколько стоит генерация одного вопроса? Если брать Anthropic Claude 4.5 on-premise с 4-bit квантизацией — около 0.03 цента за вопрос (с учётом амортизации железа). При облачном API — дешевле, но нет конфиденциальности.
- Как часто обновлять банк вопросов? Мы запускаем генерацию раз в неделю — по ночам, когда нагрузка на LLM-серверы минимальна. Новые вопросы проходят утверждение человеком до следующего цикла.
- Какая модель лучше для технических экзаменов? Мы перебирали несколько (Mixtral 8x22B, Llama 4, Claude). Победил Claude — меньше галлюцинаций по коду, лучше следует формату JSON. Но для каждого домена — своя модель: например, для юриспруденции придётся дообучать. Про оценку моделей читайте «Lexometrica Ground Truth: как оценить LLM в российском праве и избежать data leakage».
Главный совет, который я дал бы себе два года назад: не пытайтесь сделать всё идеально сразу. Запустите MVP с ручной загрузкой вопросов, потом добавьте генерацию, потом multi-tenant, потом stateful runtime. Иначе рискуете утонуть в сложности.
Если вас заинтересовала тема агентной архитектуры экзаменов — взгляните на «Агентное обучение с подкреплением для LLM: как LinkedIn заставляет модели думать шагами». Там показано, как сделать тесты ещё умнее, когда модель сама адаптирует сложность под отвечающего.