Перейти к содержанию

Делаем надежный LLM Client

Шаг 1

К этому моменту наш проект AI Support Desk уже выполняет полезный сценарий: получает обращение, собирает сообщения, вызывает языковую модель и показывает резюме. Конфигурация вынесена из main.py в отдельный класс Settings.

Но проект вырос, а его структура почти не изменилась. В main.py одновременно находятся:

  • запуск программы
  • консольный ввод и вывод
  • правила AI-сценария
  • создание SDK-клиента
  • вызов ProxyAPI
  • разбор ответа провайдера

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

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

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

  • консольный интерфейс меняется, если вместо терминала появляется API
  • AI-сценарий меняется, если меняются правила обработки обращения
  • LLM Client меняется, если меняется провайдер, SDK или политика надежности
  • конфигурация меняется, если появляются новые внешние параметры

Именно по этим границам будем разделять проект.

Что такое LLM Client?

LLM Client в нашем проекте — это собственный компонент приложения, через который остальной код работает с языковой моделью. Это не сама модель, не ProxyAPI и не класс OpenAI из установленного пакета.

Эти понятия удобно разделять:

Понятие Роль
Языковая модель Получает сообщения и генерирует продолжение
ProxyAPI Предоставляет сетевой эндпоинт и доступ к выбранной модели
OpenAI библиотека Формирует HTTP-запросы и преобразует ответы в Python-объекты
Наш LLMClient Скрывает SDK, настройки провайдера, обработку ответа и политику надежности от остального приложения

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

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

В результате мы получим следующую структуру:

ai-support-desk/
├── app/
│   ├── __init__.py
│   ├── config.py
│   ├── console.py
│   ├── support_service.py
│   └── llm/
│       ├── __init__.py
│       ├── client.py
│       └── errors.py
├── .env
├── .gitignore
├── main.py
└── requirements.txt

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

В структуре есть основной каталог app, который содержит код, который нужен при обычном запуске AI Support Desk

Внутри app появится дополнительный каталог llm. Он объединит все, что относится к технической интеграции с языковой моделью. Благодаря этому файлы провайдера не смешиваются с пользовательским интерфейсом и правилами службы поддержки.

А теперь давайте разберем основные файлы:

app/config.py

Этот файл особо не меняется, в нем по-прежнему будет находиться класс Settings. Он отвечает за вопрос «с какими настройками запускается приложение», но не за вопрос «что приложение делает с обращением».

app/console.py

Этот файл отвечает за взаимодействие с терминалом, и он будет содержать input(), print() и форматирование результата для человека. Этот код не знает, как устроен HTTP-запрос, что находится в choices и какой токен используется. Если позже консоль заменит веб-интерфейс, AI-сценарий останется в сервисе и его не придется переписывать.

app/support_service.py

В этом файле будет находиться логика функции создания резюме обращения. Именно здесь будет находиться системная инструкция службы поддержки, потому что она относится к назначению AI-функции. Но сервис не создает SDK-клиент, не читает .env и не работает с терминалом. Название support_service.py связано не с внешней службой или сервером, а с сервисом приложения — классом, который реализует один законченный пользовательский сценарий.

app/llm/client.py

Это основной файл интеграции. Он отвечает за техническую часть работы с моделью: создает объект OpenAI с эндпоинтом и API-ключом, передает модели сообщения и параметры генерации, получает сырой ChatCompletion и преобразует его в LLMResult. Клиент является универсальным, и его можно будет использовать не только для резюмирования, но и для других AI-функций.

app/llm/errors.py

Этот файл содержит публичный язык ошибок интеграции. То есть здесь будут описаны LLMClientError и его подклассы: ошибка доступа, неверного запроса, недостаточный баланс, лимиты, таймауты, соединения и провайдера. Файл не выполняет HTTP-запросы и не обрабатывает исключения через try. Он только определяет классы ошибок, которыми могут обмениваться модули. Отдельный errors.py позволяет сервису и тестам импортировать наши стабильные ошибки, не завися от названий классов конкретного SDK.

То есть наши компоненты будут связаны следующим образом:

Направление зависимостей важно:

  • консоль знает AI-сценарий, но не знает SDK
  • AI-сценарий знает LLM Client, но не читает .env
  • LLM Client знает провайдера, но не использует input() и print()
  • main.py создает компоненты и связывает их

На первой лекции подготовим пакет app и вынесем интеграцию с моделью в app/llm/client.py. Остальные части пока останутся в main.py, чтобы изменение было контролируемым.

Давайте перейдем к практике. В корне проекта создайте каталог app. Внутри создайте:

  • пустой файл __init__.py
  • каталог llm

Внутри app/llm создайте еще один пустой __init__.py.

ai-support-desk/
├── app/
│   ├── __init__.py
│   └── llm/
│       └── __init__.py
├── config.py
└── main.py

Переместите существующий config.py из корня проекта в каталог app. Код класса Settings пока не изменяйте.

ai-support-desk/
├── app/
│   ├── __init__.py
│   ├── config.py
│   └── llm/
│       └── __init__.py
└── main.py

После перемещения старый импорт в main.py временно станет неверным. Найдите:

from config import Settings

Замените:

from app.config import Settings

Импорт читается слева направо: из пакета app, из модуля config, импортировать класс Settings.

Теперь в каталоге app/llm создайте файл client.py и вставьте этот код:

from dataclasses import dataclass

from openai import OpenAI

from app.config import Settings

Message = dict[str, str]

@dataclass(frozen=True)
class LLMResult:
    text: str | None
    model: str
    finish_reason: str | None
    prompt_tokens: int | None
    completion_tokens: int | None
    total_tokens: int | None
    response_id: str

class LLMClient:
    def __init__(self, settings: Settings) -> None:
        self._settings = settings
        self._client = OpenAI(
            base_url=str(settings.base_url),
            api_key=settings.llm_api_key.get_secret_value(),
        )

    def generate(self, messages: list[Message]) -> LLMResult:
        response = self._client.chat.completions.create(
            model=self._settings.model,
            messages=messages,
            temperature=self._settings.temperature,
            max_completion_tokens=self._settings.max_output_tokens,
        )

        choice = response.choices[0]
        usage = response.usage

        return LLMResult(
            text=choice.message.content,
            model=response.model,
            finish_reason=choice.finish_reason,
            prompt_tokens=usage.prompt_tokens if usage is not None else None,
            completion_tokens=usage.completion_tokens if usage is not None else None,
            total_tokens=usage.total_tokens if usage is not None else None,
            response_id=response.id,
        )

