Первый запрос к модели¶
Шаг 1¶
Начиная с этого раздела, мы будем писать не отдельные учебные примеры, а один проект, который будет развиваться на протяжении всего курса. Сначала он сможет выполнить только одну небольшую AI-функцию. Затем мы постепенно добавим надежный клиент, структурированные ответы, чат, инструменты, обработку документов, HTTP API, тесты и Docker.
Такой подход близок к реальной разработке: полезный сервис не появляется целиком за один день. Сначала команда выбирает конкретную проблему, создает минимальную рабочую версию, проверяет ее, а потом расширяет систему.
AI Support Desk¶
Наш проект называется AI Support Desk. Это AI бэкенд для обработки обращений клиентов и связанных с ними документов. Его основной пользователь — это оператор службы поддержки, которому ежедневно приходится читать сообщения разной длины, понимать суть проблемы, искать важные детали и готовить ответ.
Рассмотрим обычное обращение:
Вчера курьер привез заказ № 18452. Коробка была помята, а экран монитора оказался треснут. Я приложил фотографию. Подскажите, как заменить товар и нужно ли мне самостоятельно оплачивать обратную доставку?
Из этого текста оператору нужно быстро понять несколько вещей: что произошло, к какому типу относится проблема, какой заказ упомянут, есть ли вложение, каких данных не хватает и что можно ответить клиенту. Простые обращения обрабатываются быстро, но при большом потоке даже чтение и сортировка отнимают много времени.
И наш проект будет помогать оператору выполнять эту подготовительную работу. В итоговой версии курса сервис сможет:
- принимать текст обращения
- формулировать его краткое содержание
- выделять категорию, важные факты и недостающие данные
- предлагать черновик ответа оператору
- учитывать историю диалога
- анализировать приложенные PDF, DOCX и изображения
- запрашивать разрешенные данные через ограниченные инструменты
- работать как REST API на FastAPI
- возвращать понятные ошибки и записывать безопасные диагностические данные
Ключевое слово здесь — помогать. Проект не заменяет всю службу поддержки и не принимает рискованные решения вместо сотрудника.
Еще до начала написания кода давайте сразу определим не только возможности, но и ограничения системы. Языковая модель может неправильно понять текст или добавить правдоподобную, но отсутствующую в обращении деталь. Поэтому наш помощник:
- не оформляет возврат денег самостоятельно
- не меняет статус заказа без подтверждения и разрешенного инструмента
- не обещает скидку, компенсацию или срок доставки
- не придумывает номер заказа, условия гарантии и другие факты
- не отправляет клиенту ответ без проверки оператором
Обычный Python-код будет проверять обязательные поля, права доступа и допустимые действия. Модель будет работать там, где требуется понять или сформулировать естественный язык. Позже мы отдельно реализуем эту границу в архитектуре приложения.
Что сделает первая версия?¶
Если сразу пытаться реализовать все возможности, мы одновременно столкнемся с API, промптами, файлами, базой данных, веб-сервером и безопасностью. Отлаживать такую систему будет трудно. Поэтому первая версия решает только одну задачу: получить текст обращения и сформулировать его краткое содержание в одном предложении, не добавляя новых фактов.
В конце этого раздела программа будет запускаться в терминале примерно так:
Введите текст обращения: Клиент дважды оплатил один заказ.
Результат:
Клиент сообщает о двойной оплате одного заказа.
Метрики:
Модель: gpt-4.1
Время: 1.24 с
Всего токенов: 42
Это небольшая функция, но в ней уже присутствует полный путь данных: ввод пользователя, Python-код, сетевой запрос к модели, ответ, метрики и обработка возможной ошибки.
А уже далее мы будем строить остальные возможности, где каждый этап будет добавлять одну новую ответственность. При этом уже написанная логика будет расти, и консольный прототип постепенно превратится в типизированный backend-сервис. А во втором курсе «AI Engineer на Python» этот же проект мы расширим базой знаний, RAG, памятью, управляемыми агентами, LangGraph и MCP.
Создаем проект¶
Давайте приступим к практике. В этом курсе будем работать в PyCharm, поэтому сразу открываем окно нового проекта.
Если PyCharm показывает приветственный экран, нажмите New Project. Если другой проект уже открыт, выберите в верхнем меню File → New Project.
В списке типов выберите Pure Python. Такой шаблон создает пустой Python-проект без Django, Jupyter и других фреймворков, которые нам пока не нужны.
В поле Location выберите место для учебных проектов и укажите конечный каталог ai-support-desk. Имя проекта PyCharm возьмет из последней части пути. Используйте латинские буквы, строчные символы и дефисы, такой путь гораздо удобнее для терминала, Docker и будущих скриптов.
В настройках интерпретатора оставьте вариант Custom environment, и выберите:
- тип окружения — Virtualenv
- режим — создание нового окружения
- base interpreter — установленный Python
- location — каталог
.venvвнутриai-support-desk
Нажмите Create и тем самым у нас откроется новый проект:
Создаем файлы проекта¶
Щелкните правой кнопкой по корню ai-support-desk и создайте файлы:
- New → Python File, имя
main. PyCharm сам добавит расширение.py. - New → File, имя
.gitignore. - New → File, имя
requirements.txt.
После создания дерево выглядит примерно так:
.idea/— локальные настройки проекта PyCharm.venv/— отдельный Python и пакеты проектаmain.py— точка запуска приложенияrequirements.txt— список зависимостей
Давайте сразу откроем .gitignore и запишем:
Мы полностью исключаем настройки PyCharm, виртуальное окружение и кэш Python. Эти строки не удаляют каталоги с диска, они только сообщают Git не добавлять их в историю.
Откройте main.py, удалите автоматически созданный пример, если он есть, и запишите:
PROJECT_NAME = "AI Support Desk"
def main() -> None:
print(f"{PROJECT_NAME}: каркас проекта создан")
if __name__ == "__main__":
main()
PROJECT_NAME хранит название проекта. main() является явной точкой запуска. Последнее условие вызывает функцию только при прямом выполнении файла и позволит позднее безопасно импортировать код из тестов.
Запустить main.py можно любым из способов:
- нажать зеленый треугольник в левом поле рядом с
main() - щелкнуть правой кнопкой внутри редактора и выбрать Run 'main'
PyCharm создаст временную Run-конфигурацию и откроет окно Run. В нем должно появиться:
Значит, мы создали простейший исполняемый каркас нашего приложения, где зафиксировали его название. И уже в следующей лекции установим необходимые зависимости, получим токен для работы с моделью ИИ и подготовим main.py к первому запросу.
да ты шуешь бр0
Шаг 2¶
Продолжим работать над проектом. Теперь установим готовую библиотеку и настроим учетные данные для доступа к модели. В PyCharm откройте терминал и выполните:
Пакет openai используется не только с API-эндпоинтами компании OpenAI. Многие провайдеры предоставляют интерфейс Chat Completions. То есть мы передадим свой адрес API и укажем токен.
Выполним следующую команду:
Установим библиотеку python-dotenv. Она загружает переменные из файла .env в окружение Python-программы. С её помощью мы будем получать токен, необходимый для отправки запросов к моделям через ProxyAPI.
В завершение выполним команду:
Она перезаписывает requirements.txt фактическим списком установленных версий. Таким образом, другой студент или сервер сможет установить необходимые зависимости простой командой.
Где разные провайдеры выдают API-ключи?¶
Общий принцип у всех сервисов примерно одинаков: разработчик регистрируется у провайдера, создает проект или рабочее пространство, выпускает секретный ключ и передает его приложению через переменную окружения.
- В OpenAI ключ создается на платформе OpenAI в разделе API keys.
- В Google Gemini ключ можно получить в Google AI Studio в разделе API Keys.
- В Anthropic Claude ключ создается в Anthropic Console в разделе API Keys.
- В Mistral AI ключ создается в личном кабинете Mistral AI Console в разделе API Keys. После создания проекта можно выпустить ключ для доступа к моделям Mistral через API.
- В Cohere ключ создается в Cohere Dashboard в разделе API Keys. Обычно он используется для работы с языковыми моделями и embedding-моделями Cohere.
- В Groq ключ можно получить в Groq Console в разделе API Keys. Groq предоставляет доступ к различным открытым моделям через собственный API с очень высокой скоростью генерации.
- В DeepSeek ключ создается в DeepSeek Platform в разделе управления API-ключами. API совместим с форматом OpenAI API, поэтому часто достаточно заменить
base_urlи использовать тот же SDK.
Важно учитывать, что регистрация в чат-приложении провайдера не всегда дает доступ к API. Подписка на пользовательский чат и оплата API могут быть отдельными продуктами. Перед началом работы следует проверить актуальные лимиты, условия оплаты и доступность сервиса из вашей страны, в том числе из России.
Подключение к модели через ProxyAPI¶
Мы пойдем немного другим путем и будем использовать сервис, который предоставляет доступ к языковой модели через API. Таких сервисов достаточно много, но при его выборе важно учитывать не только список моделей и стоимость запросов, но и его доступность из России.
Многие зарубежные сервисы требуют VPN уже на этапе регистрации или входа в личный кабинет. А такая интеграция плохо подходит для учебного курса, ведь у всех студентов должна быть возможность запустить проект в обычном окружении без дополнительных средств обхода ограничений.
В качестве одного из вариантов можно было бы выбрать GitHub Models. Этот сервис предоставляет бесплатную квоту для экспериментов, однако его доступность зависит от региона и сетевого окружения. Для учебного курса такой вариант неудобен: часть студентов не сможет стабильно зарегистрироваться и выполнить запрос без VPN.
Поэтому в курсе мы будем работать через ProxyAPI — российский сервис, который предоставляет доступ к API языковых моделей без VPN. Он поддерживает оплату в рублях и позволяет обращаться к моделям с помощью официальной Python-библиотеки openai. То есть ProxyAPI совместим с форматом OpenAI: приложение отправляет массив сообщений, параметры генерации и идентификатор модели, а в ответ получает сгенерированный текст и метаданные запроса. Благодаря этому мы сможем изучать основные принципы разработки LLM-приложений, не привязывая прикладную логику к особенностям российского сервиса. При необходимости мы сможем подключить другой OpenAI-совместимый API через настройки приложения.
Сам ProxyAPI не является отдельной языковой моделью. Это промежуточный сервис, который принимает запрос нашего приложения, передает его выбранной модели и возвращает полученный ответ. То есть при необходимости ProxyAPI можно будет заменить другим совместимым провайдером через настройки приложения.
Откройте сайт ProxyAPI и перейдите в личный кабинет. Зарегистрируйтесь и подтвердите адрес электронной почты.
После входа откройте раздел с балансом и пополните счет. На момент подготовки лекции минимальная сумма пополнения составляет 200 рублей, но перед оплатой проверьте актуальное значение в личном кабинете. То есть ProxyAPI не предоставляет постоянный бесплатный доступ к моделям и стоимость каждого запроса зависит от количества переданных и сгенерированных токенов, как и у других провайдеров. Для нашего учебного проекта большой баланс не требуется, средняя стоимость запроса и ответа будет в районе 5-30 копеек, так как мы отправляем относительно небольшие текстовые сообщения и ограничиваем максимальный размер ответа модели. Конечно в 4 модуле объем запросов сильно повысится, но если не играться с запросами, то баланса 200р вам должно хватить для прохождения курса. Кроме того, вместо полной gpt-4.1 при необходимости можно использовать более дешевую gpt-4.1-mini или другую более дешевую модель. Актуальную стоимость моделей всегда следует проверять на странице тарифов ProxyAPI.
Создание API-ключа¶
После пополнения баланса откройте в личном кабинете раздел Ключи API и нажмите кнопку создания нового ключа.
Укажите понятное имя, например:
Имя помогает понять, в каком приложении используется ключ. Оно не отправляется модели и не влияет на генерацию ответа.
После создания скопируйте значение ключа. Полный ключ отображается только один раз — сразу после создания. Если ключ будет потерян, потребуется удалить его и создать новый.
Создаем локальный файл .env¶
После того как мы получили API-ключ, мы не будем вставлять его напрямую в main.py, вместо этого сохраним секрет в локальном файле .env, а программа сама загрузит его при запуске.
В окне Project щелкните правой кнопкой по корневой папке ai-support-desk, выберите New → File и укажите имя .env. Файл должен находиться рядом с main.py и requirements.txt, а не внутри .venv.
Откройте созданный файл и добавьте одну строку, подставив скопированный ключ ProxyAPI:
Обратите внимание, в имени файла точка в начале обязательна. Вокруг знака = лучше не ставить пробелы, а само значение не нужно оборачивать в кавычки.
Теперь откройте существующий .gitignore и добавьте в конец отдельную строку:
Создаем сам клиент¶
Теперь внесем первое изменение в наш проект. Откроем main.py и полностью заменим каркас из прошлого шага следующим кодом:
import os
from dotenv import load_dotenv
from openai import OpenAI
BASE_URL = "https://api.proxyapi.ru/openai/v1"
def main() -> None:
load_dotenv()
token = os.getenv("LLM_API_KEY")
if not token:
raise SystemExit(
"Не найдена переменная LLM_API_KEY. "
"Проверьте файл .env в корне проекта."
)
client = OpenAI(
base_url=BASE_URL,
api_key=token,
)
print("Файл .env загружен, токен найден, клиент создан")
if __name__ == "__main__":
main()
Разберем, что появилось в файле:
import osдает доступ к переменным окруженияfrom dotenv import load_dotenvимпортирует функцию загрузки.envfrom openai import OpenAIимпортирует класс клиента из установленного SDKBASE_URLхранит эндпоинт отдельно от логики запуска. Благодаря этому позднее можно подключить другой OpenAI-совместимый API через настройки.load_dotenv()загружает локальную конфигурацию до чтения токена из файла.env.if not tokenостанавливает программу до сетевого запроса, если секрет отсутствуетOpenAI(...)создает объект, через который позже выполним запрос. При создании клиента мы передаемbase_url— адрес, на который будут отправляться запросы. Без него клиент работал бы с API OpenAI, где наш токен не подойдет. В строкеapi_key=tokenпередаем секретный токен.
Обратите внимание, создание объекта client еще не вызывает модель и не расходует квоту. Сетевой запрос мы отправим только в следующем шаге при вызове client.chat.completions.create().
Давайте запустим наш код. Если .env найден и строка записана правильно, вы увидите сообщение о созданном клиенте:
Если программа пишет, что LLM_API_KEY не найден, проверьте имя и расположение файла, имя переменной и наличие вызова load_dotenv() перед os.getenv().
Теперь все наши учетные данные настроены и проверены, давайте в следующем шаге выполним первый запрос к API и извлечем текст из ответа.
Шаг 3¶
В прошлом шаге мы установили зависимости, создали локальный файл .env, загрузили из него токен и подготовили клиент. Теперь выполним первый настоящий запрос к модели.
Будем изучать код последовательно: сначала целиком обновим main.py, затем разберем каждую часть программы и только после этого запустим ее. Так мы не будем отвлекаться на результат, пока не поймем, какой код его создает.
Откроем файл main.py, заменим старый код на код ниже:
import os
from dotenv import load_dotenv
from openai import OpenAI
BASE_URL = "https://api.proxyapi.ru/openai/v1"
MODEL = "gpt-4.1"
def main() -> None:
load_dotenv()
token = os.getenv("LLM_API_KEY")
if not token:
raise SystemExit(
"Не найдена переменная LLM_API_KEY. "
"Проверьте файл .env в корне проекта."
)
client = OpenAI(
base_url=BASE_URL,
api_key=token,
)
response = client.chat.completions.create(
model=MODEL,
messages=[
{
"role": "user",
"content": (
"Сформулируй в одном предложении краткое содержание обращения: "
"Покупатель получил поврежденную упаковку и хочет заменить товар."
),
}
],
)
answer = response.choices[0].message.content
print(answer)
if __name__ == "__main__":
main()
Давайте пройдем по этому коду сверху вниз и свяжем строки кода с этапами запроса.
Шаг 1. Импортируем библиотеки¶
Модуль os входит в стандартную библиотеку Python и позволяет читать переменные окружения. Функция load_dotenv загружает пары ИМЯ=значение из локального файла .env. Класс OpenAI создает клиент, через который программа будет обращаться к совместимому API.
Шаг 2. Выносим настройки в константы¶
BASE_URL хранит адрес API сервиса, а MODEL — точный идентификатор выбранной модели. Имена записаны заглавными буквами, потому что эти значения считаются константами, ведь во время одного запуска программы они не меняются.
Шаг 3. Загружаем токен¶
load_dotenv()
token = os.getenv("LLM_API_KEY")
if not token:
raise SystemExit(
"Не найдена переменная LLM_API_KEY. "
"Проверьте файл .env в корне проекта."
)
Шаг 4. Создаем API-клиент¶
base_url направляет библиотеку к нашему сервису, а api_key передает токен для авторизации. Напомню, создание объекта client еще не отправляет сетевой запрос и не расходует лимит. Клиент только запоминает настройки, которые понадобятся при последующих вызовах.
Шаг 5. Отправляем сообщение модели¶
response = client.chat.completions.create(
model=MODEL,
messages=[
{
"role": "user",
"content": (
"Сформулируй в одном предложении краткое содержание обращения: "
"Покупатель получил поврежденную упаковку и хочет заменить товар."
),
}
],
)
Именно client.chat.completions.create() выполняет HTTPS-запрос. Программа останавливается на этой строке и ждет ответа сервиса.
Аргумент model сообщает провайдеру, какую модель нужно запустить. Аргумент messages содержит список сообщений диалога. Сейчас в списке один словарь:
role="user"обозначает сообщение пользователяcontentсодержит инструкцию и исходное обращение
Две соседние строковые части внутри круглых скобок Python объединяет автоматически. Для модели это одна фраза, а перенос в коде нужен только для удобства чтения.
Шаг 6. Извлекаем текст ответа¶
В переменной response находится не строка, а объект с результатом и служебными данными. Поле choices содержит список вариантов ответа. Мы запросили обычный текстовый ответ и берем первый вариант по индексу 0, затем его сообщение и поле content.
Общий путь запроса¶
Теперь можно собрать отдельные действия в одну схему.
Наш Python-код не подключается к модели как к локальной функции. Он отправляет сетевой запрос. Библиотека openai преобразует аргументы Python в HTTP-запрос, а затем превращает полученный JSON в удобный объект response.
ProxyAPI находится между приложением и API OpenAI. Поэтому ключ ProxyAPI получает именно ProxyAPI, а не модель. При этом формат сообщения и ответа остается совместимым с OpenAI SDK.
Если бы приложение подключалось к API OpenAI напрямую, нам понадобились бы учетная запись, ключ и доступность самого зарубежного сервиса. При работе через ProxyAPI приложение авторизуется в российском сервисе, а дальнейшее взаимодействие с API провайдера выполняет посредник.
Вторая цепочка длиннее на один узел, но для нашего Python-кода почти ничего не меняется. Мы по-прежнему используем класс OpenAI, метод chat.completions.create(), список messages и привычную структуру ответа. Отличаются адрес API, ключ и правила оплаты.
Важно учитывать, что любой посредник становится отдельной частью инфраструктуры. Доступность приложения теперь зависит не только от модели, но и от ProxyAPI. Если мы захотим подключить другой OpenAI-совместимый API, потребуется изменить BASE_URL, MODEL и значение LLM_API_KEY. Если провайдер использует другой формат запросов, одного изменения настроек будет недостаточно: придется также заменить или адаптировать код интеграции.
Теперь давайте запустим наш код и посмотрим на результат. Во время запроса терминал может несколько секунд ничего не печатать. Это нормально, ведь вызов синхронный, поэтому Python ожидает сеть и генерацию, а затем переходит к print(answer).
Мы получили ответ модели. Обратите внимание: точная формулировка может отличаться, ведь модель генерирует ответ вероятностно, а не извлекает заранее сохраненную строку.
В следующем шаге мы обновим этот же файл и научимся получать из ответа не только текст, но и модель, причину завершения и статистику токенов.
Шаг 4¶
Первый запрос вернул полезный текст, но на самом деле объект ответа содержит больше данных. AI-разработчику важно видеть, какая модель ответила, почему остановилась генерация и сколько токенов было обработано. Эти сведения помогают диагностировать поведение приложения и оценивать использование ресурсов.
Откройте main.py, полностью удалите предыдущий пример и замените его кодом ниже:
import os
from dotenv import load_dotenv
from openai import OpenAI
BASE_URL = "https://api.proxyapi.ru/openai/v1"
MODEL = "gpt-4.1"
def main() -> None:
load_dotenv()
token = os.getenv("LLM_API_KEY")
if not token:
raise SystemExit(
"Не найдена переменная LLM_API_KEY. "
"Проверьте файл .env в корне проекта."
)
client = OpenAI(
base_url=BASE_URL,
api_key=token,
)
response = client.chat.completions.create(
model=MODEL,
messages=[
{
"role": "user",
"content": "Кратко перескажи: клиент дважды оплатил один заказ.",
}
],
)
choice = response.choices[0]
answer = choice.message.content
print("Результат:")
print(answer or "Модель не вернула текст")
print("\nДиагностика:")
print(f"ID ответа: {response.id}")
print(f"Модель: {response.model}")
print(f"Причина завершения: {choice.finish_reason}")
if response.usage is not None:
print(f"Входные токены: {response.usage.prompt_tokens}")
print(f"Выходные токены: {response.usage.completion_tokens}")
print(f"Всего токенов: {response.usage.total_tokens}")
else:
print("Провайдер не вернул статистику токенов")
if __name__ == "__main__":
main()
Как и в прошлой лекции, выполняемый сценарий находится внутри main(), а условие if __name__ == "__main__" запускает его только при прямом запуске файла.
Пока не запускайте файл, давайте сначала посмотрим, что сохранилось от прошлого варианта и что появилось после получения ответа.
В начале main() программа выполняет уже знакомую подготовку:
load_dotenv()загружает значения из.envos.getenv("LLM_API_KEY")получает token- проверка останавливает программу, если секрета нет
OpenAI(...)создает клиент ProxyAPIclient.chat.completions.create(...)отправляет сообщение
Текст обращения изменился, но структура запроса осталась прежней. Основная доработка начинается после того, как API вернул объект response. response — это типизированный объект библиотеки. Поэтому к его данным можно обращаться через точку: response.id, response.model, response.choices и response.usage.
Сохраняем первый вариант ответа¶
choices — это список с вариантами ответа, которые модель сгенерировала для одного запроса. Один элемент списка называется choice и содержит отдельный вариант результата: сообщение модели с текстом, причину завершения генерации и другие служебные поля. Например, теоретически модель могла бы предложить несколько разных кратких пересказов одного обращения и каждый из них находился бы в отдельном элементе choices.
В нашем запросе API возвращает один вариант, поэтому список содержит один элемент. Индексация списков в Python начинается с нуля: запись response.choices[0] означает «взять первый вариант ответа». Мы сохраняем его в переменную choice, чтобы дальше обращаться к choice.message.content и choice.finish_reason, не повторяя длинный путь.
Текст находится в choice.message.content. В простом запросе здесь должна быть строка, однако совместимый API или более сложный сценарий может вернуть None. Поэтому вывод записан так:
Если answer содержит непустую строку, Python напечатает ее. Если значение пустое или равно None, будет показано запасное сообщение.
Разбираем служебные поля¶
print(f"ID ответа: {response.id}")
print(f"Модель: {response.model}")
print(f"Причина завершения: {choice.finish_reason}")
response.id— идентификатор конкретного ответа, полезный при диагностикеresponse.model— идентификатор модели, который вернул провайдерchoice.finish_reason— причина, по которой модель прекратила генерацию
Для обычного завершенного текста чаще всего встречается stop. Значение length означает, что генерация остановилась из-за ограничения длины. Значение tool_calls используется, когда модель решила вызвать инструмент. Поэтому успешный HTTP-ответ еще не гарантирует, что генерация завершилась именно так, как ожидало приложение.
Читаем статистику токенов¶
if response.usage is not None:
print(f"Входные токены: {response.usage.prompt_tokens}")
print(f"Выходные токены: {response.usage.completion_tokens}")
print(f"Всего токенов: {response.usage.total_tokens}")
else:
print("Провайдер не вернул статистику токенов")
response.usage содержит сведения об объеме обработанного текста:
prompt_tokens— токены входных сообщенийcompletion_tokens— токены сгенерированного ответаtotal_tokens— их общая сумма
Статистика является необязательной для совместимого провайдера, поэтому перед чтением полей мы проверяем response.usage is not None. Без этой проверки попытка обратиться к prompt_tokens у значения None завершилась бы ошибкой AttributeError.
Токен не равен длине слова или символу. В следующем разделе курса мы подробно разберем токенизацию, контекстное окно, задержку и стоимость. Сейчас достаточно понимать, где программа получает фактические показатели запроса.
И вот после полного разбора кода, давайте его запустим. В результате мы получим:
Мы видим, что появился блок «Результат» с кратким пересказом, затем блок «Диагностика» с ID, моделью, причиной завершения и токенами. Напоминаю, формулировка ответа, ID и количество токенов могут отличаться между запусками.
В следующем шаге добавим ввод обращения из консоли, измерение времени, функции и понятную обработку ожидаемых ошибок.
Шаг 5¶
Завершим этот раздел небольшим рефакторингом и доработками. До сих пор текст обращения был записан прямо в коде, а возможная ошибка API приводила к длинной трассировке. Теперь программа будет принимать обращение из консоли, измерять длительность запроса и показывать понятные сообщения при ожидаемых сбоях.
Откройте main.py и замените код на следующий:
import os
from time import perf_counter
import openai
from dotenv import load_dotenv
from openai import OpenAI
BASE_URL = "https://api.proxyapi.ru/openai/v1"
MODEL = "gpt-4.1"
def summarize_request(client: OpenAI, user_text: str) -> None:
"""Кратко пересказывает обращение и печатает метрики запроса."""
text = user_text.strip()
if not text:
print("Ошибка: обращение не должно быть пустым.")
return
started_at = perf_counter()
try:
response = client.chat.completions.create(
model=MODEL,
messages=[
{
"role": "user",
"content": (
"Сформулируй краткое содержание обращения в одном "
"предложении. Не добавляй факты, которых нет в тексте.\n\n"
f"Обращение: {text}"
),
}
],
)
except openai.AuthenticationError:
print("Ошибка авторизации: проверьте LLM_API_KEY.")
return
except openai.PermissionDeniedError:
print("Нет доступа к модели: проверьте разрешения API-ключа.")
return
except openai.RateLimitError:
print("Достигнут лимит запросов провайдера. Повторите запрос позднее.")
return
except openai.APITimeoutError:
print("ProxyAPI не успел ответить за отведенное время.")
return
except openai.APIConnectionError:
print("Не удалось соединиться с ProxyAPI. Проверьте сеть.")
return
except openai.APIStatusError as error:
if error.status_code == 402:
print("Недостаточно средств на балансе ProxyAPI.")
else:
print(f"API вернул ошибку со статусом {error.status_code}.")
if error.request_id:
print(f"Request ID: {error.request_id}")
return
elapsed_seconds = perf_counter() - started_at
choice = response.choices[0]
answer = choice.message.content
print("\nРезультат:")
print(answer or "Модель не вернула текстовый ответ")
print("\nМетрики:")
print(f"Модель: {response.model}")
print(f"Завершение: {choice.finish_reason}")
print(f"Время: {elapsed_seconds:.2f} с")
if response.usage is not None:
print(f"Входные токены: {response.usage.prompt_tokens}")
print(f"Выходные токены: {response.usage.completion_tokens}")
print(f"Всего токенов: {response.usage.total_tokens}")
print(f"ID ответа: {response.id}")
def main() -> None:
load_dotenv()
token = os.getenv("LLM_API_KEY")
if not token:
raise SystemExit(
"Не найдена переменная LLM_API_KEY. "
"Проверьте файл .env в корне проекта."
)
client = OpenAI(base_url=BASE_URL, api_key=token)
user_text = input("Введите текст обращения: ")
summarize_request(client, user_text)
if __name__ == "__main__":
main()
Мы видим, что код стал длиннее, но теперь он разбит на части, где каждая часть отвечает за отдельный этап работы приложения. В коде появилась функция summarize_request():
Функция получает две готовые зависимости: настроенный клиент и текст обращения. Она не ищет токен, не создает клиент и не спрашивает пользователя о вводе. Ее задача — проверить конкретный текст, отправить его модели и показать результат.
client: OpenAI и user_text: str — это подсказки типов. Запись -> None показывает, что функция ничего не возвращает через return, а печатает результат в консоль. Подсказки делают контракт функции понятнее, но сами по себе не проверяют данные во время выполнения.
Далее мы проверяем пользовательский ввод:
strip() удаляет пробелы и переводы строк по краям. Если пользователь просто нажал Enter или ввел несколько пробелов, после очистки получится пустая строка. В этом случае функция печатает понятное сообщение и выполняет return. Сетевой вызов не происходит, поэтому программа не тратит время и не расходует баланс на бессмысленный запрос.
Потом в коде мы измеряем длительность запроса:
perf_counter() возвращает значение монотонного счетчика. Сначала мы запоминаем момент перед API-вызовом, а после успешного ответа вычитаем начальное значение из нового. Полученное время включает подготовку и передачу запроса, ожидание модели, получение данных и разбор ответа SDK. Это клиентская длительность, то есть примерно то ожидание, которое тратит пользователь программы.
После этого мы формируем запрос из введенного текста. И обратите внимание, в messages теперь нет заранее записанного обращения. А уже очищенная строка подставляется через f-строку:
Перед ней находится постоянная инструкция, где мы просим кратко пересказать текст одним предложением и не добавлять отсутствующие факты. Два символа \n\n отделяют инструкцию от пользовательских данных пустой строкой.
Такое разделение пока существует только внутри одного сообщения user. Роли, системные инструкции и более надежное проектирование промптов подробно рассмотрим в следующих разделах.
После запроса мы обрабатываем ожидаемые ошибки API, где сетевой вызов помещен внутрь try. Если библиотека создаст известное исключение, выполнение перейдет в соответствующий блок except:
AuthenticationError— токен не принятPermissionDeniedError— API-ключ распознан, но разрешений недостаточноRateLimitError— достигнут лимит запросов или квоты провайдераAPITimeoutError— сервис не завершил запрос вовремяAPIConnectionError— не удалось соединиться с эндпоинтомAPIStatusError— другой ошибочный HTTP-статус, включая 402 при недостаточном балансе
Сначала записаны более конкретные классы, а общий APIStatusError находится ниже. Такой порядок позволяет показать наиболее точное сообщение. Мы намеренно не добавляем except Exception. Неожиданная ошибка в нашем Python-коде должна оставаться заметной разработчику, иначе широкая обработка скроет настоящую проблему под общим сообщением.
Строки после блоков except выполняются только тогда, когда запрос завершился успешно. Код вычисляет длительность, извлекает первый вариант, печатает ответ и уже знакомые метрики.
Теперь давайте запустим наш код, и после приглашения Введите текст обращения: напишите, например:
Нажмите Enter. После ответа появятся краткое содержание и метрики: модель, причина завершения, время, токены и ID ответа:
Теперь самостоятельно проверьте разные сценарии. Запустите код еще раз и вместо текста введите несколько пробелов. Тогда запрос к API выполняться не должен. Или можете попробовать изменить токен. В любом случае мы должны получить ошибку с четким описанием ее возникновения.
В конце этого шага давайте подведем итоги всего этого раздела. У нас появился простой Python-проект и первая законченная AI-функция. Она загружает секрет из .env, принимает пользовательский текст, отклоняет пустой ввод, обращается к ProxyAPI, показывает ответ и метрики, а также понятно обрабатывает основные ошибки API.
Но это еще далеко не полноценный проект. Настраиваемые таймауты, повторные попытки, отложенные запросы, типизированная конфигурация и полноценное логирование появятся позже. Но главное то, что уже сейчас программа дает правильную основу, которую можно последовательно развивать в следующих разделах курса.
Шаг 6¶
Провайдер вернул успешный ответ: response.choices содержит элемент, но response.usage is None.
Как должен поступить код приложения?
Правильный ответ
- ✅ Обработать текст ответа, а отсутствие статистики токенов учесть отдельной веткой
Шаг 7¶
Какие решения делают первый вызов модели пригодным для диагностики и безопасного развития проекта?
Правильный ответ
- ✅ Раздельно обработать типовые исключения авторизации, лимитов, таймаута и соединения
- ✅ Измерять длительность вызова монотонным таймером вроде perf_counter()
- ✅ Сохранять request ID и причину завершения для диагностики
- ✅ Проверить пустой пользовательский ввод до сетевого запроса
Шаг 8¶
Во время вызова API возникла ошибка авторизации.
Какой вариант обработки полезнее для пользователя и диагностики?
Правильный ответ
- ✅ Поймать AuthenticationError, показать безопасное сообщение и не раскрывать ключ
Шаг 9¶
Какие данные ответа стоит сохранить или вывести при отладке первого запроса?
Правильный ответ
- ✅ Причину завершения генерации
- ✅ Идентификатор успешного ответа response.id
- ✅ Статистику токенов, если провайдер её вернул
- ✅ Фактически использованную модель
Шаг 10¶
Первый запрос без реальной сети¶
Нужно проверить код первого запроса к модели, не расходуя токены и не используя настоящий API-ключ. Поэтому вместо реального SDK в задачу передаётся тестовый клиент. Он запоминает параметры вызова и возвращает заранее подготовленный ответ.
Задача¶
Реализуйте request_summary(client, model, user_text). Функция должна проверить обращение, один раз вызвать client.chat.completions.create(...) и вернуть текст первого ответа модели.
Требования¶
- Если
user_textне строка, выбросьтеTypeError. - Удалите внешние пробелы. Если текст стал пустым, выбросьте
ValueErrorдо обращения к клиенту. - Вызовите
client.chat.completions.create()ровно один раз. - Передайте аргумент
modelбез изменения и списокmessagesс одним сообщением ролиuser. Вcontentдолжны находиться инструкция кратко пересказать обращение и очищенный текст обращения. - Если
response.choicesпуст, выбросьтеValueError. - Если
message.contentравенNoneили пуст после очистки, выбросьтеValueError. - Верните текст ответа после
strip().
Пример результата¶
request_summary(fake_client, "test-model", " Дважды списали деньги. ")
# вызывает create(model="test-model", messages=[...])
# и возвращает очищенный текст ответа
Проверяющая система передаёт фейковый клиент с интерфейсом OpenAI SDK. Настоящий API-ключ и доступ к сети не нужны.
Правильный ответ
from typing import Any
def request_summary(client: Any, model: str, user_text: str) -> str:
if not isinstance(user_text, str):
raise TypeError("user_text must be a string")
user_text = user_text.strip()
if not user_text:
raise ValueError("user_text must not be empty")
messages = [
{
"role": "user",
"content": f"Кратко перескажи обращение пользователя: {user_text}",
}
]
response = client.chat.completions.create(
model=model,
messages=messages,
)
if not response.choices:
raise ValueError("response contains no choices")
content = response.choices[0].message.content
if content is None or not content.strip():
raise ValueError("response content is empty")
return content.strip()
from typing import Any
def request_summary(client: Any, model: str, user_text: str) -> str:
if type(user_text) != str:
raise TypeError
if (cleaned := user_text.strip()) == "":
raise ValueError
response = client.chat.completions.create(
model=model,
messages=[
{
"role": "user",
"content": (
"Сформулируй краткое содержание обращения в одном "
"предложении. Не добавляй факты, которых нет в тексте.\n\n"
f"Обращение: {cleaned}"
),
}
],
)
if choices := response.choices:
answer = choices[0].message.content
result = (answer or " ").strip()
if not result:
raise ValueError
return result
else:
raise ValueError
Шаг 11¶
Чтение обязательного API-ключа¶
В обычном приложении API-ключ читается из переменных окружения. Чтобы задача не зависела от настроек компьютера, окружение представлено словарём env, например {"LLM_API_KEY": "secret"}.
Задача¶
Реализуйте get_required_api_key(env, name='LLM_API_KEY'). Функция должна найти обязательный секрет, проверить его и вернуть очищенное значение.
Требования¶
nameдолжен быть непустой строкой послеstrip(). Для пустого или некорректного значения выбросьтеValueError.- Получите значение через
env.get(name). - Значение должно быть строкой и не должно быть пустым после
strip(). - При отсутствии или некорректном значении выбросьте
ValueError. - Верните очищенный ключ и не изменяйте исходный словарь.
Пример результата¶
Заготовка кода уже находится в редакторе. Дополнительные сторонние библиотеки для решения не нужны.
Правильный ответ
from typing import Mapping
def get_required_api_key(
env: Mapping[str, object],
name: str = "LLM_API_KEY",
) -> str:
if not isinstance(name, str) or not name.strip():
raise ValueError("name must be a non-empty string")
name = name.strip()
value = env.get(name)
if not isinstance(value, str) or not value.strip():
raise ValueError("API key is missing or invalid")
return value.strip()
from typing import Mapping
def get_required_api_key(
env: Mapping[str, object],
name: str = "LLM_API_KEY",
) -> str:
striped = name.strip()
if striped == '' or type(striped) != str:
raise ValueError
api_key = env.get(name, '')
try:
api_key = api_key.strip()
if api_key == '' or type(api_key) != str:
raise ValueError
except:
raise ValueError
return api_key
from typing import Mapping
def get_required_api_key(
env: Mapping[str, object],
name: str = "LLM_API_KEY",
) -> str:
# Напишите реализацию.
if not isinstance(name, str):
raise ValueError
name = name.strip()
if not name:
raise ValueError
token = env.get(name)
if not isinstance(token, str):
raise ValueError
token = token.strip()
if not token:
raise ValueError
return token
Шаг 12¶
Извлечение первого текстового ответа¶
После успешного запроса SDK возвращает объект response. В нём список choices содержит варианты продолжения, а текст первого варианта находится в response.choices[0].message.content.
Задача¶
Реализуйте extract_chat_answer(response). Функция должна безопасно извлечь первый текстовый ответ и не допустить, чтобы пустой результат прошёл дальше по приложению.
Требования¶
- Если
response.choicesпуст, выбросьтеValueError. - Возьмите
message.contentпервого варианта. - Если content не строка или пуст после очистки, выбросьте
ValueError. - Верните очищенный текст.
Пример результата¶
# response.choices[0].message.content == " Ответ модели. "
extract_chat_answer(response)
# "Ответ модели."
# response.choices == []
extract_chat_answer(response)
# ValueError
Заготовка кода уже находится в редакторе. Дополнительные сторонние библиотеки для решения не нужны.
Правильный ответ
from typing import Any
def extract_chat_answer(response: Any) -> str:
if not response.choices:
raise ValueError("response contains no choices")
content = response.choices[0].message.content
if not isinstance(content, str) or not content.strip():
raise ValueError("response content is empty or invalid")
return content.strip()
Шаг 13¶
Сбор диагностических метрик¶
Для диагностики запроса недостаточно сохранить только текст модели. Полезно знать идентификатор ответа, фактическую модель, причину завершения, длительность и расход токенов. В некоторых ответах поле usage может отсутствовать.
Задача¶
Реализуйте collect_response_metrics(response, elapsed_seconds). Функция должна собрать диагностические поля в обычный словарь, который затем можно передать в логирование или интерфейс.
Требования¶
elapsed_secondsдолжен быть числом типаintилиfloat, но неbool. Для другого типа выбросьтеTypeError, а для отрицательного значения —ValueError.- Верните словарь ровно с ключами
response_id,model,finish_reason,elapsed_seconds,prompt_tokens,completion_tokensиtotal_tokens. - В
response_id,modelиfinish_reasonсохраните соответственноresponse.id,response.modelиresponse.choices[0].finish_reason. - В
elapsed_secondsсохранитеfloat(elapsed_seconds). - Если
response.usage is None, значенияprompt_tokens,completion_tokensиtotal_tokensдолжны быть равныNone. - Иначе перенесите в эти ключи одноимённые поля из
response.usage.
Пример результата¶
collect_response_metrics(response, 1.25)
# {
# "response_id": "r1", "model": "m1",
# "finish_reason": "stop", "elapsed_seconds": 1.25,
# "prompt_tokens": 10, "completion_tokens": 4,
# "total_tokens": 14
# }
Заготовка кода уже находится в редакторе. Дополнительные сторонние библиотеки для решения не нужны.
Правильный ответ
from typing import Any
def collect_response_metrics(response: Any, elapsed_seconds: float) -> dict[str, object]:
if isinstance(elapsed_seconds, bool) or not isinstance(elapsed_seconds, (int, float)):
raise TypeError("elapsed_seconds must be a number")
if elapsed_seconds < 0:
raise ValueError("elapsed_seconds must not be negative")
usage = response.usage
return {
"response_id": response.id,
"model": response.model,
"finish_reason": response.choices[0].finish_reason,
"elapsed_seconds": float(elapsed_seconds),
"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,
}
from typing import Any
def collect_response_metrics(response: Any, elapsed_seconds: float) -> dict[str, object]:
if isinstance(elapsed_seconds, bool) or not isinstance(elapsed_seconds, float | int):
raise TypeError("Elapsed seconds should be int or float")
if elapsed_seconds < 0:
raise ValueError("Elapsed seconds should be not negative")
metrics = {
"response_id": response.id,
"model": response.model,
"finish_reason": response.choices[0].finish_reason,
"elapsed_seconds": float(elapsed_seconds),
"prompt_tokens": None,
"completion_tokens": None,
"total_tokens": None
}
if response.usage is not None:
metrics["prompt_tokens"] = response.usage.prompt_tokens
metrics["completion_tokens"] = response.usage.completion_tokens
metrics["total_tokens"] = response.usage.total_tokens
return metrics
Шаг 14¶
Создание клиента и первый запрос к LLM¶
В этой задаче вы повторите полный путь первого запроса: создадите клиент, передадите ему учебный API-ключ, подготовите пользовательское сообщение, выполните запрос и извлечёте текст ответа. Вместо настоящего SDK используется совместимый тестовый класс OpenAI, поэтому реальный ключ и доступ к сети не нужны.
Задача¶
Допишите приведённую программу. Не создавайте отдельную функцию, а только последовательно заполните переменные client, messages, response и answer.
Требования¶
- Не изменяйте значения
FAKE_API_KEY,MODELиUSER_TEXT. - Создайте клиент вызовом
OpenAI(api_key=FAKE_API_KEY). - Подготовьте список
messagesровно с одним словарём: roleдолжен быть равенusercontentдолжен содержать значениеUSER_TEXT- Один раз вызовите
client.chat.completions.create(...). - Передайте в запрос только
model=MODELиmessages=messages. - Сохраните объект ответа в
response. - Извлеките
response.choices[0].message.content, удалите внешние пробелы и сохраните результат вanswer.
Пример результата¶
# После выполнения программы:
answer == "Москва — столица России."
# Проверяющая система также убедится, что клиент создан
# с фейковым ключом, а запрос выполнен с MODEL и messages.
Класс OpenAI уже предоставлен проверяющей системой и использует учебный токен. Импортировать SDK и обращаться к сети не нужно.
Правильный ответ
FAKE_API_KEY = "sk-training-not-a-real-key"
MODEL = "test-model"
USER_TEXT = "Ответь одним предложением: какой город является столицей России?"
# Создайте клиент с учебным токеном.
client = OpenAI(api_key=FAKE_API_KEY)
# Подготовьте одно user-сообщение.
messages = [
{
"role": "user",
"content": USER_TEXT,
}
]
# Выполните запрос к тестовому LLM-клиенту.
response = client.chat.completions.create(model=MODEL, messages=messages)
# Извлеките и очистите текст первого ответа.
answer = response.choices[0].message.content.strip()