惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Attack and Defense Labs
Attack and Defense Labs
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
T
Threatpost
Project Zero
Project Zero
Know Your Adversary
Know Your Adversary
T
The Exploit Database - CXSecurity.com
P
Palo Alto Networks Blog
T
Tenable Blog
Scott Helme
Scott Helme
T
Tor Project blog
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
NISL@THU
NISL@THU
Cisco Talos Blog
Cisco Talos Blog
Security Latest
Security Latest
Simon Willison's Weblog
Simon Willison's Weblog
S
Securelist
Help Net Security
Help Net Security
Google DeepMind News
Google DeepMind News
Cloudbric
Cloudbric
C
Check Point Blog
Jina AI
Jina AI
Webroot Blog
Webroot Blog
量子位
博客园 - 三生石上(FineUI控件)
小众软件
小众软件
P
Privacy & Cybersecurity Law Blog
罗磊的独立博客
H
Heimdal Security Blog
C
CXSECURITY Database RSS Feed - CXSecurity.com
人人都是产品经理
人人都是产品经理
N
News and Events Feed by Topic
www.infosecurity-magazine.com
www.infosecurity-magazine.com
宝玉的分享
宝玉的分享
Hacker News - Newest:
Hacker News - Newest: "LLM"
L
LINUX DO - 热门话题
The GitHub Blog
The GitHub Blog
T
Troy Hunt's Blog
PCI Perspectives
PCI Perspectives
Vercel News
Vercel News
N
News | PayPal Newsroom
A
Arctic Wolf
T
The Blog of Author Tim Ferriss
博客园 - 司徒正美
博客园 - 叶小钗
Y
Y Combinator Blog
V
V2EX
美团技术团队
O
OpenAI News
Microsoft Security Blog
Microsoft Security Blog
AWS News Blog
AWS News Blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Qwen3.5 на двух V100, reverse SSH вместо Cloudflare в Telegram Mini App: собираю AI-репетитора английского
Михаил · 2026-06-01 · via Все публикации подряд на Хабре

13 мин

6.7K

У меня в углу комнаты стоит сервер с двумя Tesla V100 32GB. Они доcтались мне для другой задачи, которая отвалилась, и полгода стояли мёртвым грузом. Параллельно я в очередной раз пробовал заниматься английским — Simpler, Doalingo, ещё пара продуктов. Хорошие, но мне не подходил формат: я хотел сценарий «открыл телефон дома на семь минут, поговорил, закрыл». Без расписания, без камеры, без поиска тьютора, который понимает мой акцент с пятого раза.

Сошлось.

Идея: Telegram Mini App, в нём кнопка «говорить», за ней — AI-репетитор, который слышит, что я сказал, отвечает голосом, помнит контекст разговора, тыкает в мои повторяющиеся ошибки и подбрасывает слова, которые я пытаюсь выучить. Полностью бесплатно.

Модель Qwen3.5 вышла 25 февраля , я её гоняю всего несколько недель, продукт сырой. Эта статья — про архитектурные решения и про то, на какие грабли я уже успел наступить.

TL;DR

  • Что: Telegram Mini App для разговорной практики английского. Два режима голоса.

  • Local-режим: vLLM с QuantTrio/Qwen3.5-35B-A3B-AWQ (35B total / 3B active, MoE, 25 GiB AWQ INT4) на 2× Tesla V100 32GB + faster-whisper + Kokoro-82M, всё на тех же картах.

  • Сетевой стык: домашний сервер ↔ VPS через reverse SSH (autossh + systemd, restricted user с permitopen=localhost:23333). Никакого Cloudflare Tunnel, никакого Tailscale.

  • Главные мины: FlashAttention 2 на Volta не работает (нужен XFORMERS backend); bf16 на Volta тоже не работает (нужен --dtype float16); QuantTrio AWQ-сборка Qwen3.5 молча игнорирует enable_thinking=false в запросе (нужны серверные флаги vLLM); macOS Chrome не даёт второй AudioContext на 16 kHz если первый на 24 kHz (нужно ручной downsampling в AudioWorklet)

Архитектура одной картинкой

┌──────────────────────────────┐                ┌────────────────────────────────────┐
│ Дом (V100 server)            │                │ VPS (public IP)                    │
│                              │                │                                    │
│  ┌────────────────────────┐  │  reverse SSH   │  ┌──────────────────────────────┐  │
│  │ vLLM :23333            │◀─┼────────────────┼─▶│ FastAPI backend              │  │
│  │ Qwen3.5-35B-A3B-AWQ    │  │  autossh +     │  │  /api/* + /ws/voice          │  │
│  │ tensor-parallel = 4    │  │  systemd       │  └──────────────────────────────┘  │
│  └────────────────────────┘  │                │  ┌──────────────────────────────┐  │
│  ┌────────────────────────┐  │                │  │ aiogram 3 bot (long poll)    │  │
│  │ faster-whisper :23334  │◀─┼────────────────┤  └──────────────────────────────┘  │
│  └────────────────────────┘  │                │  ┌──────────────────────────────┐  │
│  ┌────────────────────────┐  │                │  │ Mini App (React+Vite SPA)    │  │
│  │ Kokoro TTS :23335      │◀─┼────────────────┤  └──────────────────────────────┘  │
│  └────────────────────────┘  │                │       ▲                            │
└──────────────────────────────┘                │       │  Nginx + Let's Encrypt     │
                                                │       │                            │
                                                │  ┌────┴─────────────────────────┐  │
                                                │  │  MySQL 8  +  Redis 7         │  │
                                                │  └──────────────────────────────┘  │
                                                └───────┬────────────────────────────┘
                                                        │  HTTPS / WSS
                                                        ▼
                                                ┌───────────────┐
                                                │   Telegram    │
                                                │   client      │
                                                └───────────────┘

Три поддомена за общим Nginx на VPS:

englishbot.<мой домен>      → Mini App (React SPA в контейнере с nginx внутри)
api.<мой домен>     → FastAPI backend (/api/* + /ws/*)
admin.<мой домен>   → Admin Panel (React SPA)

Бэкенд, бот, Mini App и админка — четыре сервиса в одном docker-compose.yml на VPS. MySQL и Redis пока живут прямо на хосте VPS. LLM, STT и TTS — на домашнем сервере с GPU. Между ними — reverse SSH-туннель.

Почему четыре V100 и почему Qwen3.5-35B-A3B-AWQ

V100 — это Volta, compute capability 7.0, релиз 2017. По меркам 2026 года — олдскул. Никакого FlashAttention 2, никакого bf16, на ROCm и Triton-контейнерах их выбрасывают как класс.

Из бенчмарка E2E Networks (вышел в марте 2026, sweep по vLLM/TGI/TensorRT-LLM на 7B–70B):

«V100 is vLLM-only. TGI, TensorRT-LLM, and Triton's latest containers reject Volta GPUs entirely.»

Это, кстати, и ответ на вопрос «почему vLLM, а не TGI» — никакого выбора у меня и не было. На 2×V100 PCIe в FP16 авторы того же отчёта получили 400,2 tok/s на Llama-2-7B (p50 latency 2 559 мс, gen_len 256) и 228,8 tok/s на Llama-2-13B (p50 latency 4 475 мс). Для одного-двух одновременных голосовых разговоров — более чем.

Теперь модель. Я честно пробовал Llama-3.1-8B и Qwen2.5-14B; обе для тьютора английского ощущались «вежливыми, но плосковатыми». Когда вышел Qwen3.5, я сразу пошёл смотреть, что есть в AWQ — потому что класть 70B в FP16 на 4×32 GB после KV-cache и оверхеда vLLM физически не хватает.

QuantTrio/Qwen3.5-35B-A3B-AWQлёг как родной. Архитектура — это, на мой взгляд, самое интересное в Qwen3.5. Дословно из карточки модели:

«Number of Parameters: 35B in total and 3B activated. Number of Layers: 40. Hidden Layout: 10 × (3 × (Gated DeltaNet → MoE) → 1 × (Gated Attention → MoE)). Mixture Of Experts: Number of Experts: 256. Number of Activated Experts: 8 Routed + 1 Shared. Context Length: 262,144 natively and extensible up to 1,010,000 tokens.»

Перевожу: из 40 слоёв только 10 — это полноценное Gated Attention. Остальные 30 — Gated DeltaNet, линейная attention. KV-cache считается только для тех самых десяти, и считается не квадратично по длине контекста. Грубо: при 16 query-головах и 2 KV-головах с head_dim=256 на каждый слой full-attention выходит около 2 × 2 × 256 × 2 байт = 2 KB на токен на слой, всего ~20 KB/токен на десять слоёв. На 262 144 токена контекста — около 5,4 GB на сессию в FP16. При tensor-parallel-size=2 это ~2,7 GB на карту на одну сессию. На 32 GB V100 — реально.

Грабля №1: FlashAttention и Volta

Первый запуск vLLM на V100 встретил меня логом:

Cannot use FlashAttention-2 backend for Volta and Turing GPUs.
Using XFormers backend.

Это нормально. Это даже не варнинг — это просто факт жизни. Из апстрима Dao-AILab/flash-attention, issue #1760:

«I understand that FlashAttention 2+ requires SM80+, which V100s don't support.»

И отдельно, vLLM issue #6173:

«RuntimeError: FlashAttention only supports Ampere GPUs or newer.»

Решение: явно ставим бэкенд XFORMERS. Не FLASH_ATTN, не FLASHINFER, не FLASHMLA. На Volta из живых вариантов — XFORMERS и TORCH_SDPA. TRITON_ATTN существует, но его основная отладка идёт на AMD ROCm и Intel XPU; на Volta это untested path, я туда не полез. Из блога vLLM по Triton-backend (март 2026):

«Since the Triton attention backend is always present, it also serves as a fallback backend, if FlashAttention, FlashInfer, or other dependencies are unavailable or fail to import.»

То есть он fallback, не первичный выбор. На V100 — XFORMERS.

Грабля №2: bf16 на Volta тоже не работает

Если запустить vLLM без явного --dtype, на V100 получишь:

Bfloat16 is only supported on GPUs with compute capability of at least 8.0.
Your Tesla V100-SXM2-32GB GPU has compute capability 7.0.
You can use float16 instead by explicitly setting the dtype flag in CLI,
for example: --dtype=half

Поэтому в start.sh стоит --dtype float16. Никаких сюрпризов от автоматического подбора dtype.

Грабля №3: AWQ + concurrency — резкое падение throughput

Это самое неприятное и не очень обсуждаемое. vLLM issue #20469, по AWQ-сборке Qwen3-14B на одном A100 80GB:

«We observed a significant drop in output tokens per second when serving Qwen/Qwen3-14B-AWQ on a single A100 80GB GPU using vLLM v0.9.1 with --max-model-len 16384. At concurrency=1, the model achieves ~52 output tokens per second. However, this drops sharply to ~12 at concurrency=5 and ~3 at concurrency=25.»

Перевожу: на одном запросе ты получаешь 52 tok/s, на пяти параллельных — 12, на 25 — три. Это AWQ Marlin-ядра ведут себя плохо под concurrency. На Volta с tensor-parallel=4 ситуация другая (TP помогает), но иллюзий про линейное масштабирование строить не нужно.

Практический вывод для бота: на free-tier с 10 минутами в день одного пользователя одновременно говорящих будет немного. Если пойдёт нагрузка — придётся либо ставить вторую инстанцию vLLM на другой набор карт, либо запускать в FP16 без квантизации (но тогда 35B физически не влезет). Это та самая «знаю, что неидеально» точка.

vLLM start.sh — без сокращений

/home/user/1Cat-vLLM/start_vllm.sh:

#!/usr/bin/env bash
set -e
cd "$(dirname "$0")"
source venv/bin/activate

exec python -m vllm.entrypoints.openai.api_server \
    --model QuantTrio/Qwen3.5-35B-A3B-AWQ \
    --quantization awq \
    --dtype float16 \
    --gpu-memory-utilization 0.90 \
    --max-model-len 262144 \
    --tensor-parallel-size 4 \
    --max-num-seqs 8 \
    --max-num-batched-tokens 65536 \
    --attention-backend XFORMERS \
    --skip-mm-profiling \
    --limit-mm-per-prompt '{"image":0,"video":0}' \
    --compilation-config '{"cudagraph_mode":"full_and_piecewise","cudagraph_capture_sizes":[1,2,4,8,16,32]}' \
    --disable-custom-all-reduce \
    --reasoning-parser qwen3 \
    --default-chat-template-kwargs '{"enable_thinking": false}' \
    --host 0.0.0.0 \
    --port 23333

Построчно:

  • --quantization awq --dtype float16 — единственный рабочий вариант на Volta.

  • --gpu-memory-utilization 0.90 — десять процентов оставляю буфером, всё остальное под веса и KV-cache.

  • --max-model-len 262144 — нативный контекст Qwen3.5. Можно опустить до 32 768 — авторская карточка модели прямо это рекомендует для скромных конфигов («--max-model-len 32768 --tensor-parallel-size 4 --enable-expert-parallel»). Я держу 262K, потому что на бота-репетитора большой контекст приходит не часто, а когда приходит — он очень нужен.

  • --tensor-parallel-size 4 — все четыре карты в один pipeline. Официальный гайд Qwen Team рекомендует TP=8, но у меня их физически четыре.

  • --max-num-seqs 8 --max-num-batched-tokens 65536 — крыша по параллельным сессиям и токенам в одном батче.

  • --attention-backend XFORMERS — см. граблю №1.

  • --limit-mm-per-prompt '{"image":0,"video":0}' --skip-mm-profiling — multimodal я не использую, отключаю профилирование памяти.

  • --disable-custom-all-reduce — кастомный all-reduce на старых интерконнектах PCIe Gen3 ведёт себя нестабильно, родной NCCL надёжнее.

  • --compilation-config — захват CUDA-graphs для типовых размеров батча. Холодный старт чуть дольше, hot path заметно быстрее.

  • --reasoning-parser qwen3 --default-chat-template-kwargs '{"enable_thinking": false}' — про это отдельная грабля, см. ниже.

Грабля №4: enable_thinking=false в QuantTrio молча игнорируется

Qwen3.5 — reasoning-модель. В голосовом боте это катастрофа: TTS озвучит ход рассуждения целиком. «Hmm, let me think. The user asked me about... well, in English we usually say...» — и пять секунд тишины пока модель додумает.

Документация Qwen говорит: добавляй в запрос chat_template_kwargs.enable_thinking=false и всё. Документация vLLM подтверждает: можно прокинуть через extra_body. По факту в QuantTrio AWQ-сборке это просто не срабатывает.

vLLM issue #35574 (закрыт после фикса в апстриме):

«The vllm/vllm-openai:qwen3_5 image is deployed. Describe the bug: Adding "chat_template_kwargs": {"enable_thinking": false} to the HTTP body does not disable the thinking process.»

HuggingFace discussion по Qwen3.5-27B:

«WARNING 02-25 15:46:28 [protocol.py:51] The following fields were present in the request but ignored: {'enable_thinking', 'extra_body'}»

И вишенка прямо из карточки QuantTrio:

«Qwen3.5 does not officially support the soft switch of Qwen3, i.e., /think and /nothink. Qwen3.5 will think by default before response.»

То есть soft-switch через /no_think в промпте не работает, через extra_body тоже игнорируется, а модель всё равно думает.

Решение, которое в итоге заработало надёжно: серверные флаги vLLM --reasoning-parser qwen3 плюс --default-chat-template-kwargs '{"enable_thinking": false}'. Это аргументы запуска самого сервера, а не отдельного запроса — vLLM применяет их ко всему трафику, и <think>...</think> теги либо не генерируются вовсе, либо отрезаются парсером перед отдачей в content.

Defense in depth: на стороне бэкенда я ещё дополнительно прогоняю ответ через regex, который выгрызает <think>...</think>. Если что-то протекло — TTS не получит. Это паранойя, но цена ошибки выше цены трёх строк кода.

Reverse SSH вместо Cloudflare Tunnel

В 2026 году, если ты хочешь выставить домашний сервис наружу через VPS, тебе на каждом углу советуют Cloudflare Tunnel или Tailscale Funnel. Я выбрал reverse SSH, и сейчас объясню почему.

Cloudflare Tunnel. Технически работает. Но: бесплатный тариф формально запрещает streaming-нагрузку, а у меня по туннелю прогоняется голосовой WebSocket. Плюс Cloudflare декриптит HTTPS на edge — то есть в plaintext видит каждый запрос:

«When you use Cloudflare Tunnel, Cloudflare decrypts your HTTPS traffic at their edge servers, inspects it, and then re-encrypts it before forwarding to your origin. This means Cloudflare can see the plaintext content of every request and response passing through your tunnel.»

Для большинства проектов это не проблема. Для голосового потока с initData-токенами Telegram — мне это просто не нравится.

Tailscale Funnel. Публичный доступ через инфраструктуру Tailscale-релея. Хорошо для не-критичных вещей, но это ещё одна третья сторона в пути от Telegram к моей V100.

Reverse SSH через autossh + systemd. Старо, проверено, понятно. V100 сама коннектится наружу к VPS под отдельным пользователем tunnel, у которого нет shell (command="/bin/false"), нет TTY, и которому разрешён ровно один port-forward (permitopen="localhost:23333"). Если кто-то украдёт ключ — он сможет открыть ровно этот один порт на VPS, и больше ничего.

VPS-side /home/tunnel/.ssh/authorized_keys:

command="/bin/false",no-agent-forwarding,no-X11-forwarding,no-pty,permitopen="localhost:23333" \
  ssh-ed25519 AAAA...KEY... v100-to-vps-tunnel

В /etc/ssh/sshd_config:

AllowTcpForwarding yes
ClientAliveInterval 30
ClientAliveCountMax 3

V100-side /etc/systemd/system/vllm-tunnel.service:

[Unit]
Description=Reverse SSH tunnel V100 -> VPS (vLLM :23333)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=user
Environment="AUTOSSH_GATETIME=0"
Environment="AUTOSSH_PORT=0"
ExecStart=/usr/bin/autossh -M 0 -N -T \
  -o "ServerAliveInterval=30" \
  -o "ServerAliveCountMax=3" \
  -o "ExitOnForwardFailure=yes" \
  -o "StrictHostKeyChecking=accept-new" \
  -o "TCPKeepAlive=yes" \
  -i /home/user/.ssh/vps_tunnel \
  -R 23333:localhost:23333 \
  tunnel@<vps-ip>
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

Что тут происходит:

  • autossh -M 0 — отключаем встроенный monitor port autossh, полагаемся на родной ServerAliveInterval openssh. На современных версиях ssh это работает надёжнее.

  • AUTOSSH_GATETIME=0 — коннект сразу при старте, не ждём «успешного» периода.

  • ServerAliveInterval=30, ServerAliveCountMax=3 — если три heartbeat-а подряд не ответили (90 секунд), процесс умирает.

  • ExitOnForwardFailure=yes — если VPS-порт занят другим процессом, мы громко падаем, а не висим молча. systemd перезапустит.

  • Restart=always, RestartSec=10 — после смерти процесса ждём 10 секунд и поднимаемся снова.

Бэкенд на VPS видит vLLM просто как http://localhost:23333/v1. Поскольку бэкенд живёт в Docker, в docker-compose.yml ему нужен extra_hosts для разрешения хоста:

backend:
  build: ./backend
  extra_hosts:
    - "host.docker.internal:host-gateway"
  # ...

И в .env:

LLM_PROVIDER=vllm
VLLM_BASE_URL=http://host.docker.internal:23333/v1
VLLM_MODEL_NAME=QuantTrio/Qwen3.5-35B-A3B-AWQ

Никаких TLS-сертификатов между V100 и VPS — всё внутри SSH-туннеля и так шифровано. Никаких DNS-записей для V100 — у неё нет ни одной публично известной точки входа.

Честная оговорка: это single point of failure. Если V100 упала, или интернет дома моргнул, или autossh подвис на десять секунд — локальный voice-режим недоступен. Для pet-проекта на ранней стадии я считаю это приемлемым; на проде я бы поставил вторую инстанцию в другом физическом месте и балансер. Но до этого пока далеко.

Один контраргумент, который мне присылали: «autossh редундантен, современный openssh сам умеет heartbeat». Это правда — если правильно настроить ServerAliveInterval, openssh упадёт сам, и systemd его поднимет. autossh даёт более явный exit code и пара дополнительных опций для retry — мне с ним комфортнее, но systemd-only вариант тоже валиден.

Локальный voice-режим: faster-whisper + Kokoro-82M

STT — faster-whisper. Это обёртка над CTranslate2 над OpenAI Whisper. По сравнению с whisper.cpp (winner на CPU/edge) faster-whisper на GPU быстрее и проще интегрируется в Python-пайплайн. Используется INT8 квантизация для economy или FP16 для качества — я в первой версии оставил FP16.

TTS — Kokoro-82M. Это сейчас, на мой взгляд, лучший open-weight TTS до миллиарда параметров. На 19 мая 2026 на Artificial Analysis TTS Leaderboard Kokoro v1.0 имеет ELO 1056 и занимает 5-е место среди open-weight моделей (топ-1 — Fish Audio S2 Pro с ELO 1128). 82M параметров, лицензия Apache 2.0, обучена на ~100 часах разрешённого аудио — для модели такого качества это уникальное сочетание.

Латентность: по официальным замерам авторов CodeSOTA на NVIDIA A100 — RTF 0.03 (10 секунд аудио синтезируется за 0,3 секунды). На V100 я ожидаю в районе RTF 0.05–0.10, но прямых публичных бенчмарков для Kokoro на V100 нет — это extrapolated цифра, не верьте на слово, я буду мерить и опубликую.

Голос — af_heart, sample rate 24 kHz, моно, PCM s16le. Между моим бэкендом и Kokoro-сервером — WebSocket с маленьким понятным протоколом:

client → server:  {"type": "config", "voice": "af_heart", "speed": 1.0, "lang": "a"}
server → client:  {"type": "ready"}
client → server:  {"type": "text", "text": "How was your weekend?"}
server → client:  {"type": "audio", "data": "<base64 PCM chunk>"}   # 40 ms каждый
server → client:  {"type": "audio", "data": "..."}
...
server → client:  {"type": "done"}
client → server:  {"type": "close"}

Чанки по 40 мс (960 семплов при 24 kHz) — компромисс между frequency message-passing и плавностью воспроизведения в браузере. Сам Kokoro не стримит (это offline-модель), но я режу её вывод на чанки на стороне сервера — это даёт фронту что-то проигрывать пока остальное синтезируется.

Реализация сервера — простой FastAPI + KPipeline + один asyncio.Lock на доступ к pipeline. Лок нужен потому что KPipeline не thread-safe; сериализация запросов в одну очередь — это нормально, синтез всё равно быстрее realtime даже на одной карте.

Telegram initData и WebSocket-аутентификация

Каждое подключение Mini App к /ws/voice валидирует подпись Telegram initData. Схема стандартная (https://core.telegram.org/bots/webapps), но в неё многие не вписывают одну важную проверку.

Алгоритм:

  1. data_check_string = все ключи initData кроме hash, отсортированные по ключу, в формате key=value, склеенные через \n.

  2. secret_key = HMAC_SHA256(bot_token, "WebAppData") (порядок аргументов важен: bot_token — это data, "WebAppData" — это key).

  3. computed_hash = HMAC_SHA256(data_check_string, secret_key).hex().

  4. Сравниваем computed_hash с hash из initData. Не совпало — закрываем коннект с кодом 4001.

И, что забывают:

  1. Проверяем auth_date — не старше 5 минут. Без этой проверки можно перехватить initData и переиспользовать вечно.

Реализация в FastAPI Depends, около 20 строк:

import hashlib
import hmac
import time
from urllib.parse import parse_qsl

BOT_TOKEN = settings.BOT_TOKEN.encode("utf-8")
SECRET_KEY = hmac.new(b"WebAppData", BOT_TOKEN, hashlib.sha256).digest()
INIT_DATA_TTL_SEC = 300


def validate_init_data(init_data: str) -> dict | None:
    """Возвращает распарсенный initData или None если подпись/время не сошлись."""
    parsed = dict(parse_qsl(init_data, strict_parsing=True))
    received_hash = parsed.pop("hash", None)
    if not received_hash:
        return None

    # Проверка времени
    auth_date = int(parsed.get("auth_date", 0))
    if abs(time.time() - auth_date) > INIT_DATA_TTL_SEC:
        return None

    # Проверка подписи
    data_check_string = "\n".join(
        f"{k}={v}" for k, v in sorted(parsed.items())
    )
    computed = hmac.new(
        SECRET_KEY, data_check_string.encode("utf-8"), hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(computed, received_hash):
        return None

    return parsed

И в обработчике:

@app.websocket("/ws/voice")
async def voice_ws(websocket: WebSocket):
    init_data = websocket.query_params.get("init_data", "")
    parsed = validate_init_data(init_data)
    if parsed is None and settings.ENVIRONMENT == "production":
        await websocket.close(code=4001)
        return
    await websocket.accept()
    # ... дальше run_gemini_session или run_local_session

В development-режиме валидация отключается — это нужно для wscat/websocat-тестов локально. В production — невалидная подпись или просроченный auth_date = 4001.

Что я знаю, что неидеально

Список фейлов и архитектурных мин, на которые мне будет приятно получить критику в комментариях:

  • Reverse SSH = single point of failure. Упала V100 — упал локальный voice-режим.

  • vLLM в одном инстансе. Нет реплик, нет балансера. При concurrency >5 на AWQ-сборке throughput резко падает (см. граблю №3).

  • MySQL 8 на одном инстансе. Нет master-replica, нет PITR-бэкапов, только nightly dumps.

  • Нет E2E voice-тестов. Тестируется отдельно: STT, LLM, TTS, WS. Интеграция целиком — только руками.

  • Kokoro RTF на V100 — оценочный, прямых публичных замеров нет, я буду публиковать свои.

  • Нет собственных throughput-метрик Qwen3.5-35B-A3B-AWQ на 2×V100, пока. Модель свежая, нагрузки нет — мерить нечего; ожидаю собрать данные за несколько недель боевой эксплуатации и опубликовать отдельно.

Что дальше

Сейчас я в режиме «работает, дальше копим живую нагрузку и метрики».

Если хочется потрогать руками — бот: @kmo_ai_english_bot

Буду рад жёстким комментариям, особенно по граблям.

Источники, на которые я ссылался по ходу статьи:

  • QuantTrio/Qwen3.5-35B-A3B-AWQ model card — huggingface.co/QuantTrio/Qwen3.5-35B-A3B-AWQ

  • E2E Networks LLM Inference Benchmark — e2enetworks.com (vLLM, TGI, TensorRT-LLM на A30 vs V100)

  • vLLM Recipes — Qwen3.5 & Qwen3.6 Usage Guide — docs.vllm.ai

  • vLLM issues #20469 (AWQ concurrency drop), #35574 (enable_thinking игнорится), #6173 (FlashAttention on Volta)

  • 0110.be — Resampling audio via a Web Audio API Audio Worklet

  • DeepWiki / marcinmatys/whisper_streaming — audio processing chapter

  • Artificial Analysis TTS Leaderboard — artificialanalysis.ai/text-to-speech/leaderboard

  • Telegram Mini Apps initData spec — core.telegram.org/bots/webapps

  • vLLM Triton Attention Backend Deep Dive — vllm.ai/blog/2026-03-04