В этом файле находится LLMResult — это dataclass, в который записывается результат обращения к модели. Он содержит:

  • text — текст, сгенерированный моделью
  • model — фактическое название модели, вернувшей ответ
  • finish_reason — причина завершения генерации
  • prompt_tokens — количество токенов во входных сообщениях
  • completion_tokens — количество токенов в ответе
  • total_tokens — общее количество использованных токенов
  • response_id — идентификатор ответа провайдера

Основной класс файла — это LLMClient. Он предоставляет приложению единый простой интерфейс для работы с моделью. В первой лекции у него два метода.

Метод __init__() вызывается при создании клиента:

client = LLMClient(settings)

Он получает объект Settings, сохраняет настройки и создаёт внутренний объект OpenAI. Для подключения используются адрес API и API-ключ ProxyAPI.

Метод generate() принимает список сообщений:

result = client.generate(messages)

Внутри метод:

  1. отправляет сообщения выбранной модели
  2. передаёт параметры temperature и max_completion_tokens
  3. получает ответ провайдера
  4. извлекает первый вариант ответа из choices
  5. читает текст, название модели и статистику токенов
  6. создаёт и возвращает объект LLMResult

Таким образом, остальному приложению больше не нужно самостоятельно вызывать chat.completions.create() и разбирать структуру ответа SDK. Оно передаёт сообщения в LLMClient и получает понятный объект LLMResult.

Подключаем новый клиент, для этого откройте main.py. Удалите импорт:

from openai import OpenAI

После импорта Settings добавьте:

from app.llm.client import LLMClient, LLMResult

Импорт openai пока оставьте: обработку исключений перенесем в следующей лекции.

Замените существующую функцию:

def print_usage(result: LLMResult) -> None:
    if result.total_tokens is None:
        print("Провайдер не вернул статистику токенов.")
        return

    print(f"Входные токены: {result.prompt_tokens}")
    print(f"Выходные токены: {result.completion_tokens}")
    print(f"Всего токенов: {result.total_tokens}")

Теперь функция работает с нашим объектом, а не с типом response.usage из SDK.

Далее обновляем summarize_request(), и измените тип первого параметра:

def summarize_request(client: LLMClient, user_text: str, settings: Settings) -> None:

Внутри try замените вызов client.chat.completions.create(...) одной строкой:

result = client.generate(build_messages(text))

Существующие блоки except openai... пока оставьте.

После вычисления времени удалите строку choice = response.choices[0] и выполните замены:

choice.finish_reason        → result.finish_reason
choice.message.content      → result.text
response.model              → result.model
response.id                 → result.response_id
print_usage(response.usage) → print_usage(result)

Теперь в main() замените блок:

client = OpenAI(
    base_url=str(settings.base_url),
    api_key=settings.llm_api_key.get_secret_value(),
)

одной строкой:

client = LLMClient(settings)

Сохраните файлы и запустите проект. Поведение для пользователя не должно измениться, модель сформирует резюме, а программа покажет текст и метрики. Мы изменили архитектуру, а не назначение AI-функции.

В следующем шаге создадим отдельный app/llm/errors.py и спрячем в нем публичную иерархию ошибок LLM-интеграции.

Шаг 2

LLM Client уже скрывает успешный API-вызов: main.py передает сообщения и получает LLMResult. Но при сбое наружу по-прежнему выходят openai.AuthenticationError, openai.RateLimitError и другие исключения самой библиотеки.

И у нас получилась половина границы, когда основной код не знает устройство успешного ответа, но все еще зависит от технических ошибок провайдера. На этой лекции завершим границу и вынесем публичную иерархию ошибок в отдельный файл app/llm/errors.py.

Если бы нам требовался один класс, его можно было бы оставить рядом с клиентом. Но в проекте появляются разные категории:

  • проблема доступа
  • недостаточный баланс
  • неверный запрос
  • лимит запросов
  • таймаут
  • ошибка соединения
  • ошибка провайдера

Эти классы нужны не только client.py, а их будут импортировать сервис, консоль и тесты. Поэтому отдельный errors.py имеет самостоятельную ответственность, он будет описывать публичные сбои LLM-интеграции.

Давайте сразу поговорим, какие ошибки мы будем различать:

Ситуация Обычно означает Что исправлять
401 или 403 API-ключ неверен либо не хватает разрешений Доступ
402 Недостаточно средств на балансе Баланс ProxyAPI
400, 404 или 422 Неверная модель или параметры Запрос
429 Превышена частота или квота Нагрузку или тариф
Timeout Ответ не получен вовремя Время ожидания или состояние сети
Ошибка соединения Нет связи с провайдером Сеть или endpoint
5xx или неожиданный ответ Сбой на стороне сервиса Интеграцию или работу провайдера

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

Давайте перейдем к практике, в каталоге app/llm создайте файл errors.py и вставьте в него следующий код:

class LLMClientError(RuntimeError):
    def __init__(self, message: str, request_id: str | None = None) -> None:
        super().__init__(message)
        self.request_id = request_id

    def __str__(self) -> str:
        message = super().__str__()
        if self.request_id is None:
            return message
        return f"{message} ID запроса: {self.request_id}"

class LLMAccessError(LLMClientError):
    pass

class LLMBalanceError(LLMClientError):
    pass

class LLMRequestError(LLMClientError):
    pass

class LLMRateLimitError(LLMClientError):
    pass

class LLMTimeoutError(LLMClientError):
    pass

class LLMConnectionError(LLMClientError):
    pass

class LLMProviderError(LLMClientError):
    pass

Давайте первым делом разберем общий класс. LLMClientError — общий контракт ошибки, от которого наследуются все остальные классы. Благодаря этому внешний код может выбрать уровень детализации:

except LLMClientError as error:
    ...

Такой обработчик поймает любую ожидаемую ошибку LLM Client.

А позже нашему проекту потребуется различать таймауты и лимиты запросов, а значит он сможет обработать конкретные подклассы:

except LLMRateLimitError:
    ...
except LLMTimeoutError:
    ...

При этом мы объединяем 401 и 403 коды ошибок, ведь 401 обычно связан с аутентификацией (API-ключ отсутствует, неверен или отозван), а 403 связан с авторизацией (личность известна, но операция запрещена). И для текущего пользователя обе ситуации означают «проверьте доступ». Поэтому создаем один LLMAccessError. Для недостаточного баланса используется отдельный LLMBalanceError: доступ технически настроен правильно, но запрос нельзя оплатить. Два класса без различного поведения увеличили бы количество импортов, но по сути не сделали бы лучше.

Также обратите внимание, что мы сохраняем id запроса, так как при HTTP-ошибке провайдер может вернуть идентификатор запроса. Он полезен для логов и обращения в поддержку.

