Узнайте, как сократить задержку LLM-пайплайна с 3.2 до 1.1 секунды: VAD-фильтрация, удаление классификатора, кэширование промптов и выбор модели. Примените советы сейчас!
Представьте: вы в видеозвонке, собеседник задаёт вопрос, а вы ждёте ответ от ИИ-ассистента. Три секунды — это вечность, когда человек ждёт вашей реакции. В этой статье я разберу, как я оптимизировал свой десктопный оверлей для интервью, сократив время до первого токена с 3.2 до 1.1 секунды. Вы узнаете, какие компоненты пайплайна на самом деле критичны, и как применить эти приёмы в своих проектах.
Исходная архитектура выглядела так: захват аудио (loopback) → PCM → WebSocket STT → транскрипт → классификатор «вопрос ли это?» → LLM → потоковый ответ. Итого ~3.2 секунды до первого токена. Четыре места для оптимизации, но только два из них оказались решающими.
Первая победа — не про带宽, а про эндпоинтинг на стороне STT-сервера. Если вы передаёте непрерывный аудиопоток, сервер не видит чёткой паузы и задерживает фиксацию сегмента. Решение — детектор речи (VAD) в AudioWorklet, работающий на сыром сигнале до нормализации:
this._vadThresh = 0.005; // RMS: выше — речь
this._hangoverSec = 1.0; // продолжать отправку после падения уровня
this._prerollMax = 14; // ~600ms @48k буферизованной тишины
Три параметра, каждый — из-за конкретной проблемы:
Последний пункт — разница между «что такое индекс базы данных» и «то такое индекс базы данных». И LLM уверенно отвечает на второй вариант неправильно.
ElevenLabs Scribe v2 Realtime поддерживает commit_strategy: 'vad', что позволяет серверу фиксировать сегмент после паузы, не дожидаясь вашего запроса:
commit_strategy: 'vad',
vad_silence_threshold_secs: '0.6',
В сочетании с клиентским VAD транскрипт приходит, пока собеседник ещё делает вдох. Один острый момент: лимит на keyterm biasing — 50. Если отправить 51, сокет закроется с кодом 1008 и сообщением, которое вы не увидите, если не логируете причины закрытия. Я потерял вечер на этом.
Долгие звонки означают обрывы соединения. Переподключиться легко, а вот правильно — нет. Типичный сбой: происходит reconnect, и пока он выполняется, пользователь меняет язык, что вызывает ещё один reconnect. Теперь существуют два сокета. Старый всё ещё доставляет события, новый — настоящее соединение, и транскрипт перемешивается с мусором от обоих. Или хуже: устаревший побеждает, а новый молча игнорируется — аудио идёт, но ничего не появляется, и нет ни одной ошибки.
Решение — счётчик поколений:
const myGen = ++this.gen; // это соединение
// ...позже, в каждом обработчике:
if (myGen !== this.gen) return; // новое соединение заменило нас
Каждое асинхронное продолжение проверяет, актуально ли оно. Всё, что приходит от устаревшего сокета, отбрасывается. Четыре строки, которые устранили целый класс проблем «просто перестало работать через 20 минут».
В пайплайне была дешёвая модель, которая решала, стоит ли тратить вызов дорогой модели на ответ. Это стоило ~200мс и казалось очевидно правильным. Но это была худшая часть системы. Не из-за задержки, а из-за ошибок. Реальные разговоры полны уточнений, которые не являются вопросами в изоляции:
«Что такое SOLID?» — ответ — «А вторая буква?»
«А вторая буква?» классификатор оценивает как не-вопрос. Он молчал именно тогда, когда контекст делал намерение очевидным. Точность была высокой, но ошибки были катастрофическими и происходили в самые ценные моменты.
Я удалил классификатор. Основная модель теперь видит каждое завершённое высказывание плюс недавний контекст и решает сама — у неё есть контекст, которого у классификатора не было. Это убрало 200мс и исправило проблему уточнений. Две победы от удаления кода.
Вместо классификатора я поставил простой клиентский гейт:
const AUTO_SILENCE_MS = 900; // нет «?» — пауза в середине предложения
const AUTO_UTTEREND_MS = 120; // заканчивается на «?» — реагировать почти сразу
Если буфер заканчивается вопросительным знаком, предложение завершено — запускаем через 120мс. Иначе ждём 900мс на случай, если собеседник думает. Никаких вызовов моделей, никаких сетевых запросов.
Системный промпт содержит сжатую карточку резюме, поэтому он немаленький. Кэширование промптов в Anthropic решает эту проблему, но стандартный TTL — 5 минут, а в интервью бывают паузы длиннее. Интервьюер говорит, кандидат думает, и кэш тихо истекает. Вы платите полную цену как раз за вопросы, идущие после долгой паузы.
Два блока, оба с TTL 1 час:
const CACHE_1H = { type: "ephemeral", ttl: "1h" } as const;
// блок 1: статическая персона — общая для всех сессий и пользователей
// блок 2: данные сессии (CV-карта, роль) — стабильны для одного интервью
Разделение статического и сессионного важно: блок 1 одинаков для всех, поэтому он «тёплый» ещё до первого вопроса пользователя. Если жадно интерполировать переменные (например, язык ответа) в этот блок, вы разобьёте одну запись кэша на шестнадцать.
Аутентификация, rate limit, проверка квоты, парсинг тела — всё это независимые операции, но выполнялись последовательно. Я объединил их в Promise.all:
const [, accessRes] = await Promise.all([
enforceRateLimit(db, userId, "ask", 15, 60),
assertAccess(db, userId, "ask"),
assertDailyCap(db, userId),
req.json().then((b) => { body = b; }),
]);
Экономия ~92мс без изменения гарантий. Не гламурно, но лучшее соотношение усилий и результата во всём списке.
Я предполагал, что нужна самая мощная модель. Измерив на 10 технических вопросах, получил:
В 3.4 раза дешевле и на 3.4 секунды быстрее. Цена — примерно одна фактическая ошибка на 5–6 вопросов и чуть более грубый текст. Для реального времени, где поздний ответ бесполезен, выбор очевиден.
Важна и точка контроля: клиент запрашивает модель, но сервер владеет списком разрешённых. Обновления выходят вручную, поэтому клиентский дефолт означал бы, что все установленные копии вечно запрашивают дорогую модель.
Время от конца предложения до первого токена сократилось с ~3.2с до ~1.1с. Разбивка по источникам:
Два самых больших выигрыша пришли от удаления компонента и от параметров в 40-строчном аудио-ворклете. Ноль — от самого вызова LLM, на который я потратил первую неделю.
Логируйте коды закрытия сокетов с первого дня. Лимит keyterm в 1008 был невидим целый день, потому что причина закрытия не отображалась.
Относитесь к «молчаливой остановке» как к стандартному сбою. Каждый тихий путь — устаревший сокет, истёкший кэш, проглоченный catch — выглядит как «работает» снаружи. Счётчик поколений и сторожевой таймер на потоке существуют потому, что сначала оба отказали молча.
Измеряйте, прежде чем считать модель узким местом. Она составляла 200мс из 3.2с.
Возьмите свой пайплайн и проверьте четыре вещи: есть ли VAD-гейтинг на клиенте? Используете ли вы серверный эндпоинтинг STT? Нужен ли вам отдельный классификатор или основная модель справится сама? Какая модель реально нужна по метрикам, а не по ощущениям? Начните с VAD — это самый большой выигрыш без изменения логики приложения.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →