VikhrModels · research notes research notes
релизrelease · 12 мая 2026May 12, 2026 · audio-llm · research audio-llm · research

Borealis — как обучить audio LLM по цене макбука Borealis — how to train an audio LLM for the price of a MacBook

Открытая 5B audio-language модель для русского и английского. Открытый исходный код, данные и всё для воспроизведения.

Open 5B audio-language model for Russian and English. Open source, open data, full recipe to reproduce.

СодержаниеContents

Привет! Уже как год у меня готовилась модель Borealis — открытая вариация Voxtral/Flamingo-audio. Сегодня хочу поделиться, как мы учили такую модель с нуля, какие эксперименты у нас получились — а какие нет.

В целом ничего особо нового: Whisper3 large, Qwen 4b как LLM-backbone и адаптер посередине.

Hey. Borealis has been quietly cooking for about a year now — our open take on Voxtral / Flamingo-audio. Today I want to share how we trained it from scratch, what worked, and what didn't.

Nothing especially new on the recipe: Whisper3-large, Qwen 4b as the LLM backbone, and an adapter glued in between.

~5B
параметровparameters
19.17%
средний WER (лучший)avg WER (best run)
RU+EN
языкиlanguages

Зачем нужны audio-LLMWhy audio-LLMs

Классические ASR (Whisper, Wav2Vec2) отлично транскрибируют речь, но не понимают контекст. Спросите Whisper «о чём говорится в этом аудио?» — и получите просто транскрипт. Audio-LLM решают именно эту проблему: они не только слышат, но и понимают.

Classical ASR (Whisper, Wav2Vec2) transcribes speech well — but doesn't understand it. Ask Whisper "what is this audio about?" and you get a transcript. Audio-LLMs close that gap: they hear and reason.

Какие задачи мы учили для audio-LLM:

The tasks we trained the audio-LLM for:

  • Суммаризировать длинные записи
  • Отвечать на вопросы по содержанию
  • Анализировать тон и эмоции
  • Summarize long recordings
  • Answer questions about content
  • Reason about tone and emotion

Архитектура BorealisBorealis architecture

Подход проверенный: сильный аудио-энкодер, сильная LLM и адаптер между ними.

The recipe is well-trodden: a strong audio encoder, a strong LLM, and an adapter between them.

Аудио, 16 кГцAudio @ 16 kHz

waveform → log-mel
входinput

Whisper Large V3 encoder

1280-dim · ~1500 tok / 30 s · 635M params
замороженfrozen

Downsampler 4× + MLP-адаптер4× downsampler + MLP adapter

concat 4 frames → 5120 → 2560 · ~375 tok / 30 s
обучаетсяtrained

Qwen3-4B

causal LLM, fine-tunedcausal LLM, fine-tuned
обучаетсяtrained

Текстовый ответText response

tokens
выходoutput

1Почему именно такWhy this stack

  • Whisper Large V3 — лучший открытый энкодер для речи, особенно для мультиязычных задач.
  • Заморозка энкодера — сохраняем качество ASR. И вообще так делают +- во всех VLM — а мы тут на самом деле учим VLM, только для звука.
  • Downsampling 4× — 1500 токенов → 375. Звук не супер насыщенный информацией канал — лучше сжать.
  • Qwen3-4B — взяли что было.
  • Whisper Large V3 — best open speech encoder, especially for multilingual.
  • Frozen encoder — preserves ASR quality. And basically every VLM does it this way — and we're really just training a VLM, only for audio.
  • 4× downsampling — 1500 → 375 tokens. Audio isn't an information-dense channel, so compressing it pays off.
  • Qwen3-4B — went with what we had on hand.

Итого ~5B параметров, из которых обучаются ~500M — LoRA на LLM + адаптер.

~5B parameters total, of which ~500M are trained — LoRA on the LLM + the adapter.

Датасеты: что мы использовалиTraining data

Для экспериментов мы собрали несколько пулов данных:

We assembled several data pools for the ablations:

ПулPool ДатасетыDatasets РазмерSize НазначениеPurpose
RU audio Speech-Instructions · ToneBooks · AudioBooksInstructGemini2.5 ~150k Русские аудиокниги + синтез-инструкции Gemini 2.5Russian audiobooks + Gemini-2.5 synthetic instructions
EN audio Speech-Describe ~150k Английские аудио с описаниями (речь и не-речь)English audio with descriptions (speech & non-speech)
Text GrandMaster-PRO-MAX var Текстовые инструкции без аудиоText-only instructions
IT Fresh ToneBooksPlus · ToneSpeak · ToneRuLS · ToneRuDevices + Text ~450k Финальный микс для instruction tuningFinal mix for instruction tuning
Long audioLong audio AlexWortega/longaudio · Ficbook-Audio-Instruct-10K ~10k+ Длинные литературные записи и инструкции по фрагментамLong-form literary audio and chunked instructions
ЗаметкаNote

Датасет AudioBooksInstructGemini2.5 — взяли аудиокниги, нарезали на фрагменты и сгенерировали инструкции через Gemini 2.5 Pro: суммаризация, QA, анализ, structured output и т.д. Если интересно как мы это делали — скрипт генерации лежит рядом.

The AudioBooksInstructGemini2.5 dataset comes from audiobooks chunked into clips with instructions generated via Gemini 2.5 Pro — summarization, QA, analysis, structured output and so on. If you want to see how, the generation script sits next to a sibling dataset.

Сетап экспериментовExperimental setup

Главный вопрос: как правильно обучать audio-LLM? Конкретно нас интересовало:

The core question: what's the right way to train an audio-LLM? Specifically:

  1. Нужно ли что-то размораживать?
  2. Как влияет язык данных — RU vs EN?
  3. Помогает ли добавление текстовых инструкций?
  4. Какие пропорции языков и текстовых данных оптимальны?
  1. Do we need to unfreeze anything?
  2. What's the effect of training-data language — RU vs EN?
  3. Does adding plain text instructions help?
  4. What ratios of languages and text data are optimal?

КонфигурацияConfiguration

  • Базовый чекпоинт: AlexWortega/Borealis5b_90k (претренирован на ~90k сэмплов)
  • Железо: 8× GPU
  • Batch: 1 / GPU, grad-accum 16 → effective 128
  • LR: 1e-5
  • Метрика: WER на 6 русских бенчмарках
  • Base checkpoint: AlexWortega/Borealis5b_90k (pretrained on ~90k samples)
  • Hardware: 8× GPUs
  • Batch: 1 / GPU, grad-accum 16 → effective 128
  • LR: 1e-5
  • Metric: WER on 6 Russian benchmarks

📐БенчмаркиBenchmarks

БенчмаркBenchmark ОписаниеDescription СложностьDifficulty
Russian LibriSpeechЧтение книг, чистая речьBook reading, clean speechlow
Common VoiceКраудсорсинг, разные акцентыCrowdsourced, varied accentsmedium
Tone BooksАудиокниги (наш датасет)Audiobooks (our dataset)low
Tone SpeakСпонтанная речьSpontaneous speechmedium
Tone WebinarsВебинары, шум, терминыWebinars, noise, jargonhigh
Sova RuDevicesРечь с устройствOn-device speechhigh

РезультатыResults

01

Русский vs английскийRussian vs English

Как влияет язык обучающих данных? How much does training-data language matter?
МодельModel Steps LibriSpeech CommonVoice Books Avg WER
ru_only 10006.5413.6410.0619.32
en_only 11726.8927.928.5620.88
mixed_large (RU+EN)10006.6526.6911.6922.11
EN-only модель показывает WER 20.88% на русских бенчмарках — и это всего на 1.5 п.п. хуже native-RU.
The EN-only run hits 20.88% WER on Russian benchmarks — only 1.5 pp behind the native-RU run.

Это говорит о сильном кросс-языковом трансфере:

That points to strong cross-lingual transfer:

  • Whisper уже знает русский (он учился на мультиязычных данных).
  • Qwen3 тоже знает русский.
  • Адаптеру достаточно научиться на английском — знания переносятся.
  • Whisper already knows Russian (it was trained multilingually).
  • Qwen3 also knows Russian.
  • The adapter only needs alignment in one language; the rest transfers.

Но native данные всё же дают лучшее качество. И — удивительно — добавление EN к RU делает только хуже.

Native data still wins, though. And surprisingly, mixing EN into RU makes things worse.

Кросс-языковой трансфер работает, но native-данные дают лучшее качество. Не разбавляйте RU английским «для разнообразия».

Cross-lingual transfer works, but native data wins. Don't dilute your target-language data with EN "for diversity".

02

Добавление текстовых инструкцийAdding plain-text instructions

