InquirerValencia City allots P140M as it gears up vs. El NiñoThe Jerusalem PostJerusalem's L3 light rail line extended to Damascus Gate, stops added in downtown areaPunchFG moves to license scrap dealers, curb theftInquirer EntertainmentRussell Brand sentenced to 10 and a half years in prison for rape, sexual assaultZDF heuteEntdecken Sie das ZDF-Nachrichtenstudio3DNewsAnthropic хотела привлечь $70 млрд на бирже, а потом передумала7sur7Amélie Lens, Interpol, ADÉLA: Rock Werchter dévoile sa nouvelle salve de nomsХабрRockstar продаёт реальный спортивный автомобиль Grotti Cheetah из GTA VIIl Fatto QuotidianoPerturbazione atlantica in arrivo sull’Italia, piogge fino a 18 ore e venti a 80 km/h. Liguria e Toscana le più colpiteGolem.deJensen Huang: FBI ermittelt zum Spionageverdacht bei Nvidian-tvHöchster je erfasster Wert: Immer mehr Patienten suchen Notaufnahmen aufESPN Cricinfo'IPL has earned the right to influence the calendar' - KKR co-owner wants shift to January
The Daily Newsstand · Free, Always
Tuesday, November 10, 2026

Пакетные запросы к LLM: очередь, стоимость и обработка ошибок

Translate

Представьте, что вашему LLM-сервису нужно обработать, допустим, 50 000 договоров или товаров. Можно пойти через синхронный путь — посылать запросы к LLM. Но такой путь через некоторое время упирается и в деньги, и в лимиты. И ограничения с проблемами растут в геометрической прогрессии от количества запросов.

Вот тут нам и помогает Batch API — она появилась не вчера, но стала отраслевым стандартом отправки батчей, групп запросов. OpenAI, Anthropic и Google дают асинхронную пакетную обработку со скидкой и окном завершения до 24 часов. То бишь вы собираете все задачи в один файл, отправляете его провайдеру и ждёте.

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

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

Синхронный API устроен просто: стоит отправить запрос — получил ответ. Пока запросов десяток или сотня, всё хорошо. Но чем ближе к тысячам, тем чаще вы упираетесь в TPM и RPM, а большие контекстные запросы начинают отваливаться ещё до того, как дело доходит до генерации. Батч идёт по отдельному пулу лимитов, и соответственно можно отправить 50 тысяч запросов одним файлом, не выедая себе синхронную квоту. Правда, у этого пула есть свой потолок, в виде того что если новый пакет выводит организацию за лимит enqueued-токенов, API возвращает token_limit_exceeded и просто отказывается создавать джобу.

Поэтому и нужно считать бюджет токенов до отправки, разбирать результат, а также решить, что делать с expired-запросами в батче, отправлять их или нет.

Кстати, если не хочется строить этот пайплайн с нуля — валидацию JSONL, семафор на enqueued-токены, разбор errored и expired, повторную отправку — имеет смысл посмотреть на OpenAI-совместимые шлюзы. Bothub, в частности, принимает тот же JSONL и те же параметры пакета. Архитектура полностью совместима со спецификацией OpenAI Batch API. Эндпоинт — https://openai.bothub.chat/v1, оплата российской картой, без VPN. По реферальной ссылке доступно 300 000 CAPS, так что можно потрогать батч на своих задачах ещё до того, как писать свой первый семафор. Заявленная экономия — 45% на генерациях.

Как устроена очередь

Когда отправляешь пакет в Batch API, поначалу кажется, что дальше можно просто закрыть ноутбук и ждать письма. Ну, отправил файл, провайдер сам разберётся. Но, увы, очередь устроена чуть сложнее, если не уметь, то можно и потерять деньги или данные. Давайте разберём.

Первый уровень — сам пакет, джоба. Вы формируете файл, где каждая строка это отдельный запрос с уникальным custom_id, и загружаете его через Files API. purpose всегда должен быть "batch". Дальше создаётся задача, и результаты приходят не в том порядке, в котором вы отправляли запросы. custom_id позволяет отсортировать вывод.

Второй уровень — пул enqueued-токенов. Вот тут начинается самое неприятное, потому что лимит считается не по файлам, а по общему объёму токенов во всех незавершённых пакетах сразу. У OpenAI это tier-зависимая история — чем выше tier, тем больше токенов можно держать в очереди одновременно. Если новый пакет выводит организацию за этот потолок, API возвращает token_limit_exceeded и просто отказывается создавать джобу.

