Модуль 16 · факультатив

Мультимодальность и голосовые агенты

Необязательный модуль: остальной курс и капстоун на него не опираются, его можно пройти в любой момент после модулей 2 и 9 или пропустить. Внутри — vision как инженерная задача (где VLM заменяет OCR-пайплайн, а где уверенно врёт в цифрах) и голосовые агенты: каскад STT → LLM → TTS против speech-to-speech, бюджет латентности до первого звука, перебивание с корректной историей диалога, транспорт и evals. Код — конвейер голосового хода на горутинах и каналахвиртуальных потоках и очередях с тестами без реальных API.

≈ 6 часовGoJava2 визуализациина момент написания: октябрь 2026

Vision-модели и понимание документов

VLM (vision-language model, модель «зрение + язык») режет изображение на патчи, превращает их в визуальные токены и обрабатывает вместе с текстом тем же трансформером. Как отправить картинку или PDF в API и сколько это стоит, разобрано в модуле 2 (мультимодальность). Здесь vision — инженерная задача: что поручать модели, где она ошибается и как это ловить до того, как ошибка попадёт в базу.

Что умеют

ЗадачаЧто получаетсяГде ломается
Понимание документа без OCR (OCR-free): сканы, фото, страницы PDFТекст со структурой, ответы на смысловые вопросы («какая сумма к оплате с учётом скидки»), печати и сноски в контекстеМелкий шрифт после уменьшения, рукописный текст, повороты
ТаблицыСтроки, столбцы, объединённые ячейки → JSON или Markdown. Ровно то, что классический парсер превращает в кашу из чисел (модуль 5)Значение уезжает в соседний столбец, теряется строка на стыке страниц
Графики и диаграммыТренды, сравнение серий, подписи; качественные выводы («выручка упала в Q3») надёжныТочные значения по осям приблизительны; логарифмическая шкала, две оси Y
Скриншоты UIЧто на экране, где ошибка, какой элемент нажать; основа computer use и разбора баг-репортов со скриншотамиКоординаты приблизительны, мелкие иконки и состояния (disabled, выбран)
Формы, счета, чекиПоля по JSON-схеме через structured outputsПерепутанные цифры, «додуманные» значения пустых полей

Режимы отказа

Стоимость

Формула и порядок величин — в модуле 2. Две поправки на 2026 год. Первая: у моделей с высоким разрешением та же картинка может стоить до трёх раз больше визуальных токенов, чем на стандартном уровне. Вторая: число токенов на изображение ограничено сверху (1568 на стандартном уровне, 4784 на высоком), поэтому огромный скан стоит не дороже «потолка», зато и читается хуже. Пример из документации: изображение 1000×1000 — 1296 токенов, на Opus 5 ($5 за 1M входа) это около $6.5 за тысячу картинок. Главный рычаг — отправлять только нужное: кроп области, нужные страницы, разрешение, выбранное по eval, а не на глаз.

VLM или OCR-пайплайн

Классический пайплайн (модуль 11): OCR-движок → анализ разметки страницы (layout) → правила или модель для полей. VLM делает это за один вызов. Выбор — по точности, цене и контролю.

КритерийOCR-пайплайнVLM напрямую
Сложная вёрстка, таблицы, графики, смешанные документыХрупко: правило под каждый форматСильная сторона
Миллионы страниц чистой печатиДёшево и быстро, хватает CPUДорого: тысячи токенов на страницу
Координаты и уверенность по символамЕсть: bounding box и confidenceКоординаты приблизительны, confidence нет
Детерминизм и аудитОдин вход — один выходНедетерминизм, нужны eval и версии промпта
Смысл («к оплате с учётом скидки»)Нужна ещё одна модель поверхИз коробки
Новый формат документаНовые правила или разметкаЧасто работает сразу

Гибрид — частый продакшн-вариант. OCR даёт текст и координаты, VLM получает изображение страницы вместе с OCR-текстом и извлекает поля, а расхождение между ними — повод отправить документ человеку. Ещё проще маршрутизация: цифровые PDF с текстовым слоем идут в обычный парсер, сканы и фото — в VLM.

Извлечение с проверкой

Выход VLM — недоверенный ввод. Structured outputs (модуль 2) гарантируют форму, а смысл проверяет код: суммы строк сходятся с итогом, даты разбираются, контрольные суммы реквизитов верны. Полезный приём — просить числа строкой «как напечатано» и разбирать их самим. Тогда модель только переписывает символы, а арифметика детерминирована и покрыта тестом (examples/m16-voice/invoice, функция Kopecksexamples/java/m16-voice, метод Invoice.kopecks разбирает «12 345,67» в копейки без float).

/**
 * Проверка полей, которые VLM извлекла из скана счёта.
 * Выход модели — недоверенный ввод: суммы сверяются арифметикой, а не на глаз.
 *
 * <p>Ответ модели по JSON-схеме. Суммы — строкой «как напечатано»: модель только переписывает,
 * а разбор числа детерминирован и тестируется. date — YYYY-MM-DD.
 */
public record Invoice(String number, String date, List<Line> lines, String total) {

    public record Line(String name, String amount) {}

    /** Все найденные расхождения; пустой список — счёт сходится. */
    public List<String> validate() {
        var errs = new ArrayList<String>();
        if (number == null || number.isBlank()) {
            errs.add("нет номера");
        }
        try {
            LocalDate.parse(date);
        } catch (DateTimeParseException | NullPointerException e) {
            errs.add("дата \"" + date + "\": " + e.getMessage());
        }
        long sum = 0;
        for (int i = 0; i < lines.size(); i++) {
            try {
                sum += kopecks(lines.get(i).amount());
            } catch (IllegalArgumentException e) {
                errs.add("строка " + (i + 1) + ": " + e.getMessage());
            }
        }
        try {
            long t = kopecks(total);
            if (errs.isEmpty() && sum != t) {
                // Классика VLM: перепутанные 3/8, потерянный разряд, строка прочитана дважды.
                errs.add("сумма строк " + sum + " ≠ итог " + t + " (коп.)");
            }
        } catch (IllegalArgumentException e) {
            errs.add("итог: " + e.getMessage());
        }
        return errs;
    }

    // kopecks(String) разбирает «12 345,67» в копейки без float — см. examples/java/m16-voice
}
// Invoice — ответ модели по JSON-схеме. Суммы — строкой «как напечатано»:
// модель только переписывает, а разбор числа детерминирован и тестируется.
type Invoice struct {
	Number string `json:"number"`
	Date   string `json:"date"` // YYYY-MM-DD
	Lines  []Line `json:"lines"`
	Total  string `json:"total"`
}

type Line struct {
	Name   string `json:"name"`
	Amount string `json:"amount"`
}

// Validate возвращает все найденные расхождения; nil — счёт сходится.
func (inv Invoice) Validate() error {
	var errs []error
	if strings.TrimSpace(inv.Number) == "" {
		errs = append(errs, errors.New("нет номера"))
	}
	if _, err := time.Parse(time.DateOnly, inv.Date); err != nil {
		errs = append(errs, fmt.Errorf("дата %q: %w", inv.Date, err))
	}
	var sum int64
	for i, l := range inv.Lines {
		k, err := Kopecks(l.Amount)
		if err != nil {
			errs = append(errs, fmt.Errorf("строка %d: %w", i+1, err))
		}
		sum += k
	}
	total, err := Kopecks(inv.Total)
	if err != nil {
		errs = append(errs, fmt.Errorf("итог: %w", err))
	} else if len(errs) == 0 && sum != total {
		// Классика VLM: перепутанные 3/8, потерянный разряд, строка прочитана дважды.
		errs = append(errs, fmt.Errorf("сумма строк %d ≠ итог %d (коп.)", sum, total))
	}
	return errors.Join(errs...)
}

Если проверка не прошла: повтор с кропом проблемной области в большем разрешении, затем другая модель, затем очередь на ручную проверку. Доля документов, ушедших к человеку, — полноценная метрика: это и стоимость, и ранний сигнал дрейфа входных данных.