Помогает ли добавить к аудио немного чистого текста? Does mixing in plain text instructions help?
МодельModel Steps LibriSpeech CommonVoice Avg WER
ru_only (0% text) 10006.5413.6419.32
ru_text_10pct 16246.4215.7919.17
ru_text_25pct 10006.3535.0224.02

Интересная нелинейная зависимость:

A non-linear shape:

  • 10% текста — небольшое улучшение (19.32 → 19.17).
  • 25% текста — заметное ухудшение (19.32 → 24.02).
  • 10% text — small improvement (19.32 → 19.17).
  • 25% text — noticeable degradation (19.32 → 24.02).

При 25% текста модель начинает «забывать» аудио-задачу. LLM переключается в режим text-to-text и хуже обрабатывает аудио-эмбеддинги.

At 25%, the model starts forgetting the audio task. The LLM drifts into text-to-text mode and stops fitting the audio embeddings properly.

💡
ВыводTakeaway

10–15% текста помогает, 25% — вредит. У эффекта явно есть оптимум.

10–15% text helps; 25% hurts. There's a clear sweet spot.

03

Проблема вебинаровThe webinar problem

Один бенчмарк — аномальная просадка. One benchmark stays stubbornly bad.
МодельModel Webinars WER
Whisper-v3 7.77
ru_only 61.32
en_only 59.76
ru_text_10pct 60.69

Все наши модели показывают ~60% WER на вебинарах, а чистый Whisper — 7.77%. Что не так?

All our runs sit around 60% WER on webinars while plain Whisper is at 7.77%. What's going on?

Вебинары — это шум, эхо, плохой микрофон, узкая терминология, несколько спикеров и перебивания. Whisper-энкодер с этим справляется, но LLM «портит» результат, пытаясь сделать текст более «правильным» — в итоге получаются плохие результаты.

Webinars come with noise, echo, bad mics, niche jargon, multiple speakers and interruptions. The Whisper encoder handles all of that — but the LLM "over-corrects" the transcript toward something more grammatical, and the result is just bad.

Сводные результатыAggregated results

Все эксперименты в одной таблице — отсортированы по среднему WER:

All experiments in one table, sorted by average WER:

ЭкспериментRun Steps LibriSpeech CommonVoice Webinars Books Speak RuDevices Avg
Whisper-v3 11.6812.237.7711.952.6819.8711.03
IT_fresh (no pretrain) 240014.85181.09100.6744.8324.8342.8168.18
ru_only 10006.5413.6461.3210.061.9422.4319.32
en_only 11726.8927.9259.768.562.0920.0320.88
mixed_large 10006.6526.6962.0911.691.7523.8222.11
ru_text_10pct best 16246.4215.7960.6911.361.9918.7519.17

Средний WER (%) — меньше = лучшеAverage WER (%) — lower is better

avg over 6 benchmarks
0 20 40 60 80 100 ru_text_10pct 19.17 ru_only 19.32 en_only 20.88 mixed_large 22.11 Whisper-v3 (baseline) 11.03 IT_fresh (no pretrain) 68.18
наши прогоныour runs Whisper-v3 контр-экспериментcounter-experiment

Тепловая карта WER по бенчмаркамWER heatmap by benchmark

LibriSpeech CommonVoice Webinars Books Speak RuDevices
Whisper-v3 11.68 12.23 7.77 11.95 2.68 19.87
ru_only 6.54 13.64 61.32 10.06 1.94 22.43
en_only 6.89 27.92 59.76 8.56 2.09 20.03
ru_text_10pct 6.42 15.79 60.69 11.36 1.99 18.75
низкий WERlow WER среднийmedium высокийhigh лучший в столбцеbest in column

Как всё это сервить и интегрировать с transformersServing it all & integrating with transformers

Вообще сервинг мультимодальных моделей — тема не новая. У нас есть быстрая часть (энкодер) и медленная (LLM): энкодер нужно сервить асинхронно, копить логиты, потом закидывать в LLM. У нас так и есть — режем аудио на куски для Whisper и сервим как есть. Остальная часть блога — скучная история про патч vLLM.

Serving multimodal models isn't a new topic. You have a fast part (the encoder) and a slow part (the LLM): run the encoder asynchronously, accumulate the logits, then hand them off to the LLM. That's exactly what we do — chunk audio for Whisper and serve it as-is. The rest of this post is the boring story of patching vLLM.

AАдаптер: simple vs deepAdapter: simple vs deep

В borealis/modeling.py живут две реализации адаптера. Прод-чекпоинт использует simple — двухслойный MLP без bias-векторов:

