Как запустить Claude Code локально: llama.cpp + Qwen 3.8 27B, и почему это не заводится с первого раза

Всем привет, с вами Низамов Илья. Разберём, как запустить Claude Code локально — то есть подключить агента не к облаку Anthropic, а к собственному серверу с локальной моделью. Стенд — unsloth/Qwen3.8-27B-GGUF в кванте UD-Q8_K_XL на трёх видеокартах под llama-server. Все замеры в этой статье и дальше будут на одной и той же модели: меняются движки, модель постоянна.

Для меня это было настоящей болью. По всем инструкциям связка ставится за пять минут, а у меня Claude Code честно отработал первый запрос и после этого начал возвращать API Error: 500 на всё подряд — и в тексте ошибки не было ни одной подсказки, куда смотреть. Ниже — как я нашёл причину, чем она оказалась (не тем, о чём я думал первые два часа) и почему прокси, который советуют ставить почти все инструкции, здесь не нужен вовсе.

Сразу, чтобы не листать до конца: все файлы к статье — исправленный шаблон чата и готовый конфигурационный файл проекта — лежат в архиве «Материалы к статье» внизу страницы. Забирайте оттуда, собирать их руками не нужно.

Стенд: Qwen 3.8 27B на трёх картах

Модельunsloth/Qwen3.8-27B-GGUF, квант UD-Q8_K_XL
Размер31.46 GB файл, 29.29 GiB в памяти, 27.32 млрд параметров
Архитектураqwen35, context_length 262 144
Движокllama.cpp, сборка adb55e514 (build 10446), CUDA
GPURTX 3090 (24 576 MiB) + 2× RTX 5070 Ti (16 303 MiB), суммарно 55 807 MiB
КлиентClaude Code 2.1.233

Одна оговорка про квант. В имени файла стоит UD-Q8_K_XL, а сам движок в /props называет model_ftype как Q4_K - Medium: это динамический квант unsloth со смешанной разрядностью слоёв, и заголовок GGUF отражает базовый тип, а не фактическую раскладку. Называть сборку «восьмибитной» я поэтому не берусь. На исправности это не сказалось — отдельный тест сборки дал 100 % по всем блокам, включая structured output и побайтовую повторяемость.

Три карты нужны ради полного контекста: 262 144 токена на восьмибитном KV-кэше в 32 GB не влезают, а для агента контекст — это не роскошь, а рабочий объём. Про то, сколько контекста удаётся выжать из этой же модели на двух картах в vLLM и чем за это платишь, я писал отдельно — Qwen 3.8 27B: максимальный контекст на двух RTX 5070 Ti.

Одна деталь железа пригодится дальше: 3090 сидит в слоте, разведённом на четыре линии PCIe (Gen4 x4). Это не свойство карты, а разводка платы X570 — заменой райзера не лечится. К этим четырём линиям мы вернёмся, когда дойдём до --split-mode.

Нужен ли прокси? llama.cpp умеет Anthropic API сам

Первое, что советуют почти все инструкции по подключению локальной модели к Claude Code, — поставить прослойку: claude-code-router, LiteLLM или что-то подобное. Логика понятная: клиент говорит на языке Anthropic Messages API, локальные серверы — на языке OpenAI, значит нужен переводчик.

Для llama.cpp это давно неправда. Anthropic-эндпоинт лежит прямо в сервере:

  • tools/server/server.cpp:250POST /v1/messages
  • tools/server/server.cpp:267POST /v1/messages/count_tokens
  • tools/server/server-chat.cpp:334server_chat_convert_anthropic_to_oai(), конверсия Anthropic → OpenAI внутри самого сервера
  • tools/server/server-chat.cpp:312 — отдельная обработка x-anthropic-billing-header

То есть перевод формата уже делается, просто на этаж ниже — сразу после разбора HTTP, до того как запрос попадёт в общий конвейер инференса. Прослойка снаружи дублирует эту работу и добавляет собственный слой, в котором можно потерять что-нибудь важное: cache_control, потоковые события, блоки tool_use.

Проверить наличие эндпоинта на своей сборке можно одной командой:

curl -s http://localhost:8000/props | jq '.chat_template_caps'