Аналогия из JavaVLM здесь — парсер с недокументированными багами. Вы же не поручаете Jackson readValue проверку бизнес-инвариантов: после десериализации идёт Bean Validation (@Valid) или явный validate(), и ошибка валидации — это 422 с ProblemDetail, а не 500. JSON-схема играет роль типов, сходимость сумм — роль валидации.
Аналогия из GoVLM здесь — парсер с недокументированными багами. Вы же не поручаете json.Unmarshal проверку бизнес-инвариантов: после разбора идёт Validate(), и ошибка валидации — это 422, а не паника. JSON-схема играет роль типов, сходимость сумм — роль валидации.

Evals для vision

Голос: каскад или speech-to-speech

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

Каскад STT → LLM → TTS

VAD (voice activity detection, детектор речи) и STT (speech-to-text, распознавание) превращают речь в текст. Текстовая LLM отвечает, используя RAG и инструменты, а TTS (text-to-speech, синтез) озвучивает ответ. Каждое звено — отдельный сервис, часто от разных вендоров.

Speech-to-speech (realtime)

Одна модель принимает аудио и сразу генерирует аудио. Конец реплики, перебивание и вызов инструментов обрабатываются внутри одной сессии у провайдера. Текст пользователя появляется только как побочная транскрипция, если её включить. Отдельная ветка — полнодуплексные модели, которые слушают и говорят одновременно (Moshi, Défossez et al., 2024).

КритерийКаскадSpeech-to-speech
ЛатентностьСумма звеньев плюс швы между ними; при стриминге на каждом шаге — порядка секунды (примерный бюджет в Voice AI primer — около 1,3 с), лучшие сборки быстрееМеньше швов, но endpointing, сеть и «обдумывание» моделью остаются
КонтрольТекст на каждом шаге: guardrails, PII-редакция, проверка политики до озвучки, правка произношенияМеньше точек вмешательства: проверить ответ до того, как он прозвучал, сложнее
Tool use и рассуждениеЛучшая текстовая модель под задачу и тот же агентный код, что в модуле 6По наблюдениям авторов Voice AI primer, S2S-модели следуют инструкциям и вызывают инструменты менее надёжно, чем текстовые
Логирование и evalsТранскрипт и текст ответа есть всегда, текстовые evals работают без измененийНужны записи аудио и отдельная транскрипция; по тому же primer, её надёжности не хватает для комплаенса и evals
Голос и просодияИнтонация пользователя теряется на STT, TTS о ней не знаетСильная сторона: интонация, эмоции, естественные паузы, понимание тона
ЯзыкиПод каждый язык — свой лучший STT и TTS; смешение языков в одной фразе — слабое местоПо тому же primer, заметно лучше справляется со смешанной речью
СтоимостьСумма трёх тарифов, экономить можно на каждом звенеАудиотокены дороже текстовых: у OpenAI для gpt-realtime-2.1 вход стоит $32 за 1M аудиотокенов против $4 за 1M текстовых (прайс на октябрь 2026)
Привязка к вендоруКаждое звено заменяемоОдна модель — один провайдер

Практическое правило на 2026 год: по наблюдению авторов primer'а, продакшн-агенты в основном каскадные. Speech-to-speech выбирают, когда естественность разговора важнее контроля (компаньоны, практика языка, демо) или когда смешанная речь — норма. Бывают и гибриды: S2S-модель ведёт разговор и вызывает «тяжёлую» текстовую модель как инструмент. Решение, как всегда, — по eval на своих сценариях, а не по демо.

Где здесь ClaudeВ Claude API на октябрь 2026 нет аудиовхода и аудиовыхода, поэтому Claude работает LLM-звеном каскада. Для каскада это типичная форма: то же звено, что в текстовом агенте проекта A, только с другим промптом и жёстким бюджетом латентности.

Провайдеры по категориям (октябрь 2026)

Рынок меняется быстрее курса, поэтому здесь категории и по 1–2 проверенных примера. Цифры вендоров — это их заявления, а не ваш бюджет: меряйте на своём трафике.

КатегорияПримерыНа что смотреть
Стриминговый STTDeepgram Flux — STT со встроенным определением конца реплики (события EndOfTurn, EagerEndOfTurn, TurnResumed); OpenAI gpt-live-transcribe (gpt-4o-transcribe объявлен устаревшим, удаление из API — февраль 2027)Частичные гипотезы, endpointing, WER на ваших терминах, звук 8 кГц для телефонии
STT на своих GPUWhisper (открытые веса, Radford et al., 2022)Модель обрабатывает аудио окнами по 30 с, стриминг строится надстройками; инференс — модуль 10
TTSElevenLabs eleven_flash_v2_5 (~75 мс по заявлению вендора, без учёта сети и приложения); Cartesia SonicВремя до первого звука, стриминговый вход по фразам, таймкоды слов, произношение чисел и имён
Speech-to-speechOpenAI Realtime API (gpt-realtime-2.1; WebRTC, WebSocket, SIP); Gemini Live API (WebSocket)Встроенные VAD и перебивание, tool use, качество транскрипции, цена аудиотокенов
ОркестрацияPipecat (open source); LiveKit Agents (open source, поверх WebRTC)Определение конца реплики, перебивания, транспорт, готовые адаптеры к провайдерам
VAD и конец репликиSilero VAD (по README — меньше 1 мс на чанк от 30 мс на одном потоке CPU); turn detector в LiveKit Agents; Smart Turn в PipecatЛожные срабатывания на шум, какие языки понимает модель конца реплики
ТелефонияTwilio Media Streams (звук μ-law 8 кГц по WebSocket); SIP-транкиУзкая полоса, DTMF, перевод на оператора

Бюджет латентности голосового хода

В чате пользователь видит, что ответ печатается. В голосе у него есть только тишина, а тишина в разговоре — сигнал: собеседник не понял, завис или связь оборвалась. Поэтому главная метрика голосового агента — voice-to-voice latency: время от момента, когда пользователь замолчал, до первого звука ответа в его динамике.

На что ориентироваться

Ориентиры задают порядок, а не норматив. Что приемлемо для вашего сценария, решают ваши данные: доля перебиваний, брошенных звонков, успешно решённых задач.

Из чего складывается

СегментЧто происходитРычаги
Аудиостек и сеть ↑Микрофон, кодек (Opus), jitter buffer, доставка до сервераWebRTC вместо TCP, регион рядом с пользователем
EndpointingЖдём, пока тишина подтвердит конец реплики. Часто самый большой и самый незаметный в трейсах сегментМодель конца реплики вместо голой тишины, адаптивный порог
STTСтриминговый STT распознаёт по ходу речи, после конца реплики остаётся финализация. Batch-STT начинает работу только после конца фразыСтриминг, заранее открытое соединение
LLM TTFTPrefill промпта, retrieval, рассуждениеМаленькая быстрая модель, без thinking или с низким effort, prompt caching (модуль 9), retrieval параллельно с другими шагами
Первая фразаTTS нужна законченная фраза, иначе интонация будет неестественнойКороткая первая фраза, заданная промптом
TTS до первого звукаСинтез первого чанкаБыстрая модель, стриминговый вход, кэш частых фраз
Сеть ↓ и воспроизведениеДоставка, jitter buffer, динамикТо же, что для пути вверх

Endpointing — главный компромисс. Короткий порог тишины ускоряет ответ, но агент начинает перебивать человека, который задумался посреди фразы: «мой номер заказа… эээ… 4815». Длинный порог безопасен, но медленен. Для ориентира: Pipecat по умолчанию подтверждает конец речи после 0,2 с тишины по VAD и затем спрашивает модель Smart Turn, закончил ли человек мысль; в примере OpenAI для server_vad стоит 500 мс тишины. Поэтому и появились модели конца реплики, которые учитывают смысл и интонацию, а не только паузу: semantic_vad у OpenAI ждёт дольше после незаконченной фразы и отвечает быстрее после законченной, turn detector в LiveKit Agents, Smart Turn в Pipecat, Flux у Deepgram.

Приёмы

Бюджет латентности голосового хода

От момента, когда пользователь замолчал, до первого звука ответа в его динамике. Двигайте длительности этапов, включайте приёмы и сравнивайте каскад со speech-to-speech. Длительности — порядок величин для экспериментов, а не бенчмарк; аудиостек клиента взят по таблице из Voice AI primer. Свои цифры берите из трейсов.

