При проектировании LLM-сервиса, который обрабатывает N независимых элементов (наблюдений / параметров / документов / страниц), есть дилемма гранулярности: сколько LLM-вызовов делать на одну задачу? Три типичных точки на спектре, и каждая со своими trade-off’ами. Когда выбираешь «батчами», архитектура естественно раскладывается на три вложенных уровня, и это переиспользуемый паттерн — встречается в нескольких наших сервисах (валидити-классификатор Артура — каноничный пример; другие — TBD locate). Эта страница — про сам паттерн как объект знания.
Три точки на спектре
- 1 вызов на всё — вся задача в одном промпте, N элементов внутри одного user-message, один JSON-вывод с массивом из N результатов. Самое простое, минимум moving parts. Но: упирается в
maxOutputTokensна больших N (см. gemini-doom-loop); один «ядовитый» вход валит вообще всё; параллелизма нет; один промпт обязан покрыть все edge-кейсы. - N вызовов (1 на элемент) — максимум параллелизма, изоляция отказов до 1 элемента, промпт простой (один элемент за раз). Но: shared-контекст (анамнез пациента, retrieval-результаты, преамбула) гоняется N раз — overhead × N; пресс на rate-limits провайдера; observability взрывается (N трейсов на задачу); затраты могут вылететь.
- Батчами — группы по
kэлементов, общий контекст в каждом батче. Промежуточное состояние: shared-контекст амортизируется на k, параллелизм управляемый (concurrency × batches), partial-failure локализован одним батчем (плюс трюк с бисекцией при терминальном падении доводит изоляцию до ≤1 «ядовитого» элемента). Но:k— параметр настройки; состав батча может детонировать truncation / repetition-loop у провайдера; требуется anti-hallucination guard на per-item identifiers (две системы индексов: «состояния» и «элементы», модель копирует их обратно verbatim).
Когда что выбирать
- Элементы реально независимы (без cross-item зависимостей) и shared-контекст лёгкий → 1-на-элемент или батчами; observability + стоимость решают.
- Shared-контекст тяжёлый (большой patient state, retrieval, длинная преамбула) → батчами, чтобы амортизировать.
- Per-item output большой → меньше
k(или 1-на-элемент), чтобы не упереться вmaxOutputTokens. - Бисекция работает как poison-isolation только при реальной независимости элементов — если есть cross-item dependencies, split не сделать.
- Rate-limit / concurrency на стороне провайдера иногда вынуждают батчинг, даже когда 1-на-элемент идеален.
- Если в перспективе context-окно модели сильно вырастет — может стать выгоднее снова 1-вызов-на-всё; «батчами» — это не навсегда правильный ответ.
| 1 вызов | N вызовов (per-item) | Батчами (k items) | |
|---|---|---|---|
| Setup overhead | минимум | × N (без shared-context savings) | × ⌈N/k⌉ (амортизировано) |
| Failure scope | весь task | один item | один батч (можно бисектить до 1 item) |
| Parallelism | нет | максимум (все N) | до min(concurrency, ⌈N/k⌉) |
| Observability | один trace | N трейсов (взрыв) | ⌈N/k⌉ трейсов (управляемо) |
Output-size pressure (maxOutputTokens) | максимум | минимум | управляемо через k |
| Anti-hallucination guard | проще (один результат) | тривиально | нужна index-схема (per-item identifiers + verbatim echo) |
| Provider rate-limit | один request | риск на больших N | управляемо через k и concurrency |
Когда выбрано «батчами» — три уровня обработки
«Батчами» естественно раскладывается на три вложенных уровня — их легко спутать, и явное называние помогает и проектировать, и документировать:
один LLM-вызов ⊂ один батч (= вызов + валидация N результатов) ⊂ один прогон (много батчей + поверх — бисекция / orchestration)
- Один LLM-вызов — «как сделать один robust вызов»: model-резолвинг (через прокси), schema cleaning (для провайдеров, криво держащих structured-output),
generateObject(schema, temp=0), таймаут, retry с пертурбацией промпта (nonce / prefix-cache-bust) на transport-зависание, transport-fallback на другую модель. - Один батч = preprocess + (один вызов) + postprocess. Preprocess —
buildUserMessage: общий контекст + два пронумерованных списка (контекстных элементов для verbatim-echo + сами обрабатываемые элементы для index-join). Postprocess — anti-hallucination валидатор (set-equality по индексам, verbatim-энумерация контекста, cross-field consistency) → reject → один retry-with-hint → degrade в sentinel-значение (unknown-аналог). - Один прогон / вся задача — chunk элементов на батчи по
k→ worker-pool / fan-out → каждый батч через «один батч» → если терминально упал → бисекция (split в половины, recurse, изолирует «ядовитый» элемент до 1-obs-фейла) → собрать финальный результат + метаданные failures.
Каноничный пример — validity-classifier § «Три уровня обработки» (внутри одного LLM-вызова: модель / cleanSchema / nonce-retry / fallback; внутри батча: buildUserMessage / generateObject / validateClassifierOutput → retry-with-hint → unknown; внутри прогона: chunk × 8 / worker-pool × 4 / classifyBatchWithSplit / PatientValidityProfile).
Применение в нашей кодовой базе
- validity-классификатор (Артур,
feat/v2-5) — каноничный носитель паттерна; всё три уровня явные. - TBD locate — Ильдар помнит, что похожий shape встречается в других сервисах Артура; перечислить и сверить структуру (carry-over).
При портировании Артуровых сервисов в наш стек учитывать эту трёхуровневую декомпозицию специально: внутренний worker-pool + рекурсивная бисекция + ручной retry-loop = самодельная in-app очередь (no-self-rolled-queues); это не портируется как есть — переразлагается на Inngest-шаги. Конкретные варианты декомпозиции (батч = step.run? дочерняя функция через step.invoke? один шаг на весь прогон с внутренними retry?) — biomarker-actuality-integration § Вопрос 3, inngest-pipeline-orchestration-vs-choreography.
Открытые вопросы
- Перечислить другие Артуровы сервисы с этим shape (TBD locate) — сверить структуру / собрать общие port-gotcha.
- Когда стоит «снять» батчинг и вернуться к 1-вызов-на-всё (если context-окно модели сильно выросло, и накладные на batching/coordination становятся дороже одного большого вызова)?
- Adaptive batch sizing — поднимать
kдля гомогенного входа, понижать для гетерогенного / для входов с подозрением на «ядовитые» элементы — есть ли такое в практике, стоит ли делать. - Бисекция работает только при независимости элементов — какие у нас сервисы её нарушают (cross-item dependency) и не могут использовать этот трюк.
Связано
- validity-classifier § «Три уровня обработки» — каноничный пример; конкретные механики
- structured-output-field-order-cot — смежный приём: chain-of-thought через порядок полей output-схемы (независимо от выбора гранулярности)
- llm-call-failure-classes — классы сбоев применяются на каждом уровне (транспорт/схема → инфра, на уровне «один LLM-вызов» и через прокси; семантика → app-level, на уровне «один батч»)
- gemini-doom-loop — Vertex-specific фейлы, которые давят на output-size → меньше
k - no-self-rolled-queues — самодельные worker-pool / бисекция / retry-loop = анти-паттерн при портах; на «прогоне» это значит — переразложить на Inngest
- inngest-pipeline-orchestration-vs-choreography — как «прогон целиком» ложится на Inngest (batch = step? function? один step?)
- biomarker-actuality-integration § Вопрос 3 (Inngest-декомпозиция) — конкретный кейс
- structured-llm-service-page-template — мета-шаблон страницы для сервиса с такой архитектурой (по мотивам validity-классификатора)