Третий уровень — окно завершения. 24 часа это SLA, обещание, а не оценка. Внутри окна провайдер сам решает, когда запускать пакет, и обычно это 2–6 часов, потому что батчи стараются ставить в окно низкой загрузки GPU. Anthropic, например, пишет, что большинство пакетов завершается меньше чем за час, но максимальный срок всё равно 24.

У OpenAI незавершённые запросы отменяются, но за уже завершённые деньги списываются.

Вот пример структуры JSONL, который показывает сразу два запроса с разными задачами — перевод и генерация хокку:

{"custom_id": "task-translate-001", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "claude-opus-4.6", "messages": [{"role": "system", "content": "Переведи текст на французский язык."}, {"role": "user", "content": "Привет, как дела?"}], "max_tokens": 100}}
{"custom_id": "task-generate-003", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "claude-opus-4.6", "messages": [{"role": "user", "content": "Напиши короткое хокку про нейросети."}], "max_tokens": 50}}

Формат простой: custom_id, method, url и body с моделью и сообщениями. Выглядит почти как обычный запрос к Chat Completions, только упакованный в строку. Вот как это выглядит в Python, если использовать официальный SDK OpenAI с baseURL на Bothub:

from openai import OpenAI

client = OpenAI(
    api_key='ВАШ_API_КЛЮЧ_BOTHUB',
    base_url='https://openai.bothub.chat/v1'
)

file = client.files.create(
    file=open("my_tasks.jsonl", "rb"),
    purpose="batch"
)
print(file.id)  # сохраните этот ID

Это ровно тот же код, что и для OpenAI Batch API, — архитектура Bothub полностью совместима со спецификацией, поэтому SDK не нужно переписывать. Дальше создаётся сам batch:

batch = client.batches.create(
    input_file_id=file.id,
    endpoint="/v1/chat/completions",
    completion_window="24h"
)

completion_window пока всегда "24h", других вариантов нет. После создания остаётся поллинг — периодически дёргать статус, пока status не станет completed или failed. Для пакетов до 1000 строк опрашивать раз в 30 секунд, для крупных — раз в 2–5 минут. Если проверять статус каждые пять секунд, можно поймать 429 на сам polling, и это будет выглядеть как «пакет ещё не готов», хотя на самом деле вы просто упёрлись в rate limit на чтение.

И вот тут самое время вспомнить про третий уровень, окно. Потому что когда пакет завершается, вы скачиваете output-файл, и в нём может быть не всё. Часть строк будет с result.type = "succeeded", часть с "errored", часть с "expired". Если ваш пайплайн просто читает content из каждой строки и складывает в базу, строки с ошибками молча выпадут.

Решить можно примерно так:

results = client.files.content(batch.output_file_id)

for line in results.text.strip().split("\n"):
    row = json.loads(line)
    custom_id = row["custom_id"]

    if row["result"]["type"] == "succeeded":
        content = row["result"]["message"]["content"]
        save(custom_id, content)
    elif row["result"]["type"] == "errored":
        # разбираем ошибку и решаем, повторять или чинить запрос
        handle_error(custom_id, row["result"]["error"])
    elif row["result"]["type"] == "expired":
        # запрос не успел выполниться — только в новый пакет
        retry_later(custom_id, row["body"])

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

Для отслеживания подробных расходов по операции передавайте флаг include_usage=true в строке запроса:

batch = client.batches.retrieve("АЙДИ_БАТЧА")
print("Текущий статус:", batch.status)

if batch.status == "completed":
    print("ID готового файла:", batch.output_file_id)

А чтобы скачать результат, используйте идентификатор файла из поля output_file_id, чтобы получить итоговый документ.

import requests

def get_result():
    response = requests.get(
        "https://openai.bothub.chat/v1/files/АЙДИ_ФАЙЛА",
        headers={"Authorization": "Bearer ВАШ_API_КЛЮЧ_BOTHUB"}
    )

    link = response.text
    print(link)

get_result()

Можно отменить батч через функцию client.batches.cancel("АЙДИ_БАТЧА"), а получить список всех пакетов — через функцию batches = client.batches.list(limit=20).

Обработка ошибок

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

Ошибки до создания пакета

Пакет не создаётся или сразу падает в failed. Причины скучные — лишняя запятая в JSONL, неэкранированный символ, забытый custom_id, неверный endpoint, файл больше лимита. У Anthropic, кстати, параметры запроса проверяются только после завершения всего пакета — то бишь вы можете отправить 100 тысяч строк, прождать час и узнать, что в трёх из них кривой max_tokens. У OpenAI формат проверяется до запуска.

