Руководство по сборке локального AI‑стека

Ссылки на все части:
Часть 1. Общий план
Часть 2. Запуск llama‑cpp
Часть 3. Наполнение моделями
Часть 4. Запуск сервера генерации изображений
Часть 5. Open WebUI поверх llama.cpp
Часть 6. Конвертация моделей Hugging Face в GGUF
Часть 7. Финал. Офлайн‑развёртывание

Часть 2. Запуск llama.cpp на тестовой модели

На этом шаге мы соберём llama.cpp, скачаем компактную модель для теста, разберёмся с ключевыми опциями (mmap, лимиты памяти, mmproj) и запустим сервер. После этого у вас будет работающий чат по адресу http://127.0.0.1:8080 — с родным веб‑интерфейсом llama.cpp (пока без Open WebUI).

Что нужно понять до начала

llama.cpp — это C++ движок для инференса LLM. Он умеет:

  • Работать на CPU (без GPU) — то, что нам нужно.

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

  • Ограничивать потребление памяти через cgroup (через systemd-run).

  • Работать в двух режимах: одна модель (-m) или «роутер» моделей (--models-dir), когда в UI виден список всех .gguf, а в памяти держится одна.

Тестовая модель — Qwen3.5–2B‑Q4_K_M (~1.5 ГБ), чтобы проверить пайплайн быстро, не дожидаясь загрузки 9–10 ГБ модели. Она же потом пригодится для «сверхбыстрых» задач (черновики, проверка идей).

Шаг 1. Системные зависимости

sudo apt update
sudo apt install -y \
  cmake build-essential libopenblas-dev \
  python3 python3-venv python3-pip \
  git curl wget jq

libopenblas-dev — ускоряет матричные операции на CPU. build-essential — компилятор для сборки llama.cpp. python3-venv понадобится позже для Open WebUI, но поставим сразу.

Шаг 2. Сборка llama.cpp

Скрипт run_llama_mmap.sh собирает llama.cpp автоматически при первом запуске, если бинарника нет. Но полезно понимать, что происходит:

git clone --depth=1 https://github.com/ggerganov/llama.cpp.git
cd llama.cpp
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DLLAMA_BUILD_UI=ON
cmake --build build --config Release -j"$(nproc)"
  • -DLLAMA_BUILD_UI=ON — собирает родной веб‑интерфейс llama.cpp (Svelte). Без него сервер отдаёт только API, а корень (/) пустой.

  • -j$(nproc) — параллельная сборка на всех ядрах. На 4-ядерном CPU занимает 5–10 минут, на 8+ — 3–5 минут.

  • Сборка требует Node.js для UI, но современные версии llama.cpp встраивают UI как статику, так что Node.js не нужен. Если сборка ругнётся на UI — -DLLAMA_BUILD_UI=OFF, но тогда родного интерфейса не будет (только API, а чат вы будете смотреть через Open WebUI).

После сборки появляются бинарники в llama.cpp/build/bin/:

  • llama-server — HTTP‑сервер с API и UI (то, что использует наш скрипт).

  • llama-cli — CLI для одной генерации (для отладки).

  • llama-quantize — утилита квантования (пригодится в части про конвертацию моделей).

Шаг 3. Скачивание тестовой модели

Модель — Qwen3.5–2B‑Q4_K_M. Квантование Q4_K_M — «золотая середина»: файл ~1.5 ГБ, качество близко к F16.

Качаем через wget (без Python):

mkdir -p models
wget -c -O models/Qwen3.5-2B-Q4_K_M.gguf \
  "https://huggingface.co/unsloth/Qwen3.5-2B-GGUF/resolve/main/Qwen3.5-2B-Q4\_K\_M.gguf"

-c — докачка при обрыве. Для больших файлов лучше wget -c, чем curl, потому что curl без -C - начинает заново.

Правила раскладки:

  1. Обычные (текстовые) модели — можно плоско, прямо в models/. Имя файла — <модель>-<квант>.gguf.

  2. Мультимодальные модели — всегда в отдельной подпапке, вместе с mmproj-F16.gguf. Это требование режима роутера: он ищет mmproj только в той же папке, где лежит модель, и имя проектора должно начинаться с mmproj.

  3. Один mmproj на несколько квантований (например, Q4_K_M и Q8_0 одной модели, если одинаковые) — можно сделать симлинк:

    ln -s mmproj-F16.gguf mmproj-Q8_0.gguf
    

    Но проще — один mmproj на папку, а в папке одно квантование.