Теперь импортируем ошибки, для этого откройте app/llm/client.py и добавьте новые импорты:

import openai

from app.llm.errors import (
    LLMAccessError,
    LLMBalanceError,
    LLMConnectionError,
    LLMProviderError,
    LLMRateLimitError,
    LLMRequestError,
    LLMTimeoutError,
)

Теперь client.py знает, какой класс SDK нужно перевести в какую ошибку приложения.

А теперь в этом же файле давайте разделим публичный метод и одну попытку, для этого найдите метод generate() и переименуйте его в _generate_once():

def _generate_once(self, messages: list[Message]) -> LLMResult:

Подчеркивание означает, что метод является внутренним. Внешний код продолжит вызывать публичный generate().

А теперь давайте добавим новый метод generate(), и перед _generate_once() вставьте:

def generate(self, messages: list[Message]) -> LLMResult:
    try:
        return self._generate_once(messages)
    except openai.AuthenticationError as error:
        raise LLMAccessError(
            "Провайдер отклонил API-ключ.",
            request_id=error.request_id,
        ) from error
    except openai.PermissionDeniedError as error:
        raise LLMAccessError(
            "Недостаточно прав для обращения к модели.",
            request_id=error.request_id,
        ) from error
    except (
        openai.BadRequestError,
        openai.NotFoundError,
        openai.UnprocessableEntityError,
    ) as error:
        raise LLMRequestError(
            "Провайдер отклонил модель или параметры запроса.",
            request_id=error.request_id,
        ) from error
    except openai.RateLimitError as error:
        raise LLMRateLimitError(
            "Достигнуто ограничение частоты или квоты.",
            request_id=error.request_id,
        ) from error
    except openai.APITimeoutError as error:
        raise LLMTimeoutError("Провайдер не ответил за отведенное время.") from error
    except openai.APIConnectionError as error:
        raise LLMConnectionError("Не удалось соединиться с провайдером.") from error
    except openai.APIResponseValidationError as error:
        raise LLMProviderError(
            "Провайдер вернул ответ неожиданного формата."
        ) from error
    except openai.APIStatusError as error:
        if error.status_code == 402:
            raise LLMBalanceError(
                "Недостаточно средств на балансе ProxyAPI.",
                request_id=error.request_id,
            ) from error
        raise LLMProviderError(
            f"Провайдер вернул HTTP {error.status_code}.",
            request_id=error.request_id,
        ) from error
    except openai.APIError as error:
        raise LLMProviderError(
            "SDK сообщил о неизвестной ошибке провайдера."
        ) from error

Обратите внимание, что порядок except важен. AuthenticationError, RateLimitError и другие HTTP-ошибки являются более конкретными разновидностями APIStatusError. Поэтому общий класс расположен последним. Если поставить APIStatusError первым, он перехватит и 401, и 403, и 429. Точная причина потеряется. А APITimeoutError проверяется перед APIConnectionError, поскольку таймаут относится к сетевым ошибкам, но мы хотим сохранить более точное значение.

Последним расположен базовый APIError. Он не заменяет конкретные обработчики, а служит страховкой для других ошибок самой библиотеки.

Также мы проверяем пустой ответ, ведь успешный HTTP-ответ тоже может иметь непригодную структуру. В _generate_once() после вызова SDK и перед строкой choice = response.choices[0] добавьте:

if not response.choices:
    raise LLMProviderError("Провайдер вернул ответ без вариантов продолжения.")

Без проверки обращение к индексу [0] привело бы к IndexError, а такое исключение ничего не говорит о реальной причине.

Теперь давайте временно упростим main.py, удалите из него:

import openai

После импорта клиента добавьте:

from app.llm.errors import LLMClientError

В summarize_request() оставьте вызов:

    try:
        result = client.generate(build_messages(text))

Все прежние except openai... замените одним блоком:

except LLMClientError as error:
    print(f"Не удалось получить ответ модели: {error}")
    return

Это временное состояние, ведь уже в следующем шаге весь AI-сценарий вместе с обработкой LLM-ошибок уйдет из main.py в support_service.py.

В конце этого шага можете запустить наш проект и проверить его работу. Он должен работать, и сейчас интеграционный слой уже отделен, но main.py все еще содержит правила AI-сценария и консольный интерфейс, а это не лучший вариант. В следующем шаге мы создадим SupportService, и вынесем ввод и вывод в console.py, а в main.py оставим только саму сборку приложения.

Шаг 3

Сейчас main.py больше не создает SDK-клиент и не разбирает choices. Однако он по-прежнему выполняет три разные задачи:

  • создает компоненты приложения
  • работает с терминалом через input() и print()
  • реализует AI-сценарий резюмирования обращения

В этом шаге мы разделим эти обязанности и создадим SupportService для сценария приложения, console.py для терминала и превратим main.py в небольшую точку сборки.

Что такое сервис приложения?

Сервис приложения описывает полезный сценарий, но не привязан к конкретному пользовательскому интерфейсу.

Наш сценарий звучит так:

  1. получить текст обращения
  2. проверить, что он не пустой
  3. собрать сообщения для языковой модели
  4. вызвать LLM Client
  5. проверить результат
  6. вернуть готовое резюме и метрики

В этом списке нет слов «прочитать из терминала» или «напечатать». Значит, сценарий можно использовать и из консоли, и из будущего FastAPI-обработчика, к которому мы придем дальше.

То есть у нас будет следующая ответственность:

Модуль Что он делает Чего он не делает
support_service.py Резюмирует обращение Не использует input() и print()
console.py Показывает текст и читает ввод Не вызывает SDK
main.py Создает и связывает компоненты Не содержит AI-правил

Сейчас LLM Client возвращает технический LLMResult. В нем текст может быть None, а finish_reason может сообщать об остановке из-за лимита. Но консоли нужен результат уже выполненного сценария: проверенный текст резюме и метрики.

Получается, что два объекта дают разные гарантии:

  • LLMResult честно описывает технический ответ провайдера
  • SupportSummary означает, что результат принят правилами нашего проекта AI Support Desk

Перейдем к практике, в каталоге app создайте файл support_service.py и вставьте в него следующий код:

from dataclasses import dataclass
from time import perf_counter

from app.llm.client import LLMClient, Message
from app.llm.errors import LLMClientError

SYSTEM_INSTRUCTION = (
    "Ты помощник службы поддержки. "
    "Кратко перескажи обращение одним предложением. "
    "Не добавляй факты, которых нет в обращении."
)

class SupportServiceError(RuntimeError):
    pass

