Всем привет, с вами Низамов Илья. Разберём, как запустить 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 |
| GPU | RTX 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:250—POST /v1/messagestools/server/server.cpp:267—POST /v1/messages/count_tokenstools/server/server-chat.cpp:334—server_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, но не показывает, какое сообщение шаблон счёл лишним и откуда оно взялось. А поскольку я эти сообщения не формирую — их формирует клиент, — по тексту ошибки не понять вообще ничего.
Диагностика: сервер оказался ни при чём
Прежде чем лезть в клиент, я прогнал сервер по всем подозрениям на синтетических запросах. Идея простая: если сервер ломается на чём-то из агентной нагрузки, это должно воспроизводиться руками.
| Проверка | Результат |
|---|---|
/props → chat_template_caps | инструменты и параллельные вызовы поддерживаются |
простой tool call через /v1/messages | корректный блок tool_use, stop_reason: "tool_use" |
Edit с длинными строками кода, 5 прогонов, temp 1.0 | 5/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 против своего сервера:
- Чтение файла. «Read the file
note.txtand tell me exactly what it contains» → агент вызвалRead, вернул содержимое. - Агентная цепочка. В
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
Два условия, без которых команда не сработает:
- Запускать из каталога
llama.cppпосле сборки — путь./build/bin/llama-serverотсчитывается от корня репозитория движка, аCUDA_VISIBLE_DEVICES=0,1,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, MTP | vLLM, fp8 |
|---|---|---|---|
| Контекст заявленный | 262 144 | 131 072 | 170 912 |
| Подтверждён поиском иголки | 123 848 | 123 848 | 123 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 — стоит ли брать новое поколение. Замеры скоростей на разных картах лежат в бенчмарках.