Асинхронные запросы OpenAI в Python: тысячи запросов с повторами и учётом

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

6 мин · · разработка · автоматизация

Асинхронные запросы OpenAI в Python нужны, когда надо прогнать через модель тысячи текстов: описания товаров, обращения клиентов, строки из базы. Последовательный цикл здесь слишком медленный, а «запустить всё разом» упирается в ошибки и неучтённые расходы. Ниже — готовый скрипт на asyncio и AsyncOpenAI с ограничением параллельности, повторами при 429 и 503, учётом токенов и стоимости, а также расчёт цены партии.

Асинхронные запросы OpenAI в Python: из чего состоит скрипт

Четыре части:

  1. AsyncOpenAI — асинхронный клиент из официального пакета openai (pip install openai). Подключается к Zukko так же, как обычный: base_url="https://api.zukko.pro/v1" и ключ fetch_....
  2. asyncio.Semaphore — ограничивает, сколько запросов выполняется одновременно. Без него 10 000 задач стартуют разом.
  3. Повторы с экспоненциальной задержкой — только для временных ошибок. Ошибки ключа, баланса и формата запроса повторять бессмысленно.
  4. Учёт usage — считаем входные, выходные и кэшированные токены и сверяем стоимость.

Модель в примере — deepseek-v4.1-flash, одна из самых дешёвых в каталоге ($0.013 за 1M входа, $0.038 за 1M выхода). Любую другую модель можно подставить в MODEL. Актуальный список и цены — GET https://api.zukko.pro/v1/models или страница «Модели» в кабинете.

Какие ошибки повторять

Ошибки Zukko и что с ними делать в пакетной обработке:

КодЧто значитЧто делать
401неверный ключостановить партию, проверить ключ
402не хватает балансаостановить, пополнить баланс
403 model_not_allowed_for_keyмодели нет в списке разрешённых для ключаостановить, поправить ключ или модель
413слишком большой запросне повторять, уменьшить вход
429 key_daily_limit_reached / key_monthly_limit_reachedисчерпан лимит ключаостановить: повтор не поможет до сброса лимита
429 с другим кодомограничение на стороне провайдера модели (общего rate limit у Zukko нет)повторить с паузой
503провайдер недоступенповторить с паузой
503 model_disabledмодель выключенаостановить и взять другую модель из GET /v1/models

Если сервер прислал заголовок Retry-After, ждём столько секунд, сколько в нём указано. Иначе пауза растёт экспоненциально: 1, 2, 4, 8… секунд плюс случайная добавка, чтобы повторы разных задач не совпадали по времени.

Подробнее про 429 — в статье об ошибке 429, а как переключаться на запасную модель при 503 — в статье об обработке ошибок и fallback.

Полный скрипт

bash
import asyncio
import random

import openai
from openai import AsyncOpenAI

client = AsyncOpenAI(
    base_url="https://api.zukko.pro/v1",
    api_key="fetch_...",
    max_retries=0,  # повторы делаем сами, чтобы видеть коды ошибок
    timeout=120,
)

MODEL = "deepseek-v4.1-flash"

Разбор ключевых мест

Почему max_retries=0

OpenAI SDK по умолчанию сам повторяет часть ошибок (два раза). Это удобно для единичных вызовов, но в партии мешает: повторы SDK не видят кодов Zukko и не отличают исчерпанный лимит ключа от временного 429. Поэтому встроенные повторы выключены, и вся логика — в функции call.

Семафор

asyncio.Semaphore(CONCURRENCY) пропускает к API не больше 16 запросов одновременно, остальные задачи ждут. Значение 16 — стартовое, а не рекомендация провайдера. Если 429 почти нет, можно поднимать; если их много, снижайте. Пауза перед повтором выполняется внутри семафора: пока задача ждёт, она держит слот, и при волне ошибок общий темп сам замедляется.

Повторы и Idempotency-Key

Каждому элементу партии назначен свой заголовок Idempotency-Key. Если запрос уже выполнился и списал деньги, а ответ до вас не дошёл, повтор с тем же ключом вернёт сохранённый ответ и не спишет второй раз. Ключ живёт 24 часа и работает для успешных ответов. Повтор нужно отправлять после того, как первый запрос завершился или упал, — в скрипте так и происходит. Исключение — таймаут на стороне клиента: если сервер в это время ещё обрабатывает первый запрос, повтор может уйти к модели повторно. Поэтому таймаут стоит ставить с запасом. Подробности — в документации о лимитах и идемпотентности.

