GenOps Engineer: Подготовка к собеседованию в ДАДМ

Интенсивный курс для MLOps-специалистов, фокусирующийся на развертывании и оптимизации LLM-агентов, высоконагруженном инференсе и построении отказоустойчивых RAG-систем. Вы систематизируете знания по vLLM, Kubernetes и асинхронному Python для успешного прохождения технического интервью на позицию Senior-уровня.

Архитектура RAG и пайплайн обработки данных

Архитектура RAG и пайплайн обработки данных

Представьте, что вы внедрили самую мощную LLM в корпоративный портал. Пользователь спрашивает: «Как оформить отпуск по новым правилам от 15 октября?». Модель уверенно, красивым языком выдает инструкцию. Проблема в одном: она выдумала эту инструкцию, опираясь на общие знания из интернета трёхлетней давности.

Большие языковые модели (LLM) обладают прекрасными навыками синтеза речи, но у них есть два фундаментальных недостатка: знания заморожены на момент окончания обучения, и они склонны к галлюцинациям, когда не знают точного ответа.

Переобучать (Fine-tuning) модель каждый раз, когда в компании выходит новый приказ — невероятно дорого, долго и не гарантирует, что модель не перепутает факты. Индустриальным стандартом решения этой проблемы стала архитектура RAG.

RAG (Retrieval-Augmented Generation) — это архитектурный паттерн, который разделяет базу знаний и языковые навыки. Вместо того чтобы полагаться на внутреннюю память модели, система сначала ищет релевантную информацию во внешней базе данных, а затем передает её LLM для генерации ответа.

Для инженера GenOps понимание RAG — это абсолютный базис. На собеседованиях редко просят написать RAG с нуля, но всегда спрашивают, как его масштабировать, оптимизировать и почему пайплайн работает не так, как ожидалось.

Чтобы разобраться в этом, мы разделим RAG на две независимые фазы: Offline (подготовка данных) и Online (обслуживание запроса).

Offline-пайплайн: Подготовка базы знаний

LLM не умеет читать базу данных напрямую. Чтобы система могла быстро находить нужные абзацы среди миллионов документов, текст нужно превратить в математические векторы. Этот процесс происходит асинхронно, до того как пользователь задаст свой вопрос.

Пайплайн подготовки состоит из трех шагов: Ingestion, Chunking и Embedding.

1. Ingestion (Сбор и очистка)

Документы в реальном мире грязные: это PDF с колонтитулами, HTML с тегами, логи в JSON. На этом этапе мы извлекаем сырой текст. Если в базу попадет мусор (например, меню навигации сайта), он испортит качество поиска.

2. Chunking (Разбиение на фрагменты)

Мы не можем скормить LLM книгу целиком — у моделей есть лимит контекстного окна (Context Window). Кроме того, если искать по целым книгам, мы найдем книгу, но не конкретный ответ в ней.

Поэтому текст разбивают на чанки (chunks) — небольшие фрагменты.

При нарезке текста возникают два ключевых параметра:

  • chunk_size — размер фрагмента (например, 500 токенов).
  • chunk_overlap — перекрытие между соседними чанками (например, 50 токенов). Перекрытие нужно, чтобы не разорвать смысл, если конец одного предложения оказался в первом чанке, а его продолжение — во втором.

3. Embedding (Векторизация)

Каждый чанк текста пропускается через специальную модель (Embedding Model), которая превращает текст в массив чисел с плавающей точкой — вектор.

Суть эмбеддинга в том, что он кодирует семантический смысл текста. В многомерном пространстве векторы текстов с похожим смыслом будут находиться рядом, даже если в них нет общих слов.

Полученные векторы вместе с исходным текстом чанка и метаданными (автор, дата, ссылка на оригинал) сохраняются в Векторную базу данных (Vector DB). На этом Offline-фаза завершена.

Online-пайплайн: От запроса до ответа

Теперь система готова к работе с пользователями. Когда поступает запрос, запускается синхронный Online-пайплайн.

1. Векторизация запроса

Пользователь пишет: «Как оформить отпуск?». Этот текст передается в ту же самую Embedding-модель, которая использовалась в Offline-фазе. Запрос превращается в вектор.

2. Retrieval (Поиск)

Вектор запроса отправляется в Векторную БД. База вычисляет математическое расстояние между вектором запроса и всеми векторами чанков. Чаще всего для этого используется косинусное сходство (Cosine Similarity).

similarity(A,B)=cos(θ)=ABAB\text{similarity}(A, B) = \cos(\theta) = \frac{\mathbf{A} \cdot \mathbf{B}}{\|\mathbf{A}\| \|\mathbf{B}\|}

Где:

  • A\mathbf{A} — вектор запроса пользователя.
  • B\mathbf{B} — вектор чанка из базы данных.
  • θ\theta — угол между этими векторами в многомерном пространстве. Чем меньше угол, тем ближе смысл текстов, и тем ближе значение косинуса к единице.

База возвращает топ-K (например, 5) самых похожих чанков. Это и есть наш найденный контекст.

3. Prompting (Сборка промпта)

Мы берем найденные тексты и вставляем их в заранее заготовленный шаблон (Prompt Template).

Ты — корпоративный ассистент. Ответь на вопрос пользователя,
используя ТОЛЬКО предоставленный контекст. Если ответа в контексте нет,
скажи "Я не знаю".

КОНТЕКСТ:
[Сюда вставляются найденные чанки из базы]

ВОПРОС ПОЛЬЗОВАТЕЛЯ:
Как оформить отпуск?

4. Generation (Генерация ответа)

Собранный промпт отправляется в LLM. Модель читает правила оформления отпуска из предоставленного контекста и формулирует красивый, связный ответ. Так как она опирается на факты, которые мы ей дали, риск галлюцинаций снижается до минимума.

Главный вызов для GenOps

На собеседовании в ДАДМ от вас будут ждать понимания, что RAG — это не просто «вызвать API OpenAI». Это сложная распределенная система.

Вам предстоит управлять жизненным циклом данных: что делать, если документ удалили из базы? Как обновить эмбеддинги, если мы решили сменить модель векторизации? Как обеспечить отказоустойчивость векторной базы и как оптимизировать задержку (latency) при обращении к LLM?

Архитектура RAG — это фундамент. В следующих статьях мы углубимся в то, как именно работают векторные хранилища и как оптимизировать инференс моделей, чтобы пайплайн выдерживал продакшен-нагрузки.

Векторные базы данных и стратегии гибридного поиска

Векторные базы данных и стратегии гибридного поиска

Представим корпоративную базу знаний на 10 миллионов документов. Вычисление косинусного сходства между вектором запроса пользователя и каждым из 10 миллионов векторов базы — это операция с линейной сложностью O(N)O(N). На CPU это займет секунды, а при десятках одновременных запросов система просто рухнет. В продакшене GenOps счет идет на миллисекунды. Нам нужен способ находить ближайшие векторы, не сравнивая запрос с каждым из них.

Здесь на сцену выходят векторные базы данных и алгоритмы приближенного поиска.

Индексация в многомерном пространстве: HNSW

Классические реляционные базы данных используют B-деревья для быстрого поиска. Но B-дерево перестает эффективно работать, когда размерность данных превышает 10-20 измерений. Эмбеддинги современных моделей имеют размерность 768, 1536 или даже больше.

Для решения этой проблемы векторные БД используют алгоритмы ANN (Approximate Nearest Neighbor — приближенный поиск ближайшего соседа). Мы осознанно жертвуем долями процента точности ради ускорения поиска в сотни раз.

Индустриальным стандартом среди алгоритмов ANN стал HNSW (Hierarchical Navigable Small World). Он реализован практически везде: от расширения pgvector для PostgreSQL до специализированных решений вроде Qdrant, Milvus и Pinecone.

Представьте себе систему междугородних трасс. Чтобы добраться из маленького городка А в городок Б на другом конце страны, вы не едете местными дорогами. Вы выезжаете на локальное шоссе, затем на скоростную магистраль, быстро пересекаете страну, съезжаете на шоссе и, наконец, на местную улицу.

HNSW работает точно так же, строя многослойный граф:

  1. Верхние слои содержат мало узлов (векторов) с длинными связями. Они позволяют алгоритму мгновенно «перепрыгнуть» в нужную область векторного пространства.
  2. Нижние слои содержат все узлы сети с короткими связями для точного поиска на месте.

Использование графа HNSW снижает вычислительную сложность поиска от линейной O(N)O(N) до логарифмической O(logN)O(\log N), делая возможным RAG в реальном времени даже на миллиардах чанков.

Слепое пятно семантического поиска

Векторный поиск (Dense Retrieval) великолепен в понимании контекста. Если пользователь ищет «проблемы с питанием сервера», система найдет документ со словами «сбой блока напряжения в дата-центре».

Но у эмбеддингов есть критическая уязвимость — они плохо справляются с точными совпадениями.

Если инженер поддержки ищет конкретный код ошибки ERR-749-X или артикул детали A-8900, семантический поиск может выдать документы с кодами ERR-749-Y или A-8901. Для нейросети эти векторы лежат очень близко (оба описывают «код ошибки оборудования»), но для бизнеса это совершенно бесполезный ответ.

Чтобы закрыть эту уязвимость, применяется BM25 (Best Matching 25) — классический алгоритм лексического поиска (Sparse Retrieval). Он работает на основе частотности слов: чем реже слово встречается во всей базе (например, специфический артикул) и чем чаще оно встречается в конкретном документе, тем выше релевантность этого документа.

Гибридный поиск: объединение Dense и Sparse

Современный продакшен-RAG практически всегда использует гибридный поиск. Мы параллельно отправляем запрос в два движка и получаем два списка кандидатов.

Характеристика Векторный поиск (Dense) Лексический поиск (Sparse / BM25)
Механизм Эмбеддинги и косинусное сходство Инвертированный индекс и частотность токенов
Сильная сторона Понимание синонимов, контекста и сложных формулировок Точный поиск по ID, именам, аббревиатурам и артикулам
Слабая сторона Игнорирует специфические термины, "размывает" точные запросы Не понимает опечаток, синонимов и перефразирования

Проблема слияния: Reciprocal Rank Fusion (RRF)

Получив два списка результатов (топ-10 от BM25 и топ-10 от векторного поиска), мы сталкиваемся с проблемой их объединения.

Мы не можем просто сложить их оценки (score). Косинусное сходство обычно выдает значения от 00 до 11. А алгоритм BM25 не имеет верхней границы, его оценка может быть 15.415.4 или 120.5120.5. Сложение таких метрик приведет к тому, что BM25 полностью подавит векторный поиск.

