TL;DR. Установка ML‑пакета через pip не означает готовность к работе. Многие библиотеки — Hugging Face, sentence‑transformers, FastEmbed, tiktoken, а через них и LangChain — догружают модели, токенизаторы и таблицы кодировок в системный кэш при первом вызове. В открытом интернете это незаметно. В изолированном контуре приложение падает на строчке, где нет ни одного явного обращения к сети. Ниже — почему так устроено, какие пакеты этим грешат, как найти скрытую загрузку и что с ней делать.


Как выглядит проблема

Меня зовут Павел, я занимаюсь outsource‑разработкой ИИ‑приложений для BigTech‑компаний. Почти все такие проекты рано или поздно приезжают во внутренний контур заказчика — сеть без доступа к внешним адресам. И там начинается интересное.

Представьте, что вы пишете ai‑ассистента. Вот кусок вашего кода — класс для предобработки текста перед укладкой в векторное хранилище:

from langchain_text_splitters import RecursiveCharacterTextSplitter


class MLClient:
    """Client for processing and loading data into RAG storage."""

    def __init__(self):
        self._rag_instance = None
        self._embeddings = None
        self._classify_llm = None

        self._text_splitter = (
            RecursiveCharacterTextSplitter.from_tiktoken_encoder(
                encoding_name="cl100k_base",
                chunk_size=700,
                chunk_overlap=50,
                disallowed_special=(),
            )
        )


ml_client = MLClient()

Локально на синтетике всё работает замечательно. Выкатываете во внутренний контур — и приложение падает на инициализации ml_client. В чём дело? Код на первый взгляд совершенно безобидный: мы просто создаём объект для нарезки текста.

Второй пример:

from langchain_community.document_loaders import TextLoader
from langchain_community.embeddings import FastEmbedEmbeddings
from langchain_chroma import Chroma

docs = TextLoader("manual.txt").load()

embeddings = FastEmbedEmbeddings()

db = Chroma.from_documents(
    docs,
    embedding=embeddings,
)

Ситуация повторяется. Читаем локальный файл, считаем эмбеддинги, пишем в локальную базу — всё автономно. На машине разработчика отрабатывает, на тестовом стенде падает.

В обоих случаях под капотом происходит скачивание файлов из внешней сети. В первом — таблицы кодировок cl100k_base для tiktoken, во втором — ONNX‑модели эмбеддингов. Пока есть интернет, оба вызова молча сходят в сеть, положат файлы в кэш и продолжат работу. В изолированном контуре они падают.

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

Почему библиотеки так устроены

Причина простая и вполне разумная — размер пакета. Если бы transformers тащил внутрь себя все доступные модели, дистрибутив измерялся бы десятками терабайт. Поэтому применяется схема lazy loading:

  • через pip / conda / uv ставится только код библиотеки;

  • модели, веса, словари и конфигурации скачиваются при первом использовании;

  • скачанное складывается в локальный кэш и переиспользуется дальше.

С точки зрения пользователя с открытым интернетом это выглядит идеально: поставил пакет — всё сразу работает. Вот более очевидный пример той же механики:

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained(
    "sentence-transformers/all-MiniLM-L6-v2"
)

Если модели нет локально, библиотека скачает:

  • tokenizer.json

  • tokenizer_config.json

  • config.json

  • special_tokens_map.json

  • словарь (vocabulary)

  • веса модели

Дальше всё берётся из кэша. Здесь хотя бы видно имя модели — понятно, что её откуда‑то надо взять. Проблема в том, что чаще всего этой подсказки в коде нет.

Каталог подозреваемых

Библиотека

Что скачивает

Насколько очевидно

transformers (Hugging Face)

модели, токенизаторы, конфиги, процессоры изображений, LoRA‑адаптеры

явно указано имя модели, но факт похода в сеть — нет

sentence-transformers

то же самое, под капотом ходит в Hugging Face Hub

скрыто за одной строкой конструктора

fastembed

ONNX‑модели эмбеддингов

не очевидно, выглядит как локальная библиотека

tiktoken

таблицы кодировок (cl100k_base, o200k_base и др.)

совсем не очевидно

datasets

датасеты и скрипты загрузки

скорее очевидно

spaCy, NLTK, Stanza

языковые модели, корпуса, словари

полускрыто, часто через отдельную команду

openai-whisper

веса модели по имени (base, small, …)

полускрыто

langchain / langchain-community

сам почти ничего, но тянет всё перечисленное выше

максимально скрыто

Разберу три самых неприятных случая подробнее.

Hugging Face. Вызовы вида AutoModel.from_pretrained(...) или pipeline(...) при отсутствии файлов в кэше почти всегда приводят к обращению на Hugging Face Hub. Это же справедливо и для sentence-transformers, который использует transformers под капотом: строка SentenceTransformer("all-MiniLM-L6-v2") — это попытка скачать модель.

FastEmbed. Менее очевидный кандидат. Библиотека позиционируется как быстрое локальное решение на ONNX Runtime без тяжёлых зависимостей, и это создаёт ложное ощущение автономности. При первом вызове она скачивает ONNX‑модель — в зависимости от версии либо с Hugging Face Hub, либо из бакета Qdrant в Google Cloud Storage. Второй источник особенно неприятен: даже корпоративное зеркало Hugging Face его не закрывает.

tiktoken. Пожалуй, самый опасный участник списка, потому что вокруг него сложился устойчивый миф: «tiktoken — это просто быстрый BPE на Rust, он полностью автономен». Это не так. Файлы кодировок (.tiktoken) не входят в состав пакета — при первом обращении к незнакомой кодировке библиотека скачивает их с openaipublic.blob.core.windows.net и кладёт в кэш. В air‑gapped‑средах это регулярно приводит к падениям: есть открытые issue и в самом tiktoken, и в LiteLLM, и в openai/harmony — везде один и тот же сценарий.

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

LangChain как раз такой транзит. Сам по себе он практически ничего не скачивает, но активно проксирует чужие загрузки:

  • интеграция с OpenAI → tiktoken → таблицы кодировок;

  • FastEmbedEmbeddingsfastembed → ONNX‑модель;

  • HuggingFaceEmbeddingssentence-transformers → Hugging Face Hub;

  • CrossEncoder для реранкинга → туда же.

Именно поэтому первый листинг в начале статьи и падает: from_tiktoken_encoder дёргает tiktoken, а тот идёт в сеть за cl100k_base.

Куда всё это складывается

На Linux и macOS большинство библиотек пишут в ~/.cache:

~/.cache/huggingface/     # transformers, sentence-transformers, datasets, hub
~/.cache/torch/           # torch.hub, веса моделей
~/.cache/chroma/          # ONNX-модель эмбеддера по умолчанию

Но есть исключения, о которые легко споткнуться. У tiktoken кэш по умолчанию лежит не в ~/.cache, а во временном каталоге системы — $TMPDIR/data-gym-cache (имя файла внутри — SHA-1 от URL, поэтому глазами там ничего не найти). У Python‑версии fastembed по умолчанию тоже используется временный каталог. Практическое следствие: такой кэш переживает перезапуск процесса, но не переживает перезапуск пода или чистку /tmp — и приложение, которое «уже один раз прогрелось», внезапно снова идёт в сеть.

Расположение кэша управляется переменными окружения:

Переменная

На что влияет

HF_HOME

корневой каталог всей экосистемы Hugging Face

HF_HUB_CACHE

кэш загрузок из Hub

TIKTOKEN_CACHE_DIR

кэш таблиц кодировок tiktoken

TORCH_HOME

кэш torch.hub

XDG_CACHE_HOME

базовый каталог кэша, влияет на всех, кто следует XDG

Про TRANSFORMERS_CACHE и HUGGINGFACE_HUB_CACHE стоит сказать отдельно: они признаны устаревшими в пользу HF_HOME / HF_HUB_CACHE. В старых версиях они ещё работают, но приоритета над новыми переменными не имеют, а в transformers v5 TRANSFORMERS_CACHE уже удалена. Если вы копируете рецепт настройки кэша из статьи трёхлетней давности — проверьте, что переменные всё ещё актуальны для вашей версии.docker run ‑rm ‑network none my‑ml‑app:latest python ‑m app.smoke_test

Как это обнаружить заранее

Универсального и лёгкого способа, к сожалению, нет — придётся выбирать между разными степенями неудобства.

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

docker run --rm --network none my-ml-app:latest python -m app.smoke_test

Смысл в том, чтобы прогонять не импорт модулей, а реальный сценарий: инициализацию всех клиентов, один проход по документу, один запрос к ретриверу. Скрытые загрузки живут в первом вызове, а не в импорте, поэтому пустой smoke‑тест ничего не покажет. Такой прогон логично поставить отдельным шагом в CI — тогда проблема ловится до выкатки в контур, а не после.