Как мерить

Аналогия из JavaБюджет хода устроен как SLO цепочки RPC между Spring-сервисами: p95 суммы не равен сумме p95. Если пять сегментов независимо попадают в свой хвост в 5% случаев, хотя бы один хвост случится почти в каждом четвёртом ходе (1 − 0,95⁵ ≈ 23%). Поэтому бюджет задают на итог (p95 до первого звука, один Timer в Micrometer), а по сегментам и спанам OpenTelemetry ищут, кто его съедает.
Аналогия из GoБюджет хода устроен как SLO цепочки RPC в Go-сервисе: p95 суммы не равен сумме p95. Если пять сегментов независимо попадают в свой хвост в 5% случаев, хотя бы один хвост случится почти в каждом четвёртом ходе (1 − 0,95⁵ ≈ 23%). Поэтому бюджет задают на итог (p95 до первого звука), а по сегментам ищут, кто его съедает.

Barge-in: перебивание

Barge-in — пользователь начинает говорить, пока агент ещё отвечает. Люди делают это постоянно: уточняют, поправляют, говорят «нет-нет, мне другое». Агент, который не умеет замолкать, договаривает 15 секунд в пустоту, а пользователь повторяет громче. Задача распадается на пять шагов.

  1. Обнаружить речь пользователя во время собственного ответа: VAD на микрофоне работает и тогда, когда говорит агент.
  2. Подтвердить, что это перебивание, а не шум: минимальная длительность речи, минимальное число распознанных слов, классификатор «перехват хода или поддакивание».
  3. Отменить всё, что ещё в работе: генерацию LLM, синтез TTS и, главное, звук, уже лежащий в буферах.
  4. Обрезать историю до фактически произнесённого.
  5. Слушать новую реплику как обычный ход.

Что отменять: звук в буферах

К моменту перебивания LLM обычно уже дописала ответ, TTS синтезировала его быстрее реального времени, а сервер отправил клиенту звук на секунды вперёд. Остановить генерацию мало: пользователь продолжит слышать буфер. Звук нужно останавливать там, где он играет.

Что попадает в историю

СтратегияЧто будет дальше
Весь сгенерированный ответМодель «помнит», что назвала окно доставки, которого пользователь не слышал, и через ход скажет «как я уже говорила, с 10 до 14». Самая частая ошибка.
Всё отправленное клиентуТа же ошибка, только меньше: в буфере клиента были секунды звука.
Ответ удалён целикомМодель не знает, что начала отвечать, и повторяет тот же текст с начала.
Фактически прозвучавшее + отметка обрываПравильно: модель видит, на чём её перебили, и отвечает на уточнение. В коде ниже текст обрезается до последнего доигранного чанка и заканчивается многоточием.

Точность обрезки ограничена выравниванием текста и звука. TTS с таймкодами слов позволяет резать по слову, без них — по чанку или приблизительно; OpenAI прямо пишет, что realtime-модель не может точно выровнять транскрипт и звук. Для истории точности до слова хватает.

Действия не отменяются вместе со звукомЕсли до перебивания агент уже вызвал reschedule_delivery, доставка перенесена, даже если подтверждения никто не услышал. Результаты инструментов остаются в истории независимо от обрезки речи: состояние мира и сказанное вслух — разные вещи. Для действий с побочными эффектами нужны подтверждение до вызова и ключи идемпотентности (модуль 6).

Ложные срабатывания

Компромисс тот же, что у endpointing. Строгие фильтры — и агент реагирует медленнее, иногда договаривая поверх человека. Мягкие — и он замолкает от каждого шороха. Удобный приём — двухступенчатая реакция: по сигналу VAD сразу приглушить звук или поставить его на паузу, а отменять ответ, только когда STT подтвердил слова.

Barge-in: что отменяется и что попадает в историю

Агент отвечает про доставку. Выберите, что услышал микрофон и когда, настройте фильтры и посмотрите, когда стихнет звук, сколько текста было сгенерировано, отправлено и реально прозвучало, и что окажется в истории диалога.

прозвучало целикомоборвано на серединене прозвучалоречь пользователя
Аналогия из JavaBarge-in — это отмена подписки в Spring WebFlux, когда клиент закрыл соединение: сигнал cancel должен дойти до каждого этапа конвейера, и каждый обязан на него отреагировать. В блокирующем коде на виртуальных потоках то же самое делает Thread.interrupt() каждого потока хода. Разница в том, что «закрыть соединение» здесь может только голос, а решение об отмене принимает VAD, который иногда ошибается.
Аналогия из GoBarge-in — это r.Context() HTTP-хендлера, когда клиент закрыл соединение: одна отмена должна дойти до каждой горутины конвейера, и каждая обязана на неё отреагировать. Разница в том, что «закрыть соединение» здесь может только голос, а решение об отмене принимает VAD, который иногда ошибается.

Конвейер голосового хода на Go

Конвейер голосового хода на Java

Каскад естественно ложится на виртуальные потоки Java 21: этап — виртуальный поток, стрим — блокирующая очередь (маленький класс Chan поверх LinkedBlockingQueue с закрытием), отмена — Thread.interrupt() всех потоков хода. Тот же конвейер можно собрать на Reactor (Flux, нарезка на фразы через bufferUntil, TTS через concatMap, отмена через dispose()), но блокирующий вариант проще читать и отлаживать. Полный код с тестами лежит в examples/java/m16-voice. Внешние API не нужны: провайдеры заменены фейками с настраиваемыми задержками, а Claude-звено вынесено в отдельный адаптер.

Каскад естественно ложится на Go: этап — горутина, стрим — канал, отмена — context. Полный код с тестами лежит в examples/m16-voice. Внешние API не нужны: провайдеры заменены фейками с настраиваемыми задержками, а Claude-звено вынесено в отдельный адаптер.

/** interrupted — ответ оборван пользователем: в text только произнесённое. */
public record Message(String role, String text, boolean interrupted) {
    public Message(String role, String text) {
        this(role, text, false);
    }
}

/** isFinal == false — частичная гипотеза, ещё может измениться. */
public record Transcript(String text, boolean isFinal) {}

/**
 * Кусок синтезированного звука и текст, который в нём звучит
 * (выравнивание: многие TTS-API отдают таймкоды слов или символов).
 */
public record Chunk(byte[] audio, String text, Duration dur) {}

/** Латентность, отсчитанная от конца речи пользователя. */
public record Timings(
        Duration stt,   // финальный транскрипт (endpointing + финализация)
        Duration llm,   // первый токен ответа
        Duration tts,   // первый чанк синтезированного звука
        Duration audio, // начало воспроизведения: voice-to-voice без сети и аудиостека
        boolean interrupted) {}

@FunctionalInterface
public interface Sink<T> {
    void send(T v) throws InterruptedException;
}

public interface Stt {
    void stream(Chan<byte[]> audio, Sink<Transcript> out) throws Exception;
}

public interface Llm {
    void stream(List<Message> history, Sink<String> out) throws Exception;
}

public interface Tts {
    void stream(String text, Sink<Chunk> out) throws Exception;
}

/** Проигрывает чанк и возвращает управление, только если чанк прозвучал целиком. */
public interface Player {
    void play(Chunk c) throws InterruptedException;
}
type Transcript struct {
	Text  string
	Final bool // false — частичная гипотеза, ещё может измениться
}

// Chunk — кусок синтезированного звука и текст, который в нём звучит
// (выравнивание: многие TTS-API отдают таймкоды слов или символов).
type Chunk struct {
	Audio []byte
	Text  string
	Dur   time.Duration
}

// Контракт всех реализаций: выходной канал закрывается в конце и при отмене ctx,
// отправка в канал всегда через select с ctx.Done() — иначе отмена оставит висящие горутины.
// ponytail: ошибки посреди стрима не передаются, в проде — поле Err в событии.
type STT interface {
	Stream(ctx context.Context, audio <-chan []byte) (<-chan Transcript, error)
}

type LLM interface {
	Stream(ctx context.Context, history []Message) (<-chan string, error)
}