Решение — алгоритм RRF (Reciprocal Rank Fusion). Он полностью игнорирует абсолютные баллы (scores) и смотрит только на позицию документа в выдаче (rank).

Формула расчета веса документа в RRF:

RRF_Score=1k+Rankdense+1k+RanksparseRRF\_Score = \frac{1}{k + Rank_{dense}} + \frac{1}{k + Rank_{sparse}}

Где kk — константа сглаживания (обычно в индустрии используют k=60k = 60), а RankRank — место документа в соответствующей выдаче.

Практический пример: Документ Doc_A занял 1-е место в лексическом поиске (точно совпал артикул) и 4-е место в векторном поиске. Его итоговый балл: RRF_Score=160+1+160+4=161+1640.0163+0.0156=0.0319RRF\_Score = \frac{1}{60 + 1} + \frac{1}{60 + 4} = \frac{1}{61} + \frac{1}{64} \approx 0.0163 + 0.0156 = 0.0319

Документ, который стабильно попадает в топ обеих систем, получит наивысший балл RRF и будет передан в финальный промпт для LLM.

Выбор инфраструктуры для GenOps

Для реализации гибридного поиска архитектура базы данных имеет решающее значение. В вакансиях уровня ДАДМ стек часто подразумевает выбор между двумя подходами:

  1. PostgreSQL + pgvector. Отличный выбор, если у вас уже есть мощный кластер Postgres. С версии 0.5.0 pgvector поддерживает индексы HNSW. Лексический поиск реализуется встроенными средствами полнотекстового поиска Postgres, а гибридизацию и RRF можно написать на уровне FastAPI-бэкенда.
  2. Специализированные векторные БД (Qdrant, Milvus). Если объем эмбеддингов превышает десятки миллионов, а нагрузка на инференс растет, специализированные решения работают быстрее и предлагают гибридный поиск "из коробки", самостоятельно выполняя BM25 и RRF под капотом.

Векторные БД решают проблему поиска по неструктурированному тексту. Но когда агенту нужно понимать сложные связи (например, «какие микросервисы зависят от базы данных, которая упала в дата-центре А?»), плоского списка документов недостаточно. Для таких задач векторный поиск дополняют графовыми базами данных (GraphRAG) — концепция, которая особенно ценится при построении продвинутых мультиагентных систем.

Мультиагентные системы и управление состоянием агентов

Мультиагентные системы и управление состоянием агентов

Представьте, что пользователь отправляет в корпоративный чат запрос: «Проанализируй нагрузку на платежный кластер за последний час, сравни с логами за вчерашний день, и если есть аномалия — перезапусти под». Классический RAG, который мы спроектировали ранее, здесь бессилен. Он найдет документацию по перезапуску подов, но не сможет выполнить саму последовательность действий. Для решения задач, требующих планирования, использования внешних инструментов и принятия решений на лету, нужны AI-агенты.

В этой статье мы разберем, как эволюционирует архитектура от одиночных запросов к мультиагентным системам, как управлять их памятью (состоянием) и зачем в этом процессе нужны графовые базы данных.

От LLM к Агенту: паттерн ReAct

Языковая модель сама по себе — это просто функция предсказания следующего токена. Чтобы модель стала агентом, ей нужен цикл рассуждения и доступ к инструментам (API, базы данных, интерпретатор кода).

Самый популярный фреймворк для реализации агентного поведения — ReAct (Reason + Act). Агент работает в бесконечном цикле, пока не достигнет цели:

  1. Thought (Мысль): LLM анализирует текущую задачу и решает, что делать дальше.
  2. Action (Действие): LLM выбирает инструмент и формирует параметры для его вызова.
  3. Observation (Наблюдение): Система выполняет вызов инструмента (например, SQL-запрос) и возвращает результат обратно в LLM.

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

Зачем нужны мультиагентные системы (MAS)?

Если один агент может использовать инструменты, зачем создавать систему из нескольких? Проблема кроется в ограничениях внимания (Context Window) и «размытии» промпта.

Если в системный промпт одного агента заложить инструкции для написания кода, ревью, тестирования, деплоя и работы с базой данных, модель начнет путаться в ролях и галлюцинировать. Разделение труда решает эту проблему.

В мультиагентных системах каждый агент имеет узкую специализацию, свой набор инструментов и свой системный промпт.

Характеристика Одиночный универсальный агент Мультиагентная система
Системный промпт Огромный, перегруженный правилами Короткий, сфокусированный на одной роли
Инструменты Доступ ко всем (риск ошибки выбора) Только специфичные для роли
Отладка Сложно понять, на каком этапе сбой Легко изолировать проблемного агента

Самый надежный паттерн взаимодействия в MAS — Supervisor (Супервизор). В этой топологии есть один главный агент-маршрутизатор, который принимает задачу от пользователя, разбивает её на подзадачи и делегирует специализированным агентам (например, CoderAgent, ReviewerAgent, DBAgent). Агенты-исполнители не общаются друг с другом напрямую, а возвращают результаты Супервизору.

Управление состоянием (State Management)

Взаимодействие агентов порождает новую инженерную проблему — управление состоянием. LLM по своей природе stateless (не сохраняет состояние). Чтобы агент помнил, что он делал шаг назад, мы должны каждый раз передавать ему всю историю.

В мультиагентных фреймворках (например, LangGraph) процесс выполнения представляется как направленный граф, где узлы — это агенты или инструменты, а ребра — логика перехода. По графу циркулирует объект State (Состояние).

Пример структуры состояния в Python:

from typing import TypedDict, Annotated
import operator

class AgentState(TypedDict):
    messages: Annotated[list, operator.add] # История переписки
    current_task: str                       # Текущая подзадача
    db_results: dict                        # Данные из базы
    errors: list[str]                       # Накопленные ошибки

Проблема надежности и Checkpointing

Для GenOps-инженера критически важно понимать математику отказов. Допустим, агент должен выполнить 5 последовательных шагов. Вероятность успешного выполнения каждого шага (без таймаутов API, ошибок парсинга JSON или галлюцинаций) равна pp.

Вероятность успеха всей цепочки вычисляется по формуле: P=pnP = p^n Где:

  • PP — общая вероятность успешного завершения задачи.
  • pp — вероятность успеха одного шага.
  • nn — количество шагов.

Если p=0.9p = 0.9 (90% успеха на шаг), а шагов n=5n = 5, то общая вероятность успеха P=0.950.59P = 0.9^5 \approx 0.59. То есть 41% задач завершатся сбоем.

Именно поэтому в продакшене внедряют Checkpointing (сохранение контрольных точек). После каждого узла графа объект AgentState сериализуется и сохраняется в надежное хранилище (PostgreSQL или Redis). Если на 4-м шаге ReviewerAgent упадет из-за таймаута LLM, система не начнет работу с нуля. Оркестратор поднимет состояние из базы и повторит только 4-й шаг.

Графовые базы данных и GraphRAG

В прошлой главе мы разобрали векторный поиск, который отлично находит семантически похожие тексты. Но агентам часто нужно понимать структуру и связи.

Представьте задачу для DevOps-агента: «База данных UsersDB недоступна. Какие микросервисы пострадают?». Векторный поиск найдет документы, где часто встречаются слова «UsersDB» и «микросервисы», но не даст точной топологии. Здесь на сцену выходят графовые базы данных (например, Neo4j) и концепция GraphRAG.

В графовой БД данные хранятся в виде узлов (сущностей) и ребер (отношений): (Microservice: Auth) -[READS_FROM]-> (Database: UsersDB)

Как работает пайплайн GraphRAG:

  1. Извлечение: LLM читает сырую документацию и извлекает из нее тройки: Субъект -> Предикат -> Объект.
  2. Построение: Эти тройки загружаются в графовую базу данных.
  3. Поиск: Когда агент получает запрос, он использует язык запросов (например, Cypher для Neo4j), чтобы обойти граф.

Векторный поиск отвечает на вопрос «Что похоже на X?». Графовый поиск отвечает на вопрос «Как X связано с Y?».

Использование графовых БД дает агентам детерминированный контекст. Агент не просто читает текст о микросервисах, он получает точный JSON-ответ с деревом зависимостей, что радикально снижает риск галлюцинаций при принятии решений.

Сборка воедино: GenOps-фабрика

Переход от исследовательских прототипов к отказоустойчивой системе требует объединения всех этих концепций. Мультиагентная система в продакшене — это не просто скрипт на Python. Это асинхронный сервис, где:

  1. Запросы маршрутизируются Супервизором.
  2. Контекст обогащается гибридным поиском (Vector + GraphRAG).
  3. Состояние каждого шага персистентно сохраняется в PostgreSQL.
  4. В случае сбоя пайплайн возобновляется с последней контрольной точки.

Построение логики агентов — это половина дела. Вторая половина — это обернуть эту логику в надежные API и заставить работать под нагрузкой, что мы и начнем разбирать далее, интегрируя эти концепции с современными фреймворками.

Интеграция LLM-фреймворков: FastAPI, LangChain и LlamaIndex

Интеграция LLM-фреймворков: FastAPI, LangChain и LlamaIndex

В Jupyter Notebook ваш мультиагентный прототип работает безупречно: он извлекает данные, рассуждает и выдает точный ответ. Но когда вы оборачиваете этот код в веб-сервис и отправляете ссылку тестировщикам, при первых же десяти одновременных запросах сервис «зависает» на минуту, а затем падает с ошибкой таймаута. Почему блестящий AI-алгоритм превращается в неработоспособный продукт?

Проблема кроется в переходе от скрипта к сервису. В этой статье мы разберем, как превратить оркестраторы LLM-логики в отказоустойчивый production-ready API, избежав главных архитектурных ловушек.

Анатомия GenOps-сервиса: разделение ответственности

Исторически сложилось ложное противопоставление: LangChain против LlamaIndex. В реальности зрелой GenOps-фабрики цифровых агентов это не конкуренты, а комплементарные слои единой архитектуры. Чтобы система масштабировалась, каждый фреймворк должен решать строго свою задачу.

Слой архитектуры Инструмент Зона ответственности
Data Framework LlamaIndex Подключение к источникам, чанкинг, создание индексов, интеграция с векторными и графовыми БД. Это слой потока данных.
Orchestration Framework LangChain (и LangGraph) Реализация паттернов ReAct и Supervisor, управление графом состояний (Checkpointing), маршрутизация задач. Это слой потока управления.
Delivery Framework FastAPI Прием HTTP-запросов, валидация Pydantic-схем, асинхронный ввод-вывод, потоковая передача ответов. Это слой доставки.

