ZhukV/AI

The test run AI models/tools/mcp on local machine

★ 0Forks 0JavaScriptGitHub ↗Compare

README

AI

Песочница для сравнения инструментов, которые крутят LLM локально.

Идея простая: один и тот же вопрос уходит в несколько рантаймов одновременно, ответы и метрики оказываются рядом. Всё живёт на loopback, всё говорит на OpenAI-совместимом API, поэтому рантаймы взаимозаменяемы и сравнимы.

Структура

bin/            скрипты
  serve.sh      старт/стоп моделей
  web.sh        старт/стоп веб-морды
  setup-mlx.sh  Python-окружение для рантайма mlx
  proc.sh       общая машинерия демонов: локи, pid, готовность
                (библиотека, подключается остальными через `.`)
  typecheck.sh  mypy --strict по src/server
src/
  public/       фронтенд
    index.html    разметка обеих вкладок
    style.css
    js/           ES-модули, точка входа main.js
  server/       бэкенд
    main.py       точка входа: аргументы, сигналы
    server.py     HTTP-сервер и роутинг
    responses.py  как пишется ответ: JSON, статика, chunked, SSE
    registry.py   models.conf и живое состояние процессов
    config.py     пути и лимиты
    controllers/  по контроллеру на эндпоинт
    extractors/   по экстрактору на формат файла
libs/           внешние библиотеки (git submodules)
var/            изменяемые данные, в git не попадают
  models/       скачанные веса, включая whisper
  venv/         Python-окружение: mlx, извлекатели, whisper
  hf/           кэш HuggingFace (уведён сюда из ~/.cache)
  run/          pid-файлы и порты
  log/          логи процессов
  tmp/          черновики, заметки
models.conf     реестр моделей

Правила игнора все в корневом .gitignore; папки, которые должны существовать в свежем клоне, держатся через .gitkeep.

Рантаймы

Два подхода к одной и той же задаче — в этом весь смысл проекта.

swiftlet mlx
Что резидентно только плотное ядро вся модель
Эксперты MoE стримятся с SSD по запросу в памяти
RAM на 35B 4-bit ~3.5 ГБ ~19 ГБ
Скорость 12 tok/s 81 tok/s
Устанавливается сабмодуль + swift build pip install mlx-lm

Выбор не про «лучше/хуже», а про размен: MLX в 6.5 раза быстрее и стоит в 20 раз больше памяти. Если машина параллельно занята работой, swiftlet оставляет её пригодной для жизни; если память свободна, mlx выигрывает вчистую.

Рантайм указывается второй колонкой в models.conf. Всё остальное — скрипты, прокси, морда — с обоими работает одинаково.

Быстрый старт

git clone --recurse-submodules <repo-url> && cd AI

Если репозиторий уже склонирован:

git submodule update --init --recursive

swiftlet

cd libs/Swiftlet && swift build -c release && cd ../..
libs/Swiftlet/.build/release/swiftlet-repack \
  --from-url https://pub-c0cfece2dbc340dbb2cd9d94310a7d68.r2.dev/qwen3.6-35b-qpack \
  --output var/models/qwen3.6-35b.qpack

18 ГБ, около 8 минут. Контейнер — это директория, а не файл: плотное ядро в model.safetensors плюс packed_experts/ с блобами по слоям.

mlx

bin/setup-mlx.sh

Создаёт var/venv и ставит туда mlx-lm, извлекатели файлов (pypdf, python-docx) и mlx-whisper. Нужен Python 3.11+ — системный 3.9 не подойдёт, скрипт сам поищет свежий. Веб-морда запускается этим же интерпретатором: без него загрузка файлов ограничится простым текстом.

var/venv/bin/hf download mlx-community/Qwen3.6-35B-A3B-4bit \
  --local-dir var/models/qwen3.6-35b-mlx

19 ГБ.

Для распознавания аудио — модель Whisper:

var/venv/bin/hf download mlx-community/whisper-large-v3-turbo \
  --local-dir var/models/whisper-large-v3-turbo

1.5 ГБ. Нужна только если будешь загружать аудио.

Поехали

bin/serve.sh start qwen35b-mlx && bin/web.sh start

Открыть http://127.0.0.1:8080.

Модели

