Как собрать воспроизводимый пакет модели для GPU-ноды
Воспроизводимый пакет модели фиксирует веса, токенизатор, шаблон чата, конфигурацию и SHA256, чтобы новая GPU-нода не меняла ответы.

Новая GPU-нода не обязана отвечать так же, как старая, даже если на неё скопировали файл с весами и она успешно подняла API. Обычно расхождение создают не сами веса. Его создают токенизатор, шаблон диалога, параметры декодирования, квантование, версия движка и незадокументированная ручная правка в каталоге модели.
Я видел этот сбой в знакомом виде: команда заменяет железо, проверяет один запрос, радуется скорости, а через неделю продуктовая команда замечает, что ответы стали короче, JSON иногда ломается, а системная инструкция будто ослабла. Инженеры начинают сравнивать GPU и драйверы. Часто проблема лежит в файле tokenizer_config.json, в другом chat_template.jinja или в значении EOS-токена, которое никто не считал частью поставки.
Воспроизводимый пакет модели должен быть выпускным артефактом, а не папкой, которую кто-то однажды собрал на своей машине. Его задача проста: на новой ноде получить тот же контракт ответа для одних и тех же входных данных. Побайтное совпадение текста полезно, когда оно достижимо, но для продакшена важнее заранее определить, что именно считается одинаковым.
Весы не описывают модель целиком
Файл весов задаёт параметры сети, но не говорит рантайму, как превратить сообщения пользователя в последовательность токенов и где остановить генерацию. Поэтому фраза «мы развернули ту же модель» без списка артефактов ничего не гарантирует.
Для типичной instruction-модели пакет должен включать как минимум:
- веса в выбранном формате и все части шардированного набора;
config.jsonс архитектурой, идентификаторами токенов и настройками позиции;- файлы токенизатора, например
tokenizer.json,tokenizer.model,tokenizer_config.json,special_tokens_map.json; - шаблон чата, если он вынесен в
chat_template.jinjaили записан в конфигурации токенизатора; - правила генерации и ограничители остановки;
- описание рантайма, квантования и способа запуска.
В экосистеме Transformers шаблон чата хранится у токенизатора и применяется методом apply_chat_template(). Документация Hugging Face также указывает, что при сохранении токенизатора шаблон сохраняется вместе с ним, в том числе отдельным файлом chat_template.jinja. Это правильное поведение, но оно не спасёт вас, если при переносе вы скопировали только model.safetensors.
Разделяйте четыре вещи, которые команды постоянно смешивают.
Первая вещь, это идентичность артефактов. Она означает, что байты файлов совпадают. Её проверяют хешами.
Вторая, это идентичность входа в модель. Она означает, что сервер построил ровно одну и ту же последовательность token ID из одинакового массива сообщений. Здесь решают токенизатор и шаблон чата.
Третья, это идентичность декодирования. Она зависит от temperature, top_p, top_k, seed, логики stop-последовательностей, повторных попыток и ограничений длины.
Четвёртая, это идентичность исполнения. Она зависит от версии inference-движка, режима attention, CUDA, драйвера, GPU-архитектуры и представления весов.
Если спутать первый уровень с остальными, команда получит идеальные SHA256 и всё равно будет расследовать разные ответы.
Шаблон сообщений меняет вход сильнее, чем кажется
Шаблон чата не является презентационным слоем. Он формирует фактический промпт, на котором модель продолжает текст. Один шаблон добавит системное сообщение, другой проигнорирует его. Один поставит EOS после каждого сообщения, другой добавит маркер начала ответа ассистента. Один сериализует инструменты в JSON, другой передаст их как обычный текст.
Возьмём один запрос приложения:
{
"messages": [
{"role": "system", "content": "Отвечай только JSON."},
{"role": "user", "content": "Назови столицу Казахстана."}
]
}
На одном узле шаблон может превратить его в условную строку такого вида:
<bos><system>
Отвечай только JSON.<eos>
<user>
Назови столицу Казахстана.<eos>
<assistant>
На другом узле библиотека может использовать другой вариант:
<bos>[INST] Отвечай только JSON.
Назови столицу Казахстана. [/INST]
Модель не видит исходный JSON API. Она видит token ID, полученные после этой сериализации. Два варианта выше имеют разную длину, разные специальные токены и другой контекст. Ожидать одинаковый вывод было бы ошибкой.
Проверьте шаблон как программный код, а не как текстовую заметку. Для каждого выпуска включайте в тестовый набор один диалог с system, несколько ходов user и assistant, один пустой контент, Unicode, JSON в сообщении и, если продукт использует инструменты, вызов инструмента. Проверяйте не только результат, но и сформированный промпт или массив token ID.
Минимальный скрипт для такого снимка может выглядеть так:
from transformers import AutoTokenizer
import json
path = "./bundle/model"
tokenizer = AutoTokenizer.from_pretrained(path, local_files_only=True)
messages = [
{"role": "system", "content": "Отвечай только JSON."},
{"role": "user", "content": "Назови столицу Казахстана."}
]
rendered = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True
)
ids = tokenizer.apply_chat_template(
messages,
tokenize=True,
add_generation_prompt=True
)
print(json.dumps({
"rendered": rendered,
"token_count": len(ids),
"token_ids": ids
}, ensure_ascii=False, indent=2))
Не пытайтесь хранить только красивую текстовую версию промпта. Она помогает человеку, но не даёт строгого сравнения, когда в тексте скрыты специальные символы или разница между похожими токенами. Храните и текст, и token ID. Если token ID разошлись, проблема возникла до GPU.
Манифест связывает файлы с контрактом запуска
Каталог с артефактами становится поставляемым пакетом только после манифеста. Манифест должен отвечать на вопросы, на которые дежурный инженер не должен искать ответы в чате: откуда взяты файлы, какая ревизия была одобрена, что именно надо проверить, какой движок запускает модель и какие свойства ответа считаются приемлемыми.
Не используйте плавающие указатели вроде main, latest или имени модели без ревизии. Hugging Face Hub по умолчанию скачивает последнюю ревизию репозитория, а snapshot_download() позволяет явно передать параметр revision. Документация показывает, что локальные снимки связываются с конкретной ревизией. Это и нужно брать за основу, но одного идентификатора репозитория мало: после загрузки всё равно проверяйте собственный манифест файлов.
Ниже пример manifest.json. Значения намеренно условные. В реальной поставке не оставляйте поля с многоточиями и не позволяйте скрипту молча подставлять значения по умолчанию.
{
"bundle_format": 1,
"model_id": "instruction_model_32b",
"source_revision": "8f2c1a7b4d8e9f00112233445566778899aabbcc",
"artifacts": {
"config.json": "sha256:4c1f...",
"generation_config.json": "sha256:9a20...",
"tokenizer.json": "sha256:6dc8...",
"tokenizer_config.json": "sha256:28f1...",
"special_tokens_map.json": "sha256:73ab...",
"chat_template.jinja": "sha256:1ef4...",
"model_00001_of_00004.safetensors": "sha256:ad92...",
"model_00002_of_00004.safetensors": "sha256:c030...",
"model_00003_of_00004.safetensors": "sha256:71a6...",
"model_00004_of_00004.safetensors": "sha256:fe25..."
},
"runtime": {
"engine": "approved_engine",
"engine_version": "0.0.0",
"container_digest": "sha256:0e4d...",
"quantization": "none",
"dtype": "bfloat16",
"tensor_parallel_size": 2
},
"generation": {
"temperature": 0,
"top_p": 1,
"max_tokens": 256,
"seed": 12345,
"stop_token_ids": [2]
},
"acceptance_suite": "acceptance_2026_07_23.json"
}
Не записывайте в одном поле «версия модели». Это удобная человеческая этикетка и плохой технический идентификатор. В манифесте отдельно фиксируйте ревизию исходного хранилища, хеш каждого файла, версию сборки пакета, движок и его образ. Тогда расследование не начнётся со спора о том, что команда имела в виду под «релизом 3».
Модельная карточка тоже полезна, но она не заменяет манифест. Hugging Face описывает model card как документ для воспроизводимости, сведений о применении, обучении и оценке. Оставьте там происхождение, лицензию, ограничения и ссылку на внутренний процесс одобрения. Манифест же должен быть машиночитаемым и блокировать запуск при несоответствии артефактов.
Контрольные суммы надо считать до первого запуска
Хеш, записанный в wiki, не защищает поставку. Проверка должна входить в процедуру установки и завершаться ошибкой до того, как процесс загрузит веса в память.
На машине сборки создайте список так:
cd bundle/model
find . -type f ! -path './.cache/*' -print0 | sort -z | xargs -0 sha256sum > ../SHA256SUMS
На новой ноде проверьте его так:
cd bundle
sha256sum -c SHA256SUMS
Корректный вывод имеет форму:
model/config.json: OK
model/tokenizer.json: OK
model/chat_template.jinja: OK
model/model_00001_of_00004.safetensors: OK
Команда sha256sum -c ловит испорченный или подменённый файл, но не ловит лишний файл, который рантайм может выбрать вместо ожидаемого. Поэтому установщик должен работать в пустом каталоге и разрешать только файлы из манифеста. После проверки скрипт обходит дерево, нормализует относительные пути и сравнивает множество найденных файлов с artifacts из manifest.json.
Особенно опасна ситуация с двумя форматами весов. В папке лежат и .bin, и .safetensors, а разные версии рантайма выбирают разные файлы. Команда считает, что поставила одну модель, но узлы фактически используют разные представления. Оставляйте в релизном каталоге только формат, одобренный для конкретного способа запуска. Архив исходных файлов храните отдельно.
SHA256 защищает целостность, а не происхождение. Если злоумышленник или ошибочный процесс изменил файл и манифест одновременно, проверка пройдёт. Для пакетов, которые перемещаются между контурами, подпишите манифест ключом выпуска и проверяйте подпись доверенным публичным ключом на ноде. Хеш отвечает на вопрос «совпадает ли файл с манифестом», подпись отвечает на вопрос «кто утвердил этот манифест». Это разные проверки, и обе нужны там, где пакет пересекает границы доверия.
Детерминизм генерации имеет пределы
Temperature 0 не делает весь сервис математически неизменным. Этот параметр обычно убирает случайную выборку и ведёт к выбору наиболее вероятного следующего токена, но равные или почти равные логиты, разные kernels и отличия в числах с плавающей точкой всё ещё способны изменить выбор. После первого другого токена весь последующий текст уйдёт по другой траектории.
Не обещайте пользователям «одинаковый ответ на любом железе», пока не провели проверку. Вместо этого задайте один из трёх контрактов.
Первый контракт, строгий: одинаковый массив token ID на одобренной аппаратной конфигурации и точном образе. Он оправдан для регрессионных тестов и узких задач с температурой 0.
Второй, прикладной: одинаковые обязательные поля JSON, типы, значения классификации, вызванный инструмент или решение маршрутизации. Он подходит для большинства сервисов.
Третий, качественный: ответ проходит отдельный оценщик по рубрике. Он нужен для открытого текста, но не годится как единственная защита при миграции, потому что оценщик сам может менять решение.
Зафиксируйте параметры генерации полностью. «Используем temperature 0» не является полной настройкой. Нужны max_tokens, top_p, top_k, min_p при наличии, seed, repetition penalty, presence penalty, frequency penalty, stop-строки, stop token ID, логика удаления стоп-маркера из ответа и поведение при достижении лимита. В одном API строка остановки может проверяться после декодирования, в другом до него. Для JSON это иногда решает, получите вы закрывающую скобку или оборванный объект.
В выпускном пакете предпочтительнее указывать stop token ID, если их семантика известна для конкретной модели. Строковые stop-последовательности оставляйте лишь там, где приложение действительно требует их и где вы проверили результат на многобайтном Unicode. Строка «</answer>» выглядит безопасно до момента, когда модель выдаёт часть маркера в другом разбиении токенов или клиент обрезает поток раньше сервера.
Версия движка и GPU входят в область проверки
Контейнер полезен, потому что фиксирует Python, библиотеки и серверный процесс. Он не фиксирует драйвер ядра и физическую GPU. Нода с тем же образом, но с другой архитектурой ускорителя или драйвером вне допустимого диапазона может не стартовать, выбрать другой kernel или показать другое поведение при высокой нагрузке.
NVIDIA описывает совместимость CUDA как набор ограниченных режимов, а не как универсальное обещание. Для CUDA 11 и новее возможна совместимость внутри семейства основной версии при достаточном драйвере, но документация отдельно предупреждает об ограничениях функций и проблемах у приложений, использующих PTX на старом драйвере.
Не надо превращать это в ручной список из десятков версий в статье или runbook. Снимайте фактический паспорт ноды во время приёмки и прикладывайте его к записи выпуска:
nvidia-smi --query-gpu=name,driver_version,memory.total --format=csv,noheader
python -c "import torch; print(torch.__version__); print(torch.version.cuda); print(torch.cuda.get_device_name(0))"
Вывод должен попасть в журнал развёртывания вместе с digest контейнера и результатом acceptance suite. Сравнение покажет, что именно изменилось, когда новый кластер начал отвечать иначе.
Квантование относится сюда же. FP16, BF16, AWQ, GPTQ и другие форматы не являются взаимозаменяемыми копиями одного поведения. Они могут менять логиты, потребление памяти, допустимый размер batch и итоговый текст. Указывайте метод квантования, версию конвертера, калибровочный набор, если он применялся, параметры группировки и формат файлов. Нельзя назвать две модели одинаковыми только потому, что у них один исходный checkpoint.
Приёмочные тесты должны ловить знакомые поломки
Один вопрос «какая столица Казахстана?» проверяет, что сервер жив и модель знает очевидный факт. Он почти ничего не говорит о корректности поставки. Хороший набор тестов мал, но специально неудобен.
Соберите пять классов кейсов:
- системная инструкция с требованием строгого формата;
- многоходовый диалог, где ответ зависит от предыдущей реплики ассистента;
- Unicode, кириллица, казахские символы и смешанный текст;
- структурированный JSON с проверкой схемы;
- сценарий с инструментом или RAG-контекстом, если он есть в продукте.
Каждый кейс храните как вход, ожидаемые инварианты и, где допустимо, эталонные token ID либо эталонный текст. Не складывайте в пакет реальные запросы клиентов. Синтетические примеры лучше: их можно безопасно отправить в любой контур, включить в CI и обсуждать при инциденте.
Пример одного теста:
{
"id": "json_city_kz_01",
"messages": [
{"role": "system", "content": "Верни JSON с полем city. Без пояснений."},
{"role": "user", "content": "Столица Казахстана?"}
],
"generation": {
"temperature": 0,
"top_p": 1,
"max_tokens": 32,
"seed": 12345
},
"expect": {
"json_schema": {
"type": "object",
"required": ["city"],
"properties": {"city": {"type": "string"}},
"additionalProperties": false
},
"rendered_prompt_sha256": "sha256:replace_me",
"must_contain": ["Астана"]
}
}
Здесь rendered_prompt_sha256 проверяет путь до модели: сериализацию сообщений. JSON Schema и must_contain проверяют выход. Если сломался только хеш промпта, не надо тратить ночь на анализ CUDA. Если хеш промпта совпал, а JSON стал невалидным, смотрите параметры генерации, движок и стоп-условия.
Не делайте acceptance suite слишком хрупким. Тест, который требует дословный абзац от генеративной модели на всех GPU, создаст ложные тревоги. Тест, который принимает любой непустой текст, ничего не защищает. У каждого кейса должен быть один понятный смысл: сохранить формат, удержать контекст, вызвать правильный инструмент, не потерять Unicode или не перейти допустимый лимит.
Перенос на новую ноду надо проводить как выпуск
Ручное копирование каталога через SSH почти всегда заканчивается тем, что история изменений остаётся в голове одного человека. Даже если сегодня это работает, завтра никто не скажет, откуда в папке появился второй tokenizer или почему один узел получил другой файл конфигурации.
Рабочая процедура выглядит так:
- Сборочная среда получает конкретную ревизию исходных артефактов в пустой каталог и записывает её в манифест.
- Скрипт выбирает разрешённые файлы, проверяет отсутствие дублей форматов, считает SHA256 и формирует
SHA256SUMS. - Отдельный процесс создаёт или обновляет тесты, рассчитывает хеши сформированных промптов и запускает их на эталонной среде.
- Пакет, манифест, подпись и тестовый набор попадают в неизменяемое хранилище выпусков.
- Новая нода получает пакет, проверяет подпись и контрольные суммы, запускает контейнер с указанными параметрами, затем выполняет acceptance suite до добавления в балансировку.
Самая неприятная ошибка на четвёртом шаге, это возможность заменить файлы по тому же пути. Если объектное хранилище разрешает перезапись models/prod/current, вы создали указатель, а не выпуск. Путь пакета должен включать неизменяемую версию или хеш манифеста. Указатель на одобренный выпуск можно хранить отдельно, но он не должен быть единственным источником правды.
При маршрутизации нескольких моделей этот подход даёт ещё один практический эффект. AI Router можно использовать как совместимый с OpenAI слой доступа, но идентификатор модели в запросе всё равно должен ссылаться на конкретный одобренный пакет, а не на неясное имя вроде assistant_prod. Иначе смена ноды или локального варианта модели будет выглядеть для клиента как необъяснимое изменение поведения.
Полезный пакет можно восстановить без автора
Проверка проста и неприятна: передайте пакет инженеру, который не участвовал в сборке, и попросите поднять его на чистой ноде. Не давайте устных подсказок, не пересылайте «маленький фикс» в мессенджере и не разрешайте менять файлы, пока не завершится приёмка.
Этот инженер должен получить понятный результат на каждом этапе: какие файлы установлены, какие хеши проверены, какой образ запущен, какой шаблон применён, какие тесты прошли, где лежит паспорт GPU. Если он упирается в вопрос «а какой tokenizer здесь правильный?», выпуск не завершён.
Не гонитесь за красивой папкой с весами. Соберите пакет, который доказывает своё происхождение, описывает исполнение и ловит смену поведения до того, как трафик увидит новую ноду. Тогда перенос GPU перестаёт быть рискованной операцией и становится обычной процедурой выпуска.
Часто задаваемые вопросы
Какие файлы нужны для полного переноса LLM на новый GPU-сервер?
Нужны сами веса, все файлы токенизатора, конфигурация модели, шаблон чата, правила генерации, версия рантайма и зафиксированный набор тестовых запросов. Если отсутствует хотя бы один из этих элементов, узел может загрузить модель, но отвечать иначе.
Достаточно ли SHA256 для воспроизводимого развёртывания модели?
Нет. SHA256 доказывает, что байты файла не изменились, но не доказывает, что вы запустили те же параметры генерации, тот же шаблон сообщений и ту же версию движка. Контрольные суммы нужны, но они закрывают только целостность файлов.
Почему chat template влияет на ответы модели?
Потому что они меняют строку токенов до запуска декодера. Один и тот же JSON с сообщениями при разных шаблонах может получить другой системный префикс, другой маркер роли и другой признак начала ответа. После этого совпадения текста ждать бессмысленно.
Будет ли одна и та же LLM всегда выдавать одинаковый текст на разных GPU?
Нет, если под одинаковостью понимать побайтное совпадение текста. Разные GPU, версии CUDA, библиотеки внимания и режимы квантования могут менять вычисления с плавающей точкой. Для критичных сценариев заранее определите более практичный контракт: совпадение структуры ответа, обязательных полей и результатов контрольных тестов.
Как настроить детерминированный вывод LLM?
Температура должна быть 0, top_p равен 1, а генератор случайных чисел не должен участвовать в выборе токенов. Но даже такой режим не отменяет различия из-за численной реализации и скрытых настроек рантайма. Поэтому проверяйте результат на чистом узле, а не верьте одному параметру temperature.
Можно ли закрепить модель только тегом latest или main?
Плавающий тег удобен для эксперимента и плох для выпуска. Он может указывать на новую ревизию весов, токенизатора или README без изменения вашего кода. В манифесте храните неизменяемый идентификатор ревизии и собственные SHA256 файлов после получения пакета.
Нужно ли менять версию пакета при изменении параметров генерации?
Да, если вы выпускаете модель из одного и того же набора весов, но меняете режим генерации, шаблон или рантайм. Это не косметическое изменение: для клиента такая версия может вести себя как другая модель. В номере пакета отдельно отражайте ревизию артефактов и ревизию контракта вывода.
Что фиксировать помимо весов модели в контейнере?
В комплект включите хеш образа или список точных версий пакетов, версию драйвера, CUDA, сведения о GPU и команду запуска. Контейнер уменьшает разброс пользовательского пространства, но не заменяет проверку драйвера и аппаратной совместимости. NVIDIA прямо описывает ограничения совместимости между версиями CUDA и драйвера.
Можно ли хранить реальные промпты клиентов в пакете модели?
Пакет не должен содержать пользовательские диалоги, дампы запросов или секреты доступа к хранилищу. Внутри оставляют только синтетические тесты и обезличенные ожидаемые свойства ответа. Если нужен боевой пример, храните его отдельно в защищённом наборе и выдавайте доступ по роли.
С чего начать, если модели сейчас копируют вручную?
Сначала отделите хранилище артефактов от способа запуска. Затем добавьте манифест, посчитайте SHA256, подготовьте несколько контрольных диалогов и прогоните их на чистой ноде. Если этот прогон нельзя повторить без ручных правок, пакет ещё не готов к выпуску.