Вместо того чтобы заставлять LangChain парсить PDF-файлы (с чем он справляется хуже), а LlamaIndex — управлять сложной иерархией агентов, мы интегрируем их через FastAPI. LlamaIndex предоставляет агенту LangChain инструменты (Tools) для поиска (Dense и Sparse Retrieval), а FastAPI выставляет эту связку наружу.

Ловушка Event Loop'а: синхронное в асинхронном

FastAPI построен на базе асинхронного программирования. Его ядро — цикл событий (Event Loop), который позволяет одному процессу обрабатывать тысячи соединений конкурентно, переключаясь между ними во время ожидания (I/O операций).

Генерация ответа LLM — это классическая I/O-операция. Ваш сервер отправляет промпт по сети к инференс-серверу (или внешнему API) и ждет ответа.

Главная ошибка при интеграции LangChain в FastAPI — использование синхронных методов внутри асинхронных эндпоинтов.

# ОПАСНЫЙ АНТИПАТТЕРН
@app.post("/chat")
async def chat_endpoint(query: str):
    # agent.invoke — синхронный вызов!
    response = agent.invoke({"input": query})
    return {"answer": response}

Когда вы вызываете синхронный метод invoke (или run) внутри функции async def, цикл событий блокируется. FastAPI не может переключиться на обработку запроса от второго пользователя, пока LLM не закончит генерировать ответ для первого. Если генерация занимает 10 секунд, пропускная способность вашего сервиса падает до 0.1 запроса в секунду на один воркер.

Правильная интеграция требует использования асинхронных двойников:

# ПРАВИЛЬНЫЙ ПОДХОД
@app.post("/chat")
async def chat_endpoint(query: str):
    # ainvoke освобождает Event Loop на время ожидания LLM
    response = await agent.ainvoke({"input": query})
    return {"answer": response}

Асинхронность не ускоряет выполнение одного запроса к LLM. Она позволяет вашему API не «умирать», пока десятки пользователей одновременно ждут ответов от моделей.

Документация FastAPI по Concurrency

Если вы используете кастомные инструменты (Tools) для агентов, которые обращаются к старым синхронным базам данных, их необходимо оборачивать в run_in_threadpool, чтобы вынести блокирующий код в отдельный пул потоков и спасти Event Loop.

Потоковая передача (Streaming) и метрика TTFT

В классическом REST API клиент отправляет запрос и ждет полного формирования JSON-ответа. Для LLM-агентов этот подход неприемлем. Если агент выполняет цикл ReAct (ищет в базе, анализирует, делает второй запрос, формирует финальный ответ), ожидание может занять 15–20 секунд.

С точки зрения UX (пользовательского опыта) и мониторинга, важнейшей метрикой становится TTFT (Time to First Token) — время от отправки запроса до появления на экране первого сгенерированного слова.

Чтобы минимизировать TTFT, FastAPI интегрируется с асинхронными генераторами LangChain/LlamaIndex через механизм Server-Sent Events (SSE). Это технология, позволяющая серверу пушить данные клиенту по одному открытому HTTP-соединению.

Вместо ожидания всего ответа, мы используем метод astream_events (в LangGraph) и возвращаем StreamingResponse из FastAPI:

from fastapi.responses import StreamingResponse

async def generate_token_stream(query: str):
    # Асинхронный генератор отдает токены по мере их появления
    async for event in agent.astream_events({"input": query}, version="v1"):
        if event["event"] == "on_chat_model_stream":
            yield f"data: {event['data']['chunk'].content}\n\n"

@app.post("/chat/stream")
async def stream_chat(query: str):
    return StreamingResponse(
        generate_token_stream(query),
        media_type="text/event-stream"
    )

При таком подходе клиент видит, как текст печатается в реальном времени. Более того, через SSE можно транслировать не только финальные токены, но и промежуточные состояния агента: «Ищу в базе данных...», «Найдено 3 документа...», что делает процесс прозрачным для пользователя.

Проброс контекста и управление состоянием

В прошлой главе мы выяснили, что агентам необходимо сохранять состояние (Checkpointing) для реализации многошаговой логики. HTTP-протокол сам по себе является stateless (не хранит состояние).

При интеграции с FastAPI состояние агента привязывается к конкретному пользователю или сессии через метаданные запроса. Обычно клиент передает session_id или thread_id в заголовках (Headers) или теле запроса.

FastAPI извлекает этот идентификатор и передает его в конфигурацию LangGraph:

@app.post("/chat")
async def chat_endpoint(query: str, thread_id: str = Header(...)):
    config = {"configurable": {"thread_id": thread_id}}

    # LangGraph автоматически поднимет историю графа для этого thread_id
    # из базы данных (например, PostgreSQL), выполнит шаг и сохранит новое состояние
    response = await agent.ainvoke({"input": query}, config=config)
    return response

Таким образом, FastAPI выступает тонким, но критически важным связующим звеном. Он берет на себя валидацию thread_id через Pydantic, защищает приложение от блокировок с помощью asyncio и обеспечивает потоковую доставку токенов через SSE, оставляя сложную логику рассуждений внутри LangChain.

Мы построили надежный и неблокирующий API. Однако, как только нагрузка возрастет, узким местом перестанет быть веб-фреймворк. Бутылочным горлышком станет сам инференс LLM: видеокарты начнут задыхаться от нехватки памяти (VRAM), а скорость генерации токенов резко упадет. Чтобы наша GenOps-фабрика выдержала продакшен-нагрузку, нам предстоит спуститься на уровень железа и оптимизации самих моделей.

Оптимизация инференса: квантизация и дистилляция

Оптимизация инференса: квантизация и дистилляция

Стриминг токенов через SSE настроен, thread_id успешно связывает запросы с агентами, но при попытке развернуть модель Llama 3 70B на сервере с одной топовой видеокартой NVIDIA A100 (80 ГБ) процесс мгновенно падает с ошибкой CUDA Out of Memory. Проблема в фундаментальной физике LLM: нейросети слишком велики.

Каждый параметр модели по умолчанию хранится в формате половинной точности FP16 (Float16), занимая 2 байта. Для модели на 70 миллиардов параметров требуется:

70×109×2 байта140 ГБ70 \times 10^9 \times 2 \text{ байта} \approx 140 \text{ ГБ}

Одной карты на 80 ГБ физически не хватит даже для загрузки весов в память (VRAM), не говоря уже о выделении места под контекст пользователя. Чтобы превратить исследовательский прототип в отказоустойчивую GenOps-фабрику, инженеру необходимо сжать модель без критической потери её интеллектуальных способностей.

Упираясь в память: Memory-bound инференс

Генерация текста в LLM (авторегрессионный декодинг) — это процесс, при котором модель предсказывает следующий токен, опираясь на все предыдущие. Чтобы сгенерировать один токен, графический процессор должен прочитать все веса модели из VRAM в вычислительные ядра (ALU).

В LLM-инференсе узким местом являются не вычислительные мощности GPU, а пропускная способность памяти. Это называется Memory-bound процессом.

Чем меньше весят параметры, тем быстрее они перемещаются по шине памяти, и тем выше скорость генерации (токены в секунду). Главный инструмент для достижения этой цели — квантизация.

Квантизация: математика сжатия весов

Квантизация (Quantization) — это процесс снижения точности представления чисел. Мы берем веса из формата FP16 (16 бит / 2 байта) и «упаковываем» их в целочисленные форматы: INT8 (1 байт) или INT4 (0.5 байта). Переход в INT4 позволяет сжать 140 ГБ весов Llama 3 70B до ~35 ГБ, что легко помещается на одну A100.

Как дробные числа превращаются в целые? Используется линейная проекция с двумя параметрами: масштаб (Scale, SS) и сдвиг нуля (Zero-point, ZZ).

Формула квантизации выглядит так:

WINT=round(WFPS)+ZW_{INT} = \mathbf{round}\left(\frac{W_{FP}}{S}\right) + Z

Где:

  • WFPW_{FP} — исходный вес в формате с плавающей точкой (например, 1.251.25).
  • SS — фактор масштабирования, определяющий шаг сетки.
  • ZZ — целочисленное значение, куда проецируется реальный ноль (чтобы нули в матрицах не давали погрешность при умножении).
  • WINTW_{INT} — итоговый квантованный вес (например, в диапазоне от 0 до 255 для INT8).

Пример: если в слое нейросети веса лежат в диапазоне от 2.0-2.0 до 2.02.0, мы разбиваем этот отрезок на 255 шагов. Значение 2.0-2.0 станет 00, 2.02.0 станет 255255, а 0.00.0 превратится в 127127.

Проблема в том, что при округлении (round) теряется часть информации. Эта потеря накапливается от слоя к слою, что приводит к деградации качества ответов модели (росту Perplexity).

Алгоритмы PTQ: как квантовать без потери смысла

В GenOps чаще всего используется Post-Training Quantization (PTQ) — квантизация уже обученной модели без необходимости её дообучать.

Современные LLM имеют особенность: среди миллиардов параметров есть небольшая доля (около 1%) так называемых «выбросов» (outliers) — весов с аномально большими значениями, которые критически важны для правильной работы модели. Если их грубо округлить, модель начнет галлюцинировать.

Для решения этой проблемы применяются умные алгоритмы:

Алгоритм Принцип работы Сценарий использования
GPTQ Прогоняет через модель небольшой калибровочный датасет. Анализирует, как ошибка округления каждого веса влияет на финальный результат, и компенсирует эту ошибку, корректируя соседние веса. Стандартный серверный инференс (vLLM, Triton). Хороший баланс скорости и качества для INT4.
AWQ Activation-aware Weight Quantization. Анализирует активации (данные, проходящие через сеть) и находит тот самый 1% критически важных весов. Они остаются в FP16, а остальные 99% жестко квантуются в INT4. Оптимально для LLM. Меньше деградация качества, чем у GPTQ, быстрее работает на архитектуре Ampere/Hopper.
GGUF Формат от сообщества llama.cpp. Поддерживает смешанную квантизацию (например, Q4_K_M), где важные слои (внимание) квантуются слабее (6 бит), а менее важные — сильнее (4 бита). Запуск агентов на CPU или гибридных системах (MacBook, edge-устройства).

В промышленном продакшене чаще всего применяется подход W4A16: веса (Weights) хранятся в INT4 для экономии VRAM, но в момент вычислений активации (Activations — промпт пользователя) остаются в FP16. Это спасает модель от потери контекста.