borealis/modeling.py ships two adapters. The production checkpoint uses the simple one — a two-layer MLP, no biases:

python · AudioLanguageAdapter
class AudioLanguageAdapter(nn.Module):
    # ~31M params for Whisper-large × Qwen3-4B
    def __init__(self, hidden_size: int, dim: int):
        super().__init__()
        self.w_in  = nn.Linear(hidden_size, dim, bias=False)
        self.gelu  = nn.GELU()
        self.w_out = nn.Linear(dim, dim, bias=False)

    def forward(self, x):
        return self.w_out(self.gelu(self.w_in(x)))

Размерности:

Dimensions:

  • encoder.d_model = 1280 · downsample_factor = 4hidden_size = 5120
  • llm.config.hidden_size = 2560 = dim
  • Итого: Linear(5120, 2560) → GELU → Linear(2560, 2560)
  • Параметров: 5120·2560 + 2560·2560 ≈ 19.7M по матрицам; с учётом доп. буферов ~31M
  • encoder.d_model = 1280 · downsample_factor = 4hidden_size = 5120
  • llm.config.hidden_size = 2560 = dim
  • Net: Linear(5120, 2560) → GELU → Linear(2560, 2560)
  • Params: 5120·2560 + 2560·2560 ≈ 19.7M matrices; with buffers ~31M

Есть и более жирный вариант AudioLanguageAdapterDeep на ~80M параметров: expansion_factor=1.5, три transformer-like блока с LayerNorm + GELU + residual + dropout и Xavier-инициализацией. В прод не пошёл — простой MLP справился; держим как задел на будущие эксперименты.

A fatter AudioLanguageAdapterDeep (~80M params) also lives in the repo: expansion_factor=1.5, three transformer-like blocks with LayerNorm + GELU + residual + dropout and Xavier init. Didn't make it to production — the simple MLP was enough — but we keep it for future ablations.

Дальше — downsampling 4×. Это не conv1d и не avg-pool, а простой view: четыре соседних фрейма склеиваются в один с учетверённой размерностью.

Then the 4× downsample. It isn't a conv1d or avg-pool — it's a plain view: four neighboring frames are concatenated into one with 4× the channel dimension.

