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

Конфигурация и секреты

Шаг 1

Наш проект AI Support Desk уже умеет принять обращение пользователя, отправить его языковой модели и проверить полученный ответ. С точки зрения пользователя это уже полезный сценарий, но с точки зрения разработчика у приложения остается важная проблема, ведь оно слишком сильно привязано к значениям, записанным в разных частях проекта.

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

Сейчас настройки AI Support Desk находятся в разных местах:

  • API-ключ ProxyAPI хранится в .env
  • эндпоинт API работы с моделью записан в константе BASE_URL
  • модель указана в MODEL
  • параметры генерации находятся в TEMPERATURE и MAX_OUTPUT_TOKENS

В этом разделе соберем настройки в отдельном файле config.py, и в итоге main.py будет получать один готовый объект Settings, а не самостоятельно искать переменные, преобразовывать строки и проверять обязательные значения.

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

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

Что такое конфигурация?

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

Например:

  • адрес API
  • идентификатор языковой модели
  • temperature и ограничение ответа
  • название окружения
  • API-ключ для доступа к провайдеру

Если говорить простыми словами, то логика приложения отвечает на вопрос «что делать?», а конфигурация отвечает на вопрос «с какими значениями и в каком окружении это делать?».

В текущем main.py у нас используется простой подход:

load_dotenv()
token = os.getenv("LLM_API_KEY")

То есть load_dotenv() читает локальный файл .env, а os.getenv() получает строку по имени. Для первого запроса это был хороший и понятный вариант.

Но по мере роста количества настроек у такого подхода появляются ограничения:

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

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

Но в проектах одно и то же значение может быть задано несколькими способами:

  1. значение по умолчанию в классе настроек
  2. локальный файл .env
  3. переменная окружения процесса
  4. явное значение, переданное при создании объекта настроек

Если значение встретилось в нескольких местах, нужен понятный приоритет. В Pydantic Settings, который мы подключим в следующем шаге, явный аргумент имеет более высокий приоритет, чем переменная окружения, а переменная окружения важнее .env, но при этом .env важнее значения по умолчанию.

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

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

Шаг 2

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

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

Для этого мы будем использовать Pydantic Settings — библиотеку, которая загружает значения из окружения и .env, преобразует их к указанным типам и сообщает об ошибках конфигурации. В этом шаге мы пока не меняем main.py, а сначала создадим и отдельно проверим новый компонент.

Устанавливаем Pydantic Settings

Откройте терминал в корне проекта и выполните:

pip install pydantic-settings

Эта команда устанавливает библиотеку в активное виртуальное окружение.

pip freeze > requirements.txt

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

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

from typing import Literal

from pydantic import AnyHttpUrl, Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    llm_api_key: SecretStr = Field(
        validation_alias="LLM_API_KEY",
    )
    base_url: AnyHttpUrl = Field(
        default="https://api.proxyapi.ru/openai/v1",
        validation_alias="BASE_URL",
    )
    model: str = Field(
        default="gpt-4.1",
        min_length=1,
        validation_alias="MODEL",
    )
    temperature: float = Field(
        default=0.2,
        ge=0.0,
        le=1.0,
        validation_alias="TEMPERATURE",
    )
    max_output_tokens: int = Field(
        default=80,
        gt=0,
        validation_alias="MAX_OUTPUT_TOKENS",
    )
    app_env: Literal["development", "test", "production"] = Field(
        default="development",
        validation_alias="APP_ENV",
    )

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        env_ignore_empty=True,
        extra="ignore",
    )

Тип AnyHttpUrl проверяет, что base_url является HTTP- или HTTPS-адресом. Ограничение min_length=1 не позволяет передать пустое имя модели. Для temperature мы намеренно задаем диапазон от 0 до 1: API отдельных моделей допускает и другие значения, но нашему приложению более широкий диапазон не нужен.

Обратите внимание, в config.py нет вызова языковой модели и пользовательского сценария. Файл отвечает только за получение и проверку настроек. Такое разделение поможет дальше менять способ работы с API, не переписывая правила загрузки конфигурации.

Settings наследуется от BaseSettings. Когда позже мы вызовем Settings(), Pydantic выполнит несколько действий:

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

И давайте на примере настройки temperature разберемся что значат эти значения поля:

temperature: float = Field(
    default=0.2,
    ge=0.0,
    le=1.0,
    validation_alias="TEMPERATURE",
)
  • temperature — имя поля в Python-коде
  • float — ожидаемый тип
  • default=0.2 — значение, если настройка нигде не задана
  • ge=0.0 — правило, что число должно быть не меньше нуля
  • le=1.0 — правило, что число должно быть не больше единицы в настройках нашего приложения
  • validation_alias="TEMPERATURE" — имя переменной в окружении и .env