Дистилляция знаний: когда сжимать больше некуда

Квантизация имеет предел — сжатие ниже 3-4 бит разрушает логику модели. Если целевая инфраструктура жестко ограничена (например, выделено всего 16 ГБ VRAM на весь под в Kubernetes), на помощь приходит дистилляция знаний (Knowledge Distillation).

Дистилляция — это процесс переноса знаний от огромной модели-учителя (Teacher) к компактной модели-ученику (Student).

В классическом обучении нейросеть учится на «жестких метках» (Hard labels): правильный ответ — 11, все остальные — 00. При дистилляции Student учится на «мягких метках» (Soft labels) — распределении вероятностей, которое выдает Teacher.

Например, на задачу «Столица Франции» Teacher (70B) выдает вероятности:

  • «Париж» — 0.85
  • «Лион» — 0.05
  • «Марсель» — 0.02

Обучаясь на этих вероятностях через минимизацию дивергенции Кульбака-Лейблера (штраф за расхождение двух распределений), Student (8B) перенимает не просто сухие факты, но и внутреннюю логику рассуждений учителя, его понимание семантической близости слов.

Синтез подходов

В реальном пайплайне GenOps эти методы работают в связке. Сначала исследователи дистиллируют логику гигантской Llama 3 70B в компактную архитектуру 8B. Затем MLOps-инженер применяет AWQ-квантизацию, сжимая веса этой 8B модели из FP16 в INT4.

В результате модель, обладающая паттернами рассуждения гиганта, занимает всего ~5 ГБ VRAM.

Мы решили проблему хранения весов. Модель успешно загрузилась на GPU. Но как только к нашему FastAPI-сервису подключаются сотни пользователей одновременно, VRAM снова начинает стремительно утекать, и сервер падает. Причина кроется в динамической памяти, которая растет с каждым сгенерированным токеном — KV Cache. Управление этой структурой требует совершенно иных архитектурных подходов.

Эффективное управление памятью: PagedAttention и vLLM

Эффективное управление памятью: PagedAttention и vLLM

Вы успешно квантовали веса модели до парадигмы W4A16 и уместили огромную LLM в 40 ГБ видеопамяти. На GPU с 80 ГБ VRAM осталась еще половина свободного места для обработки пользовательских запросов. Вы запускаете стресс-тест на 50 одновременных сессий и моментально получаете ошибку CUDA Out Of Memory. Почему это происходит, если сами веса занимают так мало места?

Дело в том, что статические веса — это лишь вершина айсберга. В условиях реального продакшена главным потребителем памяти становится динамическое состояние модели.

Скрытый гигант: KV Cache

Архитектура трансформеров генерирует текст авторегрессионно: каждый новый токен предсказывается на основе всех предыдущих. Чтобы не вычислять заново математические представления (матрицы Key и Value) для уже сгенерированных слов на каждом шаге, движок инференса сохраняет их в оперативной памяти GPU.

KV Cache (Key-Value Cache) — это механизм кэширования промежуточных тензоров внимания для прошлых токенов, который превращает квадратичную сложность генерации O(N2)O(N^2) в линейную O(N)O(N).

За скорость приходится платить видеопамятью. Объем KV Cache для одного токена вычисляется просто: 2 (Key и Value) × Количество слоев × Скрытая размерность (Hidden size) × Размер типа данных в байтах.

Рассмотрим это на примере модели Llama 2 13B, работающей в точности FP16 (2 байта на параметр). Модель имеет 40 слоев и скрытую размерность 5120: 2×40×5120×2=8192002 \times 40 \times 5120 \times 2 = 819\,200 байт.

Один токен требует около 0.80.8 МБ памяти. Если контекст диалога с агентом достигает 2048 токенов, один пользовательский запрос резервирует под свой KV Cache около 1.61.6 ГБ VRAM. Пятьдесят таких пользователей потребуют 80 ГБ — и ваша видеокарта переполнена, хотя генерация едва началась.

Ловушка фрагментации

В реальности Out Of Memory наступает еще раньше, чем предсказывает математика. Проблема кроется в наивном подходе к выделению памяти, который использовался в ранних фреймворках (например, в базовом Hugging Face transformers).

Длина ответа LLM неизвестна заранее: модель может ответить коротким «Да» (1 токен), а может написать эссе на 1000 слов. Поскольку тензоры в GPU традиционно требуют непрерывных (contiguous) участков памяти, система вынуждена резервировать память по максимальной возможной длине для каждого запроса сразу при его поступлении.

Если вы выделили память под 2048 токенов, а модель сгенерировала ответ за 10 шагов и остановилась, 2038 зарезервированных слотов остаются пустыми. Это классическая внутренняя фрагментация. Из-за нее в традиционных системах инференса впустую простаивает от 60% до 80% драгоценной VRAM.

PagedAttention: Уроки операционных систем

Решение этой проблемы пришло из классической информатики. Операционные системы давно решили проблему фрагментации RAM с помощью виртуальной памяти и постраничного разделения (paging). Исследователи из UC Berkeley применили этот же принцип к нейросетям, создав алгоритм PagedAttention.

Вместо того чтобы выделять один гигантский непрерывный кусок памяти под весь запрос, PagedAttention разбивает KV Cache на блоки фиксированного размера (например, по 16 токенов).

Как это работает на практике:

  1. Физические блоки в VRAM не обязаны лежать подряд.
  2. Движок поддерживает таблицу блоков (Block Table), которая маппит логические позиции токенов запроса на физические адреса в памяти.
  3. Когда текущий блок заполняется сгенерированными токенами, система динамически выделяет следующий свободный блок в любом месте VRAM.

Благодаря такому подходу внутренняя фрагментация снижается до размера одного неполного блока (менее 4%), а внешняя фрагментация исчезает полностью. Мы больше не резервируем память «про запас».

Continuous Batching: Фабрика токенов

Эффективное управление памятью открывает дверь для радикальной оптимизации пропускной способности (Throughput).

В классическом батчинге (Static Batching) запросы объединяются в группу, и сервер ждет, пока самый длинный запрос в батче не завершит генерацию. Если три запроса закончились за 5 токенов, а четвертый генерирует 500, вычислительные мощности GPU для первых трех простаивают.

PagedAttention делает возможным Continuous Batching (непрерывный батчинг). Поскольку память теперь выделяется динамически поблочно, серверу не нужно ждать окончания всего батча.

  • Как только один запрос генерирует токен [EOS] (End of Sequence), его физические блоки KV Cache мгновенно освобождаются.
  • На освободившееся место в батч на следующей же итерации (на уровне отдельного токена, а не всего запроса) вставляется новый запрос из очереди.
Характеристика Static Batching Continuous Batching (vLLM)
Единица планирования Запрос целиком Отдельный токен (итерация)
Выделение памяти Заранее, по максимальной длине Динамически, по мере генерации
Простой GPU Высокий (ожидание длинных ответов) Минимальный (постоянная ротация)
Пропускная способность Низкая В 2–4 раза выше

vLLM как стандарт GenOps

Алгоритм PagedAttention и механизм Continuous Batching лежат в основе vLLM — одного из самых популярных open-source движков для высоконагруженного инференса.

Для GenOps-инженера переход от базового пайплайна к vLLM означает смену парадигмы. Вы перестаете бороться с непредсказуемыми OOM-ошибками через жесткие лимиты длины и начинаете утилизировать GPU как конвейерную ленту, где каждый такт процессора конвертируется в полезные токены для пользователей.

Интеграция vLLM решает проблему памяти и пропускной способности, но в масштабных мультимодельных фабриках цифровых агентов требуется еще более сложная оркестрация маршрутизации запросов на уровне кластера.

Высокопроизводительный инференс с Triton Inference Server and SGLang

Высокопроизводительный инференс с Triton Inference Server и SGLang

Представьте, что ваша мультиагентная система из третьей главы успешно прошла стадию прототипа и готова к релизу. Но в продакшене вас ждет суровая реальность: пайплайн использует модель эмбеддингов для RAG, легковесную Llama 3 8B для маршрутизации задач, массивную 70B модель для сложной логики и отдельную модель для транскрибации аудио. Если вы развернете каждую из них в отдельном FastAPI-контейнере, вы мгновенно исчерпаете VRAM, столкнетесь с сетевыми задержками и не сможете утилизировать GPU даже на 50%. Как оркестрировать этот «зоопарк» моделей и заставить агентов работать молниеносно?

В прошлой главе мы решили проблему памяти для одной LLM с помощью vLLM и PagedAttention. Теперь мы поднимемся на уровень кластера и архитектуры агентов, чтобы построить настоящую GenOps-фабрику.

Triton Inference Server: Единый фасад для всех моделей

Разворачивать каждую модель как независимый микросервис — антипаттерн в MLOps. GPU — дорогой ресурс, и жесткое разделение памяти между десятком FastAPI-приложений приводит к тому, что одна модель простаивает, пока другая задыхается от нагрузки.

Triton Inference Server от NVIDIA решает эту проблему, выступая единым оркестратором. Это высокопроизводительный сервер, который может одновременно держать в памяти GPU разные типы моделей (PyTorch, ONNX, TensorRT) и предоставлять к ним доступ через единый gRPC/HTTP интерфейс.

Главное преимущество Triton для классических (не-LLM) моделей — это Dynamic Batching (динамическое пакетирование). В отличие от Continuous Batching из vLLM, который работает на уровне генерации отдельных токенов, Dynamic Batching работает на уровне целых запросов.

Когда десятки пользователей одновременно отправляют тексты для векторизации (эмбеддинги), Triton не отправляет их на GPU по одному. Он задерживает выполнение первых запросов на несколько миллисекунд, собирает их в плотный батч (пакет) и отправляет на видеокарту разом. Это колоссально повышает пропускную способность.

Чтобы понять математику очередей в Triton, используется классическая формула из теории массового обслуживания — закон Литтла:

L=λ×WL = \lambda \times W

  • LL — среднее количество запросов, находящихся в системе (в очереди Triton и в обработке на GPU).
  • λ\lambda — интенсивность входящего потока, или требуемая пропускная способность (запросов в секунду).
  • WW — среднее время ответа, или задержка (latency), которую видит клиент.

Практический пример: Допустим, по SLA (Service Level Agreement) наша модель эмбеддингов должна отвечать в среднем за W=0.2W = 0.2 секунды. Ожидаемая пиковая нагрузка составляет λ=500\lambda = 500 запросов в секунду. Согласно формуле, L=500×0.2=100L = 500 \times 0.2 = 100. Это значит, что Triton должен уметь эффективно удерживать в памяти и очередях 100 одновременных запросов. Динамическое пакетирование позволяет сгруппировать эти 100 запросов, например, в 4 батча по 25 штук, утилизируя все ядра GPU и укладываясь в SLA.