python · BorealisForConditionalGeneration._downsample
def _downsample(self, seq: torch.Tensor):
    k = self.downsample_factor          # 4
    T, d = seq.shape                    # 1500 × 1280
    target = k * math.ceil(T / k)
    if target != T:
        seq = F.pad(seq, (0, 0, 0, target - T))
    return seq.contiguous().view(target // k, d * k)   # 375 × 5120

Поток токенов: 1500 × 1280 (выход Whisper) → 375 × 5120 (downsample) → 375 × 2560 (адаптер). Эти 375 эмбеддингов и попадают в LLM на место <|AUDIO|>-токенов.

Token flow: 1500 × 1280 (Whisper output) → 375 × 5120 (downsample) → 375 × 2560 (adapter). These 375 embeddings fill the slots of <|AUDIO|> placeholder tokens.

Энкодер — заморожен буквально: encoder.eval(), потом for p in encoder.parameters(): p.requires_grad = False.

The encoder is frozen hard: encoder.eval(), then for p in encoder.parameters(): p.requires_grad = False.

BАугментации звукаAudio augmentations

Модуль borealis/augmentations.py — большая курсорная конструкция: AugmentationPipeline с десятком случайных эффектов и AugmentationScheduler-калбэк, который на разных эпохах включает разные «этапы». Идея простая: на старте — чистое аудио, потом постепенно усложняем.

borealis/augmentations.py is a big curriculum machine: an AugmentationPipeline with a dozen randomized effects, plus an AugmentationScheduler callback that activates different "stages" at different epochs. Start clean, get harsher over time.

Какие операции есть в пайплайне (каждая со своей вероятностью p):

What's in the pipeline (each gated by its own p):

ЭффектEffect ДиапазонRange Что моделируетSimulates
Подмешивание шумаBackground noise mixSNR 18–28 dBкафе, улица, кондиционерcafe, street, A/C hum
Свёртка с IRImpulse-response convреверберация комнат и заловroom & hall reverb
EQ±6 dBразные микрофоныdifferent mic curves
Случайный gainRandom gain±3 dBтихая/громкая речьquiet vs loud speakers
Band-pass150–350 / 3200–5200 Hzдешёвые микрофоныcheap mics
Resample14–20 kHzlow-bandwidth каналыlow-bandwidth channels
TelephonyTelephony8–12 kHz · 180–4200 Hzобычные звонки и колл-центрыphone calls, call-centers
Codec96–160 kbpsMP3/Opus сжатиеMP3/Opus compression
Clipping0.82–0.95пересжатый сигналoverdriven signal
Pitch / Speed±4 st · 0.8–1.2×разные голоса и темпvoice & tempo variation
SpecAugment≤2 freq (27) · ≤2 time (100)регуляризация по mel-спектруmel-spec regularization

AugmentationScheduler — это TrainerCallback, который на on_epoch_begin выбирает текущий AugmentationStage по start_epoch. Так у нас получается «куррикулум»: на первых эпохах модель видит чистое аудио, дальше — всё более жёсткие искажения.

AugmentationScheduler is a HF TrainerCallback; on on_epoch_begin it picks the current AugmentationStage by start_epoch. The result is a curriculum: clean audio first, progressively harsher distortions later.

Послушать «до» и «после»Listen: before & after

Чистая речь — из ToneBooks, шум — сэмпл из Musan-сплита Vikhrmodels/Audio_Noise_Dataset. Микс — то что модель видит на training-time с включённой шумовой аугментацией.

The clean clip comes from ToneBooks; the noise is sampled from the Musan split of Vikhrmodels/Audio_Noise_Dataset. The mix is what the model sees at training time with noise augmentation enabled.

«Вот только они совсем не радовали, а, напротив, потрясали и ужасали.» "Yet they were not pleasing at all — quite the opposite, they shocked and horrified."
① Чистый сэмпл① Clean sample ToneBooks
② Шум один② Noise only Musan
③ Речь + шум③ Speech + noise SNR ~10 dB
④ Телефония④ Telephony 300–3400 Hz · 8 kHz

Третий сэмпл — то, на чём учится модель в тяжёлых эпохах куррикулума: текст почти на грани разборчивости, но именно это и заставляет адаптер не «прилипать» к чистому Whisper-сигналу. Четвёртый — классическая телефонная полоса (300–3400 Hz через resample 8 kHz туда-обратно).

The third clip is what the model trains on in the heavier curriculum stages — text near the edge of intelligibility, which forces the adapter to stop relying on a clean Whisper signal. The fourth is a classic telephone band (300–3400 Hz via an 8 kHz resample round-trip).

CКак мы патчили vLLMPatching vLLM

Самая интересная часть. У vLLM есть закрытый список мульти-модальных архитектур, которые он умеет из коробки: Qwen2-Audio, LLaVA, Phi-4-MM и ещё пара. Borealis в этом списке нет — у нас Whisper-encoder + кастомный адаптер + Qwen3 + два собственных токена в словаре. Чтобы получить ускорение, пришлось писать vLLM plugin.

The most interesting part. vLLM ships with a closed set of multi-modal architectures it knows out of the box: Qwen2-Audio, LLaVA, Phi-4-MM and a few others. Borealis isn't there — we have a Whisper encoder, a custom adapter, Qwen3, and two extra vocab tokens. To get the speedup we had to write a vLLM plugin.

Плагин — пакет vllm_borealis, лежит прямо в репозитории модели на HF. В нём всего два файла:

The plugin is a package called vllm_borealis, sitting next to the weights in the HF model repo. Two files:

  • __init__.py — точка входа. Регистрирует модель в vllm.ModelRegistry.
  • borealis.py — собственно имплементация. ~400 строк, четыре класса для vLLM-API.
  • __init__.py — entry point. Registers the model in vllm.ModelRegistry.
  • borealis.py — the actual implementation. ~400 lines, four classes for the vLLM API.
python · vllm_borealis/__init__.py
def register():
    from vllm import ModelRegistry
    if "BorealisForConditionalGeneration" not in ModelRegistry.get_supported_archs():
        ModelRegistry.register_model(
            "BorealisForConditionalGeneration",
            "vllm_borealis.borealis:BorealisForConditionalGeneration",
        )

vLLM находит register() через entry_points в pyproject.toml (группа vllm.general_plugins) и вызывает при старте. С этого момента строка "BorealisForConditionalGeneration" в config.json модели становится валидным архитектурным именем — как будто это нативная архитектура.

vLLM picks up register() via an entry_points declaration in pyproject.toml (group vllm.general_plugins) and calls it at startup. From that moment, "BorealisForConditionalGeneration" in the model's config.json is a first-class architecture name.

Четыре класса, которые ждёт vLLMThe four classes vLLM expects

  1. BorealisProcessingInfo — описывает модальность. Главное здесь — get_supported_mm_limits() возвращает {"audio": 1}: одно аудио на промпт, больше vLLM просто не пустит. Также отсюда vLLM забирает WhisperFeatureExtractor для конвертации waveform → mel-спектрограмма.
  2. BorealisDummyInputsBuilder — синтезирует «пустое» 30-секундное аудио для warm-up'а и профилирования. vLLM использует это чтобы посчитать максимальный sequence length и зарезервировать KV-cache — иначе он не знает, сколько токенов «весит» одно аудио.
  3. BorealisMultiModalProcessor — самый магический класс. Когда пользователь пишет промпт с одним токеном <|AUDIO|>, процессор расширяет его в реальную последовательность: <|start_of_audio|> + 375×<|AUDIO|> + <|start_of_audio|>. А ещё помечает каждый из этих 375 токенов как «эмбеддинг будет подменён извне» — через PromptUpdateDetails.select_token_id(..., embed_token_id=audio_token_id).
  4. BorealisForConditionalGeneration — собственно модель. Декорирована @MULTIMODAL_REGISTRY.register_processor(...); внутри хранит WhisperEncoder, наш AudioLanguageAdapter и — самое интересное — init_vllm_registered_model(architectures=["Qwen3ForCausalLM"]) вместо переписанной с нуля LLM.
  1. BorealisProcessingInfo — declares the modality. The key line is get_supported_mm_limits() == {"audio": 1}: at most one audio per prompt, vLLM enforces it. It also exposes the WhisperFeatureExtractor for waveform → mel conversion.
  2. BorealisDummyInputsBuilder — synthesizes an "empty" 30-second audio for warm-up and profiling. vLLM uses this to compute the max sequence length and reserve KV-cache — otherwise it has no idea how many tokens an audio is worth.
  3. BorealisMultiModalProcessor — the magic class. When the user writes a prompt with a single <|AUDIO|> token, the processor expands it into the real sequence: <|start_of_audio|> + 375×<|AUDIO|> + <|start_of_audio|>. Each of those 375 tokens is also marked "embedding will be supplied externally" via PromptUpdateDetails.select_token_id(..., embed_token_id=audio_token_id).
  4. BorealisForConditionalGeneration — the model itself. Decorated with @MULTIMODAL_REGISTRY.register_processor(...); holds the WhisperEncoder, our AudioLanguageAdapter and — the cool part — uses init_vllm_registered_model(architectures=["Qwen3ForCausalLM"]) instead of a re-implemented LLM.
Главный трюкThe key trick

Мы не переписываем Qwen3 для vLLM. Мы говорим vLLM «возьми свой собственный оптимизированный Qwen3-блок» через init_vllm_registered_model — и получаем paged-attention, continuous batching и fused-kernels бесплатно. Своё у нас только аудио инпут и адаптер: Whisper → downsample → adapter.

We never re-implement Qwen3 for vLLM. We tell vLLM "give us your own optimized Qwen3 block" via init_vllm_registered_model and get paged-attention, continuous batching and fused kernels for free. The only thing we own is the audio input and adapter: Whisper → downsample → adapter.

python · вместо ручного Qwen3
from vllm.model_executor.models.utils import (
    init_vllm_registered_model, maybe_prefix,
)

llm_config = AutoConfig.from_pretrained("Qwen/Qwen3-4B")
llm_config.vocab_size = 151671            # base 151669 + 2 audio tokens

self.llm = init_vllm_registered_model(
    vllm_config=vllm_config,
    hf_config=llm_config,
    prefix=maybe_prefix(prefix, "llm"),
    architectures=["Qwen3ForCausalLM"],     # vLLM's own optimized impl
)

Магия с токенамиToken-level magic

Главная сложность мульти-модального инференса в vLLM — подменить эмбеддинги отдельных токенов на вычисленные снаружи (вышедшие из адаптера) так, чтобы все остальные оптимизации продолжали работать. vLLM решает это через PromptReplacement + PromptUpdateDetails.select_token_id.

The hard part of multi-modal inference in vLLM is splicing externally-computed embeddings (the adapter output) into specific token positions, without losing any of the other optimizations. vLLM does it via PromptReplacement + PromptUpdateDetails.select_token_id.

python · _get_prompt_updates (упрощённо)
def get_replacement_borealis(item_idx):
    # 30s audio → 1500 mel frames / 4 = 375 audio tokens
    num_features = audio_embeds[item_idx].shape[0]  # or 375 default
    audio_tokens = [audio_token_id] * num_features

    return PromptUpdateDetails.select_token_id(
        [audio_marker_id] + audio_tokens + [audio_marker_id],
        embed_token_id=audio_token_id,   # <-- "these tokens carry external embeddings"
    )

return [PromptReplacement(
    modality="audio",
    target="<|AUDIO|>",             # single placeholder in user prompt
    replacement=get_replacement_borealis,
)]

То есть пользователь пишет один <|AUDIO|> в промпте, а внутри он раздувается в 377 токенов (маркер + 375 + маркер). 375 «настоящих» аудио-токенов получают свои эмбеддинги из адаптера через хук embed_token_id; остальные токены текста идут как обычно через embedding-таблицу LLM.

So the user writes one <|AUDIO|> in the prompt, and internally it inflates to 377 tokens (marker + 375 + marker). The 375 "real" audio tokens get their embeddings from the adapter via the embed_token_id hook; everything else flows through the LLM's normal embedding table.

Несколько мелких подкапотных деталейA few small under-the-hood details

  • Vocab resize. Qwen3-4B имеет словарь 151669. Мы добавили два токена: <|AUDIO|> (id 151669) и <|start_of_audio|> (id 151670). Итого vocab_size = 151671, и плагин падает на эти id'шники если в config.json не нашлось явных.
  • Лишний batch-dim. vLLM иногда отдаёт мел в форме [N, 1, 128, 3000] вместо [N, 128, 3000], потому что мульти-модальные поля он пакует в свою «таблицу». Плагин делает if input_features.dim() == 4 and shape[1] == 1: squeeze(1). Классический футган.
  • merge_by_field_config = True. Этот флаг говорит vLLM: «когда батчуешь запросы, склеивай мульти-модальные поля автоматически». Без него пришлось бы вручную писать collator.
  • Аудио считается один раз. Whisper-encoder и адаптер запускаются один раз на весь generate-цикл, результат живёт в KV-cache LLM наравне с текстовыми токенами. Каждый последующий next-token-step работает уже только в LLM — поэтому overhead аудио-фронтенда амортизируется на длинной генерации.
  • Vocab resize. Qwen3-4B base vocab is 151669. We add two tokens: <|AUDIO|> (id 151669) and <|start_of_audio|> (id 151670). Final vocab_size = 151671; the plugin falls back to those exact ids if not in config.json.
  • Stray batch dim. vLLM sometimes ships mel as [N, 1, 128, 3000] instead of [N, 128, 3000] because it packs multimodal fields into its own table. The plugin guards with if input_features.dim() == 4 and shape[1] == 1: squeeze(1). Classic footgun.
  • merge_by_field_config = True. Tells vLLM to auto-batch multimodal fields when merging requests. Without it you'd write a collator by hand.
  • Audio is computed once. The Whisper encoder + adapter run once per generate call; the resulting 375 embeddings live in the LLM's KV-cache like normal tokens. Every subsequent next-token-step only touches the LLM — so the audio-frontend cost amortizes across long generations.

Откуда 2.1× ускорениеWhere the 2.1× actually comes from

Сравнение нечестное в одну сторону: native-transformers делает eager-attention с динамическим аллокатором, без continuous batching. vLLM добавляет три вещи поверх:

The comparison is unfair in one direction: native transformers does eager attention with dynamic allocation, no continuous batching. vLLM adds three things on top:

  • PagedAttention — KV-cache живёт в page-table, перестают тратиться gpu-минуты на padding и фрагментацию.
  • Continuous batching — запросы разной длины не ждут самого медленного, вливаются «по мере готовности».
  • Fused-kernels Qwen3 — оптимизированные CUDA-ядра внимания и MLP, особенно для bf16.
  • PagedAttention — KV-cache lives in a page table; you stop wasting GPU time on padding and fragmentation.
  • Continuous batching — variable-length requests don't wait for the slowest in the batch.
  • Fused Qwen3 kernels — optimized CUDA kernels for attention and MLP, especially in bf16.

Замер: NVIDIA A100, 30s аудио, max_tokens=128, bf16. Native transformers: 44.9 tok/s. vLLM-плагин: 95.9 tok/s. Это per-request throughput; при батче 4+ относительная победа vLLM ещё растёт.

Measurement: NVIDIA A100, 30s audio, max_tokens=128, bf16. Native transformers: 44.9 tok/s. vLLM plugin: 95.9 tok/s. That's per-request throughput; with batch ≥4 the gap widens further.

Полный сорс плагинаFull plugin source

Всё что выше — упрощённые версии. Реальный код: vllm_borealis на 🤗 (~400 строк). Если хочется адаптировать тот же подход под свою audio/video-LLM — это хороший reference.

Everything above is simplified. The real source is vllm_borealis on 🤗 (~400 lines). If you want to adapt the same pattern to your own audio/video-LLM, that's a solid reference.

Практические рекомендацииPractical recommendations

1Всегда используйте претрейнAlways start from a pretrain

Без претрейна модель не сойдётся за разумное время. Нет готового чекпоинта — сначала обучите на большом объёме простых ASR-данных (чистая транскрипция), потом переходите к instruction tuning.

Without a pretrain, the model won't converge in reasonable time. No checkpoint? Start with a large ASR-style pretrain (plain transcription) before moving to instruction tuning.

2Начинайте с native-языкаStart native

Кросс-языковой трансфер работает, но native-данные дают лучшее качество. Для русской модели — собирайте русские аудио.

Cross-lingual transfer works, but native data wins. For a Russian model — collect Russian audio.

3Добавляйте текст, но немногоAdd text — but only a little

10–15% текстовых инструкций в миксе улучшают качество. 25% — уже регрессия.

10–15% of plain-text instructions in the mix improves quality. 25% regresses.

4Не смешивайте языки аудиоDon't mix audio languages

Микс RU+EN аудио не дал улучшений по сравнению с чистым RU. Похоже, языки «конкурируют» за capacity.

An RU+EN audio mix didn't beat pure RU. Languages seem to compete for capacity.

5Для шумных аудио нужен отдельный подходPlan a separate path for noisy audio

Если ваш use-case — митинги или колл-центры, либо отдельный fine-tune под этот домен, либо fallback на Whisper. Один общий ckpt — не вытянет.

If your use case is meetings or call-centers, either fine-tune separately for that domain or fall back to Whisper. One general checkpoint won't cover both.

Ограничения BorealisLimitations

Честно о том, что модель пока не умеет:

Being honest about what the model can't do yet:

  • Аудио длиннее ~30 секунд — нужно резать на чанки на стороне приложения.
  • Сильно зашумленные записи — WER деградирует.
  • Streaming — только offline-обработка.
  • Несколько аудио в одном промпте — пока limit = 1.
  • Audio longer than ~30s — caller must chunk it.
  • Heavy noise — WER degrades.
  • Streaming — offline only for now.
  • Multiple audio per prompt — limit = 1.

Как попробоватьHow to try it

Минимальный инференс через transformers:

Minimal inference via transformers:

python
from transformers import AutoModel
import torchaudio

model = AutoModel.from_pretrained(
    "Vikhrmodels/Borealis-5b-it",
    trust_remote_code=True,
    device="cuda",
)

audio, sr = torchaudio.load("audio.wav")
if sr != 16000:
    audio = torchaudio.functional.resample(audio, sr, 16000)

output = model.generate(
    audio=audio.squeeze(),
    user_prompt="О чём говорится в этом аудио? <|start_of_audio|><|end_of_audio|>",
    system_prompt="Ты полезный голосовой ассистент.",
    max_new_tokens=256,
)
print(model.decode(output[0]))

Также есть Colab-ноутбук и Gradio-демо — ссылки в репозитории. Для продакшена рекомендуем vLLM (2× быстрее):

There is also a Colab notebook and a Gradio demo — see the repo. For production we recommend vLLM (2× faster):

bash
# install
pip install vllm>=0.12.0

# serve
vllm serve Vikhrmodels/Borealis-5b-it \
  --trust-remote-code \
  --dtype bfloat16

Обучение audio-LLM для русского — решаемая задача, но с нюансами. Кратко: претрейн критичен, native-данные лучше, текст помогает в меру, и шум — отдельная проблема. Borealis — не идеальная модель, но solid baseline для русскоязычных audio-LLM-задач. Надеемся, эта статья сэкономит кому-то GPU-часы.

Training an audio-LLM for Russian is doable but nuanced. In one line: pretrain is critical, native data wins, a little text helps, and noise is its own problem. Borealis isn't perfect — but it is a solid open baseline for Russian audio-LLM work, and we hope this post saves someone a few hundred GPU-hours.