Повторы Идемпотентность Надёжность LLM API

Повторы и идемпотентность LLM API без риска

Повторяйте вызовы LLM API без двойных списаний и дублирующихся побочных эффектов: какие ошибки повторять, бэкофф с джиттером, ключи идемпотентности и дедупликация на уровне шлюза.

AveMujica API 7 мин чтения

Повторять неудавшийся вызов LLM API безопасно только тогда, когда вы можете гарантировать, что вызов выполнится не более одного раза, даже если из-за сети кажется, будто он провалился дважды. Опасность тонка: запрос может завершиться успехом на стороне провайдера, затем ответ теряется на обратном пути, ваш клиент видит таймаут, повторяет — и вот с вас списали дважды или побочный эффект сработал дважды. Решение — пара дисциплин, которые всегда идут вместе: повторять только те ошибки, которые стоит повторять, с бэкоффом и джиттером, и делать каждый повторяемый запрос идемпотентным, чтобы дубликат распознавался и схлопывался, а не выполнялся заново. Эта статья показывает, как сделать и то, и другое.

Почему это особенно важно именно для трафика LLM: вызовы генерации стоят реальных денег за токен, а агентные рабочие процессы связывают вызовы в цепочки, где дублированный шаг искажает состояние ниже по потоку. «Просто повтори при сбое» — нормально для GET; для POST, который генерирует токены, это готовый инцидент с биллингом.

Классифицируйте ошибки, прежде чем что-либо повторять

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

КлассПримерыДействие
Повторяемый временныйтаймауты, сбросы соединения, 500, 502, 503, 429Повторить с бэкоффом
Неповторяемая ошибка клиента400 неверный запрос, 401/403 авторизация, 404, 422Быстро завершить с ошибкой, показать её
Неоднозначныйтаймаут после того, как запрос был отправленПовторять только если идемпотентен

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

Цикл повторов: бэкофф, джиттер и предел

Корректный цикл повторов делает экспоненциальный бэкофф, добавляет джиттер, чтобы одновременно работающие клиенты не синхронизировались, ограничивает число попыток и повторяет только повторяемые классы выше.

import random, time
import httpx

RETRYABLE = {429, 500, 502, 503, 504}

def call_with_retries(payload, key, max_attempts=5):
    for attempt in range(max_attempts):
        try:
            r = httpx.post(
                "https://your-gateway.example.com/v1/chat/completions",
                json=payload,
                headers={"Authorization": "Bearer AVE_GATEWAY_API_KEY",
                         "Idempotency-Key": key},
                timeout=30,
            )
        except (httpx.TimeoutException, httpx.TransportError):
            _sleep(attempt); continue          # ambiguous: safe because key is stable
        if r.status_code in RETRYABLE:
            _sleep(attempt); continue
        return r                                # success or non-retryable client error
    raise RuntimeError("exhausted retries")

def _sleep(attempt, base=0.5, cap=15.0):
    time.sleep(random.uniform(0, min(cap, base * (2 ** attempt))))

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

Что на самом деле делает ключ идемпотентности

Ключ идемпотентности — это уникальный идентификатор, сгенерированный клиентом (обычный выбор — UUID версии 4, v4 UUID), который вы прикрепляете к изменяющему состояние запросу через заголовок Idempotency-Key. Сервер сохраняет результат первого запроса под этим ключом и на любой дубликат с тем же ключом возвращает сохранённый результат, вместо того чтобы выполнять операцию снова. Генерируйте ключ один раз на логическую операцию и переиспользуйте его на каждом повторе этой операции:

import uuid
key = str(uuid.uuid4())          # once, before the first attempt
result = call_with_retries(payload, key)

Надёжные реализации добавляют отпечаток (fingerprint): хеш тела запроса, хранящийся рядом с ключом. Если позднее приходит запрос с тем же ключом, но другим телом, сервер отклоняет его — обычно с HTTP 409 Conflict (то есть idempotency_conflict) — вместо того чтобы молча вернуть неверный кешированный результат. У ключей есть TTL (24 часа — типичное окно); после истечения ключ забывается, и повторное использование начинает новую операцию.

Замечание о поддержке провайдерами: OpenAI явно документирует Idempotency-Key для процесса оформления заказа Agentic Commerce, где повтор вызова create или complete с тем же ключом обязан вернуть тот же результат, а несовпадение параметров возвращает 409. Обычные эндпоинты chat и responses не преподносят это как ключевую возможность, и большинство шлюзов, включая этот проект, сегодня не дедуплицирует вызовы /v1/chat/completions или /v1/responses автоматически. Именно поэтому шлюз — правильное место для добавления идемпотентности: это единая точка концентрации, где можно один раз создать хранилище дедупликации и охватить все сервисы независимо от поведения каждого апстрима.

Реализуйте идемпотентность на шлюзе

Когда множество сервисов вызывают модели через один шлюз, именно шлюз — то место, где дубликат можно перехватить для всей системы, если вы реализуете кеш с ключом. Это паттерн, который нужно построить, а не переключатель, который достаточно включить:

  1. Клиент отправляет запрос с Idempotency-Key: <uuid>.
  2. Шлюз ищет ключ в хранилище с коротким TTL (Redis подходит хорошо).
  3. Промах → переслать в апстрим, затем сохранить {ключ → ответ, отпечаток} перед возвратом.
  4. Попадание, тот же отпечаток → вернуть сохранённый ответ, не вызывая апстрим.
  5. Попадание, другой отпечаток → вернуть 409 Conflict.
  6. Попадание, всё ещё в процессе → вернуть 409 (или подождать), чтобы быстрая двойная отправка не выполнилась дважды.

