Делаем надежный 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.
Переместите существующий config.py из корня проекта в каталог app. Код класса Settings пока не изменяйте.
ai-support-desk/
├── app/
│ ├── __init__.py
│ ├── config.py
│ └── llm/
│ └── __init__.py
└── main.py
После перемещения старый импорт в main.py временно станет неверным. Найдите:
Замените:
Импорт читается слева направо: из пакета 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__() вызывается при создании клиента:
Он получает объект Settings, сохраняет настройки и создаёт внутренний объект OpenAI. Для подключения используются адрес API и API-ключ ProxyAPI.
Метод generate() принимает список сообщений:
Внутри метод:
- отправляет сообщения выбранной модели
- передаёт параметры
temperatureиmax_completion_tokens - получает ответ провайдера
- извлекает первый вариант ответа из
choices - читает текст, название модели и статистику токенов
- создаёт и возвращает объект
LLMResult
Таким образом, остальному приложению больше не нужно самостоятельно вызывать chat.completions.create() и разбирать структуру ответа SDK. Оно передаёт сообщения в LLMClient и получает понятный объект LLMResult.
Подключаем новый клиент, для этого откройте main.py. Удалите импорт:
После импорта Settings добавьте:
Импорт 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(), и измените тип первого параметра:
Внутри try замените вызов client.chat.completions.create(...) одной строкой:
Существующие блоки 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(),
)
одной строкой:
Сохраните файлы и запустите проект. Поведение для пользователя не должно измениться, модель сформирует резюме, а программа покажет текст и метрики. Мы изменили архитектуру, а не назначение 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 — общий контракт ошибки, от которого наследуются все остальные классы. Благодаря этому внешний код может выбрать уровень детализации:
Такой обработчик поймает любую ожидаемую ошибку LLM Client.
А позже нашему проекту потребуется различать таймауты и лимиты запросов, а значит он сможет обработать конкретные подклассы:
При этом мы объединяем 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():
Подчеркивание означает, что метод является внутренним. Внешний код продолжит вызывать публичный 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, удалите из него:
После импорта клиента добавьте:
В summarize_request() оставьте вызов:
Все прежние except openai... замените одним блоком:
Это временное состояние, ведь уже в следующем шаге весь 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 в небольшую точку сборки.
Что такое сервис приложения?¶
Сервис приложения описывает полезный сценарий, но не привязан к конкретному пользовательскому интерфейсу.
Наш сценарий звучит так:
- получить текст обращения
- проверить, что он не пустой
- собрать сообщения для языковой модели
- вызвать LLM Client
- проверить результат
- вернуть готовое резюме и метрики
В этом списке нет слов «прочитать из терминала» или «напечатать». Значит, сценарий можно использовать и из консоли, и из будущего 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:
Сервис не знает ничего о настройках и подключении, а он получает готовый клиент снаружи. В результате сервис отвечает за сценарий, а LLM Client за провайдера.
Теперь SYSTEM_INSTRUCTION перемещается из main.py в support_service.py, потому что она описывает правила конкретной AI-функции. И это правильно, ведь LLMClient не должен хранить эту инструкцию, так как один и тот же клиент позднее сможет использоваться для классификации, извлечения данных и других задач с разными сообщениями.
Теперь посмотрим на первый метод summarize(), он является публичным контрактом сервиса и принимает обычную строку, возвращая готовый результат сценария. Внутри последовательно выполняются:
- очистка пробелов
- проверка пустого ввода
- измерение времени
- сборка сообщений
- вызов LLM Client
- перевод ошибки интеграции в ошибку сервиса
- проверка причины завершения, включая
lengthи другие неожиданные значения, а также проверка текста - создание
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 и вызывает один метод. Она не импортирует:
openaiLLMClientLLMClientErrorSettings
В будущем вместо консоли появится 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 стал точкой сборки приложения: здесь создаются необходимые компоненты и устанавливаются связи между ними. Саму обработку обращения этот файл больше не выполняет, ведь каждая часть работы передана соответствующему модулю:
То есть простыми словами можно сказать, что main.py знает общую конструкцию приложения, но не содержит деталей каждого компонента.
Давайте запустим наш обновленный проект и его внешнее поведение снова должно остаться прежним.
Пользователь вводит обращение и получает резюме. Но теперь путь запроса проходит через явные компоненты, а значит наши структурные границы готовы. Давайте в следующих шагах усилим LLM Client технически: добавим таймаут, научимся закрывать HTTP-клиент и разберем жизненный цикл синхронной интеграции.
Шаг 4¶
Архитектурные обязанности уже разделены, но надежность сетевого вызова пока определяется скрытыми дефолтными настройками самой библиотеки. Если соединение зависнет или провайдер будет отвечать слишком долго, пользователь может долго не понимать, работает ли программа.
В этом шаге мы зададим явный timeout, добавим управление жизненным циклом HTTP-клиента и разберем, почему текущий интерфейс остается синхронным.
Что такое timeout?¶
Timeout — это максимальное время ожидания сетевой операции, после которого текущая попытка признается неуспешной.
Если таймаут равен 30 секундам:
- ответ за 4 секунды обрабатывается сразу
- программа не ждет оставшиеся 26 секунд
- слишком долгое ожидание завершается
APITimeoutError client.pyпереводит его вLLMTimeoutErrorSupportServiceпревращает интеграционный сбой в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 добавьте:
А теперь давайте передадим этот параметр при создании клиента. Откройте 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() добавьте:
Этот метод освобождает соединения внутреннего 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 выполняется после успешного результата, ожидаемой ошибки сервиса и неожиданного исключения. Поэтому ресурс закрывается независимо от пути выполнения.
Также в конце этого шага давайте поговорим о том, почему наш интерфейс синхронный. Сейчас сервис вызывает:
Текущий поток ожидает ответа. Это синхронный интерфейс. Для консольной программы, которая последовательно обрабатывает одно обращение, он проще и полностью соответствует задаче.
Асинхронный вариант концептуально выглядел бы так:
Но для него нужны 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 добавьте:
Ограничения защищают конфигурацию:
ge=0разрешает полностью отключить повторыle=5не позволяет случайно указать сотни повторовdefault=2задает небольшое учебное значение
Большое количество повторов не делает систему автоматически надежнее, ведь оно увеличивает задержку, число запросов и возможный расход токенов.
Теперь откройте .env. После таймаута добавьте:
А если переменная отсутствует, используется дефолт из app/config.py.
Теперь добавим параметр в клиента, для этого откройте app/llm/client.py. В создании OpenAI найдите:
Замените:
Теперь политика надежности находится в типизированной конфигурации и применяется ко всем запросам одного 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. Внутри этого механизма библиотека сама:
- определяет, можно ли повторять конкретную ошибку
- проверяет, остались ли разрешенные повторы
- пытается прочитать рекомендованную задержку из ответа сервера
- если сервер не сообщил задержку, рассчитывает задержку
- добавляет к рассчитанной паузе случайную поправку
- останавливает текущий поток на время паузы
- повторно отправляет тот же HTTP-запрос
Поэтому нам не нужны дополнительные настройки и мы не настраиваем паузы отдельно, потому что они уже являются частью реализации библиотеки. Вы можете посмотреть на схеме как работает наш проект шаг за шагом:
Последовательность внутри одного вызова выглядит так:
SupportServiceодин раз вызываетLLMClient.generate().generate()один раз доходит доchat.completions.create().- Внутри SDK начинается цикл сетевых попыток.
- Если ответ успешный, SDK сразу возвращает
ChatCompletion. - Если произошла ошибка, SDK проверяет ее тип или HTTP-статус.
- Для неповторяемой ошибки SDK сразу завершает цикл.
- Для временной ошибки SDK проверяет остаток повторов.
- Если повтор разрешен, SDK выбирает время паузы, ждет и сам отправляет запрос снова.
- После успеха наш код создает
LLMResult. - После исчерпания попыток 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 |
Фиксирует установленные внешние зависимости | Исходный код проекта |
Давайте еще раз разберем, как теперь проходит обращение:
Разберем этот путь последовательно.
main.pyсоздает проверенный объектSettings.- Настройки передаются в
LLMClient. Только клиенту нужны endpoint, token, timeout и retries. - Готовый клиент передается в
SupportService. - Консоль получает сервис и читает обращение пользователя.
- Сервис удаляет внешние пробелы и запрещает пустой текст.
- Метод
_build_messages()создает сообщения ролейsystemиuser. - LLM Client отправляет сообщения выбранной языковой модели.
- Сырой ответ SDK преобразуется в
LLMResult. - Сервис принимает только завершение
stop, отдельно сообщает об ограничении длины и отклоняет другие неожиданные причины, после чего проверяет непустой текст. - Консоль получает
SupportSummaryи показывает результат. - В блоке
finallyзакрывается внутренний HTTP-клиент.
Каждый компонент видит только необходимую ему часть процесса. Например, консоль знает метод summarize(), но не знает метод chat.completions.create(). Сервис знает LLM Client, но не знает API-ключ ProxyAPI.
Что дает класс LLMClient?¶
LLMClient стал границей между нашим приложением и внешней библиотекой. Остальной код использует простой контракт:
- передать список сообщений
- получить
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.- Верните максимальное число сетевых попыток: одна первоначальная плюс разрешённые повторы.
Пример результата¶
Заготовка кода уже находится в редакторе. Дополнительные сторонние библиотеки для решения не нужны.
Правильный ответ
Шаг 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 создаёт проверяющая система перед запуском решения.