Почему mmproj — это отдельный файл, а не часть модели. mmproj (multimodal projector) — это «переходник» между визуальным энкодером и языковой моделью. В llama.cpp он подключается отдельным флагом --mmproj. Без него VL‑модель работает как обычная текстовая (картинки не понимает), с ним — «видит». Подробнее — в шаге 5 ниже.

Шаг 4. Ключевые опции llama‑server

Скрипт run_llama_mmap.sh — это обёртка над llama-server, которая добавляет умные дефолты. Разберём важные флаги, которые он использует.

‑load‑mode mmap — главная опция

--load-mode mmap

Заставляет llama.cpp использовать mmap для загрузки весов. Что это даёт:

  • Модель не грузится в RAM целиком. Ядро отображает файл в адресное пространство процесса и подгружает страницы по требованию. Когда модель обращается к слою, страница подгружается с диска; когда слой больше не нужен — ядро может её вытеснить.

  • Позволяет запускать модель больше RAM. Например, 20-гиговая модель на 10 ГБ RAM. Ядро держит в памяти «горячие» страницы, остальные — на диске.

  • Нет износа SSD. В отличие от swap, mmap не пишет страницы обратно — данные уже на диске, их не нужно сохранять.

Альтернативы:

  • --load-mode none — грузит всю модель в RAM. Упадёт с OOM, если модель больше памяти.

  • --load-mode mlock — грузит и блокирует в RAM (запрещает вытеснять). Быстро, но только если модель помещается.

  • --load-mode dio — DirectIO, чтение в обход page cache. В некоторых версиях llama.cpp багован (создаёт временные буферы размером с модель). Не используйте без проверки.

Что происходит на практике. Первый запрос к модели — самый медленный: ядро подгружает нужные страницы с диска. На HDD это 5–10 минут для 20-гиговой модели. Последующие запросы быстрее — часть страниц уже в page cache. Чем больше RAM свободно, тем больше «горячих» страниц, тем быстрее.

‑no‑repack — экономия памяти на CPU

--no-repack

llama.cpp при загрузке может создавать вторую копию весов, переупакованную для более быстрых вычислений (CPU_REPACK). Это почти удваивает пиковое потребление RAM. Для модели, которая и так не влезает, — фатально (был реальный случай: CPU model buffer 9.4 ГБ + CPU_REPACK 11.2 ГБ = 20.6 ГБ при лимите 10 ГБ → OOM). --no-repack отключает переупаковку. Цена — ~10–15% скорости вычислений. Для нашего сценария «запустить хоть как‑то» — правильный компромисс.

‑ctk q8_0 ‑ctv q8_0 — квантизация KV‑cache

--cache-type-k q8_0 --cache-type-v q8_0

KV‑cache — это память под контекст (история диалога). По умолчанию он в f16, что для длинного контекста съедает гигабайты. q8_0 сжимает его в 2 раза (~50% экономии) с минимальной потерей качества. Для моделей, которые «еле влезают» в лимит, это критично.

‑reasoning on | off | auto — «размышления» Qwen3.x

--reasoning auto
--reasoning-budget 768

Новые Qwen‑модели (3.x, 3.5) поддерживают режим «размышлений»: перед ответом модель генерирует цепочку рассуждений (reasoning_content), потом — финальный ответ. Это улучшает качество на сложных задачах (математика, RAG, поэзия), но:

  • На тривиальных вопросах («Привет») модель может «думать» 400+ токенов, что на CPU занимает минуты. Отсюда --reasoning off для интерактивного чата.

  • --reasoning-budget N — жёсткий лимит токенов на размышления. После N токенов движок принудительно закрывает блок размышлений и заставляет модель отвечать. Без него reasoning может «съесть» весь ctx-size и на ответ не останется места (реальный случай: модель «думала правильно», но ответа так и не дала — упёрлась в контекст).

