Привет! Меня зовут Владимир Суворов, я Senior Data Scientist и core-разработчик open-source AutoML-фреймворка OutBoxML.
В прошлой статье я разбирал четыре инженерных принципа, которые перенёс из расчётной механики в архитектуру ML-систем. Сегодня — про то, как спроектировать LLM-систему, которая надежно работает каждый день, не сжигает бюджет на токены и работает предсказуемо. Разберём на реальном примере: ассистенте, который сверяет входящий документ технического задания свободного формата со сводом документации и ставит комментарии наличии запрашиваемых пунктов в соотвествии с документацией.
Рассмотрим задачу по пяти пунктам:
Чем ассистент отличается от агента.
Ассистент как оркестратор.
Пайплайн обработки.
Заменяемые интерфейсы и взаимозаменяемость модели.
Основная сложность продакшена.
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)

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