@dataclass(frozen=True)
class SupportSummary:
    text: str
    model: str
    elapsed_seconds: float
    prompt_tokens: int | None
    completion_tokens: int | None
    total_tokens: int | None
    response_id: str

class SupportService:
    def __init__(self, llm_client: LLMClient) -> None:
        self._llm_client = llm_client

    def summarize(self, user_text: str) -> SupportSummary:
        text = user_text.strip()
        if not text:
            raise SupportServiceError("Обращение не должно быть пустым.")

        started_at = perf_counter()
        try:
            llm_result = self._llm_client.generate(self._build_messages(text))
        except LLMClientError as error:
            raise SupportServiceError(
                f"Не удалось получить ответ модели: {error}"
            ) from error
        elapsed_seconds = perf_counter() - started_at

        if llm_result.finish_reason == "length":
            raise SupportServiceError(
                "Ответ модели остановлен из-за ограничения длины."
            )
        if llm_result.finish_reason != "stop":
            raise SupportServiceError(
                "Модель не вернула готовое резюме. "
                f"Причина завершения: {llm_result.finish_reason}."
            )

        summary = self._validate_summary(llm_result.text)

        return SupportSummary(
            text=summary,
            model=llm_result.model,
            elapsed_seconds=elapsed_seconds,
            prompt_tokens=llm_result.prompt_tokens,
            completion_tokens=llm_result.completion_tokens,
            total_tokens=llm_result.total_tokens,
            response_id=llm_result.response_id,
        )

    @staticmethod
    def _build_messages(user_text: str) -> list[Message]:
        return [
            {
                "role": "system",
                "content": SYSTEM_INSTRUCTION,
            },
            {
                "role": "user",
                "content": user_text,
            },
        ]

    @staticmethod
    def _validate_summary(summary: str | None) -> str:
        if summary is None:
            raise SupportServiceError("Модель не вернула текст резюме.")

        cleaned_summary = summary.strip()
        if not cleaned_summary:
            raise SupportServiceError("Модель вернула пустое резюме.")

        return cleaned_summary

Давайте разберем этот сервис. Первым делом SupportService получает готовый LLMClient:

def __init__(self, llm_client: LLMClient) -> None:
    self._llm_client = llm_client

Сервис не знает ничего о настройках и подключении, а он получает готовый клиент снаружи. В результате сервис отвечает за сценарий, а LLM Client за провайдера.

Теперь SYSTEM_INSTRUCTION перемещается из main.py в support_service.py, потому что она описывает правила конкретной AI-функции. И это правильно, ведь LLMClient не должен хранить эту инструкцию, так как один и тот же клиент позднее сможет использоваться для классификации, извлечения данных и других задач с разными сообщениями.

Теперь посмотрим на первый метод summarize(), он является публичным контрактом сервиса и принимает обычную строку, возвращая готовый результат сценария. Внутри последовательно выполняются:

  1. очистка пробелов
  2. проверка пустого ввода
  3. измерение времени
  4. сборка сообщений
  5. вызов LLM Client
  6. перевод ошибки интеграции в ошибку сервиса
  7. проверка причины завершения, включая length и другие неожиданные значения, а также проверка текста
  8. создание SupportSummary

Также обратите внимание на этот код:

except LLMClientError as error:
    raise SupportServiceError(
        f"Не удалось получить ответ модели: {error}"
    ) from error

Консоль не должна знать, через какой компонент сервис получает резюме. Она работает с одним понятием, где сценарий либо вернул SupportSummary, либо создал SupportServiceError. Но при этом исходная LLM-ошибка не теряется она сохранена через from error и доступна разработчику при диагностике.

Теперь разберем методы. Метод _build_messages() собирает список сообщений, который будет отправлен языковой модели. Он добавляет системную инструкцию с правилами работы модели и пользовательское сообщение с текстом обращения. А метод _validate_summary() проверяет результат генерации. Он убеждается, что модель вернула текст, удаляет лишние пробелы и не позволяет вернуть пустое резюме.

Оба метода являются приватными, поэтому их имена начинаются с подчёркивания. Они нужны только для внутренней работы SupportService и не представляют самостоятельные операции приложения. Внешний код должен вызывать публичный метод summarize(), который сам собирает сообщения, обращается к LLM Client и проверяет результат.

Теперь в каталоге app создайте файл console.py:

from app.support_service import SupportService, SupportServiceError, SupportSummary

def print_usage(result: SupportSummary) -> None:
    if result.total_tokens is None:
        print("Провайдер не вернул статистику токенов.")
        return

    print(f"Входные токены: {result.prompt_tokens}")
    print(f"Выходные токены: {result.completion_tokens}")
    print(f"Всего токенов: {result.total_tokens}")

def run_console(service: SupportService, app_env: str, configured_model: str) -> None:
    print(f"Окружение: {app_env}")
    print(f"Настроенная модель: {configured_model}")

    user_text = input("Введите текст обращения: ")

    try:
        result = service.summarize(user_text)
    except SupportServiceError as error:
        print(f"Не удалось создать резюме: {error}")
        return

    print("\nРезюме обращения:")
    print(result.text)
    print("\nМетрики запроса:")
    print(f"Модель ответа: {result.model}")
    print(f"Время ответа: {result.elapsed_seconds:.2f} с")
    print_usage(result)
    print(f"ID ответа: {result.response_id}")

Консоль получает SupportService и вызывает один метод. Она не импортирует:

  • openai
  • LLMClient
  • LLMClientError
  • Settings

В будущем вместо консоли появится HTTP-обработчик. Он сможет использовать тот же SupportService, не копируя промт и проверку результата.

Теперь откройте корневой main.py и замените весь его код:

from pydantic import ValidationError

from app.config import Settings
from app.console import run_console
from app.llm.client import LLMClient
from app.support_service import SupportService

def main() -> None:
    try:
        settings = Settings()
    except ValidationError as error:
        print("Ошибка конфигурации:")
        for issue in error.errors():
            field = ".".join(str(part) for part in issue["loc"])
            print(f"- {field}: {issue['msg']}")
        return

    llm_client = LLMClient(settings)
    support_service = SupportService(llm_client)

    run_console(
        support_service,
        app_env=settings.app_env,
        configured_model=settings.model,
    )

if __name__ == "__main__":
    main()

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

llm_client = LLMClient(settings)
support_service = SupportService(llm_client)

То есть простыми словами можно сказать, что main.py знает общую конструкцию приложения, но не содержит деталей каждого компонента.

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

Пользователь вводит обращение и получает резюме. Но теперь путь запроса проходит через явные компоненты, а значит наши структурные границы готовы. Давайте в следующих шагах усилим LLM Client технически: добавим таймаут, научимся закрывать HTTP-клиент и разберем жизненный цикл синхронной интеграции.