Рекомендация: auto (по умолчанию в скрипте) с --reasoning-budget 768. Для быстрых ответов — -rn (off).

‑ctx‑size — размер контекста

--ctx-size 3096

Сколько токенов модель «помнит» (промпт + история + reasoning + ответ). Больше — лучше для длинных диалогов и RAG, но пропорционально больше памяти под KV‑cache. Подбирайте под задачу: для «Привет/Как дела» хватит 2048, для саммари статьи — 4096+.

‑threads — число потоков CPU

--threads $(( $(nproc) - 2 ))

Скрипт берёт nproc - 2: оставляет 2 ядра системе, чтобы она не подвисала, пока модель считает. Для 4-ядерного CPU — 2 потока, для 8-ядерного — 6. Больше потоков ≠ быстрее: на гипертрединге может быть медленнее из‑за конкуренции за кэш.

‑models‑dir и ‑models‑max 1 — режим роутера

--models-dir ./models --models-max 1

Вместо -m <модель> скрипт по умолчанию запускает роутер: в UI виден дропдаун со всеми .gguf из models/, можно переключаться без перезапуска сервера. --models-max 1 гарантирует, что в памяти держится одна модель: при переключении старая выгружается, новая грузится. Для лимита 10 ГБ — обязательно, иначе роутер попытается держать несколько моделей и получит OOM.

Альтернатива — режим одной модели: -m <имя> (или -s для интерактивного выбора). Тогда сервер грузит только эту модель, без роутера. Чуть быстрее стартует, чуть меньше памяти накладных расходов. Удобно, когда модель уже выбрана.

Лимиты памяти через systemd‑run

systemd-run --user --unit=llama-server-$$ \    -p MemoryMax=10G \    -p MemoryHigh=9G \    -p MemorySwapMax=0 \    -- llama-server ...

Скрипт оборачивает запуск в cgroup с лимитами:

  • MemoryMax=10G — жёсткий лимит. При превышении cgroup убивает процесс (OOM-kill). Система при этом остаётся жива.

  • MemoryHigh=9G — мягкий лимит. При приближении к нему ядро начинает агрессивно вытеснять страницы mmap обратно на диск (не дожидаясь OOM).

  • MemorySwapMax=0 — запрет swap. Без этого ядро могло бы сбрасывать анонимную память (KV‑cache, буферы) в swap, что для mmap‑модели — катастрофа по I/O.

Значения MEM_MAX/MEM_HIGH можно переопределить через env: MEM_MAX=14G ./run_llama_mmap.sh.

Шаг 5. mmproj — что это и как раскладывать

mmproj (multimodal projector) — это небольшой файл (~300–600 МБ), который «переводит» выходы визуального энкодера (ViT) в пространство, понятное языковой модели. Без него VL‑модель работает как обычная текстовая; с ним — «видит» картинки.

Требования:

  1. Имя файла начинается с mmproj (например, mmproj-F16.gguf, mmproj-Qwen3VL-4B-F16.gguf).

  2. Лежит в той же папке, что и модель — особенно в режиме роутера, где llama.cpp ищет проектор по папке модели.

Проверка, что модель «видит» картинки. После запуска сервера в логе должно появиться:

clip_model_load: loaded meta data ...
clip_model_load: model size = ...

Если этих строк нет — mmproj не подхватился, и при попытке отправить картинку сервер скажет Images require a vision-capable model.

Что делать, если mmproj нет для вашей модели:

  • Не страшно, если vision не нужен — модель работает как текстовая.

  • Если vision нужен, но mmproj нет — ищите его в том же репозитории на Hugging Face (обычно mmproj-*.gguf лежит рядом с .gguf‑моделью).

  • Не путайте mmproj с LLM‑энкодером для генерации изображений (там --llm в sd.cpp — это текстовый энкодер, не то же самое).

Код запуска run_llama_mmap.sh

