Конфигурация и секреты¶
Шаг 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() читает локальный файл .env, а os.getenv() получает строку по имени. Для первого запроса это был хороший и понятный вариант.
Но по мере роста количества настроек у такого подхода появляются ограничения:
- каждую переменную нужно читать отдельно
- все значения сначала являются строками
- преобразование чисел приходится писать самостоятельно
- проверки обязательных полей разбросаны по приложению
- опечатка в имени обнаруживается только во время запуска
- нет одного объекта, который показывает полную конфигурацию
Файл .env — это обычный текстовый файл. Он удобен для локальной разработки, ведь нам не нужно задавать переменные после каждого открытия терминала, а формат одинаков на разных операционных системах. На локальном компьютере мы продолжим использовать .env. В продакшен секрет обычно передает платформа через настоящее окружение процесса или специальное хранилище.
Но в проектах одно и то же значение может быть задано несколькими способами:
- значение по умолчанию в классе настроек
- локальный файл
.env - переменная окружения процесса
- явное значение, переданное при создании объекта настроек
Если значение встретилось в нескольких местах, нужен понятный приоритет. В Pydantic Settings, который мы подключим в следующем шаге, явный аргумент имеет более высокий приоритет, чем переменная окружения, а переменная окружения важнее .env, но при этом .env важнее значения по умолчанию.
Такой подход удобен: локально модель мы укажем в .env, а на сервере платформа передаст другую переменную окружения без изменения исходного кода.
Мы подробно обсудили пользу отдельной конфигурации проекта. В следующем шаге мы перейдем к практике и создадим типизированный класс Settings, который сам загрузит .env, преобразует строки в числа и проверит обязательные значения до обращения к языковой модели.
Шаг 2¶
В прошлом шаге мы определили цель, до создания полноценного API-клиента приложение должно получить полностью готовую и проверенную конфигурацию. Теперь давайте реализуем эту границу в коде.
В этом шаге мы создадим отдельный файл config.py и класс Settings. Он станет своеобразным договором запуска приложения и будет знать какие значения нужны, как они называются во внешнем окружении, какие типы ожидаются и какие ограничения должны выполняться.
Для этого мы будем использовать Pydantic Settings — библиотеку, которая загружает значения из окружения и .env, преобразует их к указанным типам и сообщает об ошибках конфигурации. В этом шаге мы пока не меняем main.py, а сначала создадим и отдельно проверим новый компонент.
Устанавливаем Pydantic Settings¶
Откройте терминал в корне проекта и выполните:
Эта команда устанавливает библиотеку в активное виртуальное окружение.
А эта команда обновляет 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 выполнит несколько действий:
- прочитает настоящие переменные окружения
- прочитает локальный
.env - применит значения по умолчанию
- преобразует строки к указанным типам
- проверит ограничения
- создаст объект настроек или сообщит об ошибке
И давайте на примере настройки 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:
Это как раз говорит о том, что это обязательная настройка, которая обязательно должна быть задана, ведь без токена приложение не сможет авторизоваться, поэтому лучше остановиться при запуске, а не дожидаться ошибки во время API-вызова. Также мы указываем тип SecretStr, она говорит о том, что поле хранит секретную строку и маскирует ее при обычном выводе объекта. Это снижает вероятность случайно напечатать токен в консоли или в логах. А когда нашему проекту понадобится настоящее значение, его придется получить явным методом get_secret_value(). Иными словами, SecretStr дополнительная защита от случайного вывода.
Также давайте посмотрим на поле app_env, в нем мы ограничиваем название окружения:
Здесь 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-ключ должен отображаться замаскированным, а не настоящим значением.
Теперь проверим обработку ошибок. Если значение окажется неверным, проект остановится еще при создании конфигурации.
Временно измените в .env:
Повторите команду проверки. 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
Затем обязательно верните:
Пока ошибка выглядит как стандартное сообщение 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-функции, ведь инструкция объясняет языковой модели, какую задачу нужно выполнить.
Далее найдите заголовок функции:
Замените его на:
Теперь функция явно получает конфигурацию параметром. Она не создает 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)
Блок ниже оставьте без изменений:
Пока не запускайте программу, а давайте сначала разберем новую точку запуска.
Настройки создаются один раз:
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-вызова. В результате мы получим ошибку:
То есть если обязательного поля нет или значение имеет неверный тип, 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