Шаг 4

Архитектурные обязанности уже разделены, но надежность сетевого вызова пока определяется скрытыми дефолтными настройками самой библиотеки. Если соединение зависнет или провайдер будет отвечать слишком долго, пользователь может долго не понимать, работает ли программа.

В этом шаге мы зададим явный timeout, добавим управление жизненным циклом HTTP-клиента и разберем, почему текущий интерфейс остается синхронным.

Что такое timeout?

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

Если таймаут равен 30 секундам:

  • ответ за 4 секунды обрабатывается сразу
  • программа не ждет оставшиеся 26 секунд
  • слишком долгое ожидание завершается APITimeoutError
  • client.py переводит его в LLMTimeoutError
  • SupportService превращает интеграционный сбой в SupportServiceError
  • при этом консоль показывает понятное сообщение

То есть это можно отобразить на схеме:

То есть таймаут не заставляет LLM генерировать быстрее. Он лишь определяет, сколько приложение готово ждать сетевой результат и слишком маленькое значение будет обрывать нормальные запросы. А слишком большое заставит пользователя долго ждать явного сбоя. В реальном продукте таймаут выбирают по измерениям задержки, длине ответа и требованиям интерфейса. Для нашего учебного приложения мы начнем с 30 секунд.

Согласно официальной документации OpenAI, дефолтный таймаут составляет 10 минут. Такое значение может быть допустимо для длинной фоновой операции, но у нас интерактивный проект, а значит AI Support Desk не должен десять минут молча ожидать. Поэтому время ожидания становится явной частью нашей конфигурации.

Давайте перейдем к практике. Откройте app/config.py. В классе Settings после max_output_tokens добавьте:

request_timeout_seconds: float = Field(
    default=30.0,
    gt=0.0,
    validation_alias="REQUEST_TIMEOUT_SECONDS",
)

Давайте разберем эту настройку:

  • float разрешает целые и дробные секунды
  • default=30.0 используется без переменной окружения
  • gt=0.0 запрещает ноль и отрицательные значения
  • validation_alias связывает поле с внешним именем

Если написать данный параметр в виде текста или 0, Settings остановит приложение до создания LLM Client.

Теперь откройте локальный .env и после MAX_OUTPUT_TOKENS добавьте:

REQUEST_TIMEOUT_SECONDS=30

А теперь давайте передадим этот параметр при создании клиента. Откройте app/llm/client.py. В __init__() найдите создание OpenAI:

self._client = OpenAI(
    base_url=str(settings.base_url),
    api_key=settings.llm_api_key.get_secret_value(),
)

Замените блок:

self._client = OpenAI(
    base_url=str(settings.base_url),
    api_key=settings.llm_api_key.get_secret_value(),
    timeout=settings.request_timeout_seconds,
    max_retries=0,
)

timeout получает проверенное число, а max_retries=0 временно отключает автоматические повторы, хотя библиотека по умолчанию автоматически повторяет некоторые временные ошибки. Но пока мы хотим получить простое правило: один вызов generate() — это одна сетевая попытка. В следующем шаге мы включим повторные запросы, разберем подходящие для них ошибки и посчитаем возможное количество попыток.

Теперь давайте поговорим о том, почему объект OpenAI создается в LLMClient.__init__(), а не внутри каждого generate(). Благодаря этому он переиспользует HTTP-клиент и пул соединений.

То есть в текущей консольной версии выполняется один запрос. Но уже дальше у нас появятся диалоги и серверные вызовы. Правильный жизненный цикл лучше определить заранее, для этого в конец класса LLMClient после _generate_once() добавьте:

def close(self) -> None:
    self._client.close()

Этот метод освобождает соединения внутреннего HTTP-клиента. Конечно, сама библиотека может закрыть их при удалении объекта, но явное управление делает момент освобождения предсказуемым. И так как у нас клиент создается в main.py, следовательно, main.py владеет его жизненным циклом и должен его закрыть.

Ни SupportService, ни console.py не должны вызывать close(). Они получили зависимость снаружи и не знают, используется ли она еще где-то. Здесь действует полезное правило: компонент, который создал ресурс, обычно отвечает за его завершение.

В main.py найдите:

    llm_client = LLMClient(settings)
    support_service = SupportService(llm_client)

    run_console(
        support_service,
        app_env=settings.app_env,
        configured_model=settings.model,
    )

Замените:

llm_client = LLMClient(settings)
support_service = SupportService(llm_client)

try:
    run_console(
        support_service,
        app_env=settings.app_env,
        configured_model=settings.model,
    )
finally:
    llm_client.close()

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

Также в конце этого шага давайте поговорим о том, почему наш интерфейс синхронный. Сейчас сервис вызывает:

llm_result = self._llm_client.generate(messages)

Текущий поток ожидает ответа. Это синхронный интерфейс. Для консольной программы, которая последовательно обрабатывает одно обращение, он проще и полностью соответствует задаче.

Асинхронный вариант концептуально выглядел бы так:

llm_result = await self._llm_client.generate(messages)

Но для него нужны AsyncOpenAI, async def, await и асинхронное закрытие клиента. Такой подход полезен серверу, который во время ожидания одной I/O-операции обслуживает другие запросы. Пока мы не создаем второй интерфейс, и вернемся к этому при переходе к FastAPI, когда будет понятна вся цепочка вызовов.

Запустим наше приложение и отправим запрос:

В нормальном случае ответ придет раньше 30 секунд. Для проверки временно изменим значение на REQUEST_TIMEOUT_SECONDS=0.1. При достаточно долгом ответе мы получим ошибку:

Пока верните прежнее значение, ведь такой эксперимент зависит от сети и не доказывает надежность клиента, но мы должны были проверить корректную обработку ошибки.

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

Шаг 5

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

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

Что такое Retry?

Retry отправляет ту же сетевую операцию еще раз. Он полезен, когда внешние условия могли измениться. Но важно понимать: не каждую ошибку имеет смысл обрабатывать повторной отправкой запроса. Обычно повторить запрос можно при временных проблемах: обрыве соединения, таймауте, ошибке 408 Request Timeout, некоторых случаях 409 Conflict, ответе 429 Too Many Requests после подходящего ожидания, а также при серверных ошибках 5xx.

При этом есть ошибки, которые повтор запроса сам по себе не исправит. Например, 401 Unauthorized при неверном или отозванном токене, 403 Forbidden при недостаточных разрешениях, 400 Bad Request из-за неверных параметров, 404 Not Found при указании неизвестной модели, 422 Unprocessable Entity, если запрос не прошёл проверку, а также ошибки локальной конфигурации приложения. В таких случаях сначала необходимо устранить причину ошибки, и только после этого отправлять запрос повторно.