type TTS interface {
	Stream(ctx context.Context, text string) (<-chan Chunk, error)
}

// Player проигрывает чанк и возвращает nil, только если чанк прозвучал целиком.
type Player interface {
	Play(ctx context.Context, c Chunk) error
}

// Timings — латентность, отсчитанная от конца речи пользователя.
type Timings struct {
	STT         time.Duration // финальный транскрипт (endpointing + финализация)
	LLM         time.Duration // первый токен ответа
	TTS         time.Duration // первый чанк синтезированного звука
	Audio       time.Duration // начало воспроизведения: voice-to-voice без сети и аудиостека
	Interrupted bool
}
/** Причина отмены «пользователь перебил» — не ошибка хода. */
private static final Exception BARGE_IN = new Exception("voice: пользователь перебил", null, false, false) {};

/**
 * Проводит один ход. audio закрывается, когда пользователь замолчал (VAD);
 * bargeIn (может быть null) срабатывает, когда пользователь заговорил во время ответа.
 */
public Timings turn(Chan<byte[]> audio, CountDownLatch bargeIn) throws VoiceException, InterruptedException {
    var t = new Stamps();
    var spoken = new ArrayList<String>();
    boolean[] cut = {false}; // часть ответа не прозвучала
    Throwable cause;

    try (var scope = new Scope()) {
        // Пересылаем аудио в STT и запоминаем момент конца речи — точку отсчёта бюджета.
        var in = new Chan<byte[]>();
        scope.go(() -> {
            try {
                for (byte[] f; (f = audio.receive()) != null; ) in.send(f);
                t.ended = System.nanoTime();
            } finally {
                in.close();
            }
        });
        var transcripts = new Chan<Transcript>();
        scope.go(() -> {
            try {
                stt.stream(in, transcripts);
            } finally {
                transcripts.close();
            }
        });
        String user = "";
        for (Transcript tr; (tr = transcripts.receive()) != null; ) {
            if (tr.isFinal()) {
                user = tr.text();
                break;
            }
        }
        t.stt = System.nanoTime();
        if (user.isEmpty()) {
            throw new NoSpeechException(scope.cause.get());
        }
        t.t0 = t.ended != 0 ? t.ended : t.stt; // финал мог прийти раньше конца аудио (eager endpointing)

        history.add(new Message("user", user));
        var snapshot = List.copyOf(history);

        // LLM → предложения: TTS стартует на первой фразе, а не на всём ответе.
        var sentences = new Chan<String>();
        scope.go(() -> {
            var buf = new StringBuilder();
            try {
                llm.stream(snapshot, d -> {
                    if (t.llm == 0) t.llm = System.nanoTime();
                    buf.append(d);
                    int i = lastSentenceEnd(buf);
                    if (i >= 0) {
                        sentences.send(buf.substring(0, i + 1).strip());
                        buf.delete(0, i + 1);
                    }
                });
                if (!buf.isEmpty() && !buf.toString().isBlank() && !scope.cancelled()) {
                    sentences.send(buf.toString().strip());
                }
            } finally {
                sentences.close();
            }
        });

        // Barge-in: одна отмена прерывает LLM, TTS и воспроизведение сразу.
        if (bargeIn != null) {
            scope.go(() -> {
                bargeIn.await();
                scope.cancel(BARGE_IN);
            });
        }

        // Предложения → звук. Синтез идёт быстрее реального времени, очередь chunks
        // даёт TTS работать над следующей фразой, пока звучит текущая.
        var chunks = new Chan<Chunk>();
        scope.go(() -> {
            try {
                for (String s; (s = sentences.receive()) != null; ) {
                    tts.stream(s, c -> {
                        if (t.tts == 0) t.tts = System.nanoTime();
                        chunks.send(c);
                    });
                }
            } finally {
                chunks.close();
            }
        });

        // Воспроизведение: недоигранный чанк в историю не попадает.
        Thread playback = scope.go(() -> {
            try {
                for (Chunk c; (c = chunks.receive()) != null; ) {
                    if (t.audio == 0) t.audio = System.nanoTime();
                    player.play(c);
                    spoken.add(c.text().strip());
                }
            } catch (InterruptedException e) {
                cut[0] = true;
            }
        });
        playback.join();
        cause = scope.cause.get();
    } // close(): прерываем и дожидаемся всех потоков хода — утечек нет, поля t видны после join

    // В историю — только произнесённое: модель не должна «помнить», что сказала то,
    // чего пользователь не услышал.
    boolean interrupted = cause == BARGE_IN;
    String text = String.join(" ", spoken);
    if (!text.isEmpty()) {
        if (cut[0]) text += "…";
        history.add(new Message("assistant", text, cut[0]));
    }
    var tm = new Timings(t.since(t.stt), t.since(t.llm), t.since(t.tts), t.since(t.audio), interrupted);
    if (cause != null && !interrupted) {
        throw new VoiceException("voice: ход прерван ошибкой", cause);
    }
    return tm;
}

@FunctionalInterface
interface Task {
    void run() throws Exception;
}

/**
 * Потоки одного хода с общей отменой — аналог context.WithCancelCause.
 * Первая причина запоминается, все потоки прерываются; close() прерывает оставшиеся и ждёт их.
 * (StructuredTaskScope в Java 21 ещё preview, поэтому вручную.)
 */
private static final class Scope implements AutoCloseable {
    final AtomicReference<Throwable> cause = new AtomicReference<>();
    private final List<Thread> threads = new CopyOnWriteArrayList<>();

    Thread go(Task task) {
        Thread th = Thread.ofVirtual().unstarted(() -> {
            try {
                task.run();
            } catch (InterruptedException e) {
                // отмена: выходим молча, причина уже в cause
            } catch (Exception e) {
                cancel(e);
            }
        });
        threads.add(th);
        th.start();
        if (cancelled()) th.interrupt(); // отмена могла случиться до добавления в список
        return th;
    }

    boolean cancelled() {
        return cause.get() != null;
    }

    void cancel(Throwable c) {
        if (cause.compareAndSet(null, c)) threads.forEach(Thread::interrupt);
    }

    @Override
    public void close() throws InterruptedException {
        threads.forEach(Thread::interrupt);
        for (Thread th : threads) th.join();
    }
}
var (
	ErrBargeIn  = errors.New("voice: пользователь перебил")
	ErrNoSpeech = errors.New("voice: нет финального транскрипта")
)

type Agent struct {
	STT     STT
	LLM     LLM
	TTS     TTS
	Player  Player
	History []Message
}

