English translation is not available yet. Showing Russian content.
Как проектировать 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 с | $$$ |
| 2 | Anthropic Claude 3 / 3.5 | высокое | ~2–4 с | $$ |
| 3 | Groq (Llama 3) | среднее | ~0.5–1 с | $ |
| 4 | Self-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 (норма) | основной провайдер | полный функционал |
| 1 | fallback на другой API | без изменений для пользователя |
| 2 | fallback на 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.
Инструменты
- Python 3.11
- FastAPI
- aiohttp (асинхронные HTTP-запросы)
- Redis (кэш)
- prometheus_client (метрики)
- pytest + pytest-asyncio (тесты)
Шаги
- Создать класс
LLMProviderс методамиcall(query). - Реализовать 3 провайдера: OpenAI (заглушка с имитацией таймаута), Anthropic (заглушка с успешным ответом), Local (заглушка с шаблоном).
- Реализовать
FallbackRouter: принимает список провайдеров, вызывает их по порядку с таймаутами. - Добавить
CacheLayerна Redis: точный хеш + semantic cache через эмбеддинги (можно использоватьsentence-transformers). - В endpoint
/chatвызвать роутер, при полном отказе вернуть шаблонный ответ. - Добавить метрики: счётчики вызовов, fallback-переключений, cache hit/miss.
- Написать интеграционные тесты: подмена провайдеров через monkeypatch, проверка fallback цепочки.
Ожидаемый результат
Сервис, который:
- при доступности OpenAI возвращает ответ от него;
- при отказе OpenAI (выброс исключения) автоматически переключается на Anthropic;
- при отказе обоих коммерческих API возвращает кэшированный ответ (если есть);
- при пустом кэше возвращает шаблонное сообщение с HTTP 200 и полем
status: degraded; - экспортирует метрики на
/metricsдля Prometheus.
Связь с другими вопросами
| Вопрос | Тема |
|---|---|
| 830 | Кэширование в RAG-системах |
| 831 | Rate limiting и управление нагрузкой |
| 833 | Мониторинг и observability в Agentic RAG |
| 834 | Обеспечение отказоустойчивости |
| 826 | Асинхронные вызовы LLM |
| 829 | Балансировка запросов между провайдерами |
Навигация
- Предыдущий: 831
- Следующий: 833
- Индекс: 00. Индекс разборов