Если в ответе есть supports_tools: true и supports_tool_calls: true — сервер к агентной работе готов. У меня было так:

{
  "supports_tools": true,
  "supports_tool_calls": true,
  "supports_parallel_tool_calls": true,
  "supports_object_arguments": true
}

И тем не менее Claude Code не работал.

Почему Claude Code падает с 500 на каждом рабочем запросе?

Выглядело это так:

API Error: 500
------------
While executing CallExpression at line 110, column 28 in source:
...eveloper" %}↵        {{- raise_exception('System message must be at the beginnin...
                                           ^
Error: Jinja Exception: System message must be at the beginning.

Обидная деталь: самый первый запрос проходил. Claude Code при старте генерирует заголовок сессии — короткий запрос, он отрабатывал нормально, и создавалось впечатление, что связка живая. Умирало всё следующее.

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

Диагностика: сервер оказался ни при чём

Прежде чем лезть в клиент, я прогнал сервер по всем подозрениям на синтетических запросах. Идея простая: если сервер ломается на чём-то из агентной нагрузки, это должно воспроизводиться руками.

ПроверкаРезультат
/propschat_template_capsинструменты и параллельные вызовы поддерживаются
простой tool call через /v1/messagesкорректный блок tool_use, stop_reason: "tool_use"
Edit с длинными строками кода, 5 прогонов, temp 1.05/5 валидных вызовов, ни одной ошибки разбора JSON
стриминг + tool_result + cache_control + billing-headerкорректный SSE-поток
/v1/messages/count_tokens{"input_tokens": 23}
ошибки в логе сервера0

Ни одна проверка не упала. Дальше можно было либо продолжать угадывать, либо посмотреть, что именно шлёт клиент. Я поднял логирующий HTTP-прокси на порт :8001 — он писал тело каждого запроса в файл и передавал дальше на :8000 — и запустил claude через него. Прокси, заметьте, здесь не средство интеграции, а инструмент отладки: нужен ровно на один прогон.

Вот что он показал.

Корень: Claude Code кладёт system внутрь messages

Структура перехваченного POST /v1/messages?beta=true:

system (top-level): 3 блока
  - "x-anthropic-billing-header: cc_version=2.1.233.9ec; cc_entrypoint=sdk-cli;"
  - "You are a Claude agent, built on Anthropic's Claude Agent SDK."
  - "\nYou are an interactive agent that helps users with software engineering tasks..."
messages: ['user', 'system']        ← вот оно
  user   : "<system-reminder>...# currentDate\nToday's date is 2026-08-16..."
  user   : "Read the file note.txt and tell me what it contains."
  system : "Available agent types for the Agent tool:\n- claude: Catch-all..."
tools: 28
stream: true
temperature: None

Системный промпт лежит там, где ему положено, — в поле system верхнего уровня, тремя блоками. Но кроме него в массиве messages после пользовательского сообщения едет ещё одно, с role: "system". Внутри — список доступных типов агентов для инструмента Agent.

Формально Anthropic API этого не запрещает. А шаблон чата Qwen 3.8 запрещает: он считает ведущие системные сообщения в переменную num_sys и на любом последующем бросает исключение (orig.jinja:106-110):

{%- for message in messages %}
    {%- if loop.index0 >= num_sys %}
    {%- set content = render_content(message.content, true)|trim %}
    {%- if message.role == "system" or message.role == "developer" %}
        {{- raise_exception('System message must be at the beginning.') }}

Вот и вся загадка. Первый запрос — генерация заголовка — состоял из одного user-сообщения и проходил. Любой рабочий запрос тащил за собой описание агентов и падал.

Момент, который стоит забрать из этой истории отдельно: несовместимость жила не в коде сервера и не в коде клиента, а в шаблоне чата, который приехал вместе с весами модели. Это самое незаметное место из всех возможных — шаблон лежит внутри GGUF, его никто не читает, и он молча решает, как разговор превратится в строку токенов.

Как починить: правка шаблона в одну строку

Лечится заменой исключения на обычный рендер системного хода. Достаём шаблон из своей же сборки — так гарантированно берётся тот, что реально используется, а не похожий с Hugging Face:

curl -s http://localhost:8000/props | jq -r .chat_template > qwen38-orig.jinja
cp qwen38-orig.jinja qwen38-fixed.jinja

