Привет! Меня зовут Владимир Суворов, я Senior Data Scientist и core-разработчик open-source AutoML-фреймворка OutBoxML.

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

Рассмотрим задачу по пяти пунктам:

  1. Чем ассистент отличается от агента.

  2. Ассистент как оркестратор.

  3. Пайплайн обработки.

  4. Заменяемые интерфейсы и взаимозаменяемость модели.

  5. Основная сложность продакшена.


1. Ассистент против агента

Термины «ИИ-агент» и «ИИ-ассистент» сегодня путают. Кажется, что это одно и то же: оба автоматизируют рутину, у обоих под капотом LLM, FastAPI и похожий стек. Но для разработчика это две разные сущности — по контролю, предсказуемости и экономике.

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

Агент — это аутсорс. Вы даёте ТЗ и ждёте результат. Вы не контролируете, какими путями подрядчик пойдёт, какие инструменты подключит, сколько раз перечитает документ. Он автономно строит цепочку шагов и сам решает, куда идти за данными. Вы платите за этот комфорт — подписками, токенами, временем на перебор вариантов.

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

Почему я выбираю штат

Возьмём нашу задачу — сверку документа со сводом правил.

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

Но у аутсорса есть проблема, и она не в цене. Она в доверии. Отдав сверку автономному агенту, я каждый раз не знаю: свёрился ли он именно с моим сводом правил — или подтянул то, что он «думает» об этой предметной области из обучающих данных? Для LLM это главный риск: модель охотно достроит ответ из общего знания там, где я жду сверки по букве конкретного документа.

Со штатным сотрудником этот вопрос не стоит. Я сам дал ему правила и точно знаю, что он ходит только в них.

Тут важная оговорка.. Мой ассистент ничего не «выучил» — свод правил грузится в контекст на каждом запросе. Но метафора от этого только крепче: живой сотрудник со временем начинает помнить неточно и ошибаться по памяти, а ассистент не умеет работать по памяти — он всегда открывает регламент на нужной странице. Предсказуемость именно потому, что памяти нет.

И вот что доказуемо — без всяких бенчмарков на «точность»: ассистент не устаёт. Он сверяет трёхсотую строку так же внимательно, как первую. Человек к вечеру начинает листать по диагонали — ассистент нет. А освободившийся человек уходит туда, где он действительно нужен: на сложные, нетиповые случаи, которые под стандарт не подгоняются.

Немного экономики — как мостик

Стоит оговорить, когда что выбирать, потому что штат нужен не всегда.

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

Регулярный процесс — стройте ассистента. Если задача стала операционной и ежедневной, бесконечный аутсорс съест бюджет, а «чёрный ящик» рано или поздно уронит продакшен. Выгоднее один раз вложиться в разработку.

Дальше — про то, как эта разработка устроена внутри.


2. Ассистент как оркестратор

Ключевая идея всей архитектуры: оркестратор ничего не делает сам. Он владеет подсистемами и управляет ими. Ровно как в OutBoxML, где AutoMLManager, DataSetsManager и MonitoringManager разделяют ответственность и не лезут внутрь друг друга.

Вот как собирается ассистент:

@app.post("/api/update")
def submit(request: APIRequest):
    
  task = ProcessingTask(
          request_id=request.request_id,
          file_path=request.file_path,
          user_name=request.user_name,
      )
  
  service = AIAssistantService(
              preprocessor=DocumentPreprocessor(
                data_parser=DataParser(file_path=request.file_path),
                request=task,
                encoder=TextEncoder(),
                prompt_engine=PromptEngine(
                  role=settings.ai_role,
                  template=settings.ai_prompt_template,
                  normative_base=settings.normative_base,
                  num_ctx=settings.qwen_num_ctx,
                  ),
                examples_path=settings.examples_path,
              ),
            postprocessor=PostProcessor(),
            ai_model=ModelFactory.create(),
            report_export=ReportExport(task),
     )
  
  result = service.result(max_chunks_override=request.max_chunks)
  return JSONResponse(content=jsonable_encoder(result))

Обратите внимание на две вещи.

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

Второе — оркестратор состоит ровно из четырёх подсистем:

  • DocumentPreprocessor — превращает файл в промпты;

  • AIModel — общается с LLM;

  • PostProcessor — разбирает ответ модели в структуру;

  • ReportExport — пишет результат в файл.

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

И главное: AIAssistantService не знает, какая под ним модель. Он вызывает ai_model.response(query) — а что там, Qwen или что-то ещё, ему безразлично. Далее мы и разберём в пункте 4.


3. Пайплайн обработки

Оркестратор дирижирует — а вот сама партитура. Путь документа от файла до отчёта:

  • Клиентский документ (.xlsx / .docx / .pdf)

       ↓ DataParser

  • Сырой текст

       ↓ TextEncoder (нормализация, обрезка по лимиту)

  • Нормализованный текст

       ↓ DocumentChunker (батчинг по LLM_BATCH_SIZE)[chunk1, chunk2, ..., chunkN]

       ↓ для каждого чанка: PromptEngine.build()

  • Промпты со сводом правил (RAG через ContextBuilder)

       ↓ AIModel.response() с retry

           ├─ Успех → сохранить результат

           ├─ Ошибка 5xx → пауза, повтор

           └─ После N попыток → пропустить чанк, идти дальше

  • Результаты по всем чанкам (даже если часть упала)

       ↓ InsuranceReport.merge()

  • Итоговый отчёт

       ↓ ReportExport

  • Файл результата (тот же формат, что на входе)