Объявляются в models.conf, одна строка на модель:

name          runtime    path                             port   [args...]
qwen35b       swiftlet   var/models/qwen3.6-35b.qpack     8100   --cache-gb 2
qwen80b       swiftlet   var/models/qwen3-next-80b.qpack  8101   --cache-gb 2
qwen35b-mlx   mlx        var/models/qwen3.6-35b-mlx       8102   --chat-template-args {"enable_thinking":false}
qwen9b-mlx    mlx        var/models/qwen3.5-9b-mlx        8103   --chat-template-args {"enable_thinking":false}
coder30b      mlx        var/models/qwen3-coder-30b-mlx   8104

coder30b идёт без enable_thinking: это instruct-модель, режима рассуждений у неё нет. Модели различаются не только размером — 30B-A3B при 16 ГБ выдаёт 82 tok/s, а плотная 9B при 5.5 ГБ только 40: у MoE на токен работают ~3B параметров вместо всех.

Всё после порта уходит рантайму как есть.

Порты. Веб-морда занимает 8080, модели живут в 8100+. Низкие 8000-е слишком популярны, чтобы их занимать.

Веса кладём в var/models/. Папка трекается через .gitkeep, содержимое — нет.

Туда же кладётся модель Whisper, а HF_HOME в скриптах запуска указывает на var/hf. Иначе неявные загрузки уходят в ~/.cache/huggingface — Whisper так и утёк полутора гигабайтами мимо проекта, пока это не поправили.

Добавить модель

Строка в models.conf. Если рантайм уже известен — больше ничего не нужно.

Добавить рантайм

Одна ветка в build_cmd() в bin/serve.sh: заполнить массив CMD и, если нужно, READY_PATH. Локи, pid-файлы, ожидание готовности, коды выхода и вся морда достаются бесплатно — общая машинерия демонов лежит в bin/proc.sh и подключается остальными скриптами через ..

Запуск

bin/serve.sh list                 # что объявлено
bin/serve.sh start qwen35b-mlx    # поднять (ждёт готовности, потом отдаёт управление)
bin/serve.sh status               # что живо
bin/serve.sh logs qwen35b-mlx -f  # хвост лога
bin/serve.sh stop qwen35b-mlx     # погасить
bin/serve.sh stop --all

Стартует детачем: pid в var/run/<name>.pid, вывод в var/log/<name>.log. Порт переопределяется через --port.

Готовность определяется не таймером, а опросом /v1/models — управление возвращается, когда модель реально загружена и отвечает.

Повторный старт — ошибка, а не второй процесс:

$ bin/serve.sh start qwen35b-mlx
serve.sh: 'qwen35b-mlx' is already running (pid 29404, port 8102) — stop it first: bin/serve.sh stop qwen35b-mlx

Коды выхода: 1 ошибка конфига/аргументов, 3 уже запущено, 4 порт занят чужим процессом, 5 не поднялось за READY_TIMEOUT (по умолчанию 300 с), 6 остановка того, что не запущено.

Проверка из терминала — через прокси, по имени из реестра:

curl -s http://127.0.0.1:8080/v1/qwen35b-mlx/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{"model":"qwen35b-mlx","messages":[{"role":"user","content":"hi"}],"max_tokens":40}'

Ходить напрямую в порт модели можно, но у mlx поле model должно быть абсолютным путём — он резолвит его на каждый запрос и на незнакомое имя полезет в HuggingFace. Прокси эту разницу и скрывает, см. ниже.

Веб-морда

bin/web.sh start   # http://127.0.0.1:8080
bin/web.sh status
bin/web.sh logs -f
bin/web.sh stop

Две вкладки, по ролям:

  • AI Models — чат и сравнение моделей;
  • AI Audio — расшифровка записей, без участия моделей.

AI Models

Чат со стримингом, markdown, метрики под каждым ответом (TTFT, tok/s, токены), переключатель режима think, system prompt / temperature / max tokens, история в localStorage.

Сравнение. Кнопка «+ панель» добавляет колонку со своей моделью и своей историей; промпт из общего поля уходит во все панели сразу, метрики считаются раздельно. Ради этого всё и затевалось.