Принудительный офлайн‑режим. Для экосистемы Hugging Face достаточно выставить переменные и посмотреть, что сломается:

HF_HUB_OFFLINE=1 HF_DATASETS_OFFLINE=1 python -m app.smoke_test

Метод быстрый, но покрывает только Hugging Face — tiktoken и часть путей fastembed так не отловить.

Чтение трейсбека. Когда падение уже случилось, полезно смотреть не на верхнюю строчку исключения, а на середину стека — там будет видно, какой слой ушёл в сеть: huggingface_hub, requests, urllib3. Из ошибки таймаута соединения обычно можно вытащить и конкретный хост, а по хосту — понять, какая библиотека виновата.

Анализ трафика. tcpdump или Wireshark дадут исчерпывающую картину, но это долго и муторно; я бы держал этот вариант на крайний случай, когда предыдущие не дали ответа.

По‑хорошему же основной защитой остаётся знание используемых фреймворков и привычка задавать себе вопрос: «а откуда этот объект берёт веса?»

Что с этим делать

Зеркала во внутреннем контуре

Самый комфортный сценарий: в контуре подняты корпоративные зеркала — Hugging Face Hub, Nexus, S3-хранилище с моделями. Тогда достаточно перенастроить библиотеки на внутренние адреса (для Hugging Face это HF_ENDPOINT), и всё работает как раньше. Подход хорошо масштабируется и позволяет централизованно управлять версиями моделей.

На практике, правда, рассчитывать на зеркало для fastembed или tiktoken почти не приходится. Компании обычно зеркалируют крупные и популярные артефакты: docker registry, PyPI, внутренний реестр чат‑моделей вроде LiteLLM. Экзотика вроде бакета с ONNX‑моделями в этот список не попадает.

Предварительное скачивание артефактов

Скачиваем всё нужное во внешней сети и приносим на стенд руками. Вариант рабочий, но требует дисциплины: артефакты нужно версионировать и обновлять. Разумный компромисс — хранить их в монорепозитории через git lfs и раскладывать по кэшам на этапе CI/CD.

Для Hugging Face удобнее всего забирать модель целиком:

from huggingface_hub import snapshot_download

snapshot_download(
    repo_id="sentence-transformers/all-MiniLM-L6-v2",
    local_dir="artifacts/all-MiniLM-L6-v2",
)

Для tiktoken — положить .tiktoken‑файл в каталог, на который указывает TIKTOKEN_CACHE_DIR, не забыв про правило именования файла (SHA-1 от URL источника).

Офлайн‑режим

Некоторые библиотеки умеют работать строго без сети. У Hugging Face это переменные окружения:

HF_HUB_OFFLINE=1
HF_DATASETS_OFFLINE=1

(TRANSFORMERS_OFFLINE — устаревший синоним первой из них.) У fastembed аналогичную роль играет параметр local_files_only=True.

Само по себе это не решает проблему отсутствующих файлов — модель из воздуха не появится. Но офлайн‑режим переводит ошибку из разряда «повисло на таймауте соединения через две минуты» в разряд «сразу упало с внятным сообщением, какого файла не хватает». Диагностировать такое несоизмеримо проще.

Свой Docker‑образ со всем необходимым

Наиболее надёжный вариант — собрать образ, в который заранее уложены:

  • Python‑пакеты;

  • веса моделей;

  • токенизаторы;

  • кэш Hugging Face;

  • кэш fastembed и таблицы tiktoken;

  • словари и корпуса (NLTK, spaCy и др.).

Прогрев кэша делается прямо на этапе сборки, в сети сборочного агента:

ENV HF_HOME=/opt/hf-cache \
    TIKTOKEN_CACHE_DIR=/opt/tiktoken-cache

RUN python -c "\
from transformers import AutoTokenizer; \
AutoTokenizer.from_pretrained('sentence-transformers/all-MiniLM-L6-v2')"

RUN python -c "import tiktoken; tiktoken.get_encoding('cl100k_base')"

ENV HF_HUB_OFFLINE=1

Обратите внимание на две детали. Во‑первых, кэши явно переносятся из ~/.cache и /tmp в фиксированные пути — иначе в рантайме под другим пользователем или после чистки временного каталога библиотека их не найдёт. Во‑вторых, HF_HUB_OFFLINE=1 выставляется после прогрева: это страховка от того, что в рантайм просочится незамеченная загрузка.Такой образ воспроизводим и не требует ничего скачивать при старте.

Такой образ воспроизводим и не требует ничего скачивать при старте.

Разбор цепочек вызовов

Напоследок — несколько примеров, чтобы натренировать насмотренность.

LangChain + FastEmbed

Начнём с уже знакомого случая:

from langchain_community.embeddings import FastEmbedEmbeddings

embeddings = FastEmbedEmbeddings()

В коде вообще нет имени модели — тем не менее она есть, просто зашита в дефолт (BAAI/bge-small-en-v1.5). Внутри происходит примерно следующее:

FastEmbedEmbeddings()
        │
        ▼
fastembed.TextEmbedding()
        │
        ▼
проверка локального кэша
        │
        ├── модель есть → используем
        │
        └── модели нет
                 │
                 ▼
        скачивание ONNX-модели

Отсутствие имени модели в коде — как раз главный признак опасности. Если объект создаётся без параметров, но при этом что‑то умеет, значит дефолт где‑то прописан, и этот дефолт откуда‑то берётся.

Загрузка «через третьи руки»

Здесь разработчик вообще не упоминает transformers:

from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_chroma import Chroma

vectorstore = Chroma.from_documents(
    documents,
    embedding=HuggingFaceEmbeddings(),
)

Весь код посвящён работе с Chroma, а цепочка вызовов выглядит так:

Chroma
   │
   ▼
HuggingFaceEmbeddings          # дефолт: sentence-transformers/all-mpnet-base-v2
   │
   ▼
SentenceTransformer
   │
   ▼
transformers
   │
   ▼
huggingface_hub                # ← поход в сеть

Четыре слоя абстракции между вашей строкой кода и HTTP‑запросом. Ни один из них в листинге не виден.

То же самое, но ещё безобиднее на вид

from langchain_community.document_loaders import TextLoader
from langchain_community.embeddings import FastEmbedEmbeddings
from langchain_chroma import Chroma

docs = TextLoader("manual.txt").load()

embeddings = FastEmbedEmbeddings()

db = Chroma.from_documents(
    docs,
    embedding=embeddings,
)

Локальный файл, локальные эмбеддинги, локальная база — код выглядит полностью автономным. Но при первом запуске FastEmbedEmbeddings() проверит наличие ONNX‑модели в кэше, не найдёт её и пойдёт скачивать. В открытой сети это остаётся незамеченным, в закрытом контуре приложение упадёт уже на создании объекта — хотя в коде нет ни одного явного обращения к сети.

Чек‑лист перед выкаткой в закрытый контур

  1. Пройтись по коду и выписать все объекты, которые «что‑то умеют» без явно указанного пути к локальным файлам: эмбеддеры, токенизаторы, сплиттеры, реранкеры, пайплайны, загрузчики датасетов.

  2. Для каждого — найти, откуда берётся дефолтная модель, и внести артефакт в список зависимостей поставки.

  3. Явно задать пути кэшей через переменные окружения (HF_HOME, TIKTOKEN_CACHE_DIR, TORCH_HOME), не полагаясь на ~/.cache и тем более на /tmp.

  4. Прогреть кэши на этапе сборки образа, в сети сборочного агента.

  5. Включить офлайн‑режим в рантайме, чтобы незамеченная загрузка падала сразу и с понятным сообщением.

  6. Поставить в CI прогон реального сценария с --network none — это единственная проверка, которая ловит подобные вещи автоматически.

Вместо заключения

Скрытые загрузки — хороший пример того, как удобство в одной среде превращается в проблему в другой. Библиотеки честно оптимизируют размер пакета и опыт первого запуска, просто их авторы по умолчанию предполагают наличие интернета. Пока разработка идёт на ноутбуке с открытой сетью, этого предположения не видно вообще — оно проявляется ровно в тот момент, когда его перестают выполнять.

Практический вывод простой: относитесь к весам моделей, токенизаторам и таблицам кодировок как к части поставки, а не как к чему‑то, что «само подтянется». Тогда переезд в закрытый контур перестаёт быть отдельным этапом отладки.

А с какими скрытыми загрузками сталкивались вы? Будет интересно дополнить список в комментариях.