Как запустить 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 ГБ файл, 29.29 ГиБ в памяти, 27.32 млрд параметров
Архитектураqwen35, context_length 262 144
Движокllama.cpp, сборка adb55e514 (build 10446), CUDA
GPURTX 3090 (24 576 МиБ) + 2× RTX 5070 Ti (16 303 МиБ), суммарно 55 807 МиБ
КлиентClaude Code 2.1.233

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

Нужен ли прокси? 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 ГБ заново. В системе прописан 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. Клиент не знает модель с именем qwen и применяет к ней значение по умолчанию. То есть вы поднимаете сервер на 262 144 токена, платите за это памятью трёх карт — и клиент молча использует 200 000. Лечится переменной:

export CLAUDE_CODE_MAX_CONTEXT_TOKENS=262144

--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 всё сводится к переменным окружения. Все роли моделей указывают на одну и ту же локальную модель — выбирать всё равно не из чего:

export ANTHROPIC_BASE_URL=http://localhost:8000
export ANTHROPIC_AUTH_TOKEN=dummy
export ANTHROPIC_MODEL=qwen
export ANTHROPIC_SMALL_FAST_MODEL=qwen
export ANTHROPIC_DEFAULT_HAIKU_MODEL=qwen
export CLAUDE_CODE_MAX_CONTEXT_TOKENS=262144
claude

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

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

У export есть побочный эффект: переменные живут в оболочке, и на локальную модель уходят все запуски claude из этого терминала. Обычно хочется наоборот — один репозиторий на своём сервере, остальные по-прежнему в облаке. Это решается конфигурационным файлом проекта: положите в его корень .claude/settings.json с блоком env.

{
  "env": {
    "ANTHROPIC_BASE_URL": "http://localhost:8000",
    "ANTHROPIC_AUTH_TOKEN": "dummy",
    "ANTHROPIC_MODEL": "qwen",
    "ANTHROPIC_SMALL_FAST_MODEL": "qwen",
    "ANTHROPIC_DEFAULT_HAIKU_MODEL": "qwen",
    "CLAUDE_CODE_MAX_CONTEXT_TOKENS": "262144"
  }
}

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

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

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

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

CUDA_VISIBLE_DEVICES=0,1,2 ./build/bin/llama-server -hf unsloth/Qwen3.8-27B-GGUF: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 не придётся.

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

  • --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 — как именно модель делится между картами. Это отдельный большой сюжет с ощутимой разницей в скорости, и он разобран в статье про максимальный контекст 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 — префилл, скорость обработки входа. Плюс два обязательных условия: сервер должен уметь вызовы инструментов и держать нужный вам контекст.

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 и SGLang

Оба движка на этом стенде уже гонялись, но под другую задачу — выжать максимум контекста, а не работать с агентом. Про это отдельная статья: Qwen 3.8 27B, максимальный контекст на двух RTX 5070 Ti. Под Claude Code я их сейчас перемеряю и добавлю сюда же — с той самой агентной меркой, о которой написано выше.

Что дальше: vLLM и SGLang на той же модели

Дальше я гоняю ту же самую Qwen 3.8 27B на vLLM и SGLang и меряю их одной меркой с llama.cpp: не «токенов в секунду в чате», а префиллом на длинном входе. Разница здесь стоит дорого — на промпте в сто тысяч токенов это может быть полторы минуты ожидания против пяти.

Замеры лягут в эту же статью: я перевожу стенд на новую методику, чтобы цифры разных движков можно было честно класть в одну таблицу, а не сравнивать несравнимое.


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

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

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

qwen38-fixed-template.zip · 4,6 КБ

Скачать

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