Состояние моделей морда перечитывает сама раз в 5 секунд: поднял модель в терминале — она появилась во вкладке без перезагрузки. Перерисовка происходит только при реальном изменении состава и никогда посреди генерации.

Морда только читает. Поднимать и гасить модели — через bin/serve.sh.

AI Audio

Перетащить запись или выбрать файл — получить транскрипт. Модель не задействуется: расшифровка и есть результат, её можно скопировать, скачать .txt или отправить в чат кнопкой «В чат →», чтобы попросить резюме.

Пока идёт распознавание, показывается настоящий процент: mlx_whisper ведёт свой счётчик по кадрам аудио, и морда читает его через SSE, а не оценивает по размеру файла.

Разделения по голосам нет. Пробовали — на записях созвонов с телефонной полосой доступные модели эмбеддингов не дают устойчивого результата.

Файлы

Кнопка «Файл…» или перетаскивание в поле ввода. Читает сервер, не браузер: PDF в браузере не разобрать, не притащив стороннюю библиотеку.

текст/код   любой декодируемый (utf-8, utf-16, cp1251, latin-1)
.pdf        pypdf, постранично
.docx       python-docx, включая таблицы
аудио       m4a, mp4, mp3, wav, aac, aiff, caf, amr, m4b, w64, adts

Извлечённый текст возвращается в браузер и вклеивается в сообщение — то есть модель получает обычный промпт. Ничего не хранится: файл разбирается и забывается, чистить нечего.

В истории видно имя и размер, а полный текст лежит под «что ушло модели»: странный ответ иначе не объяснить.

Файл можно отправить без промпта — содержимое и есть сообщение.

Транскрипция. Аудио распознаёт Whisper (whisper-large-v3-turbo), примерно в 14 раз быстрее реального времени. Декодирует afconvert, встроенный в macOS, поэтому ffmpeg не нужен — это же и закрывает форматы с iPhone. Потолок 30 минут: эндпоинт синхронный, часовая запись повесила бы соединение.

Язык лучше указывать явно: автоопределение Whisper на коротких записях врёт — шестисекундный английский сэмпл оно определило как русский.

Контейнеры вроде .mov не поддерживаются: afconvert работает через AudioFile API и видео не открывает. Картинки отклоняются — ни одна из настроенных моделей их не читает, для этого нужен vision-рантайм.

Параметры сэмплинга

Кроме temperature и max tokens в панели есть top_p и штраф за повтор, по умолчанию 0.9 и 1.05. Это не украшательство — без них модель срывается в бесконечный повтор.

Замер на coder30b, промпт «Что ты умеешь делать?», по 10 прогонов:

Настройка Срывов Медиана длины
только temperature 4/10 3704
top_p 0.9 3/10 1139
repetition_penalty в одиночку не помог —
top_p 0.9 + rep 1.05 0/10 533

Нужны оба: по отдельности top_p лишь укорачивает срыв, а штраф без top_p не даёт ничего.

Два наблюдения, которые экономят время при отладке:

  • понижать температуру бесполезно и вредно — при 0.2 срывов было больше, чем при 0.7: чем меньше случайности, тем легче застрять в цикле;
  • на английском срывов не было вообще. Это подавление симптома, а не лечение: coder30b заточена под код, и свободный русский вопрос для неё вне профиля. Для такого лучше qwen35b-mlx.

Режим think

Галка think в «параметрах», по умолчанию выключена. Включённая — модель сначала пишет черновик рассуждения, и он показывается цитатой над ответом: мельче, тусклее, курсивом, чтобы его нельзя было спутать с ответом. Состояние видно и на свёрнутых параметрах — бейдж think вкл / think выкл рядом с заголовком, иначе «почему так долго» стоит клика.

Технически это одно поле в запросе:

"chat_template_kwargs": {"enable_thinking": true}

mlx_lm накладывает его поверх серверного --chat-template-args, так что модель, запущенная с {"enable_thinking":false}, всё равно может подумать по запросу — перезапуск не нужен. Флаг в models.conf остаётся дефолтом, а не приговором.

Флаг запоминается вместе с сообщением, а не читается из текущих настроек: выключив галку позже, вы не сотрёте рассуждение, которое уже сгенерировано.