Созданный один раз механизм превращает «безопасность повторов» из того, что каждый сервис реализует заново, в единое обеспеченное свойство. Он также защищает от дублирования, которое вы не вызывали: двойного клика пользователя, повторной доставки сообщения очередью или повтора прокси под капотом. Логируйте ключ и его результат, чтобы подавленный дубликат можно было проверить в собственном хранилище, а не оставить невидимым. Бури повторов по многим каналам — ещё одна причина, почему централизация помогает: прочитайте ограничения частоты и противодавление в LLM API о параллельной стороне той же проблемы.

Честная оговорка о гарантиях: настоящее «не более одного раза» выполняется только тогда, когда слой, создающий побочный эффект, участвует в механизме. Если сам апстрим не идемпотентен, дедупликация шлюза схлопывает дубликаты, видимые клиенту, но не может отменить запрос, который апстрим уже обработал до потери ответа. Считайте кеш с ключом сильной защитой от повторной отправки, а выражение «ровно один раз» оставляйте для путей, где действительно есть ключ идемпотентности апстрима или атомарная координация побочного эффекта.

Идемпотентность и стриминг

Стриминг усложняет дедупликацию, потому что ответ доставляется по частям и может быть прерван на полпути. Если поток обрывается, отдав половину токенов, слепой повтор с тем же ключом против наивного кеша может воспроизвести частичный результат. Более безопасные паттерны: кешировать под ключом только завершённый результат, трактовать прерванный поток как отсутствие результата (чтобы повтор чисто перегенерировал), или переподключаться с помощью собственного механизма возобновления потока, а не выпуская запрос заново. Детали возобновления SSE — в статье Потоковая передача ответов LLM через SSE.

Чек-лист повторов и идемпотентности

  • Ошибки классифицированы: повторять временные/429/5xx, быстро завершать на 4xx.
  • Неоднозначные таймауты повторяются только тогда, когда запрос идемпотентен.
  • Бэкофф экспоненциальный с полным джиттером и жёстким пределом попыток.
  • Один Idempotency-Key на логическую операцию, переиспользуемый на всех повторах.
  • Сервер или шлюз хранит ключ + отпечаток с TTL.
  • Дубликат с несовпадающим телом возвращает 409, а не устаревший успех.
  • Дубликаты в процессе обрабатываются, а не выполняются дважды.
  • Повторы при стриминге переподключаются или перегенерируют, а не воспроизводят частичные фрагменты.

Выберите бюджет повторов, который сбоит безопасно

Повторы обменивают задержку на вероятность успеха, а неограниченные повторы обменивают на неё всю систему целиком. Задайте явный бюджет тремя числами: максимум попыток, максимум суммарного времени и таймаут на попытку. Когда бюджет исчерпан, покажите чистую ошибку, а не повторяйте бесконечно — запрос, провалившийся пять раз с бэкоффом, почти никогда не удастся на шестой, а попытки съедают ёмкость, нужную другим запросам.

На масштабе проявляется тонкий режим отказа: если каждый из десяти сервисов повторяет независимо, их совокупный трафик повторов сам по себе может превысить ваш лимит частоты, что порождает больше 429, которые запускают новые повторы. Это самонанесённая буря повторов. Основная защита — бюджет повторов как доля от всего трафика: ограничьте повторы, скажем, 10% запросов, чтобы масштабный сбой не мог удвоить нагрузку. Когда сбоит целый канал, а не отдельные вызовы, правильная реакция — перестать повторять в стену и перенести трафик. Механика предохранителя и переключения при отказе принадлежит стратегии failover для LLM API, поэтому сочетайте с ней этот бюджет, а не дублируйте здесь логику предохранителя.

  • Ограничивайте попытки и суммарное реальное время, а не только число попыток.
  • Ограничивайте совокупные повторы, чтобы восстановительный трафик не мог усилить сбой.
  • Передавайте устойчивый отказ отдельного канала механизму failover, а не повторяйте вслепую.

Наблюдаемость замыкает петлю: измеряйте собственную частоту повторов — долю вызовов, которые являются повторами, — чтобы её рост предупреждал вас до инцидента. Эту метрику вы вычисляете по своим журналам вызовов; растущая доля повторов — самый ранний сигнал, что бюджет нужно ужесточить.

FAQ

Если запрос истёк по таймауту, был ли он обработан или нет?

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

Нужно ли генерировать новый ключ идемпотентности для каждого повтора?

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

Что, если два одинаковых запроса приходят почти в один и тот же миг?

Обрабатывайте случай «в процессе» явно. Пока первый запрос не завершился, второй с тем же ключом должен получить 409 Conflict (или заблокироваться до завершения первого), а не быть переслан, — иначе быстрая двойная отправка проскочит мимо кеша и выполнится дважды.

Проверьте дедупликацию на настоящем дубликате

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

Ссылки