Библиотека OpenAI принимает параметр max_retries. Например, если установить max_retries=2, SDK выполнит максимум три попытки:

attempt 1 — первоначальный запрос
    ↓ временная ошибка
retry 1 — вторая попытка
    ↓ временная ошибка
retry 2 — третья попытка
    ↓ ошибка сохранилась
окончательное исключение

Первоначальный запрос не является повтором. После исчерпания повторов SDK создает окончательное исключение, client.py нормализует его, а SupportService передает безопасную ошибку консоли.

Теперь давайте поговорим о том, почему повтору нужна экспоненциальная задержка (Exponential backoff). Если сервис перегружен, немедленные повторы увеличат нагрузку. Поэтому между попытками делают паузу.

первая временная ошибка → короткая пауза
вторая временная ошибка → более длинная пауза
следующая ошибка        → еще более длинная пауза

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

А теперь представим сто экземпляров приложения, которые одновременно получили 429. Если все ждут ровно одну секунду, через секунду они снова отправят запросы одновременно.

Здесь пригодится jitter — небольшая случайная поправка к паузе. Она распределяет клиентов по времени:

Мы не пишем собственный цикл с sleep(). Будем использовать механику повторов самой библиотеки SDK, которая определяет повторяемые статусы, учитывает Retry-After, применяет задержку и случайную поправку.

Перейдем к практике. Сначала добавим настройку max_retries. Для этого откройте app/config.py. После поля request_timeout_seconds добавьте:

    max_retries: int = Field(default=2, ge=0, le=5, validation_alias="MAX_RETRIES")

Ограничения защищают конфигурацию:

  • ge=0 разрешает полностью отключить повторы
  • le=5 не позволяет случайно указать сотни повторов
  • default=2 задает небольшое учебное значение

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

Теперь откройте .env. После таймаута добавьте:

MAX_RETRIES=2

А если переменная отсутствует, используется дефолт из app/config.py.

Теперь добавим параметр в клиента, для этого откройте app/llm/client.py. В создании OpenAI найдите:

max_retries=0,

Замените:

max_retries=settings.max_retries,

Теперь политика надежности находится в типизированной конфигурации и применяется ко всем запросам одного SDK-клиента.

И хотя в этом шаге мы говорили про задержки, но в проект мы добавляем в создание SDK-клиента только одну настройку:

self._client = OpenAI(
    base_url=str(settings.base_url),
    api_key=settings.llm_api_key.get_secret_value(),
    timeout=settings.request_timeout_seconds,
    max_retries=settings.max_retries,
)

Может показаться, что эта строка задает только количество повторов, а задержку и поправку к паузе нужно подключить отдельно. Но в OpenAI Python SDK отдельная настройка не требуется: max_retries включает и ограничивает встроенный механизм повторных запросов SDK. Внутри этого механизма библиотека сама:

  1. определяет, можно ли повторять конкретную ошибку
  2. проверяет, остались ли разрешенные повторы
  3. пытается прочитать рекомендованную задержку из ответа сервера
  4. если сервер не сообщил задержку, рассчитывает задержку
  5. добавляет к рассчитанной паузе случайную поправку
  6. останавливает текущий поток на время паузы
  7. повторно отправляет тот же HTTP-запрос

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

Последовательность внутри одного вызова выглядит так:

  1. SupportService один раз вызывает LLMClient.generate().
  2. generate() один раз доходит до chat.completions.create().
  3. Внутри SDK начинается цикл сетевых попыток.
  4. Если ответ успешный, SDK сразу возвращает ChatCompletion.
  5. Если произошла ошибка, SDK проверяет ее тип или HTTP-статус.
  6. Для неповторяемой ошибки SDK сразу завершает цикл.
  7. Для временной ошибки SDK проверяет остаток повторов.
  8. Если повтор разрешен, SDK выбирает время паузы, ждет и сам отправляет запрос снова.
  9. После успеха наш код создает LLMResult.
  10. После исчерпания попыток SDK выбрасывает окончательное исключение, которое наш generate() переводит в один из классов LLMClientError.

То есть SupportService не запускается заново, новые объекты LLMClient не создаются, а наш собственный цикл while не нужен. Можно было бы обернуть вызов в while, вручную вызвать time.sleep(). Но тогда в приложении появились бы два возможных уровня повторов:

  • встроенные повторы библиотеки OpenAI
  • наши дополнительные повторы вокруг библиотеки

Если каждый уровень разрешит по две повторные попытки, фактическое количество запросов станет менее очевидным. Это увеличит задержку и может сильнее расходовать квоту. Поэтому на текущем этапе используем только один механизм, встроенный в библиотеку.

И в конце этого шага давайте поговорим о том, как библиотека рассчитывает задержку и добавляет к рассчитанной паузе случайную поправку. В библиотеке базовая задержка рассчитывается примерно по следующему правилу:

base_delay = min(0.5 × 2^retry_number, 8 секунд)
actual_delay = base_delay × случайный коэффициент от 0.75 до 1.0

Первая формула дает паузу и с каждым следующим повтором базовая пауза увеличивается. Ограничение в 8 секунд не позволяет ей расти бесконечно.

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

Это детали реализации зафиксированной версии SDK, а не код, который нужно копировать в проект. Внутренние формулы могут измениться после обновления библиотеки, а в нашем случае приложения публичным контрактом остается параметр max_retries.

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

Шаг 6

В этом разделе мы значительно изменили наш проект AI Support Desk. В начале раздела почти весь код находился в main.py: там создавался SDK-клиент, собирались сообщения, выполнялся запрос, разбирался ответ и обрабатывались ошибки.

Программа работала, но обязанности разных частей были перемешаны. Добавлять новые AI-функции в такую структуру становилось бы все сложнее.

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

Для текущего проекта достаточно следующих границ:

  • консоль отвечает за общение с пользователем
  • сервис отвечает за сценарий резюмирования
  • LLM Client отвечает за техническое взаимодействие с моделью
  • конфигурация отвечает за внешние параметры
  • main.py создает компоненты и связывает их

Эта структура уже позволяет понимать и изменять проект по частям.

Давайте поговорим про ответственность каждого файла:

Файл Основная ответственность Чего в нем нет
main.py Создает настройки, клиент и сервис, связывает компоненты и управляет завершением клиента Prompt, разбор ответа SDK и работа с input()
app/config.py Читает и проверяет конфигурацию Вызов модели и пользовательский сценарий
app/console.py Получает ввод и показывает результат API-ключ ProxyAPI, SDK и структура choices
app/support_service.py Реализует сценарий создания резюме обращения Создание SDK-клиента и чтение .env
app/llm/client.py Вызывает модель, разбирает технический ответ и применяет настройки надежности Правила службы поддержки и консольный вывод
app/llm/errors.py Описывает публичные ошибки LLM-интеграции Сетевые запросы и обработка пользовательского ввода
.env Хранит локальные значения и секрет Python-код
.gitignore Исключает секреты и служебные файлы из Git Настройки приложения
requirements.txt Фиксирует установленные внешние зависимости Исходный код проекта

Давайте еще раз разберем, как теперь проходит обращение:

Разберем этот путь последовательно.

  1. main.py создает проверенный объект Settings.
  2. Настройки передаются в LLMClient. Только клиенту нужны endpoint, token, timeout и retries.
  3. Готовый клиент передается в SupportService.
  4. Консоль получает сервис и читает обращение пользователя.
  5. Сервис удаляет внешние пробелы и запрещает пустой текст.
  6. Метод _build_messages() создает сообщения ролей system и user.
  7. LLM Client отправляет сообщения выбранной языковой модели.
  8. Сырой ответ SDK преобразуется в LLMResult.
  9. Сервис принимает только завершение stop, отдельно сообщает об ограничении длины и отклоняет другие неожиданные причины, после чего проверяет непустой текст.
  10. Консоль получает SupportSummary и показывает результат.
  11. В блоке finally закрывается внутренний HTTP-клиент.

Каждый компонент видит только необходимую ему часть процесса. Например, консоль знает метод summarize(), но не знает метод chat.completions.create(). Сервис знает LLM Client, но не знает API-ключ ProxyAPI.

Что дает класс LLMClient?

LLMClient стал границей между нашим приложением и внешней библиотекой. Остальной код использует простой контракт:

  1. передать список сообщений
  2. получить LLMResult или понятную ошибку клиента

Детали провайдера остаются внутри app/llm/client.py:

  • создание объекта OpenAI
  • передача модели и параметров генерации
  • чтение choices и usage
  • проверка технической структуры ответа
  • преобразование исключений SDK, включая отдельную ошибку недостаточного баланса
  • настройка timeout и retries

При этом клиент остается универсальным. Он не содержит системную инструкцию службы поддержки и не знает, что результат используется для резюмирования.

Зачем понадобился LLMResult?

SDK возвращает большой объект ChatCompletion. Если передавать его по всему проекту, каждый модуль будет зависеть от устройства ответа конкретной библиотеки.

LLMResult оставляет только данные, необходимые приложению:

  • текст ответа
  • название модели
  • причину завершения
  • статистику токенов
  • ID ответа

В результате сервис работает с нашим объектом, а не с внутренними типами провайдера.

Теперь наш проект AI Support Desk — это не просто скрипт, который умеет отправить один запрос. У него есть понятные границы между интерфейсом, пользовательским сценарием, конфигурацией и интеграцией с языковой моделью.

Это значит, что вы умеете выполнять запросы к языковой модели, измерять время запроса и задержки, безопасно хранить конфигурацию и использовать надёжный LLM Client с таймаутом, ограниченными повторами и единым интерфейсом ошибок. То есть теперь вы понимаете ответственность основных компонентов приложения и путь AI-запроса от пользовательского интерфейса до провайдера.

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

Шаг 7

Клиент создан с max_retries=2. Первоначальный запрос и обе разрешённые повторные попытки завершились временной ошибкой.

Сколько сетевых попыток было выполнено перед окончательным исключением?

Правильный ответ
  • ✅ 3

Шаг 8

Какие утверждения соответствуют границам компонентов надёжного приложения?

Правильный ответ
  • ✅ main.py создаёт компоненты, связывает их и гарантирует закрытие клиента в finally
  • ✅ Сервис формирует сообщения сценария и проверяет, можно ли принять результат
  • ✅ Консоль работает с методом сервиса и не разбирает response.choices
  • ✅ LLM Client скрывает вызов SDK и преобразует технический ответ в LLMResult

Шаг 9

Почему сервису приложения лучше перехватывать LLMClientError, а не набор исключений конкретного SDK?

Правильный ответ
  • ✅ Сервис зависит от контракта приложения, а детали провайдера остаются внутри LLM Client

Шаг 10

Консольный сценарий завершился исключением внутри run_console().

Как гарантировать освобождение HTTP-ресурсов клиента?

Правильный ответ
  • ✅ Поместить вызов сценария в try, а llm_client.close() — в finally

Шаг 11

Какие сбои обычно подходят для ограниченного автоматического повтора SDK?

Правильный ответ
  • ✅ Временная ошибка соединения
  • ✅ HTTP 408 Request Timeout
  • ✅ HTTP 5xx со стороны провайдера
  • ✅ HTTP 429 Too Many Requests

Шаг 12

Безопасный разбор ответа SDK

SDK возвращает большой технический объект, но остальному приложению нужны только текст, модель, причина завершения, токены и ID. Чтобы сервис не зависел от устройства SDK, клиент преобразует ответ в собственный объект LLMResult.

Задача

Реализуйте parse_llm_response(response). Объект response в тестах содержит нужные поля ответа Chat Completions.

Требования

  • Если response.choices пуст, выбросьте LLMProviderError.
  • Используйте первый вариант ответа.
  • Перенесите message.content, finish_reason, response.model и response.id в соответствующие поля LLMResult.
  • Если response.usage is None, все три поля токенов должны получить None.
  • Если статистика есть, перенесите prompt_tokens, completion_tokens и total_tokens.
  • Не возвращайте наружу исходный объект SDK.

Пример результата

# response.choices[0].message.content == "Резюме"
# response.model == "test-model", response.usage == None
result = parse_llm_response(response)
# result.text == "Резюме"
# result.model == "test-model"
# result.prompt_tokens is None

Классы LLMResult и LLMProviderError, декоратор dataclass и необходимые импорты уже находятся в заготовке. Не удаляйте и не переименовывайте предоставленные классы.

Правильный ответ
from dataclasses import dataclass
from typing import Any

class LLMProviderError(RuntimeError):
    pass

@dataclass(frozen=True)
class LLMResult:
    text: str | None
    model: str
    finish_reason: str | None
    prompt_tokens: int | None
    completion_tokens: int | None
    total_tokens: int | None
    response_id: str