Цена — токены. Рассуждение идёт из того же max_tokens, и на маленькой модели съедает его целиком: qwen9b-mlx, задачка про сестёр Алисы, лимит 2048 — модель ушла в самопроверку («Wait, is it "сестёр" or "сестры"?»), лимит кончился, ответа не было вовсе. Из-за этого дефолт max tokens поднят с 2048 до 4096: потолок ничего не стоит, пока в него не упираются, а с think в 2048 упираются регулярно. Она же без think: 0.45 с до первого токена, 48 tok/s, ответ за одно предложение. Предупреждение об обрезке в режиме think это и говорит.

Swiftlet флаг игнорирует — его HTTP API рассуждает всегда, независимо от галки (swiftlet chat в терминале блок прячет, сервер — нет). Поэтому пришедшее без спроса рассуждение не выбрасывается, а сворачивается в строку «рассуждение скрыто (N симв.)»: молча съесть минуту генерации значит выдать работающую модель за зависшую.

Не все модели умеют думать. coder30b — instruct-модель, в её chat_template.jinja слова enable_thinking нет вообще, и apply_chat_template молча выбрасывает kwargs, которые шаблон не читает. Галка включена, бейдж синий, эффекта ноль — выглядит как сломанная фича.

Поэтому /api/models отдаёт по каждой модели поле thinking, и берётся оно из самого шаблона, а не из списка имён: имя ничего не гарантирует, шаблон решает.

значение что это кто
optional шаблон читает enable_thinking qwen35b/9b-mlx
none режима рассуждений нет coder30b
always рантайм рассуждает независимо от запроса swiftlet

Когда галка и модель расходятся, панель говорит об этом строкой под заголовком — серой, не жёлтой: ничего не сломано, настройка просто не применима.

Как это устроено

swiftlet-server не шлёт CORS-заголовков и не обрабатывает OPTIONS, поэтому страница с другого origin к нему не достучится. Патчить сабмодуль ради этого не хочется — и следующему рантайму такой патч всё равно не поможет. Поэтому бэкенд в src/server раздаёт статику и проксирует запросы, и всё становится same-origin:

браузер ──► :8080 ├── /                           статика из src/public/
                  ├── /api/models                 реестр + живое состояние
                  └── /v1/<name>/chat/completions ──► :<порт модели>/v1/...

Сегмент <name> в пути — то, на чём держится сравнение: две панели стримят от двух разных моделей через один origin.

/api/models на каждый запрос заново читает models.conf и проверяет процессы через kill(pid, 0). Pid-файл считается заявкой, живость доказывается сигналом, поэтому файл, оставшийся после ребута, не выдаётся за работающую модель.

Зависимостей у прокси нет — только stdlib Python 3. Markdown в морде тоже свой: он строит узлы DOM, а не innerHTML, поэтому разметка из ответа модели не может ничего выполнить.

Слои бэкенда

Роутинг решает, какой контроллер отвечает; контроллер решает, что он отвечает. Байты в сокет пишет только responses.py — контроллер получает Transaction и не знает ни про chunked-фрейминг, ни про то, что читатель мог уйти на середине стрима.

слой отвечает за
main.py аргументы, сигналы, коды выхода
server.py сокет, роутинг
controllers/ что происходит на эндпоинте и какой у него JSON
registry.py models.conf и живость процессов
extractors/ файл → текст для промпта
responses.py как это уезжает в сокет

Экстракторы — интерфейс, а не цепочка if. Каждый формат — класс с типизированными входом и выходом:

class Extractor(ABC):
    kind: ClassVar[str]

    def handles(self, ext: str) -> bool: ...
    def extract(self, request: ExtractRequest) -> Extracted: ...

ExtractRequest несёт байты, имя, язык и необязательный колбэк прогресса; Extracted — текст, вид и метаданные, которые есть только у аудио. Экстрактор ничего не знает про HTTP, поэтому вызывается из теста напрямую. Порядок в EXTRACTORS значим лишь в конце: TextExtractor берёт всё подряд и потому идёт последним. Добавить формат — это новый файл и одна строка в кортеже.

ExtractionError — единственное исключение, которое считается виной файла, а не багом: контроллер отвечает на него 415 с текстом, обращённым к человеку.

Типизация

mypy --strict по всему src/server:

bin/typecheck.sh