Triton не выполняет модели сам — он делегирует вычисления бэкендам. Для эмбеддингов он использует бэкенд ONNX или TensorRT, а для тяжелых LLM внутри Triton запускается vLLM backend. Таким образом, мы получаем маршрутизацию Triton и оптимизацию памяти PagedAttention в одном флаконе.

Узкое место агентов: проблема префикса

Связка Triton + vLLM отлично работает для независимых запросов. Но давайте вспомним паттерн ReAct из третьей главы.

Агент работает в цикле: он получает системный промпт (инструкции, описания доступных инструментов, контекст базы данных), генерирует мысль, вызывает инструмент, получает результат и... отправляет весь этот огромный контекст заново для следующего шага.

Если системный промпт занимает 2000 токенов, а у нас работают 5 параллельных агентов, то стандартный сервер (даже с vLLM) будет вести себя так:

  1. Запрос от Агента 1: вычисление KV Cache для 2000 токенов (Prefill) + генерация ответа.
  2. Запрос от Агента 2: вычисление KV Cache для тех же 2000 токенов + генерация ответа.

vLLM удаляет KV Cache после завершения HTTP-запроса. В результате GPU вхолостую сжигает вычислительные мощности (и время), раз за разом пропуская через себя один и тот же системный промпт. На H100 это может занимать сотни миллисекунд на каждый шаг агента.

SGLang и RadixAttention: Дерево памяти

Для решения проблемы мультиагентных и многошаговых систем был создан SGLang (Structured Generation Language) и его ядро исполнения. Главная инновация SGLang — механизм RadixAttention.

RadixAttention отказывается от концепции «один запрос = изолированный кэш». Вместо этого весь KV Cache на сервере хранится в виде глобального префиксного дерева (Radix Tree).

Как это работает:

  1. Когда Агент 1 отправляет запрос с системным промптом на 2000 токенов, SGLang вычисляет для них KV Cache и сохраняет его как узел в дереве.
  2. Когда Агент 2 (или Агент 3, 4, 5) отправляет запрос, начинающийся с тех же самых 2000 токенов, SGLang проходит по дереву, видит совпадение и вообще не выполняет вычисления (Prefill) для этого префикса.
  3. Агенты становятся просто ветвями (листьями), растущими из одного общего ствола в памяти GPU.

RadixAttention обеспечивает нулевую стоимость разделения памяти для идентичных промптов. Если 5 агентов используют один контекст на 6000 токенов, сервер выделит память не под 30 000 токенов, а только под 6000, плюс уникальные сгенерированные ответы каждого агента.

Это критически важно для GenOps: few-shot примеры, огромные системные инструкции и многошаговые рассуждения (Chain-of-Thought) теперь кэшируются между независимыми HTTP-запросами.

Сборка архитектуры: что и когда использовать

В современном стеке GenOps эти технологии не исключают, а дополняют друг друга. Выбор зависит от профиля нагрузки.

Инструмент Главная суперсила Ограничения Идеальный сценарий
vLLM Максимальная пропускная способность генерации токенов (Continuous Batching). Изолированный контекст запросов, нет встроенной маршрутизации разных моделей. Одиночный LLM-сервис с независимыми запросами (например, чат-бот для разных пользователей).
Triton Inference Server Оркестрация множества разных моделей на одном GPU (Dynamic Batching). Сам по себе не оптимизирует генерацию токенов (требует vLLM backend). Кластер, где одновременно работают транскрибация, эмбеддинги, классификаторы и LLM.
SGLang (RadixAttention) Глобальное кэширование префиксов и структурная генерация. Менее универсален для не-LLM моделей по сравнению с Triton. Мультиагентные системы, RAG с длинным общим контекстом, сложные ReAct-цепочки.

В реальной GenOps-фабрике ДАДМ архитектура часто выглядит как гибрид: Triton Inference Server выступает единой точкой входа (API-шлюзом), направляя запросы к эмбеддингам на TensorRT-бэкенд, а запросы к агентам — на бэкенд, использующий логику кэширования префиксов (подобную RadixAttention).

Теперь, когда мы научились выжимать максимум из GPU на уровне железа и памяти, возникает следующий вызов. Как связать эти сверхбыстрые C++ и CUDA движки с нашим Python-кодом? В следующей главе мы разберем, как асинхронный Python (asyncio) справляется с тысячами конкурентных запросов и почему неправильный await может свести на нет все оптимизации кластера.

Асинхронный Python и конкурентность в высоконагруженных AI-сервисах

Асинхронный Python и конкурентность в высоконагруженных AI-сервисах

Бэкенд на базе Triton Inference Server и SGLang способен выдавать тысячи токенов в секунду, оптимально утилизируя GPU. Но между этим вычислительным ядром и конечным пользователем стоит слой оркестрации — API-шлюз на FastAPI, который управляет агентами, хранит историю диалогов и транслирует потоки токенов. Если этот шлюз написан неэффективно, вся оптимизация инференса разобьется о бутылочное горлышко на уровне Python.

В высоконагруженных GenOps-фабриках Python-сервис должен одновременно держать тысячи открытых соединений. Разберем, как это работает на уровне операционной системы и как не позволить тяжелой логике агентов «положить» сервер.

Анатомия Event Loop: магия системных вызовов

Ранее мы выяснили, что блокировка Event Loop (цикла событий) разрушает пропускную способность FastAPI. Но почему асинхронный код вообще способен обрабатывать тысячи одновременных запросов в одном потоке?

Секрет кроется не в самом Python, а в механизмах мультиплексирования ввода-вывода на уровне операционной системы — в частности, системном вызове epoll в Linux.

Когда FastAPI открывает соединение с клиентом для стриминга токенов, создается файловый дескриптор (сокет). В синхронном мире поток сервера остановился бы на команде чтения из сокета, ожидая данных. В асинхронном мире Python передает этот дескриптор в epoll и говорит ОС: «Разбуди меня, когда в этот сокет прилетят данные от Triton, а пока я пойду обслуживать другие запросы».

epoll (event poll) — системный вызов Linux, позволяющий одному потоку эффективно отслеживать изменения состояния тысяч файловых дескрипторов за время O(1)O(1).

Именно благодаря epoll поддержание 10 000 одновременных SSE-соединений почти не потребляет процессорное время. Сервер не опрашивает каждое соединение в цикле, он просто спит в ожидании прерывания от сетевой карты.

Ловушка GenOps: когда агенты становятся CPU-bound

Асинхронность идеально работает для I/O-bound задач (ожидание сети, чтение из базы данных). Но мультиагентные системы привносят в шлюз тяжелые вычислительные задачи (CPU-bound).

Представим типичный шаг агента-супервизора:

  1. Отправить запрос в LLM.
  2. Получить массивный JSON-ответ с планом действий.
  3. Распарсить JSON и провалидировать его через Pydantic-модель.

Шаг 1 — это I/O. Шаг 3 — это CPU. Если JSON весит пару мегабайт (например, LLM возвращает структурированную выжимку из большого документа), синхронный вызов json.loads() и валидация Pydantic могут занять 50–100 миллисекунд.

Поскольку Python исполняет байт-код в одном потоке, защищенном GIL (Global Interpreter Lock), эти 100 миллисекунд Event Loop будет полностью заблокирован. Ни один из тысяч других пользователей в этот момент не получит свой очередной токен, что приведет к резкому скачку задержек и деградации метрики TTFT.

Разгрузка цикла событий

Чтобы вычислительная задача не блокировала I/O-операции, ее необходимо вынести за пределы основного потока Event Loop. В современном Python для этого используется asyncio.to_thread(), который отправляет синхронную функцию в пул потоков (Thread Pool).

import asyncio
import json
from pydantic import BaseModel

class AgentPlan(BaseModel):
    steps: list[str]
    confidence: float

# Тяжелая CPU-bound задача
def parse_and_validate(raw_data: str) -> AgentPlan:
    parsed = json.loads(raw_data)
    return AgentPlan(**parsed)

async def handle_llm_response(raw_data: str):
    # Передаем задачу в пул потоков, освобождая Event Loop
    plan = await asyncio.to_thread(parse_and_validate, raw_data)
    return plan

GIL: Потоки против Процессов в AI-сервисах

Использование потоков решает проблему блокировки Event Loop, но не делает вычисления параллельными. GIL — это мьютекс, который гарантирует, что в любой момент времени байт-код Python выполняет только один поток.

Для правильной архитектуры GenOps-шлюза необходимо четко разделять инструменты конкурентности:

Инструмент Природа Обход GIL Применение в GenOps
asyncio Кооперативная многозадачность Нет Маршрутизация HTTP-запросов, стриминг токенов, асинхронные запросы к БД (asyncpg).
ThreadPoolExecutor Вытесняющая многозадачность (Потоки) Нет (но GIL отпускается при I/O) Вызов старых синхронных API внутри инструментов агента, тяжелый парсинг JSON.
ProcessPoolExecutor Параллелизм (Процессы) Да (у каждого процесса свой GIL) Локальная генерация эмбеддингов на CPU (например, через ONNX), сложная математика.

Если агенту нужно локально векторизовать запрос пользователя с помощью легковесной модели перед отправкой в pgvector, использование потоков не поможет — математические операции матричного умножения упрутся в GIL. Такую задачу необходимо отправлять в пул процессов, где ОС выделит задаче отдельное ядро процессора.

Backpressure и управление пулом соединений

Когда оркестратор отправляет запросы к Triton Inference Server, возникает риск перегрузки бэкенда. Если шлюз будет слепо транслировать все входящие запросы к LLM, очередь Triton переполнится, и система рухнет из-за нехватки памяти (OOM).

Здесь вступает в игру концепция Backpressure (обратное давление) — способность системы сигнализировать источнику нагрузки о необходимости снизить темп.

В FastAPI за взаимодействие с внешними сервисами обычно отвечает клиент httpx.AsyncClient. Создание нового клиента на каждый запрос — грубая архитектурная ошибка. Это приводит к исчерпанию эфемерных портов и накладным расходам на установку TCP/TLS соединений.

Клиент должен быть глобальным (Singleton) и иметь жестко заданные лимиты соединений (Connection Pooling), согласованные с пропускной способностью Triton.

import httpx