#!/usr/bin/env bash
# Запуск llama-server с mmap для модели, превышающей RAM.
# Usage: ./run_llama_mmap.sh
#
# Переопределяемые env: MEM_MAX, MEM_HIGH, PORT, HOST, CTX_SIZE
#   MEM_MAX=10G   — жёсткий лимит (OOM-kill при превышении)
#   MEM_HIGH=9G   — мягкий лимит (начинается активное вытеснение)
#   CTX_SIZE=2048 — маленький контекст экономит KV-cache
#
# Мониторинг в другом терминале:
#   watch -n1 'free -h; echo; cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/user@$(id -u).service/*/memory.pressure 2>/dev/null'

set -euo pipefail

# --- Конфигурация ---
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LLAMA_DIR="$SCRIPT_DIR/llama.cpp"
MODELS_DIR="$SCRIPT_DIR/models"

# --- API-ключ (аутентификация) ---
# По умолчанию llama-server работает БЕЗ аутентификации.
# Чтобы включить защиту, задайте ключ:
#   - вручную: API_KEY="ваш-секретный-ключ"
#   - или оставьте пустым: API_KEY=""
#
# Если API_KEY пуст — флаг --api-key НЕ добавляется, сервер открыт.
# Если API_KEY задан — флаг --api-key добавляется, и тогда:
#   1. Все API-запросы требуют заголовок Authorization: Bearer <ключ>.
#   2. Web UI загружается свободно (страница чата открывается),
#      но для отправки сообщений нужно вписать ключ в настройках:
#      Settings (иконка шестерёнки) -> General -> поле "API Key".
# --- Чтение секретов ---
SECRETS_FILE="$SCRIPT_DIR/.secrets"
if [ -f "$SECRETS_FILE" ]; then
    # shellcheck disable=SC1090
    set -a; source "$SECRETS_FILE"; set +a
fi
LLAMA_API_KEY="${LLAMA_API_KEY:-}"

LOAD_MODE="mmap"
PORT="${PORT:-8080}"
HOST="${HOST:-0.0.0.0}"
CTX_SIZE="${CTX_SIZE:-3096}"
REASONING_BUDGET="${REASONING_BUDGET:-768}"  # по умолчанию 768 токенов на размышления
MEM_MAX="${MEM_MAX:-10G}"
MEM_HIGH="${MEM_HIGH:-9G}"

NPROC="$(nproc)"
THREADS=$(( NPROC - 2 ))
[ "$THREADS" -lt 2 ] && THREADS=2


# --- Разбор аргументов скрипта ---
SINGLE_MODEL=0
REASONING="${REASONING:-auto}"
while [ $# -gt 0 ]; do
    case "$1" in
        -s|--single) SINGLE_MODEL=1 ;;
        -m|--model) MODEL_NAME_ARG="$2"; SINGLE_MODEL=1; shift 2 ;;
        -r|--reasoning) REASONING="on" ;;
        -rn|--reasoning-off) REASONING="off" ;;
        -h|--help)
            cat <<EOF
Usage: $(basename "$0") [-s|--single] [-r|--reasoning] [-rn|--reasoning-off]

  Без флагов             router mode — все .gguf из ./models видны в UI,
                         переключение через дропдаун, 
                         в памяти одна модель (--models-max 1), 
                         режим размышлений auto.
                         
  -s, --single           выбор одной модели из консоли и загрузка сервера только с этой моделью.
  
  -r, --reasoning        включить режим размышлений
  -rn, --reasoning-off   выключить режим размышлений
EOF
            exit 0
            ;;
        *) echo "Неизвестный флаг: $1 (см. --help)"; exit 1 ;;
    esac
    shift
done


# --- 1. Сборка llama.cpp при первом запуске ---
LLAMA_SERVER="$LLAMA_DIR/build/bin/llama-server"
if [ ! -x "$LLAMA_SERVER" ]; then
    echo "[*] Собираю llama.cpp (первый запуск, может занять несколько минут)..."
    if [ ! -d "$LLAMA_DIR/.git" ]; then
        git clone --depth=1 https://github.com/ggerganov/llama.cpp.git "$LLAMA_DIR"
    fi
    cmake -S "$LLAMA_DIR" -B "$LLAMA_DIR/build" -DCMAKE_BUILD_TYPE=Release -DLLAMA_BUILD_UI=ON
    cmake --build "$LLAMA_DIR/build" --config Release -j "$NPROC"