Несколько комментариев по схеме:

Чанкинг. Документ на сотни позиций не влезает в один запрос, поэтому режем на батчи. Размер батча — это баланс: слишком крупный, и модель начинает терять пункты внутри чанка; слишком мелкий, и мы платим за оверхед промпта на каждом запросе (свод правил-то едет с каждым чанком). Число подбирается под конкретную модель и документ, а не берётся из туториала.

RAG появился не сразу. Первая версия была честной: весь свод правил — в промпт, все сорок страниц. Работает, пока свод небольшой. Когда правил становится много, залить всё целиком — значит жечь контекст и деньги на каждом чанке. Так появился NormativeIndex.retrieve(query, budget) и ContextBuilder: под каждый запрос вытаскиваем только релевантные разделы в рамках выделенного бюджета токенов.

Самое сложное место — не LLM. Скормить текст в промпт — дело нехитрое. Ад начинается на сборке отчёта. Ответ модели надо сматчить обратно на исходные строки документа — а формулировки в ответе и в исходнике не совпадают дословно. Отсюда многоуровневый матчинг в ExcelReportWriter: точное совпадение → нормализованное → частичное → фолбэк. Вот на это уходит непропорционально много кода и нервов, а вовсе не на «общение с нейросетью».

Запомните этот тезис: LLM — не система. LLM — один элемент системы. Как и в прошлой статье: модель — не система, система — то, в чём модель живёт.


4. Заменяемые интерфейсы

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

Модели меняются постоянно, и не каждая годится под сверку документов. Поэтому у меня не «выбрали одну и всё», а три режима под три ситуации:

Режим

Модель

Зачем

Разработка

Локальные модели через Ollama

Проверить, что связка «модуль ↔ ассистент» вообще работает: запросы ходят, ответы парсятся. Дёшево, локально, без сети.

Тестирование

Gemini

Щедрые бесплатные лимиты, которых хватает погонять сверку и понять, решается ли задача в принципе. Большое окно контекста.

Продакшен

Qwen 3 на внутреннем контуре

В бою нет доступа к Gemini или моделям Anthropic — только свой контур. Окно контекста порядка 400–500k токенов, этого достаточно.

Для работы с документами размер окна контекста — самый важный параметр модели. Текста много, и модель, которая не вмещает свод правил вместе с документом, отпадает сразу. Умная модель с маленьким окном тут проигрывает простой модели с большим — редкий случай, когда «меньше параметров» значит «лучше под задачу».

Отдельная настройка для Qwen 3 — отключить reasoning-режим (enable_thinking=false). На сверке по букве думать вслух незачем: рассуждения только жгут контекст там, где нужно сопоставление, а не размышление.

А теперь — доказательство

Смотрите, что физически потребовалось, чтобы переехать с Gemini на Qwen:

class AIModel(ABC):
    @abstractmethod
    def response(self, query: str) -> str: ...

    
class QwenModel(AIModel):
    def response(self, query: str) -> str:
        return self._call_api(query)

    # ...

class ModelFactory:
    @staticmethod
    def create() -> AIModel:
        # выбор реализации при инициализации приложения\
        ...
        

Я написал один класс — QwenModel — и указал его в ModelFactory при старте приложения, прописав параметры Qwen 3 в .env. Всё.

DocumentPreprocessor, PostProcessor, ReportExport — не тронуты. PromptEngine — не тронут. Оркестратор так и не узнал, что под ним сменилась модель. Он как вызывал ai_model.response(query), так и вызывает.

Вот ради этой фразы — «оркестратор не узнал» — и существует вся возня с абстракциями. Пока соблюдён контракт response(query) -> str, начинка может быть любой.

Интерфейс — это не про красоту, это про отказ

И ещё одна причина, зачем всё взаимодействие с моделью спрятано за интерфейс. Оно ненадёжно по своей природе: сети может не быть, модель может быть занята другими задачами и не отвечать. Изолировав её за абстракцией, я добиваюсь того, что падение модели — это отказ одного узла, а не всей системы. Это Принцип 1 из прошлой статьи: слабый элемент не должен валить конструкцию.

Заменяемость и надёжность здесь — одно и то же свойство, если смотреть с разных сторон.


5. Продакшен — как основная сложность

Всё, что выше, — это «на бумаге». Настоящая сложность начинается, когда система работает каждый день. Три истории из этой части.

Работа с большими объёмами неминуемо упирается в лимиты и перегрузку сервиса. И режим отказа меняется вместе с инфраструктурой — это, кстати, ещё один довод в пользу интерфейса из пункта 4.

