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)
веса модели
Дальше всё берётся из кэша. Здесь хотя бы видно имя модели — понятно, что её откуда‑то надо взять. Проблема в том, что чаще всего этой подсказки в коде нет.
Каталог подозреваемых
Библиотека | Что скачивает | Насколько очевидно |
| модели, токенизаторы, конфиги, процессоры изображений, LoRA‑адаптеры | явно указано имя модели, но факт похода в сеть — нет |
| то же самое, под капотом ходит в Hugging Face Hub | скрыто за одной строкой конструктора |
| ONNX‑модели эмбеддингов | не очевидно, выглядит как локальная библиотека |
| таблицы кодировок ( | совсем не очевидно |
| датасеты и скрипты загрузки | скорее очевидно |
| языковые модели, корпуса, словари | полускрыто, часто через отдельную команду |
| веса модели по имени ( | полускрыто |
| сам почти ничего, но тянет всё перечисленное выше | максимально скрыто |
Разберу три самых неприятных случая подробнее.
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→ таблицы кодировок;FastEmbedEmbeddings→fastembed→ ONNX‑модель;HuggingFaceEmbeddings→sentence-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 — и приложение, которое «уже один раз прогрелось», внезапно снова идёт в сеть.
Расположение кэша управляется переменными окружения:
Переменная | На что влияет |
| корневой каталог всей экосистемы Hugging Face |
| кэш загрузок из Hub |
| кэш таблиц кодировок tiktoken |
| кэш torch.hub |
| базовый каталог кэша, влияет на всех, кто следует 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‑модели в кэше, не найдёт её и пойдёт скачивать. В открытой сети это остаётся незамеченным, в закрытом контуре приложение упадёт уже на создании объекта — хотя в коде нет ни одного явного обращения к сети.
Чек‑лист перед выкаткой в закрытый контур
Пройтись по коду и выписать все объекты, которые «что‑то умеют» без явно указанного пути к локальным файлам: эмбеддеры, токенизаторы, сплиттеры, реранкеры, пайплайны, загрузчики датасетов.
Для каждого — найти, откуда берётся дефолтная модель, и внести артефакт в список зависимостей поставки.
Явно задать пути кэшей через переменные окружения (
HF_HOME,TIKTOKEN_CACHE_DIR,TORCH_HOME), не полагаясь на~/.cacheи тем более на/tmp.Прогреть кэши на этапе сборки образа, в сети сборочного агента.
Включить офлайн‑режим в рантайме, чтобы незамеченная загрузка падала сразу и с понятным сообщением.
Поставить в CI прогон реального сценария с
--network none— это единственная проверка, которая ловит подобные вещи автоматически.
Вместо заключения
Скрытые загрузки — хороший пример того, как удобство в одной среде превращается в проблему в другой. Библиотеки честно оптимизируют размер пакета и опыт первого запуска, просто их авторы по умолчанию предполагают наличие интернета. Пока разработка идёт на ноутбуке с открытой сетью, этого предположения не видно вообще — оно проявляется ровно в тот момент, когда его перестают выполнять.
Практический вывод простой: относитесь к весам моделей, токенизаторам и таблицам кодировок как к части поставки, а не как к чему‑то, что «само подтянется». Тогда переезд в закрытый контур перестаёт быть отдельным этапом отладки.
А с какими скрытыми загрузками сталкивались вы? Будет интересно дополнить список в комментариях.

