Мультимодальность и голосовые агенты
Необязательный модуль: остальной курс и капстоун на него не опираются, его можно пройти в любой момент после модулей 2 и 9 или пропустить. Внутри — vision как инженерная задача (где VLM заменяет OCR-пайплайн, а где уверенно врёт в цифрах) и голосовые агенты: каскад STT → LLM → TTS против speech-to-speech, бюджет латентности до первого звука, перебивание с корректной историей диалога, транспорт и evals. Код — конвейер голосового хода на горутинах и каналахвиртуальных потоках и очередях с тестами без реальных API.
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 | Перепутанные цифры, «додуманные» значения пустых полей |
Режимы отказа
- Мелкий текст и уменьшение. Модель видит не ваш PNG, а копию в пределах своего лимита. У Claude изображение режется на патчи 28×28 px и стоит ⌈w/28⌉ × ⌈h/28⌉ визуальных токенов, а картинка больше лимита уменьшается: стандартный уровень — до 1568 px по длинной стороне, высокое разрешение (модели начиная с Claude 4.7) — до 2576 px, по документации на октябрь 2026. На стандартном уровне скриншот 4K уменьшается примерно в 2,6 раза: текст в 9 px становится пятнами в 3–4 px, и модель всё равно «прочитает» его. Режьте страницу на фрагменты (tiles) или кадрируйте нужную область.
- Счёт объектов. Документация Anthropic прямо предупреждает: подсчёт приблизителен, особенно когда мелких объектов много. Вместо «сколько позиций в накладной» просите список позиций и считайте в коде.
- Пространственные отношения. В BlindTest (Rahmanzadehgervi et al., 2024) четыре сильные на тот момент VLM в среднем решали 58% элементарных задач вроде «пересекаются ли два круга» и «сколько раз пересекаются линии». Лучший результат, 77,8%, показал Claude 3.5 Sonnet, а человек решает такие задачи безошибочно. Новые модели сильнее, но «что левее», «какая подпись к какой линии» по-прежнему проверяйте.
- Галлюцинации чисел — самый дорогой отказ. Размытая 3 читается как 8, теряется разряд или минус, строка таблицы читается дважды, пустое поле заполняется правдоподобным значением. Модель не скажет «не вижу», если вы не дали ей такого выхода (модуль 3).
- Плохой вход. По документации, на повёрнутых, низкокачественных и совсем маленьких (меньше 200 px) изображениях растёт доля ошибок. Сильное JPEG-сжатие портит мелкий текст, а повторное — ещё сильнее.
- Недоверенный ввод. Текст на картинке — такой же канал prompt injection, как веб-страница: надпись «игнорируй инструкции» белым по белому модель прочтёт (модуль 7).
- Политики. Называть людей по фото Claude отказывается; сложные медицинские снимки (КТ, МРТ) документация относит к задачам, для которых модель не предназначена.
Стоимость
Формула и порядок величин — в модуле 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...)
}
Если проверка не прошла: повтор с кропом проблемной области в большем разрешении, затем другая модель, затем очередь на ручную проверку. Доля документов, ушедших к человеку, — полноценная метрика: это и стоимость, и ранний сигнал дрейфа входных данных.
readValue проверку бизнес-инвариантов: после десериализации идёт Bean Validation (@Valid) или явный validate(), и ошибка валидации — это 422 с ProblemDetail, а не 500. JSON-схема играет роль типов, сходимость сумм — роль валидации.json.Unmarshal проверку бизнес-инвариантов: после разбора идёт Validate(), и ошибка валидации — это 422, а не паника. JSON-схема играет роль типов, сходимость сумм — роль валидации.Evals для vision
- Набор из реальных входов: фото с телефона, кривые сканы, скриншоты в тёмной теме, а не чистые PDF, сгенерированные для теста. Стратифицируйте по качеству и типу документа и считайте метрики по слоям, иначе среднее спрячет провал на фото.
- Метрики по полям: точное совпадение для номеров и дат, числовое — для сумм после нормализации, CER (character error rate, доля ошибочных символов) или ANLS (метрика DocVQA на основе расстояния Левенштейна) для свободного текста.
- Метрика документа: доля документов без единой ошибки в критичных полях. Её понимает бизнес, и она жёстче: при 98% точности по каждому из 10 независимых полей без ошибок останется лишь около 82% документов.
- Отказы: отдельные кейсы, где поля нет или фрагмент нечитаем. Правильный ответ — «нет данных», а не выдумка.
- Разрешение как параметр: прогон одного набора на 2–3 разрешениях даёт кривую «качество / стоимость» и обоснованный выбор уменьшения.
- Публичные бенчмарки (DocVQA, ChartQA, OCRBench) годятся для шорт-листа моделей. Решение принимается только по своему набору (модуль 8).
Голос: каскад или 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 на своих сценариях, а не по демо.
Провайдеры по категориям (октябрь 2026)
Рынок меняется быстрее курса, поэтому здесь категории и по 1–2 проверенных примера. Цифры вендоров — это их заявления, а не ваш бюджет: меряйте на своём трафике.
| Категория | Примеры | На что смотреть |
|---|---|---|
| Стриминговый STT | Deepgram Flux — STT со встроенным определением конца реплики (события EndOfTurn, EagerEndOfTurn, TurnResumed); OpenAI gpt-live-transcribe (gpt-4o-transcribe объявлен устаревшим, удаление из API — февраль 2027) | Частичные гипотезы, endpointing, WER на ваших терминах, звук 8 кГц для телефонии |
| STT на своих GPU | Whisper (открытые веса, Radford et al., 2022) | Модель обрабатывает аудио окнами по 30 с, стриминг строится надстройками; инференс — модуль 10 |
| TTS | ElevenLabs eleven_flash_v2_5 (~75 мс по заявлению вендора, без учёта сети и приложения); Cartesia Sonic | Время до первого звука, стриминговый вход по фразам, таймкоды слов, произношение чисел и имён |
| Speech-to-speech | OpenAI 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: время от момента, когда пользователь замолчал, до первого звука ответа в его динамике.
На что ориентироваться
- Люди. В исследовании десяти языков (Stivers et al., 2009) самые частые паузы между репликами лежат в диапазоне от 0 до 200 мс, медиана по всем языкам около 100 мс. Levinson и Torreira (2015) отмечают парадокс: задержки речепорождения больше 600 мс, значит, люди предсказывают конец чужой фразы и начинают готовить ответ заранее. Каскаду до этого далеко: о конце реплики он узнаёт по тишине.
- Продакшн-агенты. Авторы Voice AI primer (Daily, Pipecat) называют 1500 мс voice-to-voice важной целью; типичная сборка у них укладывается примерно в 1,2–1,3 с, и телефония легко выталкивает её за бюджет. Агенты на Pipecat, у которых все модели стоят в одном GPU-кластере, у них отвечали за 500 мс. Для LLM-звена у них же практическое правило — TTFT не больше ~600 мс.
- Сеть. По ITU-T G.114, при односторонней задержке до 150 мс большинство приложений не страдает, а 400 мс — граница, которую не стоит превышать при планировании сети. Телефонная связь добавляет к бюджету агента сотни миллисекунд.
Ориентиры задают порядок, а не норматив. Что приемлемо для вашего сценария, решают ваши данные: доля перебиваний, брошенных звонков, успешно решённых задач.
Из чего складывается
| Сегмент | Что происходит | Рычаги |
|---|---|---|
| Аудиостек и сеть ↑ | Микрофон, кодек (Opus), jitter buffer, доставка до сервера | WebRTC вместо TCP, регион рядом с пользователем |
| Endpointing | Ждём, пока тишина подтвердит конец реплики. Часто самый большой и самый незаметный в трейсах сегмент | Модель конца реплики вместо голой тишины, адаптивный порог |
| STT | Стриминговый STT распознаёт по ходу речи, после конца реплики остаётся финализация. Batch-STT начинает работу только после конца фразы | Стриминг, заранее открытое соединение |
| LLM TTFT | Prefill промпта, 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.
Приёмы
- Стриминг на каждом шаге. STT отдаёт частичные гипотезы, LLM — токены, TTS принимает текст по фразам и отдаёт звук чанками. Без этого задержки этапов складываются целиком, и это первый рычаг по силе.
- Спекулятивный старт. LLM запускается на ранней гипотезе конца реплики (
EagerEndOfTurnу Deepgram Flux) и отменяется, если пользователь продолжил (TurnResumed). Выигрыш — часть endpointing; цена — лишние вызовы: Deepgram предупреждает, что ранний конец реплики может увеличить число вызовов LLM на 50–70%. Звук при этом не играет, пока конец реплики не подтверждён. В LiveKit Agents есть похожая настройка — preemptive generation. - Короткая первая фраза. Промпт требует, чтобы первая фраза была короткой и сразу по делу (см.
VoiceSystemниже). TTS стартует после первой фразы, и 6 токенов вместо 20 экономят сотни миллисекунд. - Филлер. «Секунду, смотрю заказ» из кэша TTS, пока работает инструмент или поиск. Он маскирует ожидание, но не сокращает его, а на каждом ходу раздражает. Включайте его по событию «ожидается долгий шаг».
- Кэш TTS. Приветствие, подтверждения, филлеры и частые ответы синтезируются заранее. Ключ кэша — текст, голос и параметры синтеза.
- Тёплые соединения. WebSocket к STT и TTS открыт на весь звонок. TLS-рукопожатие на каждом ходу — это лишние RTT.
- Один регион. STT, LLM, TTS и оркестратор стоят рядом. Каждый межконтинентальный вызов внутри хода добавляет сотни миллисекунд.
- Модель под голос. Отвечает быстрая модель, а сложный вопрос получает «дайте подумать» и вызов более сильной модели как инструмента.
Бюджет латентности голосового хода
От момента, когда пользователь замолчал, до первого звука ответа в его динамике. Двигайте длительности этапов, включайте приёмы и сравнивайте каскад со speech-to-speech. Длительности — порядок величин для экспериментов, а не бенчмарк; аудиостек клиента взят по таблице из Voice AI primer. Свои цифры берите из трейсов.
Как мерить
- Спаны на каждый сегмент в трейсе хода (модуль 9):
endpoint,stt.final,llm.first_token,tts.first_chunk,playback.start. Смотрите p50 и p95 каждого, а не среднее: в голосе заметен именно хвост. - Запись разговора. Серверные метки не видят аудиостек клиента и сеть. Настоящую voice-to-voice латентность меряют по стереозаписи (пользователь и агент на разных каналах): расстояние от конца речи пользователя до начала речи агента. 10–20 записанных звонков ловят то, чего не видно в трейсах.
- Сегменты не всегда складываются. При спекулятивном старте и параллельном retrieval сумма длительностей больше итоговой задержки. Отчитывайтесь и по сегментам, и итогом.
Timer в Micrometer), а по сегментам и спанам OpenTelemetry ищут, кто его съедает.Barge-in: перебивание
Barge-in — пользователь начинает говорить, пока агент ещё отвечает. Люди делают это постоянно: уточняют, поправляют, говорят «нет-нет, мне другое». Агент, который не умеет замолкать, договаривает 15 секунд в пустоту, а пользователь повторяет громче. Задача распадается на пять шагов.
- Обнаружить речь пользователя во время собственного ответа: VAD на микрофоне работает и тогда, когда говорит агент.
- Подтвердить, что это перебивание, а не шум: минимальная длительность речи, минимальное число распознанных слов, классификатор «перехват хода или поддакивание».
- Отменить всё, что ещё в работе: генерацию LLM, синтез TTS и, главное, звук, уже лежащий в буферах.
- Обрезать историю до фактически произнесённого.
- Слушать новую реплику как обычный ход.
Что отменять: звук в буферах
К моменту перебивания LLM обычно уже дописала ответ, TTS синтезировала его быстрее реального времени, а сервер отправил клиенту звук на секунды вперёд. Остановить генерацию мало: пользователь продолжит слышать буфер. Звук нужно останавливать там, где он играет.
- WebRTC и SIP: сервер знает, сколько проиграно. Realtime API OpenAI в этих режимах сам обрезает непроигранный звук при перебивании.
- WebSocket: воспроизведением управляет клиент, поэтому он же должен остановить звук и сообщить, сколько успело прозвучать. У OpenAI это событие
conversation.item.truncateсaudio_end_ms: оно отрезает непроигранный звук и соответствующую часть транскрипта. - Телефония: в Twilio Media Streams сообщение
clearсбрасывает буфер звука, а меткиmarkпоказывают, какие куски доиграли. ПослеclearTwilio возвращает метки непроигранного, и вы точно знаете, что пользователь не услышал.
Что попадает в историю
| Стратегия | Что будет дальше |
|---|---|
| Весь сгенерированный ответ | Модель «помнит», что назвала окно доставки, которого пользователь не слышал, и через ход скажет «как я уже говорила, с 10 до 14». Самая частая ошибка. |
| Всё отправленное клиенту | Та же ошибка, только меньше: в буфере клиента были секунды звука. |
| Ответ удалён целиком | Модель не знает, что начала отвечать, и повторяет тот же текст с начала. |
| Фактически прозвучавшее + отметка обрыва | Правильно: модель видит, на чём её перебили, и отвечает на уточнение. В коде ниже текст обрезается до последнего доигранного чанка и заканчивается многоточием. |
Точность обрезки ограничена выравниванием текста и звука. TTS с таймкодами слов позволяет резать по слову, без них — по чанку или приблизительно; OpenAI прямо пишет, что realtime-модель не может точно выровнять транскрипт и звук. Для истории точности до слова хватает.
reschedule_delivery, доставка перенесена, даже если подтверждения никто не услышал. Результаты инструментов остаются в истории независимо от обрезки речи: состояние мира и сказанное вслух — разные вещи. Для действий с побочными эффектами нужны подтверждение до вызова и ключи идемпотентности (модуль 6).Ложные срабатывания
- Эхо: голос агента из динамика возвращается в микрофон, и агент перебивает сам себя. Лечится acoustic echo cancellation (AEC, подавление акустического эха); в браузере это ограничение
echoCancellationвgetUserMedia. На громкой связи без AEC barge-in не работает в принципе. - Шум: кашель, хлопнувшая дверь, разговор в комнате. Помогают порог минимальной длительности речи и шумоподавление до VAD: документация Pipecat советует чистить вход фильтром, а не только поднимать пороги.
- Поддакивание (backchannel): «угу», «да-да», «так» означают «продолжай», а не перехват хода. Помогают порог минимального числа слов (в LiveKit Agents —
min_words) или классификатор. - Ложное перебивание: VAD сработал, а STT не вернул ни слова. В LiveKit Agents это отдельное событие, и по умолчанию агент продолжает говорить с того места, где остановился.
Компромисс тот же, что у endpointing. Строгие фильтры — и агент реагирует медленнее, иногда договаривая поверх человека. Мягкие — и он замолкает от каждого шороха. Удобный приём — двухступенчатая реакция: по сигналу VAD сразу приглушить звук или поставить его на паузу, а отменять ответ, только когда STT подтвердил слова.
Barge-in: что отменяется и что попадает в историю
Агент отвечает про доставку. Выберите, что услышал микрофон и когда, настройте фильтры и посмотрите, когда стихнет звук, сколько текста было сгенерировано, отправлено и реально прозвучало, и что окажется в истории диалога.
cancel должен дойти до каждого этапа конвейера, и каждый обязан на него отреагировать. В блокирующем коде на виртуальных потоках то же самое делает Thread.interrupt() каждого потока хода. Разница в том, что «закрыть соединение» здесь может только голос, а решение об отмене принимает VAD, который иногда ошибается.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-звено вынесено в отдельный адаптер.
- Четыре этапа: пересылка аудио в STT с меткой конца речи; LLM → нарезка на фразы; фразы → TTS; воспроизведение. Буфер каналаОчередь между этапами позволяет TTS синтезировать следующую фразу, пока звучит текущая.
- Одна отмена на ход:
Scopeзапоминает первую причину и прерывает все потоки хода (аналогStructuredTaskScope, который в Java 21 ещё preview). ПричинаBARGE_INотличает перебивание от ошибки TTS, а уход клиента — это прерывание вызывающего потока. - Одна отмена на ход:
context.WithCancelCause. ПричинаErrBargeInотличает перебивание от ухода клиента или ошибки TTS. - Правило ожидания:
Scope.close()прерывает и дожидается черезjoin()каждого потока хода. Поэтому к концуturnвсе потоки гарантированно завершены, а метки времени читаются без гонок:Thread.join— граница happens-before. - Правило дренажа: после отмены каждый этап всё равно дочитывает входной канал до закрытия. Поэтому к концу
Turnвсе горутины гарантированно завершены, а метки времени читаются без гонок: закрытие канала — граница happens-before. - История получает только чанки, которые
Playerдоиграл целиком.
/** 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.
Thread.sleep, BlockingQueue.take, сетевой I/O на виртуальном потоке) бросают InterruptedException или закрывают сокет. Это исключение нельзя проглатывать: выйдите из метода или восстановите флаг через Thread.currentThread().interrupt(). Реализация, которая ловит его и продолжает цикл, после barge-in договорит ответ в пустоту и задержит turn на join. А SDK, который крутит ретраи в собственном пуле потоков, оставит висящий поток на каждый перебитый ход, и у голосового сервиса таких ходов тысячи в час.select с ctx.Done() и закрывать канал при отмене. Реализация, которая пишет в канал без select, после barge-in навсегда повиснет на отправке. Это утечка горутины на каждый перебитый ход, а у голосового сервиса перебитых ходов тысячи в час.Транспорт и архитектура
WebRTC или WebSocket
| WebRTC | WebSocket | |
|---|---|---|
| Транспорт | 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-звонки и сообщает о входящем вебхуком. Что меняется:
- Узкая полоса: G.711 μ-law, 8 кГц, именно так Twilio отдаёт звук в Media Streams. WER на таком звуке хуже, чем на 16 кГц из браузера, поэтому STT тестируйте на телефонных записях, а не на студийных.
- Латентность: телефонная сеть съедает часть бюджета, запас меньше, чем в браузере.
- DTMF: тональный набор надёжнее голоса для номеров и PIN. Номер карты вообще не должен попадать в LLM и логи (PCI DSS): его собирают DTMF-ом в обход агента.
- Перевод на оператора — штатный сценарий, а не авария: звонок переводится вместе с кратким резюме разговора.
Где держать состояние
- Звонок закреплён за процессом. Медиапоток, VAD, текущий ход, позиция воспроизведения и история живут в памяти одного воркера на всё время звонка, как sticky-сессия WebSocket. Мощность планируют в одновременных звонках на воркер, а не в RPS.
- Долговечное — асинхронно наружу. Транскрипты, вызовы инструментов и их результаты пишутся в журнал событий или БД, не блокируя ход. Падение воркера рвёт звонок, а восстановление — это переподключение с историей из хранилища.
- Граница стейтфул и стейтлесс. Голосовой оркестратор стейтфул, а всё за ним устроено как в текстовом агенте: RAG-сервис проекта A, инструменты и LLM-шлюз (модуль 9) остаются стейтлесс.
Tool use голосом
- Ожидание. Инструмент на 2 секунды — это 2 секунды тишины. Филлер запускается параллельно с инструментом («Секунду, смотрю заказ»), а долгую операцию лучше сделать асинхронной: «пришлю SMS, когда будет готово».
- Подтверждение голосом перед действием с побочным эффектом: агент перечитывает ключевые параметры («Переношу доставку на пятницу, 16 октября, с десяти до двух. Подтверждаете?») и ждёт явного «да». STT ошибается в числах и именах, и это перечитывание — ваша проверка.
- Числа и имена. Просите продиктовать по цифрам и повторяйте для подтверждения, а для чувствительного используйте DTMF или код из SMS.
- Формат ответа. Никакого markdown, ссылок и списков, иначе TTS прочитает «звёздочка, звёздочка». Источники RAG озвучиваются названием раздела, а ссылка уходит в SMS или чат (промпт под TTS — модуль 3).
- Безопасность. Голос — не аутентификация: его клонируют. Голосовая инъекция («игнорируй инструкции») — та же прямая инъекция, поэтому права проверяются в инструментах (модуль 7).
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 и ссылок, первая фраза короткая, источники называются словами.
- Не меньше 50 ходов на одном наборе вопросов и таблица p50/p95 по сегментам: endpoint, STT, LLM TTFT, первая фраза, TTS, начало воспроизведения. Одного итогового числа недостаточно.
- Для 10 ходов voice-to-voice измерен по стереозаписи на клиенте и сопоставлен с серверными метками; разница объяснена (сеть, аудиостек).
- Назван и обоснован целевой p95 до первого звука. Если не уложились — названо, какой сегмент двигает хвост и какой приём применён (стриминг по фразам, короткая первая фраза, кэш, модель меньше), с цифрами до и после.
- Качество не просело: eval-набор проекта B прогнан на голосовом промпте и на текстовом, результаты сравнены.
Задание 2: barge-in с корректной историей
Реализуйте перебивание в своём конвейере или расширьте Turn из примера.
- Тесты: перебивание после N-го чанка — в истории ровно произнесённое и отметка обрыва; перебивание до первого звука — ответа в истории нет;
go test -raceчистый, горутины не утекают (synctest или проверка их числа)тесты стабильно зелёные при многократном прогоне (@RepeatedTest), потоки хода не утекают (@Timeoutи проверка, что послеturnне осталось живых потоков). - Фильтр ложных перебиваний: минимальная длительность речи и минимум слов. Если STT за 600 мс не вернул ни слова, агент продолжает фразу. Есть тесты на кашель, «угу» и настоящее перебивание.
- Время реакции, от начала речи пользователя до тишины агента: p95 измерен по записи, объяснено, из чего он складывается (порог VAD, сеть, буфер клиента).
- Эксперимент с эхом: перебивания на громкой связи с AEC и без; результат записан.
Задание 3: извлечение данных со сканов и скриншотов с eval
Возьмите не меньше 30 реальных документов одного типа: счета, чеки, формы или скриншоты из документации проекта A (таблицы тарифов, схемы). Извлеките поля VLM по JSON-схеме с валидацией, как в invoice.Validate.
- Есть размеченный эталон и метрики: точность по полям, доля документов без ошибок в критичных полях, доля ответов «нет данных» там, где поля действительно нет.
- Прогон на двух разрешениях (например, 1568 и 800 px по длинной стороне): построена кривая «качество / стоимость документа», выбор разрешения обоснован.
- Сравнение с OCR-базой (Tesseract или облачный OCR + правила) на тех же документах: где VLM выигрывает, где проигрывает, сколько стоит документ в каждом варианте.
- Валидация поймала хотя бы одну реальную ошибку модели; описано, что с ней делает пайплайн (повтор с кропом, очередь на человека).
- Для проекта A: сканы и скриншоты из базы знаний проходят через этот пайплайн и попадают в индекс текстом (модуль 11).
Задание 4 (по желанию): спекулятивный старт и филлер
Добавьте в конвейер ранний конец реплики: LLM стартует по EagerEndOfTurn (или по ранней гипотезе VAD), отменяется по TurnResumed, а звук не играет до подтверждения. Отдельно добавьте филлер из кэша TTS, если инструмент или поиск не ответил за 700 мс.
- На одном и том же наборе измерены выигрыш p50/p95 до первого звука и прирост числа вызовов LLM в процентах.
- Тест:
TurnResumedотменяет черновой ответ, и в истории его нет.
Самопроверка
Почему «сколько позиций в накладной?» — плохой вопрос к 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 и задачи; есть ли воспроизводимая регрессия через симуляцию; связаны ли офлайн- и онлайн-метрики и цикл «инцидент → новый кейс».
Источники
- Anthropic — Vision (лимиты, патчи 28×28, уровни разрешения, ограничения) и PDF support
- Rahmanzadehgervi et al., 2024 — Vision language models are blind (BlindTest: пространственные задачи)
- Kim et al., 2021 — OCR-free Document Understanding Transformer (Donut)
- Mathew et al., 2020 — DocVQA (метрика ANLS), Masry et al., 2022 — ChartQA, Liu et al., 2023 — OCRBench
- Kwindla Hultman Kramer et al. — Voice AI & Voice Agents: An Illustrated Primer (бюджет латентности, каскад против S2S, метрики перебиваний)
- OpenAI — Realtime API, Realtime conversations (перебивание,
conversation.item.truncate), Voice activity detection, WebRTC и SIP; прайс - Google — Gemini Live API
- Deepgram — Flux (конец реплики в STT, eager end-of-turn) и ElevenLabs — Models
- LiveKit Agents — Turn detection and interruptions и Pipecat — Speech input (VAD, Smart Turn, перебивания)
- Silero VAD
- Twilio — Media Streams WebSocket messages (
media,mark,clear) - MDN — echoCancellation
- Stivers et al., 2009 — Universals and cultural variation in turn-taking in conversation (PNAS) и Levinson, Torreira, 2015 — Timing in turn-taking and its implications for processing models of language
- ITU-T G.114 — One-way transmission time
- Radford et al., 2022 — Robust Speech Recognition via Large-Scale Weak Supervision (Whisper) и jiwer (WER)
- Défossez et al., 2024 — Moshi: a speech-text foundation model for real-time dialogue и Lin et al., 2025 — Full-Duplex-Bench
- JEP 444 — Virtual Threads и JEP 453 — Structured Concurrency
- Go — testing/synctest и context.WithCancelCause