Привет, Хабр!
Вызов языковой модели давно стал обычной строчкой в любом коде. Классификация тикетов, разбор писем, извлечение полей из накладных, генерация описаний для каталога — всё это пишется в три строки: импортировали клиент, отдали промпт, забрали response.choices[0].message.content. Библиотеки удобные, документация подробная, туториалов вагон.
Только все туториалы заканчиваются там, где начинается настоящая работа. Они показывают, как получить ответ. Они не показывают, что будет, когда ответов нужно пятьсот тысяч. Когда провайдер отвечает 429. Когда клиент отвалился по таймауту, а модель дописала ответ до конца и деньги с вас взяли. Когда один и тот же промпт при temperature=0 два раза подряд отдаёт разные строки, и в CI краснеет тест, написанный на равенство.
Забавно, что с обычными ненадёжными зависимостями питонист управляется отлично. Ставит таймаут на requests, вешает retry, помнит про идемпотентность, кеширует, следит за пулом соединений. А LLM‑клиент попадает в код так, будто это локальная функция: без таймаута, без ограничения конкурентности, без всякой оглядки на то, что она недетерминированная, платная и медленная.
В статье рассмотрим пять мест, где это вылезает.
asyncio.gather, который выжигает квоту
Первый подход к пакетной обработке всегда выглядит так. Есть десять тысяч отзывов, каждый надо классифицировать, синхронный цикл считает это до вечера. Значит, asyncio.
async def classify(text: str) -> str: resp = await client.chat.completions.create( model=MODEL, messages=[{"role": "user", "content": PROMPT + text}], ) return resp.choices[0].message.content results = await asyncio.gather(*[classify(t) for t in texts])
Коротко, красиво, но не работает. gather запускает десять тысяч корутин почти одновременно, клиент пытается их отправить, провайдер отвечает лавиной 429. И тут неудачные запросы тоже съедают лимит. Чем сильнее вы долбитесь, тем меньше проходит. Задача на десять минут не заканчивается никогда.
Первым делом нужен потолок конкурентности:
sem = asyncio.Semaphore(20) async def classify(text: str) -> str: async with sem: resp = await client.chat.completions.create(...) return resp.choices[0].message.content
Двадцать одновременных запросов вместо десяти тысяч. Число подбирается под тариф, и подбирать его надо не по RPM, а по TPM. Лимиты считаются минимум по двум осям сразу: запросы в минуту и токены в минуту. С длинными промптами вы упрётесь в токены задолго до того, как упрётесь в запросы, а семафор по числу корутин про токены не знает ничего.
Дальше — читайте, что сервер вам говорит. В каждом ответе едут заголовки x-ratelimit-remaining-*, а вместе с 429 приходит retry-after-ms: сервер прямым текстом сообщает, через сколько миллисекунд бюджет восстановится. Слепой sleep(5) вместо этого числа — либо лишнее ожидание, либо второй 429.
Ещё разберитесь, что делают встроенные ретраи SDK. Клиент OpenAI по умолчанию повторяет запрос дважды с экспоненциальной задержкой. Для случайного всплеска это нормально, при устойчивой перегрузке бесполезно: два повтора кончатся, исключение всё равно прилетит вам, просто на несколько секунд позже. Настраивается это у клиента, а не на вызове:
client = AsyncOpenAI(max_retries=5, timeout=60.0)
И самое главное, до чего доходят почему‑то в последнюю очередь. Если задача не интерактивная — ночная разметка, переразметка исторических данных, описания для каталога, ей вообще не нужен онлайн‑эндпоинт. У всех крупных провайдеров есть batch‑режим: кидаете файл с запросами, забираете результат в течение суток, платите примерно вдвое меньше. Ни 429, ни семафоров, ни борьбы за квоту. Половина кода с ретраями существует только потому, что задачу засунули в синхронный API, где ей не место.
Ретрай, который платит дважды
Потом в коде появляется retry. Обычно такой:
from tenacity import retry, stop_after_attempt, wait_exponential @retry(stop=stop_after_attempt(5), wait=wait_exponential(min=1, max=60)) async def classify(text: str) -> str: resp = await client.chat.completions.create(..., timeout=10.0) return resp.choices[0].message.content
Таймаут в десять секунд срабатывает у вас на клиенте. Модель об этом не в курсе: она продолжает генерировать, дописывает ответ до конца, и токены вам посчитают все. Клиент уже отвалился и пошёл на второй круг. При длинных ответах и бодром таймауте вы спокойно платите за четыре генерации, из которых используете одну. На тысяче документов сумма выходит заметная, а в логах видно только «иногда медленно отвечает».
Поэтому таймаут ставится по реальному времени генерации, а не от балды. Ответ на две тысячи токенов физически не появится за десять секунд, сколько его ни жди. Если хочется рано понимать, что запрос завис, включайте стриминг и смотрите на время до первого токена: вот это честный признак затыка, а не общая длительность.
Ретраить тоже надо с разбором. 429 и 5xx — повторяем. 400 (невалидная схема, контекст не влез) — повторять нечего, ответ будет тот же, а попытки оплатятся. Отказ модели повтором не лечится. Обёрнутый в @retry вызов без фильтра по типу исключения просто аккуратно оплачивает одинаковые неудачи.
И заведите счётчик токенов. В каждом ответе есть usage, десять строк кода складывают prompt_tokens и completion_tokens в метрику, и однажды это спасёт вас от объяснительной: скачок расхода будет виден в тот же день, а не в конце месяца в счёте.
temperature=0 не делает вызов детерминированным
Все знают: хочешь воспроизводимости — ставь нулевую температуру. Сэмплирования нет, декодирование жадное, функция стала чистой. Можно кешировать по хешу промпта, можно писать тест на равенство строк.
Отправьте один и тот же промпт с temperature=0 десять раз подряд — и байт в байт совпадут далеко не все ответы.
Дело в том, что ваш запрос никогда не едет по серверу один. Его собирают в батч вместе с чужими, а размер и состав батча зависят от того, кто в эту секунду ещё стучится в API. Ядра, которые считают суммы внутри модели, при разном размере батча складывают числа в разном порядке. Порядок сложения float меняет последний бит. Изменившийся бит меняет выбор токена. Дальше расхождение растёт как снежный ком, и вот у вас два разных ответа на один промпт.
seed тут, кстати, не спасает: он управляет сэмплером, а при нулевой температуре сэмплера уже нет, случайность приходит совсем с другой стороны.
Побороть это можно, только когда инференс ваш. В движках вроде SGLang есть режим детерминированного вывода на специальных ядрах, нечувствительных к размеру батча, — ценой изрядного куска пропускной способности. С внешним API остаётся принять факт и писать код, который не разваливается от смены пары символов.
Что это значит на практике. Кеш по хешу промпта работать будет, но не как оптимизация чистой функции, а как обычный кеш дорогого вызова: вы просто соглашаетесь считать первый полученный ответ правильным. Нормальное решение, только примите его осознанно.
А вот так делать нельзя:
def test_extract(): assert extract(TEXT) == "Иванов Иван Иванович, 1984"
Такой тест зелёный ровно до того дня, когда провайдер поменяет балансировку. Проверять надо свойства ответа, а не его буквы.
Тесты, которые ходят в живую модель
Раз уж заговорили про тесты. В любом проекте рано или поздно заводится набор, где каждый тест дёргает настоящий API. Медленный, платный, падает от сетевого чиха. Через месяц команда вешает на него @pytest.mark.skip и живёт дальше вообще без тестов на самую хрупкую часть системы.
Разделите это сразу, и жить станет проще.
Юнит‑тесты в сеть не ходят. Ваша логика вокруг вызова — сборка промпта, разбор ответа, валидация, обработка ошибок, ретраи — это обычный код, и тестируется он обычным способом, на подменённом клиенте. Записанные ответы держите фикстурами, руками или через vcrpy, который один раз пишет HTTP‑обмен на диск и потом проигрывает без сети.
@pytest.fixture def fake_client(monkeypatch): async def fake_create(**kwargs): return load_fixture("invoice_response.json") monkeypatch.setattr(client.chat.completions, "create", fake_create)
Так ловится подавляющая часть ваших багов, потому что баги живут не в модели. Они живут в парсинге, в валидации, в забытой ветке finish_reason == "length".
Качество самой модели меряется отдельно и в тестовый прогон не входит. Это eval‑набор: полсотни‑сотня примеров с эталонными ответами и метрикой, осмысленной для вашей задачи.
Гоняется по кнопке и перед сменой модели или промпта, а не на каждом коммите. Метрика почти никогда не «строгое равенство»: для извлечения полей это точность по каждому полю, для классификации обычные precision и recall, для свободного текста проверка обязательных элементов или оценка моделью‑судьёй, откалиброванной на размеченном куске.
После десяти лет обычной разработки к этому надо привыкнуть: у вас теперь два разных вида проверок. Тесты отвечают на вопрос «код делает то, что задумано», они быстрые и детерминированные. Evals отвечают на вопрос «модель справляется с задачей», и детерминированными они не будут никогда. Смешаете в одном прогоне — не будет ни того, ни другого.
Промпт, склеенный в цикле
Последняя история про то, что происходит с контекстом, когда данных становится много.
context = "\n".join(chunk.text for chunk in retrieved_chunks) prompt = f"{SYSTEM}\n\nКонтекст:\n{context}\n\nВопрос: {question}"
Приезжает документ на двести страниц, и дальше один из трёх сценариев.
Провайдер отвечает ошибкой про превышение контекста — это, как ни странно, лучший исход, потому что вы хотя бы узнали.
Запрос проходит, но ответ обрывается на середине JSON: упёрлись в лимит выходных токенов, а
finish_reasonникто не смотрит. Оборванный ответ выглядит точно так же, как нормальный, только неполный, и тихо портит вам данные, пока кто‑нибудь не заметит, что в базе у каждого сотого документа поле обрезано на середине слова.Или всё формально работает, а качество проседает: нужный кусок оказался в середине гигантского контекста, где модель традиционно читает хуже всего.
Значит, токены считаем до отправки. Для моделей OpenAI это tiktoken, у остальных есть свои эндпоинты подсчёта. Помните, что лимит контекста делится между входом и выходом, поэтому бюджет под ответ резервируется заранее:
enc = tiktoken.encoding_for_model(MODEL) budget = CONTEXT_LIMIT - MAX_OUTPUT_TOKENS - len(enc.encode(SYSTEM)) - 200 selected, used = [], 0 for chunk in retrieved_chunks: cost = len(enc.encode(chunk.text)) if used + cost > budget: break selected.append(chunk) used += cost
И finish_reason проверяем на каждом ответе, без исключений.
К тому же если в промпте есть большой неизменный кусок — системная инструкция, набор примеров, справочник, документ, к которому идёт серия вопросов, — включайте кеширование промпта. Неизменный префикс считается один раз, повторные запросы с тем же началом стоят заметно дешевле и отвечают быстрее. Условие одно: префикс должен совпадать побайтово. Динамический таймстамп, воткнутый в начало системного промпта, обнуляет всю экономию, и узнаете вы об этом не из логов.
Что со всем этим делать
Если свести пять историй в одну мысль: код, который вызывает модель, ломается не в модели. Он ломается там, где мы расслабились и стали обращаться с походом по сети как с локальным вызовом.
Причём все нужные привычки у нас давно есть. Ограничивать конкурентность умеем. Читать заголовки ответа умеем. Отличать повторяемую ошибку от неповторяемой умеем. Отделять тесты от нагрузочных прогонов умеем. Надо только увидеть в client.chat.completions.create не функцию, а поход в чужой перегруженный сервис, который берёт деньги за каждую попытку и не обещает одинакового ответа на одинаковый вопрос.
С недетерминизмом бороться бесполезно, он вшит в то, как работает серверный инференс. Гораздо продуктивнее строить всё исходя из того, что ответ каждый раз чуть‑чуть другой: валидация вместо доверия, свойства вместо равенства, evals вместо ассертов.
Разобраться, как встроить ИИ в Python‑разработку без лишней магии и технического долга, можно на курсе «ИИ для Python‑разработчиков». В программе — работа с большими кодовыми базами, проверка и переработка кода, тестирование, отладка, RAG, ИИ‑агенты в CI/CD и поддержка архитектурных решений.
А если хочется продолжить знакомство с новыми инструментами и практиками, ниже — дайджест бесплатных уроков на июль по разработке, инфраструктуре, данным, тестированию и другим направлениям.