Если партию перезапускают, используйте другой префикс в ключе (например, номер прогона), иначе повтор с тем же ключом, но другим текстом запроса вернёт 422.

Учёт usage и стоимости

Скрипт считает стоимость двумя способами:

  • по прайсу: свежий вход × цена входа + кэш × цена чтения кэша + выход × цена выхода. Свежий вход — это prompt_tokens минус prompt_tokens_details.cached_tokens;
  • по заголовку X-Zukko-Cost-Micros: обычный (не потоковый) ответ Zukko несёт стоимость запроса в микродолларах (1 000 000 = $1). Чтобы прочитать заголовки, вызов идёт через with_raw_response, а объект ответа получаем через raw.parse().

Второе число точнее: оно учитывает всё, что реально списано. У потоковых ответов заголовка нет — там стоимость смотрите в истории списаний.

Сколько стоит партия

Формула для партии без кэша:

стоимость = N × (вход на запрос × цена входа + выход на запрос × цена выхода) / 1 000 000

Пример: 10 000 запросов, в каждом 600 токенов на входе и 200 на выходе. Всего 6 млн входа и 2 млн выхода.

  • deepseek-v4.1-flash: 6 × $0.013 + 2 × $0.038 = $0.078 + $0.076 = $0.154
  • gpt-6.1-sol: 6 × $0.27 + 2 × $1.32 = $1.62 + $2.64 = $4.26

С кэшем. У GPT-моделей кэшированная часть входа видна в cached_tokens. Допустим, 400 из 600 токенов — общая инструкция, и со второго запроса она читается из кэша. Запись в кэш у GPT отдельно не списывается: первый запрос идёт целиком по цене входа. Тогда для gpt-6.1-sol (чтение кэша $0.013 за 1M):

  • первый запрос: 600 × $0.27 / 1 000 000 = $0.000162;
  • свежий вход остальных: 9 999 × 200 = 1 999 800 × $0.27 / 1 000 000 = $0.539946;
  • чтение кэша: 9 999 × 400 = 3 999 600 × $0.013 / 1 000 000 = $0.0519948;
  • выход: 2 млн × $1.32 = $2.64;
  • итого: $3.2321028, около $3.232.

Это допущение: сколько входа попадёт в кэш, решает провайдер, и при паузах кэш может истечь — тогда запрос снова идёт целиком по цене входа. Точную сумму дают X-Zukko-Cost-Micros и история списаний.

Перед большим прогоном запустите 50–100 запросов из реальных данных и умножьте их фактическую стоимость на размер партии. Это надёжнее, чем угадывать средний размер ответа.

Если запросов сотни тысяч

  • Сохраняйте результаты по ходу. Пишите ответ в файл или базу сразу после получения, а при перезапуске пропускайте уже обработанные item_id.
  • Делите на порции. Обрабатывайте по 5–10 тысяч задач за вызов main, чтобы не держать в памяти сотни тысяч объектов.
  • Отдельный ключ с лимитом. Создайте в кабинете ключ для пайплайна с дневным лимитом в долларах и списком разрешённых моделей. Ошибка в цикле тогда не съест весь баланс: партия остановится на 429 key_daily_limit_reached.
  • Смотрите расход. Итог прогона — в кабинете, раздел «Использование», и через API истории списаний.

Базовые примеры клиента — в документации по Python.

Частые вопросы

Сколько запросов отправлять параллельно?

Начните с 10–20 и смотрите на долю 429. Универсального числа нет: оно зависит от модели и провайдера.

Нужно ли повторять 429?

Только если это не лимит ключа. При кодах key_daily_limit_reached и key_monthly_limit_reached повтор не поможет до сброса лимита (дневной сбрасывается в 00:00 UTC).

Почему не использовать встроенные повторы SDK?

Они не различают коды Zukko: лимит ключа или выключенную модель SDK будет повторять так же, как временный сбой. Своя логика позволяет остановить партию сразу.

Как узнать точную стоимость партии?

Сложите X-Zukko-Cost-Micros по всем ответам, как в скрипте, или посмотрите историю списаний в кабинете.

zukko

Платите в 25×
меньше

за Claude Code & ChatGPT

Один ключ fetch_* и base URL api.zukko.pro — в Cursor, Claude Code, Codex и любом SDK.