И правим одну строку:

110c110
<         {{- raise_exception('System message must be at the beginning.') }}
---
>         {{- '<|im_start|>system\n' + content + '<|im_end|>' + '\n' }}

Дальше шаблон подключается флагом:

llama-server ... --jinja --chat-template-file /home/ilya/ai/qwen38-fixed.jinja

Готовый исправленный шаблон я приложил к статье — архив «Материалы к статье» под текстом. Положите qwen38-fixed.jinja в свою домашнюю папку и подставьте этот путь в команду запуска вместо моего /home/ilya/ai/.

Правка безобидная: мы не выкидываем сообщение и не переставляем его в начало — мы рендерим его там, где оно пришло. Модель видит системную инструкцию ровно в том месте разговора, куда её положил клиент, а это и есть замысел Claude Code: список агентов — не часть общего системного промпта, а уточнение, актуальное с этого момента разговора.

Как убедиться, что агент действительно работает?

Тест «сервер отдал 200» здесь ничего не доказывает — надо смотреть, доходит ли дело до инструментов. Гонял реальный claude -p против своего сервера:

  1. Чтение файла. «Read the file note.txt and tell me exactly what it contains» → агент вызвал Read, вернул содержимое.
  2. Агентная цепочка. В calc.py был подложен баг — return a - b в функции сложения. Claude Code нашёл его, исправил через Edit на a + b, проверил через Bash (python3 -c 'import calc; print(calc.add(2,3))'5) и отчитался.

Вторая проверка важнее первой: она задействует полный цикл — чтение, правку, запуск, разбор вывода. Именно на нём разваливаются модели, которые «умеют tool calling» в одном вызове, но не держат цепочку.

Контрольная строка в логе сервера после прогона:

grep -c "got exception" server.log
# 0

Три грабли, на которых теряется время

Отдельно от главной проблемы — три вещи, каждая из которых стоила мне отдельного захода.

-hf полез качать 31 GB заново. В системе прописан HF_HOME=/opt/hf-cache, модель там уже лежала. Но llama.cpp для флага -hf использует собственный кеш — LLAMA_CACHE или ~/.cache/llama.cpp (common/common.cpp:1027) — и про HF_HOME не знает ничего. Лечится прямым путём к файлу в снапшоте:

MODEL=$(ls -1 /opt/hf-cache/hub/models--unsloth--Qwen3.8-27B-GGUF/snapshots/*/Qwen3.8-27B-UD-Q8_K_XL.gguf | head -1)
llama-server -m "$MODEL" ...

Claude Code режет контекст до 200k. Незнакомой модели клиент приписывает окно по умолчанию, и полное имя с квантом тут ничего не меняет: любая строка, кроме имени claude-модели, для него одинаково незнакома. То есть вы поднимаете сервер на 262 144 токена, платите за это памятью трёх карт — а диалог уплотняется на 200 000. Лечится переменной:

export CLAUDE_CODE_MAX_CONTEXT_TOKENS=262144

Работает она при одном условии: имя модели не должно начинаться с claude- — разбор чуть ниже, в разделе про имя модели.

--reasoning off действительно выключает размышления. Флаг не косметический: он выставляет шаблону enable_thinking=false (common/arg.cpp:3641), то есть модель работает в non-thinking режиме. Это важно, потому что рекомендованные unsloth параметры сэмплирования (--temp 0.7 --top-p 0.8 --top-k 20 --presence-penalty 1.5) относятся именно к non-thinking профилю. Включите размышления — профиль надо менять.

Кстати, про presence-penalty у меня была гипотеза, что значение 1.5 ломает JSON в аргументах вызовов инструментов: штраф за повторы плюс структурированный вывод — звучит опасно. Проверил на задаче с длинными повторяющимися строками кода, пять прогонов при температуре 1.0 — пять валидных вызовов. Гипотеза не подтвердилась.

Заодно выяснилась деталь, которая избавляет от целого класса вопросов «а не переопределит ли клиент мои настройки». В конверсии Anthropic → OpenAI (server-chat.cpp:577) пробрасывается закрытый список полей:

for (const auto & key : {"temperature", "top_p", "top_k", "stream", "chat_template_kwargs"}) {

То есть presence_penalty клиентом не переопределяется в принципе — серверный флаг действует всегда. А temperature Claude Code, как видно из перехваченного запроса, вообще не присылает (temperature: None), так что серверный --temp тоже в силе.

Как подключить клиент к своему серверу

Со стороны Claude Code всё сводится к трём вещам: адрес сервера, любой непустой токен и имя модели. Дальше — по операционным системам, потому что задаются переменные везде по-своему.

Какое имя модели указывать

Короткий ответ: то, которым представляется сам сервер. Посмотреть его можно так:

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

llama.cpp собирает это имя по порядку: алиас из -a/--alias, если задан; иначе имя из метаданных GGUF; иначе имя файла модели. Гадать не нужно — проще задать алиас самому:

llama-server ... -a Qwen3.8-27B-UD-Q8_K_XL

Дальше тонкость, из-за которой расходятся чужие инструкции. Сам Claude Code имя модели не проверяет: на своём ANTHROPIC_BASE_URL имена определяет ваш сервер, и клиент передаёт строку как есть. Проверяет её сервер — и делает это по-разному:

  • llama-server с одной моделью (-m или -hf, наш случай) отвечает на любое имя: модель в памяти одна, выбирать не из чего. Здесь пройдёт и короткий qwen, и полное имя с квантом;
  • llama-server в режиме роутера (запуск без модели, --models-dir) ищет модель по имени и на незнакомом отвечает model name=... is not found;
  • vLLM сверяет имя всегда: оно должно совпадать с тем, что передано в vllm serve — либо с --served-model-name. Не совпало — 404, поэтому в статье про конфигурации на двух картах в переменных стоит полное unsloth/Qwen3.8-27B-NVFP4.

Отсюда практический вывод: ставьте полное имя с квантом, даже когда сервер вас не проверяет. Оно ничего не стоит, видно в /status и в логах сервера, а переезд на роутер или на vLLM не превратится в отладку на ровном месте.

Единственное ограничение на имя — его нельзя начинать с claude-. Такую строку клиент считает именем claude-модели, и CLAUDE_CODE_MAX_CONTEXT_TOKENS к ней не применяется — переменная сработает только вместе с DISABLE_COMPACT, который выключает уплотнение диалога вообще. Ровно тот случай, когда человек берёт из чужой инструкции алиас вида claude-sonnet-4-5, а контекст молча уезжает не туда.

Linux и macOS

export ANTHROPIC_BASE_URL=http://localhost:8000
export ANTHROPIC_AUTH_TOKEN=dummy
export ANTHROPIC_MODEL=Qwen3.8-27B-UD-Q8_K_XL
export ANTHROPIC_DEFAULT_HAIKU_MODEL=Qwen3.8-27B-UD-Q8_K_XL
export CLAUDE_CODE_MAX_CONTEXT_TOKENS=262144
claude

Токен нужен любой непустой: сервер его не проверяет, но клиент без него не стартует. ANTHROPIC_DEFAULT_HAIKU_MODEL важна не меньше основной: на быструю модель у клиента уходят служебные задачи вроде заголовка сессии, и если её не переопределить, он попытается сходить в облако. Раньше ту же роль играла ANTHROPIC_SMALL_FAST_MODEL — она объявлена устаревшей, но ещё работает; в новых конфигурациях пишите ANTHROPIC_DEFAULT_HAIKU_MODEL.

В WSL и Git Bash — эти же строки: там та же оболочка и те же переменные окружения.

Windows

PowerShell, на текущее окно:

$env:ANTHROPIC_BASE_URL = "http://10.10.1.32:8000"
$env:ANTHROPIC_AUTH_TOKEN = "dummy"
$env:ANTHROPIC_MODEL = "Qwen3.8-27B-UD-Q8_K_XL"
$env:ANTHROPIC_DEFAULT_HAIKU_MODEL = "Qwen3.8-27B-UD-Q8_K_XL"
$env:CLAUDE_CODE_MAX_CONTEXT_TOKENS = "262144"
claude

Чтобы не набирать заново в каждом окне — записать в переменные пользователя; они подхватятся при следующем запуске терминала:

[Environment]::SetEnvironmentVariable("ANTHROPIC_BASE_URL", "http://10.10.1.32:8000", "User")
[Environment]::SetEnvironmentVariable("ANTHROPIC_MODEL", "Qwen3.8-27B-UD-Q8_K_XL", "User")
[Environment]::SetEnvironmentVariable("CLAUDE_CODE_MAX_CONTEXT_TOKENS", "262144", "User")

В cmd.exe то же самое двумя командами: set — на текущее окно, setx — навсегда.

set ANTHROPIC_BASE_URL=http://10.10.1.32:8000
setx ANTHROPIC_BASE_URL http://10.10.1.32:8000

Три вещи, на которых спотыкаются именно на Windows:

  • localhost годится, только если сервер поднят на этой же машине. Обычная схема другая: рабочее место на Windows, карты — в Linux-машине рядом. Тогда в ANTHROPIC_BASE_URL идёт её адрес в сети, сервер должен быть запущен с --host 0.0.0.0 (иначе слушает только себя), а порт 8000 — открыт в фаерволе.
  • setx не меняет текущее окно. Переменная появится только в новых — а вы будете смотреть на старое и думать, почему клиент по-прежнему ходит в облако.
  • Значения в PowerShell берите в кавычки. Без них двоеточие в адресе и точки в имени модели оболочка разбирает по-своему.

Проверка одна на все системы: запустите claude и наберите /status — там видно, на какой модели идёт сессия. Если в ответе облачная модель, значит переменные до клиента не доехали.

Только в одном проекте: конфигурационный файл

У переменных окружения два неудобства. Первое: они живут в оболочке, и на локальную модель уходят все запуски claude из этого терминала — а обычно хочется наоборот, один репозиторий на своём сервере, остальные по-прежнему в облаке. Второе: синтаксис у каждой системы свой, и конфигурацию приходится переписывать при переезде с ноутбука на сервер.

Оба лечатся конфигурационным файлом проекта — он одинаков в Linux, macOS и Windows. Положите в корень репозитория .claude/settings.json с блоком env.

{
  "env": {
    "ANTHROPIC_BASE_URL": "http://localhost:8000",
    "ANTHROPIC_AUTH_TOKEN": "dummy",
    "ANTHROPIC_MODEL": "Qwen3.8-27B-UD-Q8_K_XL",
    "ANTHROPIC_DEFAULT_HAIKU_MODEL": "Qwen3.8-27B-UD-Q8_K_XL",
    "CLAUDE_CODE_MAX_CONTEXT_TOKENS": "262144"
  }
}

Дальше просто claude из корня этого проекта — переменные подхватятся, а в соседнем репозитории клиент останется на облачных моделях. Что важно знать про этот файл:

  • Значения — строки, даже число контекста: "262144", а не 262144.
  • Файлов два, и они разные по смыслу. .claude/settings.json — командный, он едет в git и виден всем, кто клонирует репозиторий. .claude/settings.local.json — личный, он в .gitignore. Адрес своего сервера почти всегда стоит класть во второй: у коллеги на localhost:8000 вашей модели нет, и командный файл сломает работу всей команде.
  • Приоритет — от частного к общему: локальный файл проекта перебивает командный, командный — пользовательский ~/.claude/settings.json.
  • Не смешивайте два способа. Если в оболочке уже висят переменные, а в проекте лежит конфиг с другим адресом, разбираться, что победило, придётся ровно в тот момент, когда что-то не заведётся. Выберите один: переменные — для разового прогона, файл — для постоянной работы.

Готовый settings.json с этими значениями лежит в архиве «Материалы к статье» внизу — останется поменять порт и алиас модели под себя.

Со стороны сервера — одна команда запуска на три видеокарты:

CUDA_VISIBLE_DEVICES=0,1,2 ./build/bin/llama-server -hf unsloth/Qwen3.8-27B-GGUF:UD-Q8_K_XL -a Qwen3.8-27B-UD-Q8_K_XL --host 0.0.0.0 --port 8000 --ctx-size 262144 --parallel 1 --cache-type-k q8_0 --cache-type-v q8_0 --flash-attn on --spec-draft-n-max 4 --spec-type draft-mtp --temp 0.7 --top-p 0.8 --top-k 20 --presence-penalty 1.5 --min-p 0.00 --reasoning off --split-mode layer --batch-size 2048 --ubatch-size 512 --jinja --chat-template-file /home/ilya/ai/qwen38-fixed.jinja

Два условия, без которых команда не сработает:

  1. Запускать из каталога llama.cpp после сборки — путь ./build/bin/llama-server отсчитывается от корня репозитория движка, а CUDA_VISIBLE_DEVICES=0,1,2 отдаёт серверу ровно три карты.
  2. Исправленный шаблон положить в домашнюю папку и поправить путь на свой. В команде стоит мой — /home/ilya/ai/qwen38-fixed.jinja; замените ilya на своего пользователя и каталог на тот, куда вы положили файл. Готовый шаблон лежит в архиве «Материалы к статье» под текстом — распакуйте его, и вытаскивать шаблон из /props с последующей правкой строки 110 не придётся.

Что здесь стоит объяснить:

  • -a Qwen3.8-27B-UD-Q8_K_XL — имя, под которым сервер представляется в API. На инференс не влияет вообще, зато делает предсказуемым значение ANTHROPIC_MODEL: без алиаса имя соберётся из метаданных GGUF или из имени файла, и придётся смотреть в /v1/models.
  • --parallel 1 — стенд однопользовательский. Слоты делят контекст между собой, а мне нужен один слот на все 262 144 токена, а не четыре по 65 тысяч.
  • --cache-type-k q8_0 --cache-type-v q8_0 — восьмибитный KV-кэш. На полном контексте без него памяти не хватает даже на трёх картах.
  • --spec-type draft-mtp --spec-draft-n-max 4 — спекулятивное декодирование на MTP-слое, который лежит прямо в GGUF. Черновая голова дёшево предсказывает несколько токенов вперёд, основная модель проверяет их одним проходом.
  • --split-mode layer — как именно модель делится между картами, и для агента это не мелочь. Я прогнал оба режима на одном стенде: tensor заставляет все три карты работать над каждым токеном и поднимает скорость печати с 49.74 до 68.49 ток/с (+38 %), но платит за каждый слой обменом через шину — а один участник сидит на тех самых четырёх линиях. Префилл на этом обваливается с 2 118 до 1 177 ток/с (−44 %). Агенту дороже префилл, поэтому здесь layer. Полные таблицы обоих режимов — в статье про максимальный контекст Qwen 3.8 27B.
  • -hf unsloth/Qwen3.8-27B-GGUF:UD-Q8_K_XL — модель скачается в кеш llama.cpp при первом запуске. Если она у вас уже лежит в HF_HOME, замените флаг на -m <путь к .gguf> — почему именно так, разобрано в граблях выше.

После правки команды запуска обязательно сверяйте в логе n_slots и n_ctx_slot. У меня один раз sed оборвал строку на середине — сервер поднялся вообще без --ctx-size, без квантованного кэша и без шаблона, но поднялся, и следующие полчаса я мерил не то, что думал. bash -n такое не ловит: синтаксис остаётся корректным, меняется смысл.

Какой движок выбрать под Claude Code?

Первое, что здесь надо понимать: агент предъявляет к движку не те требования, что чат. В чате вы смотрите на скорость печати ответа. Агент же каждым запросом тащит системный промпт, содержимое прочитанных файлов, историю диалога и описание двух десятков инструментов — и всё это надо обработать до того, как появится первый токен. Поэтому главная метрика для Claude Code — префилл, скорость обработки входа. Замеры это подтверждают буквально: на промпте в 124 тысячи токенов от 79 до 94 % времени холодного запроса уходит в обработку входа, а не в печать ответа. Плюс два обязательных условия: сервер должен уметь вызовы инструментов и держать нужный вам контекст.

Ollama

Ставится заметно проще. У Ollama свой Anthropic-совместимый API и команда ollama launch claude, а переменных нужно три:

ANTHROPIC_AUTH_TOKEN=ollama
ANTHROPIC_API_KEY=""
ANTHROPIC_BASE_URL=http://localhost:11434

Никаких шаблонов, никаких --split-mode. Документация Ollama рекомендует ставить контекст от 64k и выше — на большом репозитории агент упирается именно в него.

llama.cpp

Ollama берёт на себя раскладку по картам, размер кэша и параметры сэмплирования. Пока подобранное им устраивает — это выигрыш чистого времени. Как только вы упираетесь в железо и начинаете считать мегабайты VRAM, вам нужны те самые флаги: сколько слоёв на какой карте, каким типом квантовать KV, включать ли спекуляцию, каким шаблоном рендерить разговор. У меня задача звучала как «262 144 токена контекста на трёх разнородных картах» — это уже территория флагов.

Есть и вторая причина, менее очевидная. Когда что-то ломается — а сломалось у меня ровно в шаблоне, — важно иметь возможность вытащить шаблон, посмотреть на него и подменить. Слой, который «всё делает сам», ровно на этом шаге превращается из помощника в препятствие.

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

vLLM: те же карты, агентская мерка

Эти замеры я обещал — они есть, и с первой редакции статьи они пересняты заново. Ту же самую модель я прогнал в vLLM на двух 5070 Ti (там она берётся в NVFP4, потому что 3090 этот формат не умеет) в четырёх конфигурациях, а описанная выше сборка llama.cpp на трёх картах прошла тот же набор дважды — послойным и тензорным делением. Шесть прогонов, одна методика, одна база результатов. Полный разбор — в статье Qwen 3.8 27B: пять конфигураций на двух RTX 5070 Ti, ниже только то, что чувствует агент.

Цифры сместились не потому, что железо стало другим, а потому что изменилась мерка. Префилл и накладные теперь не отношение prompt_tokens / TTFT, а наклон и свободный член прямой, построенной по лестнице промптов 512 / 2048 / 8192 / 16384 токена, три повтора на точку. Агентская траектория выросла с двенадцати ходов до двадцати шести, в каждом запросе — двадцать пять схем инструментов, шесть из них приманки.

Что чувствует агентllama.cpp, 3 картыvLLM, MTPvLLM, fp8
Контекст заявленный262 144131 072170 912
Подтверждён поиском иголки123 848123 848123 848
Префилл2 118 ток/с3 140 ток/с3 360 ток/с
Постоянные накладные на запрос+0.417 с−0.009 с−0.016 с
TTFT, короткая реплика0.476 с0.057 с0.096 с
TTFT на промпте 123 848 токенов96.4 с67.3 с60.2 с
Медиана хода агента, 26 ходов2.83 с1.52 с2.02 с
Переиспользование кэша в сессии93.9 %82.4 %88.1 %
Кэш префикса включается с656 ток. (×5.8)2 856 ток. (×2.2)1 656 ток. (×4.3)
Скорость выдачи49.7 ток/с102.6 ток/с56.3 ток/с
Точность вызова инструментов92.3 %96.2 %84.6 %

Пять вещей, которые из этой таблицы стоит забрать.

Префилл у vLLM выше в полтора раза — на двух картах против трёх. Третья карта в llama.cpp решает вопрос памяти, а не скорости: она сидит на четырёх линиях PCIe, и обмен между картами съедает то, что даёт лишний гигабайт.

Полсекунды постоянных накладных на каждый ход. Это не префилл и не генерация — это то, что тратится ещё до начала счёта: у llama.cpp 0.42 с на запрос, у vLLM минус шесть миллисекунд, то есть ноль. В чате незаметно, а агент делает десятки коротких ходов подряд, и полсекунды на каждом видно уже руками: медиана хода 2.83 с против 1.52 у vLLM с MTP.

Кэш префикса у llama.cpp лучший в наборе, но смотреть надо не на процент. 93.9 % переиспользования против 82–88 % у vLLM — разница небольшая. Важнее длина префикса, с которой кэш вообще включается: у llama.cpp с --parallel 1 он ловит уже 656 токенов, а у vLLM кэш блочный, и чем сильнее сжат KV, тем крупнее блок — 1 656 токенов у fp8 и 2 856 у конфигурации с MTP. Системный промпт агента — это 400–2 000 токенов, и в vLLM он часто не попадает в блок целиком, то есть не переиспользуется вовсе. Постоянных накладных llama.cpp это всё равно не отыгрывает.

Полный контекст — уже не такой однозначный аргумент за три карты. Заявленные 262 144 токена у llama.cpp так и остались заявленными: лестница поиска иголки упёрлась в двадцатиминутный бюджет теста на 123 848 токенах, и что там на полной длине — я не знаю. Единственная конфигурация, где четверть миллиона подтверждена замером, оказалась в vLLM: 248 380 токенов на четырёхбитном KV. Только это пакетный режим, а не агентский — 150 секунд до первого токена, 13 ток/с выдачи и кэш префикса, который не включается ниже 5 256 токенов.

Точность вызова инструментов лучшая у vLLM с MTP — 25 верных ходов из 26 против 24 у llama.cpp. Читать её надо осторожно: один ход весит 3.85 %, и разница «96.2 против 92.3» — это разница в один ход. Тринадцатый ход, где вместо правки файла модель читает файл, провалили все шесть прогонов: это поведение модели, а не свойство движка.

И отдельно — то, ради чего вообще написана эта статья: на vLLM ломается ровно тот же шаблон, и теперь это не только воспроизведено, но и вылечено замером. Сценарий user → system → user валился во всех прогонах vLLM — тот же встроенный jinja Qwen 3.8, тот же raise_exception, только вместо пятисотки сервер отвечает HTTP 400. С подменённым шаблоном (--chat-template у vLLM, --chat-template-file у llama.cpp) сценарий проходит, и исправность сборки выросла с 90–95 % до 100 % у всех шести прогонов. Поломка приехала вместе с весами модели, достанется вам на любом движке, который честно берёт шаблон из чекпойнта, — и чинится на любом из них одним флагом.

Подключается vLLM теми же переменными, но имя модели он отдаёт полное — алиас qwen не подойдёт:

ANTHROPIC_BASE_URL=http://10.10.1.32:8000 \
ANTHROPIC_API_KEY=dummy ANTHROPIC_AUTH_TOKEN=dummy \
ANTHROPIC_DEFAULT_OPUS_MODEL=unsloth/Qwen3.8-27B-NVFP4 \
ANTHROPIC_DEFAULT_SONNET_MODEL=unsloth/Qwen3.8-27B-NVFP4 \
ANTHROPIC_DEFAULT_HAIKU_MODEL=unsloth/Qwen3.8-27B-NVFP4 \
claude

Так что же выбрать

Если контекста в 131 тысячу токенов хватает — vLLM на двух картах, конфигурация со спекулятивным декодированием и fp8-кэшем: медиана хода агента 1.52 с, выдача 102.6 ток/с, лучшая в наборе точность вызова инструментов. Если NVFP4 не вариант — старые карты, — или нужен объявленный контекст в 262 144 токена без квантования кэша до четырёх бит, то llama.cpp на трёх картах, --split-mode layer, и вы миритесь с лишними полсекунды на каждом ходу. Ollama — если разбираться с флагами не хочется вовсе.

Что дальше: SGLang и работа под нагрузкой

vLLM перемерян, следующий на очереди SGLang — на той же модели и той же меркой, чтобы цифры трёх движков ложились в одну таблицу.

Второй незакрытый вопрос — параллельные запросы. Всё, что выше, снято батчем 1: один пользователь, один слот, --parallel 1. Выигрыш спекулятивного декодирования под несколькими одновременными запросами обычно тает, и на этом стенде я это ещё не проверял.


Локальный агент — это половина дела. Вторая половина в том, что вы ему поручите: как собрать контекст своей предметной области, как дать модели доступ к рабочим системам, как проверять то, что она сделала. Этому посвящён мой курс «ChatGPT и 1С» — там разбираем и локальные модели, и сборку агентов вокруг них на реальных задачах бизнеса.

По соседним темам: Qwen 3.8 27B — максимальный контекст — сколько контекста удаётся выжать из этой модели и чем за него платишь, vLLM vs llama.cpp — что выбрать под задачу, Claude Code для 1С — как работать агентом с конфигурацией 1С, RTX 5070 Ti vs RTX 3090 — стоит ли брать новое поколение. Замеры скоростей на разных картах лежат в бенчмарках.

Материалы к статье

qwen38-fixed-template.zip · 5 KB

Скачать

Частые вопросы