Песочница для сравнения инструментов, которые крутят 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 --recursivecd 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.qpack18 ГБ, около 8 минут. Контейнер — это директория, а не файл: плотное ядро в
model.safetensors плюс packed_experts/ с блобами по слоям.
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-mlx19 ГБ.
Для распознавания аудио — модель Whisper:
var/venv/bin/hf download mlx-community/whisper-large-v3-turbo \
--local-dir var/models/whisper-large-v3-turbo1.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 — расшифровка записей, без участия моделей.
Чат со стримингом, markdown, метрики под каждым ответом (TTFT, tok/s, токены),
переключатель режима think, system prompt / temperature / max tokens,
история в localStorage.
Сравнение. Кнопка «+ панель» добавляет колонку со своей моделью и своей историей; промпт из общего поля уходит во все панели сразу, метрики считаются раздельно. Ради этого всё и затевалось.
Состояние моделей морда перечитывает сама раз в 5 секунд: поднял модель в терминале — она появилась во вкладке без перезагрузки. Перерисовка происходит только при реальном изменении состава и никогда посреди генерации.
Морда только читает. Поднимать и гасить модели — через bin/serve.sh.
Перетащить запись или выбрать файл — получить транскрипт. Модель не
задействуется: расшифровка и есть результат, её можно скопировать, скачать
.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 выкл рядом с
заголовком, иначе «почему так долго» стоит клика.
Технически это одно поле в запросе:
"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 проседает в разы. Лечится перезапуском. Не диагностировано до конца.