# Ограничиваем пул: не более 100 одновременных соединений к Triton
limits = httpx.Limits(max_keepalive_connections=50, max_connections=100)
timeout = httpx.Timeout(10.0, read=60.0) # Учитываем время генерации первого токена

client = httpx.AsyncClient(limits=limits, timeout=timeout)

Если FastAPI принимает 150 запросов, а лимит пула — 100, лишние 50 запросов не полетят в Triton, а будут поставлены в локальную асинхронную очередь внутри httpx. Если ожидание в очереди превысит таймаут, клиент получит ошибку 503 Service Unavailable.

Это контролируемый отказ: лучше быстро отказать части пользователей, чем позволить лавине запросов уничтожить инференс-сервер, обслуживающий текущие сессии.

Спроектировав надежный асинхронный шлюз, который не блокируется на парсинге данных и грамотно управляет пулами соединений, мы получаем отказоустойчивый узел. Следующий шаг — упаковать этот узел и инференс-серверы в контейнеры для развертывания и горизонтального масштабирования в кластере.

Контейнеризация и оркестрация: Kubernetes и Helm для LLM

Контейнеризация и оркестрация: Kubernetes и Helm для LLM

Ваш асинхронный Python-сервис виртуозно жонглирует тысячами соединений через epoll, а Triton Inference Server сжимает запросы в плотные батчи. Но что произойдет, если физический сервер, на котором крутится эта связка, внезапно сгорит? В классической веб-разработке мы бы просто подняли новый инстанс за пару секунд. В GenOps всё сложнее: вам нужно перенести 140 ГБ весов модели, заново инициализировать KV Cache и гарантировать, что новый узел имеет нужные GPU.

Оркестрация LLM-инфраструктуры требует совершенно иного подхода к контейнерам, сетям и хранилищам.

Специфика Docker в мире GenOps

Контейнеризация AI-агентов и моделей начинается с Docker, но стандартного демона здесь недостаточно. Чтобы контейнер «увидел» видеокарту, на хост-машине должен быть установлен NVIDIA Container Toolkit. Он пробрасывает драйверы хоста внутрь изоляции через специальный runtime.

В классическом DevOps хорошим тоном считается упаковка всех зависимостей и данных внутрь Docker-образа. Для LLM это антипаттерн. Если вы «запечете» веса Llama 3 70B в образ, его размер превысит 140 ГБ. Во-первых, ни один Docker Registry не скажет вам спасибо за пуш таких слоев. Во-вторых, любое минорное изменение в коде агента (например, правка промпта в Python-скрипте) потребует пересборки и перекачивания гигантского образа.

Поэтому в GenOps архитектура контейнера строго разделяется:

  1. Образ (Image): Содержит только код, зависимости (vLLM, SGLang, FastAPI) и системные библиотеки (CUDA, cuDNN). Размер — от 3 до 10 ГБ.
  2. Состояние (State): Веса моделей, индексы векторных баз данных монтируются в контейнер динамически при старте.

Kubernetes: как заставить планировщик понимать GPU

Kubernetes (K8s) изначально создавался для CPU-нагрузок. Чтобы кластер научился оперировать видеокартами, на узлы устанавливается NVIDIA Device Plugin. После этого GPU становится таким же исчислимым ресурсом, как память или процессор.

NVIDIA Device Plugin — системный компонент Kubernetes, который сканирует физические видеокарты на узле и сообщает API-серверу кластера об их наличии, позволяя запрашивать GPU через стандартный манифест.

В манифесте пода (Pod) запрос на видеокарту выглядит так:

resources:
  limits:
    nvidia.com/gpu: 2

Однако просто запросить GPU мало. В кластере работают и обычные микросервисы (базы данных, веб-фронтенд). Вы не хотите, чтобы дешевый stateless-сервис случайно занял дорогостоящий GPU-узел просто потому, что там было свободно.

Для изоляции ресурсов используются механизмы Taints (отторжения) и Tolerations (допуски):

  • На GPU-узел вешается ярлык (Taint): nvidia.com/gpu=present:NoSchedule. Теперь планировщик K8s не посадит туда ни один под по умолчанию.
  • В манифест нашего Triton-сервера мы добавляем Toleration — явное разрешение игнорировать это отторжение. Таким образом, на дорогие узлы попадают только те поды, которым это жизненно необходимо.

Проблема холодного старта и доставка весов

Мы отделили веса от Docker-образа. Теперь, когда K8s создает под на новом узле, ему нужно где-то взять 140 ГБ файлов модели. Это порождает проблему экстремального холодного старта.

Рассчитаем время загрузки весов по сети: Tboot=SVT_{boot} = \frac{S}{V} Где TbootT_{boot} — время загрузки в секундах, SS — объем весов модели, VV — пропускная способность сети. Например, для модели объемом 140 ГБ и внутрикластерной сети 10 Гбит/с (около 1.25 ГБ/с): Tboot=1401.25=112T_{boot} = \frac{140}{1.25} = 112 секунд.

Почти две минуты под будет просто скачивать файлы, прежде чем начнет загружать их в VRAM. Если кластер масштабируется динамически в ответ на скачок трафика, такая задержка приведет к отказу в обслуживании (Timeout) для пользователей.

Существует три основные стратегии решения этой проблемы:

Стратегия Механика Плюсы Минусы
S3 + InitContainer Специальный стартовый контейнер (InitContainer) скачивает веса из S3-хранилища в локальный emptyDir (временный том), после чего запускается основной контейнер с vLLM. Независимость от сетевых файловых систем. Легко версионировать модели в S3. Максимальное время холодного старта (каждый новый под качает веса заново).
NFS / CephFS Веса лежат на сетевом диске (Persistent Volume), который одновременно монтируется ко всем подам (ReadWriteMany). Мгновенный старт — файлы уже доступны. Сетевой диск становится узким местом (Memory-bound) при одновременном чтении с десятков узлов.
HostPath Caching Веса скачиваются один раз на физический диск узла (Node) и монтируются в поды. Скорость чтения ограничена только локальным NVMe-диском узла. При добавлении нового узла в кластер первый запуск всё равно будет долгим.

В высоконагруженных GenOps-фабриках чаще всего используют гибрид: веса хранятся в S3, но на каждом GPU-узле работает фоновый демон (DaemonSet), который предзагружает популярные модели на локальный NVMe-диск до того, как туда будут назначены поды инференса.

Helm: шаблонизация GenOps-фабрики

Развертывание мультиагентной системы из прошлых глав — это не один под. Это сложный ансамбль:

  1. Triton Inference Server для тяжелой LLM.
  2. Контейнер с SGLang для роутинга и кэширования префиксов.
  3. Векторная база данных (pgvector или Milvus).
  4. Графовая база данных для GraphRAG.
  5. Набор stateless-агентов на FastAPI.
  6. Redis для хранения Checkpointing-состояний агентов.

Управлять десятками YAML-файлов вручную для сред dev, staging и prod невозможно. Для этого используется Helm.

Helm Chart — это пакетный менеджер для Kubernetes, позволяющий описывать сложную инфраструктуру в виде набора шаблонов, где конкретные значения (например, имена моделей или лимиты памяти) выносятся в отдельный конфигурационный файл values.yaml.

Вместо того чтобы жестко прописывать nvidia.com/gpu: 2 в манифестах, вы пишете шаблон:

resources:
  limits:
    nvidia.com/gpu: {{ .Values.inference.gpuCount }}

Это позволяет DevOps-инженеру развернуть тестовую среду с легковесной квантованной моделью без GPU (на CPU), просто изменив один параметр в values-dev.yaml, а для продакшена использовать values-prod.yaml с выделением кластера H100.

Helm решает критическую задачу GenOps — воспроизводимость. Если исследовательская команда придумала новую архитектуру взаимодействия агентов, они могут упаковать её в Helm Chart и передать инженерам эксплуатации. Развертывание всей фабрики со всеми зависимостями, томами и сетевыми правилами сводится к одной команде: helm install multi-agent-system ./chart.

Теперь наша система упакована, умеет находить GPU и управлять весами. Следующий шаг — автоматизировать процесс обновления этой инфраструктуры при каждом коммите в репозиторий.

CI/CD пайплайны для GenOps в GitLab CI и Jenkins

CI/CD пайплайны для GenOps в GitLab CI и Jenkins

Классический CI/CD пайплайн для микросервиса отрабатывает за 3–5 минут: прогнали линтеры, выполнили юнит-тесты, собрали Docker-образ, обновили манифесты. Но что произойдет, если применить этот же подход к LLM-агенту? Вы рискуете заблокировать CI-раннер на несколько часов скачиванием 140 ГБ весов, а юнит-тесты пройдут успешно даже в том случае, если обновленный системный промпт заставит агента отвечать пользователям матом — ведь синтаксически код остался верным.

Переход от классического DevOps к GenOps требует переосмысления того, что именно мы тестируем и как мы доставляем изменения. В этой главе мы спроектируем self-service пайплайн, который учитывает недетерминированную природу генеративных моделей и разделяет жизненные циклы кода, промптов и весов.

Триада артефактов GenOps

В традиционной разработке артефактом является скомпилированный бинарник или Docker-образ. В GenOps-системах состояние приложения определяется тремя независимыми компонентами, которые меняются с разной частотой:

  1. Код (Python, FastAPI, LangChain/SGLang): Логика маршрутизации, интеграция с API, обработка ошибок. Меняется часто, тестируется классическими pytest.
  2. Промпты (Текстовые шаблоны): Инструкции для LLM. Меняются очень часто (Prompt Engineering). Требуют семантического тестирования, так как изменение одного слова может сломать логику агента.
  3. Веса моделей (Safetensors, GGUF): Бинарные файлы. Меняются редко. Как мы разобрали ранее, веса никогда не упаковываются в Docker-образ, а лежат во внешнем хранилище (S3) и подтягиваются через InitContainers.

GitOps в GenOps

Пайплайн не должен собирать веса. Его задача — собрать легковесный Docker-образ с кодом и промптами, а затем обновить конфигурацию (например, Helm values.yaml), указав новый тег образа и хеш/путь к весам в S3.

Оценка качества (Evaluation) внутри CI

Главный вызов GenOps-пайплайна — автоматическое тестирование промптов и поведения агента до деплоя в продакшен. Обычные assert response == "expected" здесь не работают из-за вариативности ответов LLM.

Для решения этой задачи в стадию тестирования (CI) внедряется Offline Evaluation.

Golden Dataset

Для проверки пайплайна создается эталонный набор данных — Golden Dataset. Это зафиксированная выборка из 50–200 реальных пользовательских запросов и ожидаемых критериев ответа. При каждом коммите, затрагивающем промпты или логику RAG, CI-раннер прогоняет этот датасет через обновленного агента.

