L3 Открытый урок · Стажёр

L3.1. Python для AI-инженерии

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

Объём
5 уроков · 12 часов
Компетенции
PROG-06·A, PROG-02·A, PROG-04·A

Учебный проект Research Assistant, наборы данных для практикумов и эталонные решения выдаются ученикам курса. На сайте открыты теория, практикумы и задания.

Проект, а не скрипт

На L2 был один файл. AI-приложение — это пакет с зависимостями, конфигом, тестами и командой запуска. Минимальная структура:

filter-assistant/
  pyproject.toml          # зависимости и метаданные пакета
  src/filter_assistant/
    __init__.py
    config.py             # настройки из окружения
    llm.py                # клиент модели
  tests/
  .env.example            # пример переменных БЕЗ значений секретов
  .gitignore              # .env, .venv/, __pycache__/

pyproject.toml — единое место для зависимостей. Версии закрепляйте: AI-библиотеки меняются быстро, и «вчера работало» ломается от минорного обновления.

[project]
name = "filter-assistant"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
  "anthropic>=1.0,<2",
  "pydantic>=2.7,<3",
  "pydantic-settings>=2.3,<3",
]

[project.optional-dependencies]
dev = ["pytest>=8", "ruff"]
uv venv && source .venv/bin/activate       # Windows: .venv\Scripts\activate
uv pip install -e ".[dev]"                  # -e: правки в src видны без переустановки

Аннотации типов

Типы — это документация, которую проверяет инструмент. В AI-коде они особенно полезны: ответ модели — это dict неизвестной формы, и без типов ошибка всплывёт в трёх функциях от места, где её можно было поймать.

from dataclasses import dataclass

@dataclass(frozen=True)
class Chunk:
    doc_id: str
    text: str
    position: int

def top_k(chunks: list[Chunk], scores: list[float], k: int = 4) -> list[Chunk]:
    order = sorted(range(len(chunks)), key=lambda i: scores[i], reverse=True)
    return [chunks[i] for i in order[:k]]

X | None — значение может отсутствовать; list[str], dict[str, int] — коллекции; Literal["vector", "bm25"] — только эти строки.

Конфиг — это данные

Настройки (модель, провайдер, пути, лимиты) не зашивают в код. Их читают из переменных окружения и .env, валидируя типы. Так устроен референс:

from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="FA_", env_file=".env", extra="ignore")
    llm_provider: str = "echo"          # echo | anthropic | openai_compat
    model: str = "claude-opus-5-5"
    request_timeout: float = 60.0
    max_concurrency: int = 4

settings = Settings()                   # FA_MODEL=... в окружении переопределит значение

Ключ API в Settings не кладут: SDK сам читает ANTHROPIC_API_KEY. Чем меньше мест, где лежит секрет, тем меньше мест, откуда он утечёт.

Ошибки: свои исключения и границы

Внутри пакета ловите только то, что умеете обработать. На границе (CLI, HTTP-эндпоинт) превращайте ошибки в понятный ответ.

class ExtractionError(Exception):
    """Модель не вернула валидные данные после всех попыток."""

def cli_main(path: str) -> int:
    try:
        result = process(path)
    except FileNotFoundError:
        print(f"нет файла: {path}"); return 2
    except ExtractionError as exc:
        print(f"не удалось извлечь поля: {exc}"); return 1
    print(result.model_dump_json(indent=2)); return 0

Async: зачем он в AI-коде

Вызов модели занимает секунды, и почти всё это время программа ждёт сеть. 100 документов по 3 секунды последовательно — 5 минут. Параллельно, по 8 запросов одновременно, — меньше минуты. asyncio позволяет ждать много ответов сразу в одном потоке.

import asyncio
import anthropic

client = anthropic.AsyncAnthropic()
limit = asyncio.Semaphore(8)            # не больше 8 запросов одновременно — иначе 429

async def summarize(text: str) -> str:
    async with limit:
        resp = await client.messages.create(
            model="claude-opus-5-5",
            max_tokens=16000,
            output_config={"effort": "low"},     # простая задача — меньше рассуждений, дешевле
            messages=[{"role": "user", "content": f"Перескажи в 2 предложениях:\n{text}"}],
        )
        return next(b.text for b in resp.content if b.type == "text")

async def main(texts: list[str]) -> list[str | BaseException]:
    return await asyncio.gather(*(summarize(t) for t in texts), return_exceptions=True)

results = asyncio.run(main(["...", "..."]))

Три правила:

  • Ограничивайте параллельность (Semaphore). Без ограничения вы упрётесь в лимиты провайдера и получите шквал ошибок 429.
  • return_exceptions=True — сбой одного документа не отменяет остальные; ошибки разбираются после.
  • Не смешивайте синхронные блокирующие вызовы (time.sleep, requests.get) с async-кодом: они останавливают весь цикл событий.

Практикум: async-клиент к двум API

Скрипт получает список из 20 городов и параллельно (не больше 5 одновременно) запрашивает: публичное API погоды без ключа и LLM — «одна фраза, что взять с собой в такую погоду». Требования: типы на всех функциях, настройки в Settings, таймауты на оба API, сбой одного города не роняет остальные, в конце — время выполнения и сравнение с последовательной версией.

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

Диагностика уровня

С какого уровня начать?

Оставьте контакты — свяжемся, зададим несколько вопросов об опыте и целях и подскажем уровень входа: с нуля, с середины программы или через Fast Track для разработчиков.

Или напишите: hello@superint.ru