В .env любое значение записано текстом. Pydantic преобразует строку 0.2 в Python-число float. Если вместо числа написать мало, объект настроек не будет создан.

Это и есть очень важная граница, ведь остальной код не должен повторно проверять, является ли temperature числом. Если объект Settings существует, его поля уже прошли описанные проверки.

А если мы посмотрим на поле llm_api_key, то увидим, что у него нет значения default:

llm_api_key: SecretStr = Field(
    validation_alias="LLM_API_KEY",
)

Это как раз говорит о том, что это обязательная настройка, которая обязательно должна быть задана, ведь без токена приложение не сможет авторизоваться, поэтому лучше остановиться при запуске, а не дожидаться ошибки во время API-вызова. Также мы указываем тип SecretStr, она говорит о том, что поле хранит секретную строку и маскирует ее при обычном выводе объекта. Это снижает вероятность случайно напечатать токен в консоли или в логах. А когда нашему проекту понадобится настоящее значение, его придется получить явным методом get_secret_value(). Иными словами, SecretStr дополнительная защита от случайного вывода.

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

app_env: Literal["development", "test", "production"]

Здесь Literal разрешает только три строки:

  • development — локальная разработка
  • test — тестовый запуск
  • production — рабочее окружение

Опечатка в значении, например prodution, будет обнаружена сразу, и проект не запустится с неверной конфигурацией.

Теперь разберем нижнюю часть файла и посмотрим на самый низ файла, где мы настраиваем чтение файла .env:

model_config = SettingsConfigDict(
    env_file=".env",
    env_file_encoding="utf-8",
    env_ignore_empty=True,
    extra="ignore",
)
  • env_file=".env" указывает локальный файл
  • env_file_encoding="utf-8" задает кодировку
  • env_ignore_empty=True считает пустые значения отсутствующими
  • extra="ignore" разрешает хранить в .env переменные других инструментов, которые не описаны в этом классе

То есть Pydantic Settings ищет указанный .env в текущем рабочем каталоге.

Теперь давайте обновим наш файл .env, добавив в него недостающие настройки:

LLM_API_KEY=ваш_ключ_proxyapi
BASE_URL=https://api.proxyapi.ru/openai/v1
MODEL=gpt-4.1
TEMPERATURE=0.2
MAX_OUTPUT_TOKENS=80
APP_ENV=development

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

Сохраните config.py и .env. Теперь выполните в терминале:

python -c "from config import Settings; s = Settings(); print(s.app_env, s.model, s.temperature, s.max_output_tokens); print(s.llm_api_key)"

Команда создает объект Settings и печатает несекретные поля. В последней строке API-ключ должен отображаться замаскированным, а не настоящим значением.

development gpt-4.1 0.2 80
**********

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

Временно измените в .env:

MAX_OUTPUT_TOKENS=много

Повторите команду проверки. Pydantic должен сообщить, что значение нельзя преобразовать в целое число:

pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings
MAX_OUTPUT_TOKENS
  Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='много', input_type=str]
    For further information visit https://errors.pydantic.dev/2.13/v/int_parsing

Затем обязательно верните:

MAX_OUTPUT_TOKENS=80

Пока ошибка выглядит как стандартное сообщение Pydantic. Но далее уже сам main.py перехватит ее и покажет пользователю более короткое объяснение. Давайте в следующем шаге подключим к нему Settings и удалим дублирование конфигурации.

Шаг 3

Класс Settings уже умеет загрузить и проверить конфигурацию, но основное приложение пока о нем не знает. main.py по-прежнему вызывает load_dotenv(), отдельно читает токен и использует собственные константы. Получилось два способа описать одни и те же настройки.

В этом шаге мы устраним это дублирование. И после изменений точка запуска сначала создаст Settings, затем на его основе создаст API-клиент и только после этого запустит пользовательский сценарий. Если конфигурация неверна, обращение к ProxyAPI вообще не начнется.

Полностью переписывать файл не будем. Последовательно изменим импорты, удалим старые константы, передадим настройки в AI-функцию и обновим main().

Откройте main.py. В начале файла найдите импорты:

import os
from time import perf_counter

import openai
from dotenv import load_dotenv
from openai import OpenAI

Замените их на:

from time import perf_counter

import openai
from openai import OpenAI
from pydantic import ValidationError

from config import Settings

Модули os и load_dotenv нам больше не нужны: теперь конфигурацию загружает Settings. Класс ValidationError понадобится, чтобы понятно сообщать об ошибках конфигурации.

