#!/bin/sh # Идемпотентное скачивание GGUF-модели для локального LLM-сервера # (llama.cpp), см. ADR-004 (`docs/architecture/adr/004-ai-tier-matrix.md`). # # Генерализовано под 3 уровня AI (min/medium/max, разные модели семейства # Qwen3.5) — конкретную модель задают переменные окружения, которые пишет # `install.sh` при выборе пресета 3/4/5 (см. `docs/deploy/llm-setup.md`): # LLM_MODEL_FILE — имя файла модели внутри $MODEL_DIR (ПОД ЭТИМ именем # сохраняется локально — не обязано совпадать с # именем файла в источнике, см. GGUF_URL/GGUF_FILE_NAME) # LLM_MODEL_URL — прямая ссылка на GGUF (resolve/main/...) # LLM_MODEL_MIN_SIZE — ожидаемый минимальный размер файла в байтах # (страховка от оборванной/битой докачки) # LLM_TOKENIZER_FILE — имя файла токенизатора внутри $MODEL_DIR # LLM_TOKENIZER_URL — ссылка на tokenizer.json базовой модели (нужен # QwenTokenCounter, backend/core/summarization/tokens.py) # # Имена файлов (LLM_MODEL_FILE/LLM_TOKENIZER_FILE) — КОНТРАКТ с # `backend/services/ai_tiers.py` (см. докстринг модуля): детект доступности # уровня AI и сам плагин `qwen_local` (`tokenizer_path`/`model` в # `config/plugins.yaml`) ищут файлы по путям `{QWEN_MODELS_ROOT}/{model}.gguf` # и `{QWEN_MODELS_ROOT}/{model}.tokenizer.json` из этого модуля — install.sh # обязан передавать сюда ИМЕННО эти имена, не имена файлов в источнике. # # Дефолты ниже соответствуют уровню `min` (Qwen3.5-4B-Instruct, ADR-004) — # скрипт можно запускать вручную без install.sh (см. `docs/deploy/llm-setup.md`). # # Запускается init-контейнером `llm-model-init` (образ curlimages/curl, # профили compose `llm`/`llm-gpu`) перед стартом сервиса `llm`/`llm-gpu`. # # Идемпотентность: перед скачиванием каждого файла проверяется его # наличие и размер (не меньше ожидаемого минимума) — повторный запуск # на уже заполненном томе (в т.ч. после смены пресета на модель ДРУГОГО # уровня, ранее уже скачанную в этот же volume) ничего не перекачивает. set -eu MODEL_DIR="${MODEL_DIR:-/models/qwen}" GGUF_URL="${LLM_MODEL_URL:-https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/resolve/main/Qwen3.5-4B-Q4_K_M.gguf}" GGUF_FILE_NAME="${LLM_MODEL_FILE:-qwen3.5-4b-instruct-q4_k_m.gguf}" GGUF_FILE="${MODEL_DIR}/${GGUF_FILE_NAME}" # Порог занижен относительно реального размера файла — не ловить ложные # срабатывания на разных ревизиях модели, но отсекать битые/оборванные # докачки (пустой или сильно урезанный файл). install.sh передаёт более # точный порог для выбранного уровня. GGUF_MIN_SIZE="${LLM_MODEL_MIN_SIZE:-2000000000}" TOKENIZER_URL="${LLM_TOKENIZER_URL:-https://huggingface.co/Qwen/Qwen3.5-4B/resolve/main/tokenizer.json}" TOKENIZER_FILE_NAME="${LLM_TOKENIZER_FILE:-qwen3.5-4b-instruct.tokenizer.json}" TOKENIZER_FILE="${MODEL_DIR}/${TOKENIZER_FILE_NAME}" # tokenizer.json семейства Qwen3.5 занимает ~12 МБ (словарь + merges) — # порог просто отсекает пустой/HTML-ответ вместо файла. TOKENIZER_MIN_SIZE=$((1 * 1000 * 1000)) # Размер существующего файла в байтах (0, если файла нет). file_size() { if [ -f "$1" ]; then wc -c < "$1" | tr -d ' ' else echo 0 fi } # Скачать файл по URL, если он отсутствует или меньше минимального размера. download_if_missing() { url="$1" dest="$2" min_size="$3" name="$4" size=$(file_size "$dest") if [ "$size" -ge "$min_size" ]; then echo "[download-model] ${name}: уже скачан (${dest}, ${size} байт) — пропуск" return 0 fi echo "[download-model] ${name}: скачивание из ${url} в ${dest}" tmp="${dest}.part" # -f — считать HTTP-ошибки (4xx/5xx) провалом; -L — следовать # редиректам HuggingFace на CDN; --retry — устойчивость к обрывам сети. curl -fL --retry 5 --retry-delay 5 -o "$tmp" "$url" new_size=$(file_size "$tmp") if [ "$new_size" -lt "$min_size" ]; then echo "[download-model] ${name}: скачанный файл подозрительно мал (${new_size} байт) — прерывание" >&2 rm -f "$tmp" exit 1 fi mv "$tmp" "$dest" echo "[download-model] ${name}: готово (${dest}, ${new_size} байт)" } mkdir -p "$MODEL_DIR" download_if_missing "$GGUF_URL" "$GGUF_FILE" "$GGUF_MIN_SIZE" "GGUF-модель (${GGUF_FILE_NAME})" download_if_missing "$TOKENIZER_URL" "$TOKENIZER_FILE" "$TOKENIZER_MIN_SIZE" "${TOKENIZER_FILE_NAME}" echo "[download-model] всё готово: ${MODEL_DIR}"