Из strict-режима исключены только сторонние импорты без типов — mlx-whisper и python-docx не поставляют их вовсе. Писать под них стабы вручную значило бы поддерживать копию чужого API, которая разойдётся с оригиналом; конфиг в mypy.ini говорит об этом прямо.

Фронтенд

Тоже модули, тоже по одной ответственности на файл — грузится как <script type="module" src="/js/main.js">, без сборщика: браузер сам разрешает импорты, а править исходник и перезагружать вкладку быстрее любого watch-режима.

модуль что делает
main.js собирает всё и связывает обработчики
pane.js панель: модель, история, стрим, метрики
workspace.js набор панелей и веерная отправка промпта
message.js сообщение и его отрисовка
registry.js опрос /api/models, события об изменениях
settings.js параметры сэмплинга и форма
attachments.js файлы, приложенные к следующему сообщению
audio.js вкладка AI Audio
api.js все запросы к бэкенду и общий разбор SSE
dom.js · format.js построение узлов и человекочитаемые числа

Панель владеет своим DOM. Раньше опрос реестра перерисовывал все панели целиком, и приходилось откладывать перерисовку до конца генерации, чтобы не оторвать узлы, в которые пишет стрим, и не потерять прокрутку. Теперь Pane создаётся один раз и на изменение реестра обновляет только список моделей и строку под заголовком — костыль с отложенной перерисовкой исчез вместе с причиной.

Разбор SSE был написан дважды — для чата и для загрузки с прогрессом. Теперь это один асинхронный генератор в api.js, и оба потребителя читают его через for await.

Различия рантаймов, которые пришлось сгладить

Оба говорят «OpenAI-совместимо», но по-разному. Это как раз то знание, ради которого проект и существует.

swiftlet mlx
Поле model в запросе игнорирует резолвит, скачает с HuggingFace незнакомое имя
Рассуждение внутри content, теги <think> отдельное поле delta.reasoning
finish_reason при обрезке всегда "stop" честный "length"
usage в стриме есть нет

Прокси подменяет model на путь, с которым сервер запущен: это и чинит совместимость, и закрывает дыру, где случайное имя превращается в сетевой запрос. Остальное разбирает фронтенд.

MLX по умолчанию не выключает reasoning-режим: без --chat-template-args {"enable_thinking":false} модель может потратить весь лимит на размышления и не дойти до ответа. Поэтому в конфиге он выключен, а включается на один запрос через chat_template_kwargs — см. «Режим think».

Замеры

MacBook Pro M5 Pro, 64 ГБ. Один промпт, одна модель, разные драйверы:

swiftlet mlx
TTFT 3.02 с 1.00 с
Генерация 12.4 tok/s 81.1 tok/s
RSS 0.7–4.8 ГБ 13.8→18.6 ГБ
На диске 18 ГБ 19 ГБ

Qwen3-Next-80B через swiftlet: TTFT 5.2 с, ~9 tok/s, RSS 2.3 ГБ, 42 ГБ на диске.

Prefill у swiftlet идёт с той же скоростью, что и генерация — около 10–15 tok/s, он не батчится. Поэтому большой контекст обходится дорого: 10K токенов промпта это ~15 минут ожидания, 100K — часы. Комфортный потолок 2–4K. Смягчает только кэш диалога: продолжение переписки префиллит лишь новый ход (18 токенов вместо 188 — замер), но кэш слетает, если отредактировать прошлое сообщение или пустить два диалога к одной модели вперемешку.

Известные проблемы

Swiftlet теряет текст после эмодзи. Генерация идёт до конца, но до клиента доходит малая часть: 529 токенов сгенерировано, 109 доставлено. Причина в сравнении графемных кластеров при инкрементальном декодировании. Заведено как leonickson1/Swiftlet#15. Морда это детектит и показывает предупреждение вместо молчаливого обрыва.

Модели срываются в повтор на свободных русских вопросах. Проявляется на coder30b, лечится параметрами сэмплинга (см. выше), но корень — профиль модели, а не настройки.

Swiftlet деградирует на длинных сессиях. За 13–18 часов работы память растёт, появляется своп, рантайм сам ужимает кэш экспертов, и prefill проседает в разы. Лечится перезапуском. Не диагностировано до конца.

Contributors

ZhukV

Issues