На внешнем API (Gemini) отказ — это чаще всего лимиты: RPM/TPM. Здесь стратегия превентивная: зная, что я гарантированно забью лимит, я заранее закладываю таймаут между запросами, а не жду, пока сервер начнёт выкидывать вынужденный таймаут.

На своём контуре (Qwen) отказ другой — перегрузка инстанса, таймаут гейтвея. Здесь стратегия реактивная: retry с паузой и — ключевое решение — пропуск чанка после N неудачных попыток.

2026-07-13 14:23:45 | WARN  | Чанк 55: ошибка на попытке 1/3
2026-07-13 14:23:50 | WARN  | Чанк 55: ошибка на попытке 2/3
2026-07-13 14:23:55 | ERROR | Чанк 55 пропущен после 3 попыток

Пропустить чанк — это осознанный выбор неполного результата вместо полного отказа. Документ на 120 чанков не должен погибать целиком из-за одного упавшего 55-го. Пользователь получит чанки 1–54 и 56–120, а по пропущенному — явную пометку. Это Принцип изоляции: отказ элемента отключает часть функционала, а не всё.

rebuild — система, прощающая ошибки

Отдельная боль, из которой родился отдельный эндпоинт. Все ответы модели кэшируются в JSON до сборки отчёта:

{
  "model": "Qwen3.6-35B-A3B-NVFP4",
  "chunks": [
    {"index": 1, "raw_response": "...", "rows_parsed": 25},
    {"index": 55, "raw_response": "", "rows_parsed": 0, "error": "504"}
  ]
}

Зачем? Потому что самое обидное — когда упал не LLM, а парсинг результата на пятисотой строке. Модель отработала, деньги и время потрачены — а скрипт сборки отвалился на мелочи. С кэшем это больше не катастрофа: /api/rebuild пересобирает отчёт из готовых ответов, не обращаясь к модели заново.

Это ровно четвёртый принцип прошлой статьи — система, прощающая ошибки. Как ANSYS, который пересчитает что сможет и оставит лог, вместо того чтобы упасть.

Туда же — /api/estimate: оценка объёма и времени без вызова LLM, чтобы пользователь понимал, во что ввязывается, до запуска, а не после.

Почему синхронно

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

Здесь синхронный подход закрывал всё. Пользователей — единицы, не больше пяти. Каждый работает со своими локальными файлами внутри своей сессии; чужие файлы недоступны. Нет конкуренции за общий ресурс, нет борьбы за очередь — параллелить между пользователями попросту нечего. Городить async-инфраструктуру под такой профиль нагрузки — это перфекционизм, который усложняет систему там, где выигрыш близок к нулю.

Но вот что важно развести — это два разных уровня:

  • Уровень приложения — синхронный. Один пользователь, одна сессия, один поток. Ему незачем быть асинхронным.

  • Уровень модели — асинхронный, но не мой. Параллелизм, очереди, батчинг запросов живут внутри сервиса модели. Я просто посылаю запрос. Асинхронность есть — она вынесена за границу моей ответственности.

Это не «я не умею async» и не «async ради async». Это каждый уровень своей природы: асинхронность там, где она уместна (сервис модели), синхронность там, где уместна она (изолированная пользовательская сессия). Когда пользователей станут сотни — я построю под это отдельную архитектуру, а не буду усложнять текущую на всякий случай.

(Да, распараллелить сами чанки на своей стороне — слать N запросов, не дожидаясь предыдущего — в планах есть. Это уже третий, отдельный async, не путать с двумя выше.)


Заключение

Хороший ИИ-ассистент — это инженерная система, и проектируется он по тем же правилам, что любая надёжная конструкция.

  • Ассистент, а не агент — там, где задача регулярная и нужен контроль над каждым шагом.

  • Оркестратор, который ничего не делает сам — только владеет подсистемами и дирижирует.

  • Заменяемые интерфейсы — чтобы сменить модель одним классом, не тронув соседей.

  • Продакшен как главная сложность — где реальная работа не в LLM, а в отказах, кэше и честном размере системы под задачу.

Меняются домены и инструменты — от прочности конструкций до сверки документов, от ANSYS до FastAPI с Qwen. Подходы остаются прежними.

Об инженерном взгляде на ML и проектировании таких систем я регулярно пишу в своём Telegram-канале. Буду рад видеть вас среди читателей.

Комментарии (3)


  1. Ra2007
    19.07.2026 20:21

    У нас похожая история, только на NestJS: повторяющуюся сверку договоров свели в DI-собираемый пайплайн вместо агента, который сам решал что делать. Инъекция через конструктор дала то же самое, что у вас: подменить модель на моковую в тестах без единой правки бизнес-логики. Экономика тоже совпала: разовые задачи отдаём агенту, а как только процесс становится регулярным, переводим в такой пайплайн, токены на повторный reasoning того не стоят.


    1. justsuvorov Автор
      19.07.2026 20:21

      Абсолютно!


  1. Wizloo
    19.07.2026 20:21

    Понравилась не только структура статьи, но и промежуточные выводы в ней, спасибо