LLM-as-a-Judge

Кто проверяет ответы агента? Использовать людей в CI невозможно. Поэтому применяется паттерн LLM-as-a-Judge: ответы тестируемого агента передаются другой, более мощной модели (например, GPT-4 или развернутой локально Llama 3 70B Instruct), которая оценивает их по заданным критериям (релевантность, отсутствие галлюцинаций, тональность).

Чтобы пайплайн не падал из-за случайных флуктуаций, вводится математическое условие успешности сборки:

SnewSbaselineδS_{new} \geq S_{baseline} - \delta

Где:

  • SnewS_{new} — средний балл агента на текущем коммите.
  • SbaselineS_{baseline} — балл агента на main ветке.
  • δ\delta — допустимая погрешность (margin of error), компенсирующая недетерминированность LLM. Например, при δ=0.05\delta = 0.05 мы допускаем просадку качества не более чем на 5% ради внедрения новой фичи.

Инфраструктура раннеров: CPU vs GPU

Классические задачи пайплайна (линтеры, сборка Docker) выполняются на дешевых CPU-раннерах. Однако стадия Evaluation требует инференса модели, а значит — доступа к GPU.

Смешивать эти нагрузки нельзя. В CI/CD системах применяется тегирование (маршрутизация) задач:

Инструмент Механизм маршрутизации Пример использования
GitLab CI Теги раннеров (tags) tags: [gpu, a100] направит задачу на сервер с видеокартой для прогона LLM-as-a-Judge.
Jenkins Метки узлов (node('gpu')) Изоляция тяжелых тестов на выделенном пуле Jenkins-агентов с установленным NVIDIA Container Toolkit.

Проектирование пайплайна в GitLab CI

Рассмотрим архитектуру эталонного пайплайна для мультиагентной системы. Он состоит из четырех стадий: lint, test, build и deploy.

stages:
  - lint
  - test
  - build
  - deploy

# Стадия 1: Быстрые проверки на CPU
python_linting:
  stage: lint
  tags:
    - cpu-runner
  script:
    - flake8 ./src
    - mypy ./src

# Стадия 2: Семантическое тестирование на GPU
prompt_evaluation:
  stage: test
  tags:
    - gpu-runner
  script:
    - echo "Запуск инференса тестируемой модели..."
    - python -m pytest tests/eval_rag.py --golden-dataset=s3://data/golden.json
  artifacts:
    reports:
      metrics: evaluation_metrics.txt

# Стадия 3: Сборка легковесного образа (без весов)
docker_build:
  stage: build
  tags:
    - cpu-runner
  script:
    - docker build -t registry.dadm.ru/agent:$CI_COMMIT_SHA .
    - docker push registry.dadm.ru/agent:$CI_COMMIT_SHA

# Стадия 4: Обновление Helm-релиза (GitOps)
deploy_staging:
  stage: deploy
  tags:
    - k8s-runner
  script:
    - helm upgrade --install my-agent ./helm-chart
      --set image.tag=$CI_COMMIT_SHA
      --set model.s3_path=s3://models/llama-3-8b-instruct.gguf

В этом примере мы видим четкое разделение: сборка образа занимает секунды, тестирование промптов выполняется на выделенном железе, а деплой сводится к обновлению параметров в Kubernetes через Helm-шаблоны, которые мы разбирали ранее.

Выбор оркестратора: GitLab CI vs Jenkins

Оба инструмента подходят для GenOps, но их архитектурные особенности диктуют разные подходы к построению сложных пайплайнов.

GitLab CI идеален для декларативного подхода. Вся логика описана в YAML. Он отлично интегрируется с концепцией GitOps: изменения в промптах сразу запускают пайплайн, а результаты Evaluation можно выводить прямо в Merge Request (с помощью artifacts:reports). Это позволяет разработчику сразу видеть, как его изменение повлияло на метрики генерации.

Jenkins, благодаря языку Groovy, позволяет строить сложную императивную логику. Это критично, когда пайплайн должен управлять внешней инфраструктурой. Например, если для тестирования новой огромной модели вам нужно динамически поднять кластер машин в облаке, дождаться загрузки весов, провести Evaluation, собрать метрики и затем уничтожить кластер для экономии бюджета. Jenkins с его плагинами и возможностью писать полноценный код внутри Jenkinsfile справляется с такими асинхронными стейт-машинами лучше, чем статический YAML.

Создание надежного CI/CD для AI — это первый шаг к отказоустойчивой фабрике агентов. Мы научились безопасно доставлять изменения в кластер, проверяя их семантическое качество. Однако Offline Evaluation в пайплайне не гарантирует, что модель не начнет деградировать при столкновении с реальными пользовательскими данными.

Мониторинг и Observability: метрики GPU, задержки и качество генерации

Мониторинг и Observability: метрики GPU, задержки и качество генерации

Пайплайн успешно выкатил новую версию агента в production-кластер Kubernetes. Offline-тесты пройдены, LLM-as-a-Judge подтвердил высокое качество на Golden Dataset. Спустя час в поддержку летят жалобы: «Бот зависает на минуту» и «Отвечает на вопросы невпопад». Вы открываете стандартные дашборды Grafana: утилизация CPU в норме, потребление RAM стабильно, HTTP-ошибок 5xx нет. Классический DevOps-мониторинг показывает, что система здорова, но как GenOps-инженер вы понимаете: внутри «черного ящика» LLM происходит катастрофа.

Observability в AI-системах требует трехмерного взгляда. Нам нужно видеть физическое состояние ускорителей, специфические тайминги авторегрессионной генерации и семантическое качество ответов на реальном, непредсказуемом трафике.

Аппаратный уровень: профилирование GPU

В Kubernetes стандартным стандартом де-факто для сбора телеметрии с видеокарт является DCGM-Exporter (Data Center GPU Manager Exporter) от NVIDIA. Он интегрируется с Prometheus и отдает десятки метрик, из которых для нас критичны две группы.

Первая группа — память. Метрика DCGM_FI_DEV_FB_USED показывает занятую VRAM (Frame Buffer). В классических микросервисах потребление памяти динамично, но в LLM оно жестко структурировано: базовый объем всегда занят весами модели, а колебания зависят исключительно от размера KV Cache. Если VRAM упирается в потолок, процесс не падает с OOM (Out Of Memory) благодаря PagedAttention, но новые запросы перестают обрабатываться.

Вторая группа — вычислительная нагрузка. Метрика DCGM_FI_DEV_GPU_UTIL отражает процент времени, когда потоковые мультипроцессоры (SM) выполняли инструкции.

Высокая утилизация GPU не всегда означает эффективную работу.

Из-за Memory-bound природы инференса LLM, ядро GPU может быть занято на 100%, но большую часть этого времени оно просто ждет подгрузки весов из VRAM в регистры.

Для выявления этой проблемы отслеживают DCGM_FI_PROF_SM_ACTIVE (реальная вычислительная работа) в связке с DCGM_FI_PROF_DRAM_ACTIVE (активность шины памяти). Если активность шины максимальна, а активность SM низкая — модель уперлась в пропускную способность памяти, и дальнейшее масштабирование нагрузки на этом узле бессмысленно.

Уровень инференса: анатомия задержки

Фреймворки вроде vLLM и Triton Inference Server отдают собственные метрики, позволяющие разобрать жизненный цикл запроса по миллисекундам.

Скорость HTTP-ответа (Latency) для LLM — понятие растяжимое. Мы уже рассматривали Time to First Token (TTFT) как меру отзывчивости. Но TTFT покрывает только фазу Prefill (чтение промпта). За фазу Decode (генерацию самого ответа) отвечает другая 핵심-метрика: TPOT (Time Per Output Token).

TPOTTPOT измеряет среднее время, затрачиваемое на генерацию одного токена. Общая задержка запроса вычисляется по формуле:

E2E_Latency=TTFT+TPOT×NtokensE2E\_Latency = TTFT + TPOT \times N_{tokens}

Где NtokensN_{tokens} — количество сгенерированных токенов.

Разделение этих метрик критично для локализации узких мест:

  • Высокий TTFT при нормальном TPOT означает, что запрос долго стоял в очереди. Скорее всего, KV Cache заполнен на 100%, и планировщик Continuous Batching не может выделить блоки для новых запросов. Метрика для проверки: vllm:gpu_cache_usage_perc.
  • Высокий TPOT при нормальном TTFT говорит о том, что размер батча (количество одновременно генерируемых запросов) слишком велик. GPU перегружена вычислениями, и генерация каждого следующего токена для всех запросов замедляется.

Уровень приложения: распределенная трассировка

В мультиагентных системах, где паттерн Supervisor маршрутизирует задачи между узкоспециализированными агентами, одного лога недостаточно. Запрос пользователя может инициировать поиск в векторной БД, вызов внешнего API, две промежуточные генерации LLM и финальный синтез ответа.

Для связывания этих шагов применяется стандарт OpenTelemetry. Каждому входящему HTTP-запросу присваивается уникальный trace_id. Внутри этого трейса каждый шаг становится отдельным спаном (span):

Название Span Тип операции Длительность Контекст
process_user_query HTTP Request 4.2s Входная точка API
supervisor_routing LLM Generation 0.8s Промпт: "Определи намерение..."
vector_db_search Network I/O 0.3s BM25 + HNSW (Hybrid)
synthesize_answer LLM Generation 3.1s Промпт: "Используй контекст..."

Трассировка позволяет визуализировать графы вызовов и мгновенно находить аномалии. Если общий ответ занял 10 секунд, трейс покажет, виновата ли LLM, или агент 8 секунд ждал ответа от медленной графовой базы данных.

Уровень качества: Online Evaluation и дрейф данных

Даже если железо работает идеально, а задержки минимальны, модель может генерировать бесполезный текст. Offline-тестирование на Golden Dataset в CI-пайплайне гарантирует качество только для известных сценариев. В реальности пользователи меняют поведение.

Это явление называется Prompt Drift (Дрейф промптов) — изменение семантического распределения входящих запросов. Например, компания запустила новый продукт, пользователи начали о нем спрашивать, а RAG-система еще не проиндексировала новую документацию. Модель начинает галлюцинировать.

Оценивать каждый ответ в реальном времени с помощью паттерна LLM-as-a-Judge невозможно — это удвоит затраты на инференс и задержку. Вместо этого применяется Shadow Evaluation (Теневая оценка).