def parse_llm_response(response: Any) -> LLMResult:
    if not response.choices:
        raise LLMProviderError("response contains no choices")

    choice = response.choices[0]
    usage = response.usage

    return LLMResult(
        text=choice.message.content,
        model=response.model,
        finish_reason=choice.finish_reason,
        response_id=response.id,
        prompt_tokens=None if usage is None else usage.prompt_tokens,
        completion_tokens=None if usage is None else usage.completion_tokens,
        total_tokens=None if usage is None else usage.total_tokens,
    )

Шаг 13

Количество сетевых попыток

Параметр max_retries задаёт число повторов после первого запроса. Поэтому при max_retries=2 SDK может выполнить до трёх сетевых попыток: первоначальную и две повторные.

Задача

Реализуйте max_attempts(max_retries), которая возвращает максимальное общее число сетевых попыток.

Требования

  • max_retries должен быть целым числом. Для другого типа выбросьте TypeError, а для отрицательного значения — ValueError.
  • Верните максимальное число сетевых попыток: одна первоначальная плюс разрешённые повторы.

Пример результата

max_attempts(0)  # 1
max_attempts(2)  # 3
max_attempts(5)  # 6

Заготовка кода уже находится в редакторе. Дополнительные сторонние библиотеки для решения не нужны.

Правильный ответ
def max_attempts(max_retries: int) -> int:
    if isinstance(max_retries, bool) or not isinstance(max_retries, int):
        raise TypeError("max_retries must be an integer")

    if max_retries < 0:
        raise ValueError("max_retries must be non-negative")

    return max_retries + 1

Шаг 14

Повторяемые HTTP-статусы

Не каждую HTTP-ошибку полезно повторять. Временный сбой сервера или ограничение частоты может исчезнуть, а неверный API-ключ или некорректные параметры требуют исправления запроса.

Задача

Реализуйте is_retryable_status(status_code). Функция должна определить, относится ли HTTP-статус к временным сбоям, которые политика этого раздела разрешает повторить.

Требования

  • status_code должен быть целым числом от 100 до 599. Для другого типа выбросьте TypeError, а для значения вне диапазона — ValueError.
  • Верните True для 408, 409, 429 и любого статуса 5xx.
  • Для остальных допустимых статусов верните False.

Пример результата

is_retryable_status(429)  # True: ограничение частоты
is_retryable_status(503)  # True: временный сбой сервера
is_retryable_status(401)  # False: нужно исправить ключ
is_retryable_status(400)  # False: нужно исправить запрос

Заготовка кода уже находится в редакторе. Дополнительные сторонние библиотеки для решения не нужны.

Правильный ответ
def is_retryable_status(status_code: int) -> bool:
    if isinstance(status_code, bool) or not isinstance(status_code, int):
        raise TypeError("status_code must be an integer")

    if not 100 <= status_code <= 599:
        raise ValueError("status_code must be between 100 and 599")

    return status_code in {408, 409, 429} or 500 <= status_code <= 599

Шаг 15

Нормализация ошибок провайдера

Сервис приложения не должен знать названия исключений конкретного SDK. LLM Client получает техническую ошибку провайдера и преобразует её в один из собственных типов приложения.

Задача

Реализуйте normalize_provider_error(error). Создайте и верните подходящий объект LLMClientError или его наследника.

Требования

  • Для AuthenticationError верните LLMAccessError.
  • Для RateLimitError верните LLMRateLimitError.
  • Для TimeoutError верните LLMTimeoutError, а для ConnectionError — LLMConnectionError.
  • Для StatusError с status_code == 402 верните LLMBalanceError. Для другого статуса верните LLMProviderError.
  • Для исключения любого другого типа верните LLMProviderError.
  • Если у исходной ошибки есть request_id, перенесите его в одноимённое поле новой ошибки.

Пример результата

normalize_provider_error(AuthenticationError("req-1"))
# LLMAccessError с request_id == "req-1"

normalize_provider_error(StatusError("req-2", 402))
# LLMBalanceError с request_id == "req-2"

Заготовка кода уже находится в редакторе. Дополнительные сторонние библиотеки для решения не нужны.

Правильный ответ
class ProviderError(Exception):
    def __init__(self, request_id: str | None = None, status_code: int | None = None):
        super().__init__()
        self.request_id = request_id
        self.status_code = status_code

class AuthenticationError(ProviderError): pass
class RateLimitError(ProviderError): pass
class TimeoutError(ProviderError): pass
class ConnectionError(ProviderError): pass
class StatusError(ProviderError): pass

class LLMClientError(RuntimeError):
    def __init__(self, request_id: str | None = None):
        super().__init__()
        self.request_id = request_id

class LLMAccessError(LLMClientError): pass
class LLMRateLimitError(LLMClientError): pass
class LLMTimeoutError(LLMClientError): pass
class LLMConnectionError(LLMClientError): pass
class LLMBalanceError(LLMClientError): pass
class LLMProviderError(LLMClientError): pass

def normalize_provider_error(error: Exception) -> LLMClientError:
    request_id = getattr(error, "request_id", None)

    if isinstance(error, AuthenticationError):
        return LLMAccessError(request_id=request_id)

    if isinstance(error, RateLimitError):
        return LLMRateLimitError(request_id=request_id)

    if isinstance(error, TimeoutError):
        return LLMTimeoutError(request_id=request_id)

    if isinstance(error, ConnectionError):
        return LLMConnectionError(request_id=request_id)

    if isinstance(error, StatusError):
        if error.status_code == 402:
            return LLMBalanceError(request_id=request_id)

        return LLMProviderError(request_id=request_id)

    return LLMProviderError(request_id=request_id)

Шаг 16

Закрытие LLM-клиента после запроса

SDK-клиент владеет сетевыми ресурсами. Даже если запрос к модели завершился ошибкой, соединения нужно освободить, а исходное исключение нельзя скрывать.

Задача

Допишите последовательный сценарий работы с уже созданным клиентом. Не объявляйте функцию, а только вызовите operation(client), сохраните результат и гарантированно закройте клиент.

Требования

  • Вызовите operation(client) ровно один раз.
  • Сохраните возвращённое значение в переменную result.
  • Используйте try/finally, чтобы вызвать client.close() ровно один раз и после успеха, и после исключения.
  • Не перехватывайте и не подменяйте исключение из operation.

Пример результата

# Успешный запрос:
operation(client)  # возвращает "Ответ модели"
result == "Ответ модели"
client.close()     # вызван один раз

# Ошибка запроса:
operation(client)  # выбрасывает RuntimeError
client.close()     # всё равно вызван один раз
# RuntimeError продолжает распространяться

Переменные client и operation создаёт проверяющая система перед запуском решения.

Правильный ответ
try:
    result = operation(client)
finally:
    client.close()