Всем привет, с вами Низамов Илья. Разберём, как запустить 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 |
| GPU | RTX 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: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 ГБ заново. В системе прописан 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
Два условия, без которых команда не сработает:
- Запускать из каталога
llama.cppпосле сборки — путь./build/bin/llama-serverотсчитывается от корня репозитория движка, аCUDA_VISIBLE_DEVICES=0,1,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 — стоит ли брать новое поколение. Замеры скоростей на разных картах лежат в бенчмарках.