fi
[ -x "$LLAMA_SERVER" ] || { echo "[!] Сборка не удалась: $LLAMA_SERVER не найден"; exit 1; }


# --- 2. Модели ---
mkdir -p "$MODELS_DIR"

# Папка с медиа-моделями (исключаем её из списка текстовых)
MEDIA_MODELS_DIR="$MODELS_DIR/media"

mapfile -t MODELS < <(
    find "$MODELS_DIR" -maxdepth 2 -type f -name "*.gguf" \
        ! -name "*mmproj*" \
        ! -path "$MEDIA_MODELS_DIR/*" \
    | sort
)

if [ ${#MODELS[@]} -eq 0 ]; then
    echo "[!] В $MODELS_DIR нет .gguf файлов."; exit 1
fi

if [ "$SINGLE_MODEL" -eq 1 ]; then
    # --- Режим одной модели
    if [ -n "${MODEL_NAME_ARG:-}" ]; then
        # Нормализация: добавить .gguf, если нет
        [[ "$MODEL_NAME_ARG" != *.gguf ]] && MODEL_NAME_ARG="${MODEL_NAME_ARG}.gguf"
        MODEL_PATH=""
        for m in "${MODELS[@]}"; do
            if [ "$(basename "$m")" = "$MODEL_NAME_ARG" ]; then
                MODEL_PATH="$m"
                break
            fi
        done
        [ -n "$MODEL_PATH" ] || {
            echo "[!] Модель '$MODEL_NAME_ARG' не найдена. Доступные:"
            for m in "${MODELS[@]}"; do echo "    $(basename "$m")"; done
            exit 1
        }
    else
        if [ ${#MODELS[@]} -eq 1 ]; then
            MODEL_PATH="${MODELS[0]}"
        else
            echo "[*] Найдено несколько моделей:"
            select MODEL_PATH in "${MODELS[@]}"; do
                [ -n "${MODEL_PATH:-}" ] && break
            done
        fi
    fi
    echo "[*] Режим одной модели: $MODEL_PATH ($(du -h "$MODEL_PATH" | cut -f1))"
else
    # --- Режим роутера моделей
    echo "[*] Router mode: каталог $MODELS_DIR (${#MODELS[@]} шт.), --models-max 1"
fi


# --- 3. Проверка занятости порта ---
for h in 127.0.0.1 0.0.0.0; do
    if (echo >/dev/tcp/$h/$PORT) 2>/dev/null; then
        echo "[!] Порт $PORT уже занят ($h). Остановите другой процесс или смените PORT."
        exit 1
    fi
done


# --- 4. Определение доступных флагов (на случай старых/новых версий) ---
HELP_OUT="$("$LLAMA_SERVER" --help 2>&1 || true)"

OPT_FLAGS=()
echo "$HELP_OUT" | grep -q -- "--no-warmup" && OPT_FLAGS+=("--no-warmup")
echo "$HELP_OUT" | grep -q -- "--flash-attn" && OPT_FLAGS+=("-fa" "on")
echo "$HELP_OUT" | grep -q -- "--no-repack" && OPT_FLAGS+=("--no-repack")


# Отключение reasoning для Qwen3.x (если поддерживается)
REASON_ARGS=()
if echo "$HELP_OUT" | grep -q -- "--reasoning "; then
    case "$REASONING" in
        on)   REASON_ARGS+=(--reasoning on) ;;
        auto) REASON_ARGS+=(--reasoning auto) ;;
        off)  REASON_ARGS+=(--reasoning off) ;;
    esac
    # Бюджет: 0 для off, REASONING_BUDGET для on/auto
    if echo "$HELP_OUT" | grep -q -- "--reasoning-budget"; then
        if [ "$REASONING" = "off" ]; then
            REASON_ARGS+=(--reasoning-budget 0)
        else
            REASON_ARGS+=(--reasoning-budget "$REASONING_BUDGET")
        fi
    fi
fi

# KV-cache квантизация (экономит ~50% памяти под контекст)
KV_ARGS=()
if echo "$HELP_OUT" | grep -q -- "--cache-type-k"; then
    KV_ARGS+=("-ctk" "q8_0" "-ctv" "q8_0")
fi

echo "[*] Load mode: $LOAD_MODE"
[ ${#OPT_FLAGS[@]} -gt 0 ]    && echo "[*] Флаги: ${OPT_FLAGS[*]}"
[ ${#KV_ARGS[@]} -gt 0 ]      && echo "[*] KV-квантизация: ${KV_ARGS[*]}"
[ ${#REASON_ARGS[@]} -gt 0 ]  && echo "[*] Reasoning: ${REASON_ARGS[*]}"


# Ищет mmproj-файл, соответствующий модели 
# только при запуске одной модели, роутер находит автоматически.
# $1 — полный путь к .gguf файлу модели.
# Возвращает путь к mmproj через echo или пустую строку, если не найден.
find_mmproj_for_model() {
    local model_path="$1"
    local model_dir model_file model_base short_name

    model_dir="$(dirname "$model_path")"
    model_file="$(basename "$model_path")"

    # Если это сам файл mmproj — не ищем для него проектор
    if [[ "$model_file" == *mmproj* ]]; then
        return 1
    fi

    model_base="${model_file%.gguf}"
    short_name="$(echo "$model_base" | sed -E 's/[-_](Q[0-9]+(_[A-Z0-9]+)*|F16|F32|BF16|UD-Q[0-9]+(_[A-Z0-9]+)*)$//')"

    # Приоритет 1: точное совпадение "mmproj-<short_name>.gguf"
    local candidate="$model_dir/mmproj-${short_name}.gguf"
    if [ -f "$candidate" ]; then
        echo "$candidate"
        return 0
    fi

    # Приоритет 2: любой mmproj в папке, имя которого содержит short_name
    # (плоская структура: mmproj-<имя-модели>.gguf рядом с моделью)
    for f in "$model_dir"/*mmproj*.gguf "$model_dir"/*.mmproj*.gguf; do
        [ -f "$f" ] || continue
        if [[ "$(basename "$f")" == *"$short_name"* ]]; then
            echo "$f"
            return 0
        fi
    done

    # Приоритет 3: любой mmproj в папке модели
    # (структура роутера: model.gguf + mmproj-F16.gguf в одной подпапке)
    for f in "$model_dir"/*mmproj*.gguf; do
        [ -f "$f" ] && { echo "$f"; return 0; }
    done

    echo ""
    return 1
}


# --- 5. Сборка команды ---
SERVER_CMD=(
    "$LLAMA_SERVER"
    --host "$HOST"
    --port "$PORT"
    --threads "$THREADS"
    --ctx-size "$CTX_SIZE"
    --load-mode "$LOAD_MODE"
    -lv 4
    "${OPT_FLAGS[@]}"
    "${KV_ARGS[@]}"
    "${REASON_ARGS[@]}"
)

if [ "$SINGLE_MODEL" -eq 1 ]; then
    # Одна модель, заданная явно
    SERVER_CMD+=(-m "$MODEL_PATH")
    
    # ищем mmproj для мультимодальных моделей
    MMPROJ_PATH="$(find_mmproj_for_model "$MODEL_PATH" || true)"
    if [ -n "$MMPROJ_PATH" ]; then
        echo "[*] Найден mmproj: $(basename "$MMPROJ_PATH")"
    else
        # Не ошибка: модель может быть текстовой
        echo "[*] mmproj не найден (модель будет работать как текстовая)"
    fi
    [ -n "$MMPROJ_PATH" ] && SERVER_CMD+=(--mmproj "$MMPROJ_PATH")
else
    # Router mode — модель в памяти одна, но можно переключаться в UI
    SERVER_CMD+=(--models-dir "$MODELS_DIR" --models-max 1)
fi

if [ -n "$LLAMA_API_KEY" ]; then
    SERVER_CMD+=(--api-key "$LLAMA_API_KEY")
    echo "[*] Аутентификация включена (API-ключ задан)"
else
    echo "[*] Аутентификация отключена (ключ не задан)"
fi


# --- 6. Подготовка фона и trap ---
LOG_FILE="$(mktemp -t llama-server.XXXXXX.log)"
SERVER_PID=""
TAIL_PID=""
UNIT_NAME="llama-server-$$"
USE_SYSTEMD=0

cleanup() {
    [ -t 1 ] && echo ""
    [ -t 1 ] && echo "[*] Завершение..."
    if [ "$USE_SYSTEMD" -eq 1 ]; then
        systemctl --user stop "$UNIT_NAME" 2>/dev/null || true
    fi
    if [ -n "$SERVER_PID" ]; then
        kill "$SERVER_PID" 2>/dev/null || true
        wait "$SERVER_PID" 2>/dev/null || true
    fi
    [ -n "$TAIL_PID" ] && kill "$TAIL_PID" 2>/dev/null || true
    rm -f "$LOG_FILE"
    [ -t 1 ] && echo "[*] Готово."
}
trap cleanup EXIT INT TERM


# --- 7. Запуск с лимитом памяти через systemd-run ---
if command -v systemd-run &>/dev/null; then
    USE_SYSTEMD=1
    echo "[*] Запуск под systemd-run: MemoryMax=$MEM_MAX, MemoryHigh=$MEM_HIGH, swap=off"
    if ! systemd-run --user --unit="$UNIT_NAME" \
            -p MemoryMax="$MEM_MAX" \
            -p MemoryHigh="$MEM_HIGH" \
            -p MemorySwapMax=0 \
            -p StandardOutput=file:"$LOG_FILE" \
            -p StandardError=file:"$LOG_FILE" \
            -- "${SERVER_CMD[@]}" >/dev/null 2>&1; then
        echo "[!] systemd-run недоступен, откатываюсь на запуск без лимита."
        USE_SYSTEMD=0
    fi
fi

if [ "$USE_SYSTEMD" -eq 0 ]; then
    echo "[*] Запуск в фоне без лимита памяти"
    "${SERVER_CMD[@]}" >"$LOG_FILE" 2>&1 &
    SERVER_PID=$!
fi

# Пробрасываем лог сервера в терминал в реальном времени
# если скрипт привязан к терминалу
[ -t 1 ] && tail -n +1 -f "$LOG_FILE" &
TAIL_PID=$!


# --- 8. Ожидание готовности ---
echo "[*] Жду готовности сервера (до 600 с)..."
READY=0
for _ in $(seq 1 600); do
    if [ "$USE_SYSTEMD" -eq 1 ]; then
        systemctl --user is-active "$UNIT_NAME" &>/dev/null || {
            echo "[!] Сервер умер на старте. Последние строки лога:"
            tail -n 40 "$LOG_FILE"; exit 1;
        }
    else
        kill -0 "$SERVER_PID" 2>/dev/null || {
            echo "[!] Сервер умер на старте. Последние строки лога:"
            tail -n 40 "$LOG_FILE"; exit 1;
        }
    fi
    if curl -sf "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
        READY=1; break
    fi
    sleep 1
done
[ "$READY" -eq 1 ] || { echo "[!] Сервер не поднялся за 120 с."; exit 1; }


# --- 9. Плашка для пользователя ---
echo ""
echo "============================================================"
echo "  llama.cpp server is UP"
echo "------------------------------------------------------------"
echo "  Web UI:     http://127.0.0.1:$PORT"
echo "  OpenAI API: http://127.0.0.1:$PORT/v1"
echo "  Шпаргалка: file://$SCRIPT_DIR/web/models.html"

if [ "$SINGLE_MODEL" -eq 1 ]; then
    echo "  Режим:      одна модель ($(basename "$MODEL_PATH"))"
else
    echo "  Режим:      router (модели из ./models, max 1 в RAM)"
fi

echo "  Threads:    $THREADS (из $NPROC)"
echo "  Ctx size:   $CTX_SIZE"
if [ "$USE_SYSTEMD" -eq 1 ]; then
    echo "  Mem cap:    $MEM_MAX (systemd-run, swap=off)"
else
    echo "  Mem cap:    БЕЗ ЛИМИТА (systemd-run недоступен)"
fi
echo "------------------------------------------------------------"
echo "  Ctrl+C — остановить."
echo "============================================================"
echo ""


# --- 10. Ожидание и мониторинг падений ---
while true; do
    if [ "$USE_SYSTEMD" -eq 1 ]; then
        systemctl --user is-active "$UNIT_NAME" &>/dev/null || { echo "[!] Сервер остановлен."; break; }
    else
        kill -0 "$SERVER_PID" 2>/dev/null || { echo "[!] Сервер остановлен."; break; }
    fi
    sleep 10
done

Проверка после запуска

1. Сервер поднялся:

./run_llama_mmap.sh -m Qwen3.5-2B-Q4_K_M

В терминале должна появиться плашка:

============================================================
  llama.cpp server is UP
------------------------------------------------------------
  Web UI:     http://127.0.0.1:8080
  OpenAI API: http://127.0.0.1:8080/v1
  ...

2. Родной UI открывается:

  • Откройте http://127.0.0.1:8080 в браузере.

  • Выберите модель в дропдауне (router mode) или она уже выбрана (single mode).

  • Отправьте «Привет» — должен прийти ответ.

3. API отвечает:

curl http://127.0.0.1:8080/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"Привет"}],"stream":false}'

Если вы запустили сервер в router mode (без -m, все модели в дропдауне), поле "model" обязательно.

Узнать правильный id:

curl -s http://127.0.0.1:8080/v1/models | jq -r '.data[].id'

Скопируйте id из ответа и подставьте в curl:

curl http://127.0.0.1:8080/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{{"model": "...", "messages":["role":"user", "content":"Привет"}], "stream":false}'

4. Проверить память:

# RSS процесса (сколько реально в RAM)
ps -o pid,comm,rss,%mem -C llama-server

# cgroup: сколько занято и какое давление
cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/user@$(id -u).service/llama-server-*.service/memory.current
cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/user@$(id -u).service/llama-server-*.service/memory.pressure

Если memory.current близко к MEM_MAX, а memory.pressure растёт — модель активно «подгружает» страницы с диска. Это нормально для mmap, просто медленно.

Комментарии (5)


  1. vovkats
    26.10.2026 11:34

    Доступен из локальной сети — с аутентификацией, чтобы домашние или коллеги в офисе могли пользоваться.

    Возник вопрос по применению, если ответ модели занимает 3–15 минут, насколько такой стек пригоден для интерактивного чата, особенно если им будут пользоваться несколько человек по сети?

    Также, хотелось бы посмотреть реальные замеры производительности при запуске модели, которая значительно превышает доступную оперативку. Насколько я понимаю, даже с отключённым swap страницы весов модели могут постоянно вытесняться из page cache и повторно читаться с диска. Возникает вопрос, сильно ли это влияет на скорость генерации?


    1. AntonovPsy Автор
      26.10.2026 11:34

      На несколько пользователей (пробовал на 3) - хватает. На целый коллектив - не могу сказать. Делал для дома.

      На счет запуска. С ограничением в 10 Гб (ОЗУ на ПК 16 Гб) стартовал модель Qwen3.5-35B (если верно помню 22 Гб). Работала без проблем с памятью и укладывалась в лимит. Оставлять ее не стал так как генерит медленно (CPU поскрипывает).

      На счет скорости сложно сказать. Мои субъективные ощущения могу только озвучить, на моем Core i3 работает хорошо: 9B-12B модели пишут стримом, что удобно читать. Мелкие модели до 4B почти без задержек. Но точно не замерял.


  1. SaemonZixel
    26.10.2026 11:34

    Она же потом пригодится для «сверхбыстрых» задач (черновики, проверка идей).

    Не хотите попробовать Gemma4-E2B? Скорость та-же, но она поумнее будет т.к. полный размер 4.6B. У неё есть хороший drafter. И mmproj тоже есть.


    1. AntonovPsy Автор
      26.10.2026 11:34

      Она тоже есть. Gemma в двух вариантах: E2B, E4B. Вполне хорошие модели. Не скажу, что пробовал все их возможности... До чего хватило любопытства, фантазии и желания - попробовал. В целом у каждой свои плюсы и минусы (субъективно). В разделе "Наполнение моделями" есть описания тех что опробовал (субъективные впечатления на разных задачах).


  1. nitro80
    26.10.2026 11:34

    как это попадает в DIY?