Теперь найдите и удалите четыре строки:

BASE_URL = "https://api.proxyapi.ru/openai/v1"
MODEL = "gpt-4.1"
TEMPERATURE = 0.2
MAX_OUTPUT_TOKENS = 80

Эти значения теперь описаны в config.py и доступны через объект settings.

SYSTEM_INSTRUCTION оставьте в main.py. Это не параметр подключения, а часть поведения AI-функции, ведь инструкция объясняет языковой модели, какую задачу нужно выполнить.

Далее найдите заголовок функции:

def summarize_request(client: OpenAI, user_text: str) -> None:
    ...

Замените его на:

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

Теперь функция явно получает конфигурацию параметром. Она не создает Settings внутри себя и не ищет .env.

В той же функции найдите вызов модели:

response = client.chat.completions.create(
    model=MODEL,
    messages=build_messages(text),
    temperature=TEMPERATURE,
    max_completion_tokens=MAX_OUTPUT_TOKENS,
)

Замените его следующим:

response = client.chat.completions.create(
    model=settings.model,
    messages=build_messages(text),
    temperature=settings.temperature,
    max_completion_tokens=settings.max_output_tokens,
)

Python-приложение берет значения из типизированного объекта и передает их SDK. Языковая модель по-прежнему генерирует ответ; конфигурация только определяет, какую модель вызвать и с какими параметрами.

Функция получает весь объект Settings, потому что ей нужны несколько связанных полей. При этом секрет ей не нужен и не передается отдельно: token используется только при создании клиента.

В проверке finish_reason можно показать фактическое ограничение. Замените существующий блок if choice.finish_reason == "length": на:

if choice.finish_reason == "length":
    print(
        "Ответ модели остановлен из-за ограничения длины: "
        f"{settings.max_output_tokens} токенов."
    )
    return

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

Теперь в конце файла найдите существующую функцию main() и замените ее целиком:

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

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

    print(f"Окружение: {settings.app_env}")
    print(f"Модель: {settings.model}")

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

Блок ниже оставьте без изменений:

if __name__ == "__main__":
    main()

Пока не запускайте программу, а давайте сначала разберем новую точку запуска.

Настройки создаются один раз:

settings = Settings()

PyCharm может показать предупреждение Parameter 'llm_api_key' unfilled. Это предупреждение статического анализатора, а не ошибка Pydantic. IDE видит обязательное поле класса, но не учитывает, что BaseSettings загружает LLM_API_KEY из файла .env. Передавать секрет прямо в Settings(...) не нужно. Если переменной действительно нет, Pydantic создаст ValidationError при запуске.

На этой строке Pydantic собирает значения из окружения, .env и дефолты, затем проверяет их. Если конфигурация корректна, приложение получает один готовый объект. То есть мы не вызываем Settings() внутри каждой функции, а настройки загружаются в точке запуска и затем передаются туда, где нужны. Это делает источник конфигурации понятным.

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

Сохраните main.py и запустите проект, проверьте его работу. После ввода обращения выбранная языковая модель сгенерирует резюме, а Python-приложение проверит и выведет результат. Все отработает так же, как раньше.

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

Ошибка конфигурации:
- LLM_API_KEY: Field required

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

После проверки сразу верните имя переменной LLM_API_KEY.

Что изменилось в архитектуре?

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

  • config.py описывает и проверяет настройки
  • main.py реализует пользовательский сценарий
  • .env хранит локальные значения
  • SDK получает раскрытый токен только при создании клиента
  • языковая модель получает сообщения и генерирует ответ

В конце раздела зафиксируем, куда мы движемся дальше. Ведь на этом конфигурация AI Support Desk готова и приложение получает настройки в одном месте, проверяет их до сетевого запроса и передает API-клиенту только корректные значения.

Однако main.py пока отвечает сразу за пользовательский сценарий и техническое общение с провайдером: создает OpenAI-клиента, отправляет запрос и разбирает ответ. По мере роста проекта такой файл станет трудно изменять и проверять.

Но в следующем разделе вынесем работу с языковой моделью в отдельный LLM Client. Он станет единой точкой общения с ProxyAPI: будет получать проверенный объект Settings, отправлять сообщения модели, преобразовывать ответ в удобный результат и переводить технические сбои API в понятные ошибки приложения.

Шаг 4

В .env указано MAX_OUTPUT_TOKENS=много, а поле Settings.max_output_tokens объявлено как положительный int.

Где правильнее обнаружить проблему?

Правильный ответ
  • ✅ При создании Settings, до создания клиента и сетевого запроса

Шаг 5