Нужно валидировать JSONL локально, до загрузки. Это дешевле, чем ловить failed через API. Минимальная проверка — распарсить каждую строку, убедиться, что custom_id уникален и не пустой, а body содержит model и messages.

import json

seen = set()
with open("my_tasks.jsonl") as f:
    for i, line in enumerate(f, 1):
        row = json.loads(line)  # упадёт на кривом JSON — и хорошо
        cid = row.get("custom_id")
        if not cid or cid in seen:
            raise ValueError(f"строка {i}: пустой или повторный custom_id")
        seen.add(cid)

Можно воспользоваться Pydantic-моделями для валидации DTO.

Ошибки на уровне очереди

Пакет не стартует или стартует частично, token_limit_exceeded. Вы отправили новую джобу, а он выводит организацию за потолок enqueued-токенов, и API отказывается его создавать.

Считать бюджет токенов до отправки. Если бюджет не влезает — ставить пакеты в собственную очередь на стороне приложения или ждать, пока завершатся предыдущие.

import time

def submit_with_backoff(client, file_id, max_attempts=5):
    delay = 30
    for attempt in range(max_attempts):
        try:
            return client.batches.create(
                input_file_id=file_id,
                endpoint="/v1/chat/completions",
                completion_window="24h"
            )
        except Exception as e:
            if "token_limit_exceeded" not in str(e):
                raise
            if attempt == max_attempts - 1:
                raise
            time.sleep(delay)
            delay *= 2  # 30 → 60 → 120 → ...

Ошибки внутри пакета

Самая коварная категория. Пакет completed, вы скачиваете output, а там часть строк с result.type = "succeeded", часть с "errored", часть с "expired". У Anthropic счётчики errored, canceled и expired в request_counts показывают ноль, пока обработка всего пакета не завершится — то бишь до последней строки вы не узнаете, сколько именно провалилось.

У OpenAI ошибки пишутся в отдельный error-файл, у Anthropic — в тот же results_url, но с type: "errored" и объектом ошибки внутри.

В таком случае можно парсить каждую (или не каждую, не стоит забывать про оптимизацию) строку и раскладывать по трём корзинам. invalid_request — чинить запрос и переотправлять только его; server error — повторять с задержкой; expired — только в новый пакет, потому что окно уже закрыто. И не переотправлять весь пакет из-за трёх упавших строк: успешные вы уже оплатили.

import json

def triage_results(raw_text):
    done, retry, fix = [], [], []
    for line in raw_text.strip().split("\n"):
        row = json.loads(line)
        cid = row["custom_id"]
        rtype = row["result"]["type"]

        if rtype == "succeeded":
            done.append((cid, row["result"]["message"]["content"]))
        elif rtype == "errored":
            err_type = row["result"]["error"]["type"]
            if err_type == "invalid_request_error":
                fix.append((cid, row["result"]["error"]))
            else:  # rate_limit, timeout, api_error и прочее
                retry.append(cid)
        elif rtype == "expired":
            retry.append(cid)
    return done, retry, fix

custom_id здесь работает как первичный ключ.

Ошибки опроса статуса

Отдельная ловушка, о которой в туториалах молчат. Если дёргать статус пакета каждые пять секунд, можно поймать 429 — и наивный retry-цикл решит, что «пакет ещё не готов», хотя на самом деле вы упёрлись в rate limit на сам polling. Выглядит одинаково, лечится по-разному.

Тут стоит заводить экспоненциальный backoff на polling. Для пакетов до 1000 строк разумно начинать с 30 секунд, для крупных — с 2–5 минут. И логировать created_at пакета, коли он висит дольше нескольких часов, стоит проверить, не застрял ли он в validating.

Заключение

Батч — это другой контракт. Вы платите меньше, но получаете не мгновенный ответ, а обещание вернуть результат в течение суток. Вместе с этим обещанием приходит обязанность самому разбирать, что вернулось: succeeded, errored или expired. Синхронный API такой обязанности не накладывает — там ошибка видна сразу.

Очередь — это ресурс с лимитом. Enqueued-токены, количество одновременных пакетов, окно завершения — вещи которые нужно изучить заранее.

50% скидки — это потолок. Настоящая цифра зависит от того, сколько запросов ушло в expired, сколько вы переотправили и был ли кеш системного промпта. В хорошем сценарии экономия глубже, чем 50%, потому что cached-input тоже дешевеет. В плохом — вы платите дважды за одни и те же строки.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.