Механика теневой оценки:

  1. Система асинхронно сэмплирует небольшой процент (например, 5%) реального production-трафика (запрос пользователя + найденный RAG-контекст + ответ модели).
  2. Эти данные отправляются в отдельную очередь (например, Kafka).
  3. Фоновый воркер с мощной моделью-судьей (LLM-as-a-Judge) неспеша оценивает ответы по метрикам: релевантность контексту, отсутствие токсичности, точность фактов.
  4. Результаты агрегируются в дашборде.

Самым сильным сигналом качества остается явный пользовательский фидбек — кнопки «палец вверх» и «палец вниз» (Explicit Feedback), а также неявный (Implicit Feedback) — например, если пользователь скопировал сгенерированный код в буфер обмена.

Инцидент в реальном времени: связываем слои

Вернемся к проблеме из начала статьи: бот зависает. Проведем диагностику, используя все уровни Observability.

Сначала смотрим на бизнес-метрики: 90-й перцентиль E2E_LatencyE2E\_Latency вырос с 3 до 45 секунд. Спускаемся на уровень инференса: дашборд показывает, что TPOTTPOT стабилен на уровне 25 мс. Значит, GPU генерирует токены с нормальной скоростью. Однако TTFTTTFT взлетел до 40 секунд. Запросы висят в состоянии waiting. Проверяем метрику vllm:gpu_cache_usage_perc — она прибита к отметке 99.9%.

Открываем систему трассировки OpenTelemetry и фильтруем трейсы с высоким TTFT. Мы видим, что перед инцидентом несколько пользователей загрузили в систему PDF-документы по 50 000 токенов каждый. Фаза Prefill для этих запросов моментально сожрала все свободные блоки KV Cache. Планировщик Continuous Batching заблокировал новые короткие запросы, ожидая освобождения памяти.

Решение на уровне GenOps: мы настраиваем Horizontal Pod Autoscaler (HPA) в Kubernetes. Но вместо стандартной метрики утилизации CPU, мы привязываем масштабирование к кастомной метрике vllm:num_requests_waiting. Как только очередь запросов, ожидающих выделения KV Cache, превышает 10, K8s автоматически поднимает новый под с GPU, перераспределяя трафик через балансировщик.

Симуляция технического интервью: разбор кейсов и архитектурных задач

Симуляция технического интервью: разбор кейсов и архитектурных задач

Вам дают маркер и предлагают подойти к доске. Интервьюер описывает ситуацию: «Наш мультиагентный сервис поддержки отлично работал на тестах, но в продакшене под нагрузкой TTFT улетел за 40 секунд, поды падают с OOM, а агенты начали галлюцинировать. Как будете чинить?».

Техническое собеседование на позицию GenOps Engineer в ДАДМ — это не опрос по словарю терминов. Это проверка того, как вы умеете связывать инфраструктуру, логику LLM и бэкенд в единый отказоустойчивый механизм. На этом этапе от вас ждут системного мышления: проблемы машинного обучения здесь решаются инженерными методами.

Разберем три классических кейса, которые охватывают весь стек — от асинхронного кода до оркестрации кластера.

Кейс 1: Инцидент с деградацией RAG-агента под нагрузкой

Вводная от интервьюера: Есть RAG-сервис на FastAPI и LangChain, который ищет документы в pgvector и генерирует ответы. При 5 одновременных пользователях всё работает быстро. При 50 пользователях время до первого токена (TTFT) вырастает с 1 до 45 секунд, часть запросов отваливается по таймауту, а утилизация GPU при этом скачет, периодически падая до нуля.

Анализ проблемы: Симптомы указывают на каскадный сбой на нескольких уровнях системы. Падение утилизации GPU до нуля при высоких задержках — классический признак того, что проблема не в нехватке вычислительных мощностей, а в блокировках или неэффективном управлении памятью.

Решение кандидата: Разделим траблшутинг на два слоя: бэкенд (FastAPI) и инференс (LLM).

  1. Разблокировка Event Loop. Падение утилизации GPU означает, что запросы просто не доходят до модели. Вероятнее всего, в FastAPI используется синхронный вызов agent.invoke() или тяжелый парсинг JSON-ответов от LLM прямо в основном потоке. Это блокирует Event Loop. Решение: Переводим все вызовы LangChain на ainvoke, а тяжелую валидацию Pydantic или генерацию лексических эмбеддингов выносим через asyncio.to_thread(), чтобы обойти GIL и освободить epoll для приема новых HTTP-соединений.

  2. Устранение внутренней фрагментации и очередей. Если утилизация GPU восстановилась, но TTFT всё еще высок, проблема в статическом батчинге. При 50 пользователях запросы выстраиваются в очередь, ожидая полного завершения предыдущих генераций. Решение: Переводим инференс на vLLM. Continuous Batching позволит добавлять новые запросы на уровне отдельных итераций (токенов), а PagedAttention спасет от OOM при исчерпании KV Cache длинными контекстами из RAG.

  3. Защита от перегрузки. Чтобы ситуация не повторилась, настраиваем Backpressure. Внедряем пулы соединений и ограничиваем максимальное количество конкурентных запросов к vLLM, ориентируясь на метрику vllm:num_requests_waiting.

В GenOps-архитектуре узким местом редко бывает сама математика модели. Чаще всего система задыхается из-за блокирующего ввода-вывода или фрагментации видеопамяти.

Кейс 2: Проектирование финансового мультиагентного аналитика

Вводная от интервьюера: Спроектируйте архитектуру системы, которая должна анализировать финансовые отчеты компаний за 10 лет, находить скрытые связи между дочерними предприятиями и писать аналитические сводки. Точность критична. Система должна быть автономной.

Анализ проблемы: Задача требует многошаговых рассуждений, работы со сложными связями (где векторный поиск даст сбой) и управления сложным состоянием. Одиночный агент здесь не справится из-за вероятности ошибки в длинной цепочке действий (P=pnP = p^n).

Решение кандидата: Архитектура строится вокруг паттерна Supervisor и графовых структур.

Компонент Технологическое решение Обоснование
Оркестрация Паттерн Supervisor (LangGraph) Главный агент-супервизор декомпозирует задачу и раздает поручения узкоспециализированным агентам (агент-поисковик, агент-математик, агент-копирайтер).
Отказоустойчивость Checkpointing состояния Сохранение графа состояния (AgentState) после каждого шага. Если агент-математик ошибся в расчетах, супервизор перезапускает только этот узел, а не весь процесс с нуля.
Поиск данных GraphRAG + Гибридный поиск Для поиска связей «Компания А владеет 30% Компании Б» используем графовую БД (узлы и ребра). Для поиска конкретных формулировок в отчетах — гибридный поиск (Dense + Sparse с RRF-ранжированием).
Инференс Triton Inference Server + SGLang Все агенты используют огромный общий системный промпт (правила фин. анализа). Механизм RadixAttention в SGLang закэширует этот префикс в виде дерева, избавив нас от перевычисления KV Cache (Prefill) для каждого агента.

Выстраивая такую архитектуру, мы изолируем зоны ответственности: LangGraph управляет логикой и состоянием, GraphRAG обеспечивает точным контекстом, а SGLang максимизирует пропускную способность GPU за счет переиспользования памяти.

Кейс 3: Zero-Downtime деплой тяжелой модели

Вводная от интервьюера: У нас в проде крутится модель на 70B параметров. Дата-саентисты обучили новую версию. Как выстроить CI/CD пайплайн, чтобы раскатать обновление без даунтайма, учитывая, что веса весят 140 ГБ, а свободных GPU в кластере впритык?

Анализ проблемы: Здесь пересекаются проблемы инфраструктуры (Kubernetes, холодный старт) и оптимизации (квантизация, нехватка VRAM). Классический Rolling Update убьет кластер нехваткой ресурсов или долгим скачиванием весов.

Решение кандидата: Процесс разбивается на три этапа: подготовка артефактов, тестирование и деплой.

  1. Оптимизация артефакта (CI): Модель 70B в FP16 требует 140 ГБ VRAM, что слишком дорого для дублирования подов при Rolling Update. В CI-пайплайне применяем PTQ-квантизацию (например, AWQ), снижая веса до INT4. Это ужимает модель до ~40 ГБ, позволяя ей поместиться на один GPU (например, A6000), оставляя место для KV Cache.

  2. Автоматическая оценка (CI): До деплоя запускаем Offline Evaluation. Используем паттерн LLM-as-a-Judge: мощная модель-судья прогоняет новую сборку по Golden Dataset. Если метрики качества проседают сильнее допустимой погрешности δ\delta, пайплайн в GitLab CI падает на стадии test, блокируя выкатку.

  3. Умный деплой (CD в Kubernetes): Решаем проблему холодного старта (TbootT_{boot}). Веса не храним в Docker-образе. Используем InitContainer, который скачивает квантизованные веса из S3 на примонтированный HostPath том узла. В Helm-чарте настраиваем стратегию RollingUpdate с maxSurge: 1 и maxUnavailable: 0. Kubernetes поднимет новый под на узле с нужными Tolerations. Благодаря закэшированным на HostPath весам, новый под стартует за секунды, а не минуты. Трафик переключится только после того, как Readiness Probe подтвердит загрузку модели в VRAM.

  4. Пост-деплой мониторинг: Включаем Shadow Evaluation на реальном трафике. Часть запросов асинхронно уходит на оценку для выявления Prompt Drift, а DCGM-Exporter следит за тем, чтобы квантизованная модель не уперлась в Memory-bound ограничения при новых паттернах нагрузки.

Парадигма GenOps: взгляд сверху

Успешное прохождение архитектурного интервью заключается в умении показать, что вы видите систему не как набор разрозненных скриптов, а как многослойную фабрику.

Инженер GenOps — это мост между исследовательской лабораторией Data Science и суровой реальностью высоконагруженного продакшена.

Ваш ответ всегда должен двигаться по трем осям:

  1. Надежность: Что произойдет, если база данных отвалится, а LLM вернет невалидный JSON? (Ответ: Checkpointing, Pydantic-валидация, Retry-политики).
  2. Производительность: Как выжать максимум из дорогого железа? (Ответ: Continuous Batching, RadixAttention, асинхронность).
  3. Наблюдаемость: Как мы узнаем, что система деградирует, до того, как придут пользователи? (Ответ: OpenTelemetry, E2E Latency мониторинг, LLM-as-a-Judge).

Подготовка к интервью в ДАДМ завершена. Вы собрали в единую картину пайплайны данных, внутренности LLM-инференса, асинхронный бэкенд и оркестрацию инфраструктуры. Теперь дело за практикой у доски.