Какие решения улучшают безопасность и управляемость конфигурации?

Правильный ответ
  • ✅ Описать API-ключ типом SecretStr
  • ✅ Хранить локальный .env с секретом вне Git
  • ✅ Создать один проверенный объект Settings и передавать его нужным компонентам
  • ✅ Задать типы и допустимые диапазоны параметров генерации

Шаг 6

Поле API-ключа объявлено как SecretStr.

Как с ним следует работать?

Правильный ответ
  • ✅ Передать раскрытое значение только в SDK-клиент через get_secret_value(), не выводя его в лог

Шаг 7

Почему полезно создать один объект Settings и передавать его компонентам?

Подсказка: выберите 3 варианта ответа.

Правильный ответ
  • ✅ Компоненты получают согласованный набор уже проверенных значений
  • ✅ Конфигурацию проще подменить в тестах и для другого окружения
  • ✅ Ошибки типов и диапазонов обнаруживаются до сетевого запроса

Шаг 8

Маскирование секрета

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

Задача

Реализуйте mask_secret(secret, visible=4). Функция должна сохранить длину строки, заменить скрытую часть звёздочками и не раскрывать короткий секрет целиком.

Требования

  • secret должен быть строкой. Для другого типа выбросьте TypeError, а для пустой строки — ValueError.
  • visible должен быть неотрицательным целым числом; bool не допускается. Для недопустимого типа выбросьте TypeError, а для отрицательного значения — ValueError.
  • Если visible равен 0 или не меньше длины секрета, замаскируйте все символы.
  • Иначе замените начальную часть звёздочками и оставьте последние visible символов.
  • Длина результата должна совпадать с длиной секрета.

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

mask_secret("abcdefgh", 4)  # "****efgh"
mask_secret("abcdefgh", 2)  # "******gh"
mask_secret("abc", 4)       # "***"

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

Правильный ответ
def mask_secret(secret: str, visible: int = 4) -> str:
    if not isinstance(secret, str):
        raise TypeError("secret must be a string")

    if not secret:
        raise ValueError("secret must not be empty")

    if isinstance(visible, bool) or not isinstance(visible, int):
        raise TypeError("visible must be an integer")

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

    if visible == 0 or visible >= len(secret):
        return "*" * len(secret)

    return "*" * (len(secret) - visible) + secret[-visible:]

Шаг 9

Чтение положительного числа из окружения

Переменные окружения приходят в приложение как строки. Числовую настройку, например MAX_OUTPUT_TOKENS, нужно преобразовать и проверить. Если переменная не задана, используется безопасное значение по умолчанию.

Задача

Реализуйте parse_positive_int(env, name, default). Где env — словарь окружения, name — имя настройки, default — значение при её отсутствии.

Требования

  • default должен быть положительным целым числом, а bool не допускается. Для недопустимого типа выбросьте TypeError, а для нуля или отрицательного значения — ValueError.
  • Если переменная отсутствует или содержит пустую строку, верните default.
  • Иначе значение переменной должно быть строкой, которую можно преобразовать в int. Для другого типа или строки, не содержащей целое число, выбросьте ValueError.
  • Результат должен быть положительным, иначе выбросьте ValueError.

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

parse_positive_int({}, "MAX_OUTPUT_TOKENS", 80)       # 80
parse_positive_int({"MAX_OUTPUT_TOKENS": "120"},
                   "MAX_OUTPUT_TOKENS", 80)           # 120
parse_positive_int({"MAX_OUTPUT_TOKENS": "много"},
                   "MAX_OUTPUT_TOKENS", 80)           # ValueError

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

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

def parse_positive_int(
    env: Mapping[str, object],
    name: str,
    default: int,
) -> int:
    if isinstance(default, bool) or not isinstance(default, int):
        raise TypeError("default must be an integer")

    if default <= 0:
        raise ValueError("default must be positive")

    value = env.get(name)

    if value is None or value == "":
        return default

    if not isinstance(value, str):
        raise ValueError("environment value must be a string")

    try:
        result = int(value)
    except ValueError:
        raise ValueError("environment value must contain an integer")

    if result <= 0:
        raise ValueError("environment value must be positive")

    return result
from typing import Mapping

def parse_positive_int(
    env: Mapping[str, object],
    name: str,
    default: int,
) -> int:
    # Напишите реализацию.
    if not isinstance(default, int) or isinstance(default, bool):
        raise TypeError
    if default <= 0:
        raise ValueError

    val = env.get(name, '')
    if val == '':
        return default

    if isinstance(val, str) and val.isdigit() and int(val) > 0:
        return int(val)
    else:
        raise ValueError