Архитектура корпоративной AI-экзаменационной платформы: LLM генерация вопросов, multi-tenant | AiManual
AiManual Logo Ai / Manual.
29 Июн 2026 Гайд

Корпоративная экзаменационная платформа с AI: архитектура, генерация вопросов через LLM, multi-tenant

Создаём корпоративную экзаменационную платформу с AI: архитектура на FastAPI и SQLAlchemy, генерация вопросов через LLM, stateful runtime, multi-tenant. Полный

Реклама
cliv2

Проблема: тесты, которые рождаются в муках

Каждый, кто хоть раз составлял корпоративный экзамен, знает эту боль. Ты садишься, открываешь Google Docs и пытаешься придумать 30 вопросов по микросервисам. Через час у тебя 12 вопросов, три из которых повторяют друг друга. Через неделю экзамен проводят снова, и половина сотрудников просто заучила ответы. Это не проверка знаний, это цирк.

А теперь представьте, что каждый экзамен генерируется автоматически, под конкретную должность, уровень и стек. Без галлюцинаций, с привязкой к вашим внутренним документам. И всё это работает для 50 разных отделов, у каждого свои правила, свои темы, своя изоляция данных. Это — задача, которую мы разберём по косточкам.

💡
Речь не про очередную кастомную админку на Django. Мы строим платформу, где AI — не игрушка, а рабочий engine. Multi-tenant, stateful runtime экзамена и генерация вопросов через LLM без снижения точности.

Архитектура — не 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 (пользователь мог случайно два раза ткнуть «Начать») не должны создать два экземпляра. Статус блокирует дублирование.

Стек технологий — компромиссы и почему не всё идеально

КомпонентВыборПричина
APIFastAPIАсинхронность, интеграция с SQLAlchemy async
ORMSQLAlchemy 2.0 asyncПоддержка multi-tenant схем, хорошая типизация
База данныхPostgreSQL 17 + CitusMulti-tenant на уровне схем, шардирование по tenant
LLM inferencevLLM + Anthropic Claude 4.5 (on-premise)Низкая задержка, контроль данных, деплой через Локальный ИИ за бетонной стеной
ОчередиKafka + CeleryСобытия экзамена — Kafka, генерация вопросов (долгая) — Celery
StateRedis ClusterTTL, быстрый доступ, поддержка 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 — ответы на то, что не влезло в статью

  1. Сколько стоит генерация одного вопроса? Если брать Anthropic Claude 4.5 on-premise с 4-bit квантизацией — около 0.03 цента за вопрос (с учётом амортизации железа). При облачном API — дешевле, но нет конфиденциальности.
  2. Как часто обновлять банк вопросов? Мы запускаем генерацию раз в неделю — по ночам, когда нагрузка на LLM-серверы минимальна. Новые вопросы проходят утверждение человеком до следующего цикла.
  3. Какая модель лучше для технических экзаменов? Мы перебирали несколько (Mixtral 8x22B, Llama 4, Claude). Победил Claude — меньше галлюцинаций по коду, лучше следует формату JSON. Но для каждого домена — своя модель: например, для юриспруденции придётся дообучать. Про оценку моделей читайте «Lexometrica Ground Truth: как оценить LLM в российском праве и избежать data leakage».

Главный совет, который я дал бы себе два года назад: не пытайтесь сделать всё идеально сразу. Запустите MVP с ручной загрузкой вопросов, потом добавьте генерацию, потом multi-tenant, потом stateful runtime. Иначе рискуете утонуть в сложности.

Если вас заинтересовала тема агентной архитектуры экзаменов — взгляните на «Агентное обучение с подкреплением для LLM: как LinkedIn заставляет модели думать шагами». Там показано, как сделать тесты ещё умнее, когда модель сама адаптирует сложность под отвечающего.

Подписаться на канал