// Turn проводит один ход. audio закрывается, когда пользователь замолчал (VAD);
// сигнал в bargeIn означает, что пользователь заговорил во время ответа.
func (a *Agent) Turn(ctx context.Context, audio <-chan []byte, bargeIn <-chan struct{}) (Timings, error) {
	var tm Timings
	ctx, cancel := context.WithCancelCause(ctx)
	defer cancel(nil)

	// Пересылаем аудио в STT и запоминаем момент конца речи — точку отсчёта бюджета.
	in, ended := make(chan []byte), make(chan time.Time, 1)
	go func() {
		defer close(in)
		for f := range audio {
			select {
			case in <- f:
			case <-ctx.Done():
				return
			}
		}
		ended <- time.Now()
	}()

	transcripts, err := a.STT.Stream(ctx, in)
	if err != nil {
		return tm, err
	}
	var user string
	for t := range transcripts {
		if t.Final {
			user = t.Text
			break
		}
	}
	sttAt := time.Now()
	if user == "" {
		return tm, errors.Join(ErrNoSpeech, context.Cause(ctx))
	}
	t0 := sttAt // финал пришёл раньше конца аудио (eager endpointing)
	select {
	case t0 = <-ended:
	default:
	}

	a.History = append(a.History, Message{Role: "user", Text: user})
	deltas, err := a.LLM.Stream(ctx, a.History)
	if err != nil {
		return tm, err
	}

	// Barge-in: одна отмена останавливает LLM, TTS и воспроизведение сразу.
	go func() {
		select {
		case <-bargeIn:
			cancel(ErrBargeIn)
		case <-ctx.Done():
		}
	}()

	// LLM → предложения: TTS стартует на первой фразе, а не на всём ответе.
	sentences := make(chan string, 4)
	var llmAt time.Time
	go func() {
		defer close(sentences)
		var buf string
		for d := range deltas {
			if llmAt.IsZero() {
				llmAt = time.Now()
			}
			buf += d
			if s, rest, ok := cutSentence(buf); ok {
				select {
				case sentences <- s:
				case <-ctx.Done():
					return
				}
				buf = rest
			}
		}
		if s := strings.TrimSpace(buf); s != "" && ctx.Err() == nil {
			sentences <- s
		}
	}()

	// Предложения → звук. Синтез идёт быстрее реального времени, буфер chunks
	// даёт TTS работать над следующей фразой, пока звучит текущая.
	chunks := make(chan Chunk, 8)
	var ttsAt time.Time
	go func() {
		defer close(chunks)
		for s := range sentences {
			if ctx.Err() != nil {
				continue // дочитываем канал, чтобы этап выше завершился
			}
			stream, err := a.TTS.Stream(ctx, s)
			if err != nil {
				cancel(err)
				continue
			}
			for c := range stream {
				if ttsAt.IsZero() {
					ttsAt = time.Now()
				}
				select {
				case chunks <- c:
				case <-ctx.Done():
				}
			}
		}
	}()

	// Воспроизведение. Канал дочитывается до закрытия даже после отмены:
	// так все горутины гарантированно завершились до чтения llmAt и ttsAt.
	var spoken []string
	var audioAt time.Time
	cut := false // часть ответа не прозвучала
	for c := range chunks {
		if ctx.Err() != nil {
			cut = true
			continue
		}
		if audioAt.IsZero() {
			audioAt = time.Now()
		}
		if a.Player.Play(ctx, c) != nil { // недоигранный чанк в историю не попадает
			cut = true
			continue
		}
		spoken = append(spoken, strings.TrimSpace(c.Text))
	}

	// В историю — только произнесённое: модель не должна «помнить», что сказала то,
	// чего пользователь не услышал.
	tm.Interrupted = errors.Is(context.Cause(ctx), ErrBargeIn)
	if text := strings.Join(spoken, " "); text != "" {
		if cut {
			text += "…"
		}
		a.History = append(a.History, Message{Role: "assistant", Text: text, Interrupted: cut})
	}
	since := func(t time.Time) time.Duration {
		if t.IsZero() {
			return 0
		}
		return t.Sub(t0)
	}
	tm.STT, tm.LLM, tm.TTS, tm.Audio = since(sttAt), since(llmAt), since(ttsAt), since(audioAt)
	if err := context.Cause(ctx); err != nil && !tm.Interrupted {
		return tm, err
	}
	return tm, nil
}
@Test
void bargeInTruncatesHistory() throws Exception {
    var bargeIn = new CountDownLatch(1);
    // Доигрывает 4 чанка, а на следующем «слышит» пользователя и ждёт прерывания.
    Voice.Player player = new Voice.Player() {
        int n;

        @Override
        public void play(Chunk c) throws InterruptedException {
            if (n == 4) {
                bargeIn.countDown();
                Thread.sleep(Long.MAX_VALUE);
            }
            n++;
        }
    };
    var a = new Agent(
            new FakeStt("когда доставка", ms(1)),
            new FakeLlm("Доставка в четверг с десяти до двух. Могу перенести на пятницу.", ms(1), Duration.ZERO),
            new FakeTts(ms(1), ms(1)), player);
    Timings tm = a.turn(speak(3), bargeIn);

    assertTrue(tm.interrupted(), "interrupted = false");
    assertEquals(new Message("assistant", "Доставка в четверг с…", true), a.history.getLast());
}
// bargePlayer доигрывает after чанков, а на следующем «слышит» пользователя и ждёт отмены.
type bargePlayer struct {
	after, n int
	bargeIn  chan struct{}
}

func (p *bargePlayer) Play(ctx context.Context, c Chunk) error {
	if p.n == p.after {
		close(p.bargeIn)
		<-ctx.Done()
		return context.Cause(ctx)
	}
	p.n++
	return nil
}

func TestBargeInTruncatesHistory(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		p := &bargePlayer{after: 4, bargeIn: make(chan struct{})}
		a := &Agent{
			STT:    FakeSTT{Text: "когда доставка", Endpoint: ms},
			LLM:    FakeLLM{Reply: "Доставка в четверг с десяти до двух. Могу перенести на пятницу.", TTFT: ms},
			TTS:    FakeTTS{TTFA: ms, PerWord: ms},
			Player: p,
		}
		tm, err := a.Turn(context.Background(), speak(3), p.bargeIn)
		if err != nil {
			t.Fatal(err)
		}
		if !tm.Interrupted {
			t.Error("Interrupted = false")
		}
		got := a.History[len(a.History)-1]
		want := Message{Role: "assistant", Text: "Доставка в четверг с…", Interrupted: true}
		if got != want {
			t.Errorf("в историю попало %+v, want %+v", got, want)
		}
	})
}
/**
 * LLM-звено каскада поверх стримингового Messages API.
 * model: в голосе важнее TTFT — маленькая модель, например "claude-haiku-4-5".
 */
public record ClaudeLlm(AnthropicClient client, String model) implements Voice.Llm {

    /** Ответ озвучит TTS: формат и длина — часть голосового UX. */
    public static final String VOICE_SYSTEM = """
            Ты голосовой ассистент. Ответ будет озвучен синтезатором речи, поэтому:
            без markdown, списков и ссылок; одна-три короткие фразы; первая фраза — сразу по делу;
            числа и даты пиши так, как их произносят. Если нужен уточняющий вопрос — задай один.""";

    @Override
    public void stream(List<Message> history, Sink<String> out) throws InterruptedException {
        var params = MessageCreateParams.builder().model(model).maxTokens(300).system(VOICE_SYSTEM);
        for (Message m : history) {
            if (m.role().equals("assistant")) {
                params.addAssistantMessage(m.text());
            } else {
                params.addUserMessage(m.text()); // подряд идущие user-сообщения API склеит
            }
        }
        // try-with-resources закрывает HTTP-стрим и при barge-in: генерация останавливается.
        try (StreamResponse<RawMessageStreamEvent> stream = client.messages().createStreaming(params.build())) {
            Iterator<RawMessageStreamEvent> it = stream.stream().iterator();
            while (it.hasNext()) {
                if (Thread.currentThread().isInterrupted()) throw new InterruptedException();
                var text = it.next().contentBlockDelta().flatMap(d -> d.delta().text()).map(TextDelta::text);
                if (text.isPresent()) out.send(text.get());
            }
        }
    }
}
// VoiceSystem — ответ озвучит TTS: формат и длина — часть голосового UX.
const VoiceSystem = `Ты голосовой ассистент. Ответ будет озвучен синтезатором речи, поэтому:
без markdown, списков и ссылок; одна-три короткие фразы; первая фраза — сразу по делу;
числа и даты пиши так, как их произносят. Если нужен уточняющий вопрос — задай один.`

// Claude — LLM-звено каскада поверх стримингового Messages API.
type Claude struct {
	Client anthropic.Client
	Model  string // в голосе важнее TTFT: маленькая модель, например "claude-haiku-4-5"
}

func (c Claude) Stream(ctx context.Context, history []Message) (<-chan string, error) {
	msgs := make([]anthropic.MessageParam, 0, len(history))
	for _, m := range history {
		b := anthropic.NewTextBlock(m.Text)
		if m.Role == "assistant" {
			msgs = append(msgs, anthropic.NewAssistantMessage(b))
		} else {
			msgs = append(msgs, anthropic.NewUserMessage(b)) // подряд идущие user-сообщения API склеит
		}
	}
	stream := c.Client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{
		Model:     c.Model,
		MaxTokens: 300,
		System:    []anthropic.TextBlockParam{{Text: VoiceSystem}},
		Messages:  msgs,
	})
	out := make(chan string)
	go func() {
		defer close(out)
		defer stream.Close()
		for stream.Next() {
			if d, ok := stream.Current().AsAny().(anthropic.ContentBlockDeltaEvent); ok {
				if t, ok := d.Delta.AsAny().(anthropic.TextDelta); ok && !send(ctx, out, t.Text) {
					return // barge-in: отмена ctx закрывает HTTP-стрим, генерация останавливается
				}
			}
		}
		// ponytail: stream.Err() теряется по контракту интерфейса; в проде — лог и метрика.
	}()
	return out, nil
}

В Java нет аналога testing/synctest из Go, поэтому тесты идут на настоящих часах и проверяют латентность с допуском ±25 мс. Арифметика та же: 30 мс endpoint + 40 мс TTFT + две паузы между словами по 20 мс + 15 мс до первого звука TTS дают начало воспроизведения около 125 мс, а без нарезки на фразы было бы 205 мс. Поток, который не отреагировал на прерывание, ловит @Timeout на классе теста: turn ждёт все свои потоки, поэтому утечка превращается в упавший тест, а не в зависший CI. Если нужны точные значения, инжектируйте java.time.Clock и фейковый планировщик вместо Thread.sleep.

Тесты работают в testing/synctest: время внутри «пузыря» фейковое, поэтому латентность проверяется точными значениями. Например, 30 мс endpoint + 40 мс TTFT + две паузы между словами по 20 мс + 15 мс до первого звука TTS дают начало воспроизведения ровно на 125 мс, а без нарезки на фразы было бы 205 мс. Горутина, которая не отреагировала на отмену, роняет тест как deadlock — бесплатная проверка утечек, которую трудно получить с настоящими часами.

cd examples/java
mvn -q -pl m16-voice verify
mvn -q -pl m16-voice exec:java -Dexec.mainClass=course.m16voice.Simulate -Dexec.args="-n 200"   # 200 ходов параллельно, p50/p95 по этапам

200 ходов, мс от конца речи пользователя
этап                          p50    p95
STT: финальный транскрипт     442    591
LLM: первый токен             957   1228
TTS: первый чанк             1179   1436
начало воспроизведения       1179   1436
cd examples/m16-voice
go test -race ./...
go run ./cmd/simulate -n 200   # 200 ходов параллельно, p50/p95 по этапам

200 ходов, мс от конца речи пользователя
этап                          p50    p95
STT: финальный транскрипт     444    588
LLM: первый токен             948   1244
TTS: первый чанк             1165   1511
начало воспроизведения       1165   1511

Задержки в симуляторе условные, 5% ходов получают хвост +1,5 с к TTFT, как при перегрузке провайдера. Подставьте свои замеры из трейсов, и получится ваш бюджет: сразу видно, какой сегмент двигает p95.

Контракт отменыКаждая реализация STT, LLM и TTS обязана реагировать на прерывание. Блокирующие вызовы (Thread.sleep, BlockingQueue.take, сетевой I/O на виртуальном потоке) бросают InterruptedException или закрывают сокет. Это исключение нельзя проглатывать: выйдите из метода или восстановите флаг через Thread.currentThread().interrupt(). Реализация, которая ловит его и продолжает цикл, после barge-in договорит ответ в пустоту и задержит turn на join. А SDK, который крутит ретраи в собственном пуле потоков, оставит висящий поток на каждый перебитый ход, и у голосового сервиса таких ходов тысячи в час.
Контракт каналовКаждая реализация STT, LLM и TTS обязана отправлять в канал через select с ctx.Done() и закрывать канал при отмене. Реализация, которая пишет в канал без select, после barge-in навсегда повиснет на отправке. Это утечка горутины на каждый перебитый ход, а у голосового сервиса перебитых ходов тысячи в час.

Транспорт и архитектура

WebRTC или WebSocket

WebRTCWebSocket
ТранспортUDP (SRTP), медиа отдельно от сигналингаTCP: потерянный пакет задерживает всё, что идёт за ним (head-of-line blocking)
Потери и джиттерJitter buffer и кодек Opus маскируют потери, качество деградирует плавноПауза, затем «догоняющий» звук; на мобильной сети заметно
Обработка звукаВ браузере из коробки: эхоподавление, шумоподавление, автоматическая регулировка усиленияВсё самим: кодек, ресемплинг, буферы, AEC
СложностьСигналинг, ICE/STUN/TURN для обхода NAT, часто медиасервер (SFU)Простой и знакомый протокол
Где уместенКлиент (браузер, мобильное приложение) ↔ ваш сервер или провайдерСервер ↔ сервер внутри дата-центра: оркестратор ↔ STT, TTS, realtime API

Для браузерных и мобильных клиентов Realtime API OpenAI рекомендует WebRTC ради более стабильной работы. Постоянный ключ API при этом в браузер не попадает: сервер выпускает короткоживущий client secret. Принцип общий для всех провайдеров: постоянный ключ живёт только на сервере (модуль 7).

Телефония

Звонок с обычного телефона приходит через SIP-транк (Twilio и аналоги) на ваш медиасервер или сразу к провайдеру: Realtime API OpenAI принимает SIP-звонки и сообщает о входящем вебхуком. Что меняется:

Где держать состояние

Tool use голосом

Evals и наблюдаемость голосовых агентов

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

УровеньМетрикиОткуда данные
ЗвеньяWER и точность сущностей (STT); произношение чисел и имён, оценка естественности (TTS); текстовые evals ответа (LLM)Размеченные записи вашего трафика; для LLM — модуль 8
Ход разговораp50/p95 по сегментам; доля ложных перебиваний; доля случаев, когда агент перебил пользователя (слишком ранний конец реплики); доля перебиваний агента пользователемТрейсы, события VAD, записи звонков
ЗадачаДоля решённых задач (task success), доля звонков без перевода на оператора, число ходов до результатаИсходы в CRM, сценарии симуляции
ЧеловекЕстественность, вежливость, уместность реакции на перебивание по рубрике; CSATПрослушивание выборки звонков с калибровкой оценщиков (модуль 8)

WER (word error rate) = (S + D + I) / N: замены, удаления и вставки слов относительно эталона из N слов (см. разбор суммаризации звонков в модуле 12; в Python его считает библиотека jiwer). Подвохов два. Первый — нормализация: «25» и «двадцать пять», регистр, пунктуация. Без единой нормализации эталона и гипотезы WER мерит форматирование, а не распознавание. Второй — веса: ошибка в номере заказа или фамилии ломает задачу, а в «ну вот» — нет, поэтому рядом с WER считайте точность сущностей.

Следите за двумя метриками перебиваний: агента перебивают чаще, когда ответы слишком длинные, а сам агент перебивает людей, когда endpointing слишком агрессивен. Обе метрики — прямые рычаги настройки. Авторы primer'а называют «агент перебивает слишком часто» самой частой жалобой на голосовых агентов, собранных без фреймворка.

Симуляция звонков. LLM играет звонящего по сценарию (цель, характер, манера перебивать), его реплики озвучивает TTS, иногда с шумом и акцентом. Ваш агент разговаривает с ним через весь голосовой стек, а судья оценивает транскрипт по рубрике. Это регрессионный набор для голоса: прогон перед каждым изменением промпта, модели или порогов VAD (модуль 8). Для исследований turn-taking есть академические бенчмарки, например Full-Duplex-Bench.

Наблюдаемость. Трейс на ход со спанами сегментов из урока о латентности и событиями barge-in: когда случилось, ложное или нет, сколько слов успело прозвучать. Звук записывается только по согласию и с политикой хранения. Запись голоса — персональные данные, а если голос используется для идентификации человека, то и биометрические, с отдельными требованиями. Что именно применимо к вашей системе, определяет юрист (модуль 11).

Практика

Задание 1: голосовой интерфейс к RAG-агенту проекта A

Модуль факультативный, задание тоже: соберите голосовой ход поверх своего /ask из проекта A (или агента проекта C). Конвейер возьмите из examples/m16-voiceexamples/java/m16-voice; LLM-звено — адаптер к вашему RAG-сервису или к Claude; STT и TTS — фейки или реальные API на выбор. Переделайте промпт под голос: без markdown и ссылок, первая фраза короткая, источники называются словами.

Задание 2: barge-in с корректной историей

Реализуйте перебивание в своём конвейере или расширьте Turn из примера.

Задание 3: извлечение данных со сканов и скриншотов с eval

Возьмите не меньше 30 реальных документов одного типа: счета, чеки, формы или скриншоты из документации проекта A (таблицы тарифов, схемы). Извлеките поля VLM по JSON-схеме с валидацией, как в invoice.Validate.

Задание 4 (по желанию): спекулятивный старт и филлер

Добавьте в конвейер ранний конец реплики: LLM стартует по EagerEndOfTurn (или по ранней гипотезе VAD), отменяется по TurnResumed, а звук не играет до подтверждения. Отдельно добавьте филлер из кэша TTS, если инструмент или поиск не ответил за 700 мс.

Самопроверка

Почему «сколько позиций в накладной?» — плохой вопрос к VLM и как его переформулировать?

Счёт объектов у VLM приблизителен, особенно для множества мелких элементов, и ошибка не видна по ответу. Попросите список позиций по JSON-схеме и посчитайте их в коде; заодно сверьте сумму позиций с итогом документа. Модель переписывает, код считает и проверяет.

Вы отправили скан 4000×3000 px с мелким текстом. Что произойдёт и что делать?

Изображение уменьшится до лимита модели (по длинной стороне 1568 px на стандартном уровне, 2576 px на высоком), мелкий текст станет нечитаемым, а модель всё равно уверенно его «прочитает». Нужно резать страницу на фрагменты или кадрировать нужную область и выбирать разрешение по eval, на котором видна кривая «качество / стоимость».

Когда классический OCR-пайплайн лучше VLM?

Большие объёмы чистой печати (дёшево, быстро, CPU), когда нужны координаты и уверенность по каждому символу, детерминизм и аудит, работа офлайн. VLM сильнее на сложной вёрстке, таблицах, графиках, смешанных и новых форматах и там, где нужен смысл. Частый компромисс — гибрид: OCR-текст плюс изображение страницы в VLM и сверка между ними.

Чем каскад отличается от speech-to-speech по контролю и логированию?

В каскаде на каждом шаге есть текст: можно применить guardrails и PII-редакцию до озвучки, сохранить транскрипт и ответ, прогнать обычные текстовые evals, заменить любое звено. В S2S аудио идёт в модель и из модели напрямую, транскрипция — побочная и не всегда надёжна, проверить ответ до того, как он прозвучал, сложнее, а провайдер один.

Из каких сегментов складывается voice-to-voice латентность и какой из них чаще всего забывают?

Аудиостек и сеть вверх, endpointing, финализация STT, TTFT LLM, генерация первой фразы, TTS до первого звука, сеть вниз и воспроизведение. Забывают endpointing: ожидание тишины в сотни миллисекунд не видно в спанах STT и LLM, хотя часто это крупнейший сегмент.

Что даёт спекулятивный старт LLM и чем за него платят?

LLM начинает отвечать на ранней гипотезе конца реплики, и ответ готов к моменту подтверждения, что экономит часть endpointing. Платят лишними вызовами: если пользователь продолжил, черновик отменяется (Deepgram оценивает прирост вызовов LLM в 50–70%). Звук до подтверждения не играет, иначе агент будет перебивать.

Что должно попасть в историю после barge-in и почему не весь ответ?

Только фактически прозвучавшая часть с отметкой обрыва. Если записать весь сгенерированный ответ, модель будет считать, что пользователь слышал то, чего он не слышал (окно доставки, цену), и сошлётся на это позже. Результаты уже вызванных инструментов при этом остаются: действие произошло независимо от того, услышал ли пользователь подтверждение.

Почему при barge-in мало остановить генерацию LLM?

К моменту перебивания текст обычно уже сгенерирован, TTS синтезировал звук быстрее реального времени, а сервер отправил клиенту секунды аудио вперёд. Остановить нужно воспроизведение там, где оно идёт: на клиенте, в медиасервере или у телефонного провайдера (clear в Twilio, truncate в Realtime API OpenAI для WebSocket).

Назовите три источника ложных перебиваний и защиту от каждого.

Эхо собственного голоса агента — AEC. Шум и кашель — порог минимальной длительности речи, шумоподавление до VAD, продолжение фразы, если STT не вернул слов. Поддакивание «угу» — минимальное число слов или классификатор перехвата хода.

Почему между клиентом и сервером обычно WebRTC, а между сервером и STT/TTS-провайдером — WebSocket?

На последней миле теряются пакеты и скачет задержка: WebRTC поверх UDP с jitter buffer и Opus деградирует плавно, а в браузере даёт AEC и шумоподавление из коробки. Внутри дата-центра сеть стабильная, и простота WebSocket поверх TCP перевешивает: head-of-line blocking там почти не проявляется.

Вопросы с собеседований

Спроектируйте голосового ассистента записи к врачу для сети клиник: телефон, 5 000 звонков в день.

Требования. Сценарии: запись, перенос, отмена, вопросы о подготовке к приёму. Канал — телефон. Целевые метрики, о которых договариваемся с бизнесом: p95 до первого звука в пределах полутора секунд, доля звонков без оператора, ноль записей без явного подтверждения. Нагрузка небольшая: 5 000 звонков по 3 минуты — это десятки одновременных звонков в пик, планирование мощности ведём в одновременных звонках.

Архитектура. Каскад: SIP-транк → оркестратор (Pipecat, LiveKit Agents или свой на GoJava) → стриминговый STT с моделью конца реплики → быстрая LLM с инструментами find_slots, book, cancel → TTS с кэшем частых фраз. Каскад, а не S2S, потому что здесь критичны инструменты и подтверждения, нужен текст для аудита, evals и PII-редакции. Все сервисы в одном регионе, соединения к STT и TTS открыты на весь звонок.

Диалог. Barge-in с фильтром ложных срабатываний. Подтверждение перед записью: агент перечитывает врача, дату и время и ждёт явного «да». Фамилию и дату рождения просит продиктовать и повторяет. Пока ищутся слоты — филлер. Перевод на регистратуру с резюме разговора — штатный путь при неуверенности или по просьбе.

Данные. Медицинские и персональные данные: минимизация того, что попадает в LLM и логи, запись звонков по согласию, сроки хранения, разграничение доступа к записям. Конкретные требования определяет юрист.

Evals и эксплуатация. WER и точность сущностей на телефонных записях с фамилиями врачей и медицинскими терминами; симуляция звонков по сценариям как регрессия перед каждым изменением; task success — запись создана верно; в проде — p95 по сегментам, доли ложных перебиваний и переводов на оператора, выборочное прослушивание.

На что смотрит интервьюер: обоснован ли выбор каскада или S2S, а не просто назван; есть ли бюджет латентности по сегментам; подумал ли кандидат о перебивании, подтверждении действий и ошибках STT в именах и датах; есть ли путь к человеку и план evals на телефонном звуке.

Пользователи жалуются, что голосовой агент «тормозит». Как найдёте причину и что сделаете?

Сначала измерить. Трейс хода со спанами по сегментам и p50/p95 каждого, плюс 10–20 стереозаписей звонков, чтобы увидеть реальную voice-to-voice латентность вместе с сетью и аудиостеком клиента.

Типичные находки по частоте: консервативный endpointing (500–800 мс тишины на каждом ходу); TTS ждёт весь ответ, а не первую фразу; длинный TTFT из-за последовательного retrieval, включённого thinking, большой модели или длинного системного промпта без кэша; холодные соединения к STT и TTS на каждом ходу; сервисы в разных регионах; долгие инструменты без филлера.

Лечение по сегментам: модель конца реплики вместо голой тишины, стриминг по фразам и короткая первая фраза, быстрая модель и prompt caching, параллельный retrieval, тёплые соединения, один регион, филлер на долгие шаги. Каждое изменение проверяем A/B: латентность против доли перебиваний агентом и task success, чтобы ускорение не превратилось в агента, который перебивает людей.

На что смотрит интервьюер: начинает ли кандидат с измерений, а не с «поставим модель побыстрее»; помнит ли про endpointing и запись на клиенте; понимает ли, что p95 важнее среднего; видит ли компромисс между скоростью и перебиваниями.

Каскад или speech-to-speech для голосового ассистента банка? Обоснуйте.

Каскад, и причины в требованиях банка, а не во вкусе. Нужен текст на каждом шаге: аудит, PII-редакция до хранения, guardrails и проверка политики до озвучки. Нужны надёжные инструменты с подтверждением операций, а S2S-модели, по наблюдениям практиков, вызывают инструменты менее надёжно, чем текстовые. Нужны текстовые evals и регрессия в CI. И нужна возможность заменить вендора любого звена.

Где S2S мог бы выиграть: естественность и эмоции, смешанная речь. Это реальные плюсы, но для банка они вторичны. Возможен гибрид, но действия всё равно остаются за детерминированным кодом с подтверждением. Решение я бы пересматривал раз в полгода прогоном одних и тех же сценариев на обоих вариантах: рынок S2S быстро меняется.

На что смотрит интервьюер: выводит ли кандидат выбор из требований (аудит, комплаенс, tool use, evals), а не из «S2S быстрее»; знает ли сильные стороны S2S; предлагает ли проверяемый критерий пересмотра решения.

Агент перебивает пользователей, а пользователи постоянно перебивают агента. Что настраивать?

Это две разные проблемы с разными рычагами, и обе нужно сначала измерить по событиям VAD и записям.

Агент перебивает людей — слишком ранний endpointing: человек задумался, а тишина уже засчитана как конец реплики. Решения: модель конца реплики, которая учитывает смысл (после «мой номер…» ждать дольше), адаптивный порог, более длинная пауза после незаконченной фразы.

Люди перебивают агента — ответы слишком длинные или неуместные. Решения: короче ответы и первая фраза по делу, без монологов и перечислений, уточняющий вопрос вместо лекции. Отдельно проверить ложные перебивания (эхо, «угу», шум): если агент замолкает от поддакивания, пользователи не перебивают, а агент сам обрывается.

Метрики после изменений: обе доли перебиваний, латентность до первого звука и task success. Ускорение endpointing почти всегда увеличивает первую долю, поэтому ищем точку компромисса, а не минимум одной метрики.

На что смотрит интервьюер: различает ли кандидат две проблемы; знает ли про модели конца реплики и ложные перебивания; предлагает ли метрики и компромисс, а не один «правильный» порог.

Как вы реализуете barge-in в Java-сервисе и как это протестируете?

Этапы — виртуальные потоки, стримы — блокирующие очереди, на ход — один объект отмены, который запоминает первую причину и прерывает все потоки хода (по смыслу StructuredTaskScope; в Java 21 он preview, поэтому это 30 строк своего кода или Reactor с Flux.dispose()). Сигнал VAD о речи пользователя вызывает cancel(BARGE_IN): LLM-стрим закрывается (try-with-resources на StreamResponse рвёт HTTP-соединение), синтез TTS и воспроизведение останавливаются. Все реализации уважают InterruptedException, а в конце хода turn делает join() всех потоков. Так к концу хода утечек нет, а гонок по общим меткам нет: join — граница happens-before.

Плеер возвращает управление, только если чанк прозвучал целиком; чанк несёт текст, который в нём звучит (выравнивание от TTS). В историю попадает текст доигранных чанков с отметкой обрыва. Если звук играет на клиенте, клиент сообщает, сколько проиграно. Результаты вызванных инструментов из истории не удаляются.

Тесты на JUnit 5: фейковые STT, LLM и TTS с задержками и фейковый плеер, который на N-м чанке сигналит barge-in. Проверяем историю, флаг перебивания и то, что перебивание до первого звука не оставляет ответа в истории. @Timeout превращает зависший после отмены поток в упавший тест. Латентности проверяем с допуском, а если нужна точность — инжектируем Clock и фейковый планировщик.

На что смотрит интервьюер: понимает ли кандидат, что останавливать нужно воспроизведение, а не только генерацию; как он обрезает историю; знает ли контракт прерывания (не глотать InterruptedException); умеет ли тестировать конкурентный код без флаков: таймауты, допуски, инжекция времени вместо голых Thread.sleep в ассертах.

Как вы реализуете barge-in в Go-сервисе и как это протестируете?

Этапы — горутины, стримы — каналы, на ход — один context.WithCancelCause. Сигнал VAD о речи пользователя вызывает cancel(ErrBargeIn), и отмена останавливает LLM-стрим (закрывается HTTP-соединение), синтез TTS и воспроизведение. Все реализации отправляют в каналы через select с ctx.Done(), а каждый этап дочитывает свой вход до закрытия. Так к концу хода все горутины завершены, а гонок по общим меткам нет.

Плеер возвращает успех, только если чанк прозвучал целиком; чанк несёт текст, который в нём звучит (выравнивание от TTS). В историю попадает текст доигранных чанков с отметкой обрыва. Если звук играет на клиенте, клиент сообщает, сколько проиграно. Результаты вызванных инструментов из истории не удаляются.

Тесты: фейковые STT, LLM и TTS с задержками и фейковый плеер, который на N-м чанке сигналит barge-in. Проверяем историю, флаг перебивания и то, что перебивание до первого звука не оставляет ответа в истории. Гоняем в testing/synctest: фейковое время даёт точные латентности, а зависшая горутина роняет тест как deadlock. Плюс -race.

На что смотрит интервьюер: понимает ли кандидат, что останавливать нужно воспроизведение, а не только генерацию; как он обрезает историю; знает ли контракт каналов и отмены; умеет ли тестировать конкурентный код детерминированно, без time.Sleep и флаков.

Нужно извлекать поля из миллиона сканов счетов в месяц. VLM или OCR? Посчитайте.

Расчёт VLM. Страница на стандартном уровне разрешения — до ~1,5K визуальных токенов, плюс ~500 токенов промпта и ~300 токенов JSON на выходе. На Haiku 4.5 ($1/$5): 2K × $1/1M + 300 × $5/1M ≈ $0.0035 за страницу, ≈ $3.5K в месяц, через Batch API (−50%, никто не ждёт интерактивно) ≈ $1.75K. На Opus 5 ($5/$25) с высоким разрешением (до ~4,8K токенов на страницу) — до ≈ $0.034 за страницу, ≈ $34K в месяц, через Batch ≈ $17K. Модель выбирается по eval, а не по цене заранее.

Архитектура. Маршрутизация: цифровые PDF с текстовым слоем разбираются без VLM. Сканы идут в VLM по JSON-схеме или в гибрид «OCR + VLM» со сверкой. Обязательная валидация: суммы сходятся, даты и реквизиты корректны. Провал валидации → повтор с кропом в большем разрешении → очередь на человека. Идемпотентная запись результата по custom_id.

Качество. Размеченный набор из реальных сканов по слоям качества; точность по полям и доля документов без ошибок; прогон на нескольких разрешениях для выбора даунскейла; доля ручной проверки как метрика стоимости и дрейфа.

На что смотрит интервьюер: есть ли расчёт стоимости с batch и разрешением; предлагает ли кандидат маршрутизацию и гибрид вместо «всё в VLM»; есть ли валидация и путь к человеку; понимает ли, что ошибка в сумме опаснее отказа.

Как оценивать качество голосового агента до релиза и в проде?

По четырём уровням. Звенья: WER с единой нормализацией и точность сущностей на своих записях (телефонный звук отдельно), произношение чисел и имён в TTS, текстовые evals ответов LLM. Ход разговора: p50/p95 по сегментам, доли ложных перебиваний, перебиваний агентом и агента. Задача: task success, доля звонков без оператора, число ходов. Человек: прослушивание выборки по рубрике с калибровкой оценщиков, CSAT.

До релиза — симуляция: LLM играет звонящих по сценариям (включая шум, акценты, перебивания), разговор идёт через весь голосовой стек, судья оценивает транскрипты. Набор сценариев работает как регрессия в CI при изменении промпта, модели, порогов VAD или голоса. После релиза — канареечный запуск с онлайн-метриками и прослушиванием, плохие звонки превращаются в новые сценарии.

На что смотрит интервьюер: не сводит ли кандидат качество к WER; есть ли метрики turn-taking и задачи; есть ли воспроизводимая регрессия через симуляцию; связаны ли офлайн- и онлайн-метрики и цикл «инцидент → новый кейс».