中文翻译暂不可用,显示俄语原文。

Как проектировать graceful degradation при отказе LLM API?

Краткий тезис

Graceful degradation — это способность системы продолжать работу (пусть с пониженным качеством или функциональностью) при отказе внешнего LLM API. Проектирование включает многоуровневую цепочку fallback (провайдеры, self-hosted, шаблоны), управление таймаутами и повторными попытками, кэширование ответов, прозрачную для пользователя деградацию интерфейса и систему оповещений о сбоях. Без этого любой сбой провайдера превращается в полный отказ сервиса.

1. Термин: Graceful degradation

Graceful degradation (изящная деградация) — это подход, при котором система не падает целиком при отказе одного из компонентов, а продолжает работать с ограниченной функциональностью или запасными данными. Противоположность — crash-only (полный останов при любой ошибке).

В контексте LLM API graceful degradation означает: если OpenAI вернул ошибку, мы не показываем пользователю "500 Internal Server Error", а пробуем другой провайдер, отдаём кэшированный ответ или финально показываем шаблонное сообщение с объяснением ситуации.

2. Fallback chain (цепочка запасных вариантов)

Основной механизм — fallback chain: список LLM-провайдеров, отсортированных по приоритету (обычно по стоимости / качеству). Если первый не отвечает, вызывается следующий.

Структура цепочки

УровеньПровайдерКачествоЗадержкаСтоимость
1 (основной)OpenAI GPT-4 / GPT-4oнаивысшее~1–3 с$$$
2Anthropic Claude 3 / 3.5высокое~2–4 с$$
3Groq (Llama 3)среднее~0.5–1 с$
4Self-hosted Llama 3 / Mistralсреднее–низкое~5–15 с$
5 (крайний)Локальный LLM (CPU, маленькая модель)низкое>10 сбесплатно

Принципы построения

  • Health check: перед вызовом проверять доступность провайдера (можно через отдельный эндпоинт или по последнему успешному ответу).
  • Порог ошибок: если провайдер вернул ошибку >3 раз подряд — временно исключать из цепочки.
  • Асинхронность: запросы к следующему провайдеру можно запускать после таймаута предыдущего, не блокируя поток.
  • Конфигурируемость: цепочка должна задаваться в конфиге (YAML/env) без передеплоя.

3. Timeouts и retry-политика

Таймауты — критический элемент: если один провайдер завис, мы не должны ждать его минутами.

Виды таймаутов

  • Connect timeout — время на установку TCP-соединения (обычно 3–5 с).
  • Read timeout — время ожидания первого байта ответа (10–15 с).
  • Total timeout — общее время на весь запрос (20–30 с).

Retry с exponential backoff и jitter

import asyncio
import random

async def call_llm_with_retry(provider_url, payload, max_retries=3):
    for attempt in range(max_retries):
        try:
            async with aiohttp.ClientSession() as session:
                async with session.post(provider_url, json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp:
                    return await resp.json()
        except (aiohttp.ClientError, asyncio.TimeoutError) as e:
            if attempt == max_retries - 1:
                raise
            delay = (2 ** attempt) + random.uniform(0, 0.5)  # exponential backoff + jitter
            await asyncio.sleep(delay)

Jitter (случайная прибавка) — предотвращает "громовой стад" (thundering herd) при одновременных повторах.

4. Кэширование ответов (cached responses)

Если все провайдеры отказали, можно отдать кэшированный ответ на похожий запрос.

Типы кэша

  • Exact match — по точному хешу запроса (MD5/SHA256). Подходит для частых повторных вопросов.
  • Semantic cache — по эмбеддингу запроса (ближайший сосед в векторной БД). Позволяет находить ответы на похожие вопросы.

Интеграция с graceful degradation

class LLMCache:
    def __init__(self, redis_client, similarity_threshold=0.95):
        self.redis = redis_client
        self.threshold = similarity_threshold
        self.embedding_model = SentenceTransformer('all-MiniLM-L6-v2')

    async def get(self, query: str) -> str | None:
        # Попробовать точный хеш
        if exact := await self.redis.get(f"exact:{hash(query)}"):
            return exact
        # Попробовать semantic cache
        emb = self.embedding_model.encode(query)
        nearest = await self.redis.ft_search(emb)
        if nearest and nearest.score > self.threshold:
            return nearest.answer
        return None

    async def set(self, query: str, answer: str):
        await self.redis.set(f"exact:{hash(query)}", answer, ex=3600)
        # также сохраняем эмбеддинг для semantic cache

При отказе всех провайдеров мы вызываем cache.get(query). Если есть похожий ответ — возвращаем его с пометкой "cached" (чтобы пользователь знал, что ответ не свежий).

5. Degraded UX (пониженное качество пользовательского опыта)

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

Пример шаблонного ответа

{
  "status": "degraded",
  "message": "AI-ассистент временно недоступен. Пожалуйста, попробуйте позже.",
  "fallback_answer": "К сожалению, я не могу ответить на ваш вопрос прямо сейчас. Вы можете повторить запрос через несколько минут.",
  "timestamp": "2025-04-09T10:30:00Z"
}
  • Прозрачность: явный флаг status: degraded.
  • Тональность: вежливая, без технического жаргона.
  • Action: подсказка, что можно сделать (повторить, написать в поддержку).

Уровни деградации

УровеньСостояниеUX
0 (норма)основной провайдерполный функционал
1fallback на другой APIбез изменений для пользователя
2fallback на self-hostedнебольшая задержка, возможны менее качественные ответы
3кэшированный ответпометка "ответ из кэша", предупреждение о возможной устарелости
4шаблонный ответобъяснение отказа, кнопка "повторить"

6. Alerting и мониторинг

Каждое переключение на fallback — событие, которое должно логироваться и отправлять метрики / алерты.

Метрики (Prometheus)

  • llm_provider_requests_total{provider="openai", status="ok|error|timeout"}
  • llm_fallback_switch_total{from_provider="openai", to_provider="anthropic"}
  • llm_cache_hit_total{cache_type="exact|semantic"}
  • llm_degraded_response_total{level="template"}

Alerting (PagerDuty / Slack)

  • Critical: 100% запросов ушли на шаблон >5 минут → дежурный поднимается.
  • Warning: доля отказов основного провайдера >10% за 5 минут → уведомление в Slack.

Health check endpoint

@app.get("/health")
async def health():
    providers_status = await check_all_providers()
    return {
        "status": "ok" if any(p["alive"] for p in providers_status) else "degraded",
        "providers": providers_status,
        "cache_available": await redis.ping()
    }

7. Тестирование graceful degradation

Интеграционные тесты (pytest + responses)

def test_fallback_chain(monkeypatch):
    # Имитируем отказ OpenAI, успех Anthropic
    monkeypatch.setattr("app.llm.openai_call", lambda q: (_ for _ in ()).throw(TimeoutError))
    monkeypatch.setattr("app.llm.anthropic_call", lambda q: "fallback answer")

    response = client.post("/chat", json={"query": "hello"})
    assert response.status_code == 200
    assert response.json()["answer"] == "fallback answer"
    assert "provider" in response.json()  # проверяем, что указан источник

Chaos engineering (Gremlin / Chaos Mesh)

  • Регулярно отключать один из провайдеров и проверять, что system продолжает работать.
  • Вводить случайные задержки (latency injection), чтобы триггерить таймауты.

8. Связь с Circuit Breaker паттерном

Circuit Breaker — автоматический выключатель, который после N ошибок переводит провайдер в состояние "open" (не вызывать) на некоторое время. Затем пробует снова (half-open). Graceful degradation использует circuit breaker как часть fallback chain: если провайдер открыт, он просто пропускается, и выбирается следующий.

9. Сравнение стратегий обработки отказов

СтратегияОписаниеПлюсыМинусыКогда использовать
RetryПовторить запрос к тому же провайдеруПросто, ничего не теряемУвеличивает latency, может усугубить нагрузкуПри временных ошибках (429, 503)
FallbackПереключиться на другого провайдераГарантирует ответЗависит от доступности второго провайдераПри постоянных ошибках или таймаутах
CacheВернуть старый ответМгновенно, 0 latencyОтвет может быть неактуальнымКогда нужен любой ответ, а не свежий
Degraded UXПоказать шаблонВсегда работает, честно с пользователемПользователь не получает полезного ответаКогда все остальное провалилось

Пет-проект для закрепления

Задача

Реализовать микросервис на FastAPI, который принимает POST /chat с полем "query" и возвращает ответ LLM с graceful degradation.

Инструменты

Шаги

  1. Создать класс LLMProvider с методами call(query).
  2. Реализовать 3 провайдера: OpenAI (заглушка с имитацией таймаута), Anthropic (заглушка с успешным ответом), Local (заглушка с шаблоном).
  3. Реализовать FallbackRouter: принимает список провайдеров, вызывает их по порядку с таймаутами.
  4. Добавить CacheLayer на Redis: точный хеш + semantic cache через эмбеддинги (можно использовать sentence-transformers).
  5. В endpoint /chat вызвать роутер, при полном отказе вернуть шаблонный ответ.
  6. Добавить метрики: счётчики вызовов, fallback-переключений, cache hit/miss.
  7. Написать интеграционные тесты: подмена провайдеров через monkeypatch, проверка fallback цепочки.

Ожидаемый результат

Сервис, который:

  • при доступности OpenAI возвращает ответ от него;
  • при отказе OpenAI (выброс исключения) автоматически переключается на Anthropic;
  • при отказе обоих коммерческих API возвращает кэшированный ответ (если есть);
  • при пустом кэше возвращает шаблонное сообщение с HTTP 200 и полем status: degraded;
  • экспортирует метрики на /metrics для Prometheus.

Связь с другими вопросами

ВопросТема
830Кэширование в RAG-системах
831Rate limiting и управление нагрузкой
833Мониторинг и observability в Agentic RAG
834Обеспечение отказоустойчивости
826Асинхронные вызовы LLM
829Балансировка запросов между провайдерами

Навигация