
# Введение
Многие представляют голосового агента как простую цепочку из трёх этапов: распознавание речи (STT), большая языковая модель (LLM) и синтез речи (TTS). Соединил – и готово. Такая схема действительно рабочая, и она описывает простейшую архитектуру, где каждый этап дожидается полного завершения предыдущего. Но в 2026 году это далеко не производственный стандарт – подобный подход слишком медлителен для живого разговора.
Настоящая сложность не в промте и даже не в модели, а в оркестровке: задержки, управление очередностью реплик, вызовы инструментов и обработка прерываний поверх базовой цепочки STT-LLM-TTS. Голос – это задача turn-taking, а не транскрипции. Семантическое определение конца реплики, отмена при перебивании, потоковая передача и время до первого токена – вот рычаги, отделяющие агента, который звучит естественно, от того, что напоминает автоответчик с прикрученным чат-ботом.
В этой статье мы разберём конвейер на реальные компоненты: потоковое распознавание речи, детекция окончания фразы, стриминг генерации ответа, обработка перебивания и вызов инструментов в условиях голосового интерфейса. Покажем, за что отвечает каждый из них, где обычно ломается, и приведём рабочие примеры кода. Ни один фрагмент не требует микрофона или платных API – каждый компонент демонстрируется изолированно, как вы и рассуждали бы при проектировании системы.
# Почему последовательный подход не годится
Начнём с архитектурного выбора, потому что он определяет, применимы ли вообще остальные соображения к вашей системе.
В последовательной схеме пользователь говорит, STT распознаёт всю фразу, LLM генерирует полный ответ, TTS синтезирует аудио – и только после этого пользователь что-то слышит. Это простейший в реализации и осмыслении паттерн. Он же и самый медленный: каждый этап простаивает в ожидании завершения предыдущего, и задержки суммируются.
Потоковая архитектура – производственный стандарт: каждый этап передаёт результат следующему инкрементально. STT стримит частичные расшифровки в LLM, LLM отдаёт токены в TTS, а TTS начинает синтезировать и воспроизводить аудио с первого законченного предложения, пока LLM ещё догенерирует продолжение. Такую систему строить действительно сложнее; она требует аккуратной обработки прерываний, буферизации и частичного состояния – именно об этом пойдёт речь далее. Но только она укладывается в допустимый бюджет задержки.
Бюджет этот не абстрактная цель. В человеческом разговоре пауза между репликами естественно составляет 200-300 мс. Задержка ответа более 500 мс уже ощущается как заметное торможение, а при задержке свыше 3 секунд большинство пользователей теряют интерес или решают, что система сломалась. Современные решения speech-to-speech от ведущих провайдеров укладываются в 0.8-3 секунды до первого токена. Таким образом, лишь выбор архитектуры определяет, попадёт ли ваш агент в зону «естественного разговора» или в зону «собеседник бросает трубку», ещё до того, как будет произнесено первое слово ответа.
# Потоковое распознавание речи
Задача первого компонента голосового агента – не «расшифровать аудиофайл», а непрерывно обрабатывать входящий аудиопоток, выдавая расшифровки по мере того, как пользователь говорит, и сигнализируя, когда он с высокой уверенностью закончил мысль. Промышленный STT для голосовых агентов работает через постоянное WebSocket-соединение. Аудио уходит небольшими чанками порядка 50 мс, а обратно приходят события с текстом – не единый блокирующий вызов, возвращающий итоговую строку лишь в самом конце.
Это различие критично, потому что сама расшифровка меняется «на лету». Настоящий потоковый движок STT генерирует частичные события, которые обновляются по мере поступления аудиоданных и уточнения моделью своего предположения, после чего следует одно финальное событие, когда слова «устаканились». Точность на сущностях – номерах заказов, телефонных номерах, именах собственных – здесь непропорционально важна: одна неверно распознанная цифра полностью ломает вызов функции ниже по цепочке, тогда как живой человек просто переспросил бы.
# streaming_stt.py
# Prerequisites: Python 3.10+, standard library only
# Run: python streaming_stt.py
import asyncio
from dataclasses import dataclass
from enum import Enum
class TranscriptEventType(Enum):
PARTIAL = "transcript.user.delta" # live, still-changing transcript
FINAL = "transcript.user" # confirmed, won't change again
@dataclass
class TranscriptEvent:
event_type: TranscriptEventType
text: str
confidence: float = 1.0
class MockStreamingSTT:
"""
Stands in for a real STT WebSocket connection.
Real implementations send audio chunks and receive these same two event types back --
partial deltas while the user is mid-utterance, then one final event once the model is
confident the words are settled.
"""
def __init__(self, simulated_utterance: str):
words = simulated_utterance.split()
self._partial_stages = [" ".join(words[:i]) for i in range(1, len(words) + 1)]
async def stream_events(self):
for stage in self._partial_stages[:-1]:
yield TranscriptEvent(TranscriptEventType.PARTIAL, stage, confidence=0.7)
await asyncio.sleep(0) # yield control, simulating real async I/O
yield TranscriptEvent(TranscriptEventType.FINAL, self._partial_stages[-1], confidence=0.97)
async def consume_transcript_stream(stt: MockStreamingSTT):
"""
The pattern every voice agent client implements:
render partial transcripts live for responsiveness, but only act on the FINAL event downstream --
partials can and do change before that.
"""
final_transcript = None
partial_count = 0
async for event in stt.stream_events():
if event.event_type == TranscriptEventType.PARTIAL:
partial_count += 1
print(f" [partial] '{event.text}' (confidence={event.confidence})")
elif event.event_type == TranscriptEventType.FINAL:
final_transcript = event.text
print(f" [FINAL] '{event.text}' (confidence={event.confidence})")
return final_transcript, partial_count
async def main():
stt = MockStreamingSTT("My order number is A B 3 7 9 2")
final_text, n_partials = await consume_transcript_stream(stt)
print(f"\nFinal transcript used downstream: '{final_text}'")
print(f"Partial events received before final: {n_partials}")
asyncio.run(main())Как запустить: python streaming_stt.py, внешние зависимости не требуются.
Последующий код реагирует только на единственное событие FINAL, хотя до него пришло девять частичных расшифровок – по мере пословного построения высказывания. Именно такое разделение – показывать частичные результаты для живости интерфейса, но действовать только по подтверждённому итогу – реализует любой реальный клиент потокового STT, будь то Voice Agent API от AssemblyAI или другой продакшен-эндпоинт.
# Детекция окончания реплики: когда пользователь действительно закончил
Этот компонент легко пропустить мысленно, потому что кажется, что он является частью этапа STT. Но это не так, и выделение его в отдельную заботу позволяет гибко настраивать поведение. Детекция окончания реплики – это метод системы, решающий, когда говорящий завершил мысль и агенту пора отвечать. Она опирается на паттерн тишины в аудиопотоке, а не на текстовое содержание расшифровки, поэтому это отдельная логика от STT.
Ошибка в любую сторону ломает разговор по-разному. Слишком раннее срабатывание – и агент перебивает человека, задумавшегося посреди фразы. Слишком позднее – и в каждом обмене репликами появляется неловкая пауза, из-за которой вся система кажется медлительной, даже если LLM отвечает мгновенно. В реальных системах это настраивается двумя числами: минимальная длительность тишины для объявления конца реплики (обычно около 600 мс), причём переход происходит только когда и расшифровка говорит о смысловой завершённости; и максимальный потолок тишины, принудительно вызывающий ответ даже при неоднозначной паузе (часто 1500 мс). В контекстах с обдуманной речью, например, для пожилых людей или в медицине, потолок поднимают до 2500 мс; для быстрых диалогов минимум снижают до 300 мс. Это настраиваемое политическое решение, а не архитектурная константа.
# turn_detection.py
# Prerequisites: Python 3.10+, standard library only
# Run: python turn_detection.py
from dataclasses import dataclass
from enum import Enum
class TurnState(Enum):
LISTENING = "listening"
SILENCE_PENDING = "silence_pending" # silence detected, not yet long enough to decide
END_OF_TURN = "end_of_turn"
@dataclass
class AudioFrame:
is_speech: bool
timestamp_ms: int
class TurnDetector:
"""
Standalone turn-detection state machine -- consumes a stream of (is_speech, timestamp) frames
and decides when the user has finished speaking.
Deliberately separate from STT: STT produces transcripts; turn detection decides WHEN to stop
listening and let the agent respond, using the silence pattern in the audio stream itself.
"""
def __init__(self, min_silence_ms: int = 600, max_silence_ms: int = 1500):
self.min_silence_ms = min_silence_ms
self.max_silence_ms = max_silence_ms
self._silence_start: int | None = None
self.state = TurnState.LISTENING
def process_frame(self, frame: AudioFrame, utterance_looks_complete: bool = True) -> TurnState:
"""
utterance_looks_complete carries the semantic signal from the transcript side --
whether what the user has said so far sounds like a finished thought.
The minimum threshold ends the turn only when that signal agrees;
the maximum threshold ends it regardless.
"""
if frame.is_speech:
# Any speech resets the silence clock entirely
self._silence_start = None
self.state = TurnState.LISTENING
return self.state
if self._silence_start is None:
self._silence_start = frame.timestamp_ms
silence_duration = frame.timestamp_ms - self._silence_start
if silence_duration >= self.max_silence_ms:
self.state = TurnState.END_OF_TURN # hard ceiling -- force a response
elif silence_duration >= self.min_silence_ms and utterance_looks_complete:
self.state = TurnState.END_OF_TURN # confident enough silence has settled
else:
self.state = TurnState.SILENCE_PENDING
return self.state
if __name__ == "__main__":
print("Complete-sounding utterance -- the minimum threshold applies:")
detector = TurnDetector(min_silence_ms=600, max_silence_ms=1500)
frames = [
AudioFrame(True, 0),
AudioFrame(True, 100),
AudioFrame(True, 200),
AudioFrame(False, 300),
AudioFrame(False, 400), # brief pause -- a thinking pause
AudioFrame(True, 500),
AudioFrame(True, 600), # speaker resumes
AudioFrame(False, 700),
AudioFrame(False, 900),
AudioFrame(False, 1100),
AudioFrame(False, 1300), # silence clock reaches 600ms here
]
for f in frames:
state = detector.process_frame(f)
print(f" t={f.timestamp_ms:>5}ms speech={f.is_speech!s:>5} -> {state.value}")
print("\nUtterance that still sounds unfinished -- the ceiling applies:")
trailing_detector = TurnDetector(min_silence_ms=600, max_silence_ms=1500)
trailing_frames = [AudioFrame(True, 0)] + [AudioFrame(False, t) for t in range(100, 1800, 400)]
for f in trailing_frames:
state = trailing_detector.process_frame(f, utterance_looks_complete=False)
print(f" t={f.timestamp_ms:>5}ms speech={f.is_speech!s:>5} -> {state.value}")Как запустить: python turn_detection.py, внешние зависимости не требуются.
Пауза между t=300 мс и t=500 мс никогда не выходит за пределы silence_pending, потому что говорящий возобновляет речь до того, как счётчик тишины достигает минимального порога – именно такую паузу посреди предложения не следует считать концом реплики. Когда же говорящий действительно замолкает после t=700 мс, счётчик непрерывно растёт и корректно выдаёт end_of_turn на t=1300 мс. Второй прогон показывает важность потолка: при семантическом сигнале «мысль не завершена» минимальный порог полностью игнорируется, и конец реплики наступает только при достижении жёсткого лимита. В этом и заключается ценность разделения min_silence_ms и max_silence_ms на два независимых настраиваемых параметра вместо одного фиксированного таймаута.
# Потоковая передача ответа в синтез речи
Этот раздел конкретизирует потоковую архитектуру из первого раздела в точке самого важного перехода. Как только зафиксирован конец реплики, LLM должна стримить токены по мере их генерации, а не ждать полного ответа. TTS, в свою очередь, должен запускать синтез аудио с первого законченного предложения, не дожидаясь всей реплики. Единица, которая передаётся из потока LLM в TTS, – не токен и не весь ответ, а целое предложение, определяемое в момент появления его границы в накопительном буфере.
# sentence_chunker.py
# Prerequisites: Python 3.10+, standard library only
# Run: python sentence_chunker.py
import asyncio
import re
SENTENCE_END_PATTERN = re.compile(r'(?<=[.!?])\s+')
async def mock_llm_token_stream(text: str):
"""
Stands in for a real streaming LLM call.
Yields one token (word) at a time, simulating tokens arriving incrementally
rather than the full response appearing all at once.
"""
for word in text.split(" "):
yield word + " "
await asyncio.sleep(0)
async def stream_sentences(token_stream) -> list[str]:
"""
The handoff unit between LLM streaming and TTS synthesis:
complete sentences, not raw tokens.
The instant a sentence boundary appears in the accumulated buffer,
that sentence is yielded so TTS can start speaking it while the LLM is still generating
what comes after it.
"""
buffer = ""
sentences = []
async for token in token_stream:
buffer += token
match = SENTENCE_END_PATTERN.search(buffer)
while match:
sentence = buffer[:match.start() + 1].strip()
sentences.append(sentence)
print(f" [sentence ready for TTS] '{sentence}'")
buffer = buffer[match.end():]
match = SENTENCE_END_PATTERN.search(buffer)
# Whatever remains once the stream ends is the final fragment --
# still needs to be flushed to TTS even without terminal punctuation.
if buffer.strip():
sentences.append(buffer.strip())
print(f" [final fragment flushed] '{buffer.strip()}'")
return sentences
async def main():
text = (
"Let me check that for you. Your order shipped yesterday and "
"should arrive Thursday. Is there anything else I can help with"
)
sentences = await stream_sentences(mock_llm_token_stream(text))
print(f"\nTotal sentences yielded: {len(sentences)}")
asyncio.run(main())Как запустить: python sentence_chunker.py, внешние зависимости не требуются.
На выходе получаем три предложения, причём первое, «Let me check that for you», готово для начала синтеза задолго до того, как LLM допишет третье. Именно такая ранняя передача и делает потоковый TTS отзывчивым: пользователь слышит, как агент начинает говорить через сотни миллисекунд после старта генерации LLM, вместо ожидания полного ответа.
# Обработка перебивания без разрушения состояния
Перебивание (barge-in) повсеместно считается самой сложной частью инженерии голосовых агентов, и здесь этому вопросу стоит уделить наибольшее внимание. Перебивание требует одновременного выполнения четырёх действий: остановка воспроизведения TTS, отмена текущей генерации TTS, отмена генерации LLM и сброс потокового состояния. Пропуск любого из этих шагов приводит либо к тому, что агент говорит поверх пользователя, либо, что хуже, заканчивает старую мысль уже после того, как его перебили, – такое поведение кажется неестественным, и его трудно диагностировать, если не знать о необходимости проверять каждый из четырёх шагов по отдельности.
Ключевой момент, определяющий надёжность перебивания, – защита от ложных срабатываний. Ложное перебивание возникает, когда детектор голосовой активности (VAD) принимает фоновый шум, кашель или посторонний разговор за реальную попытку перебить, и агент обрывает себя на полуслове без видимой для пользователя причины. Предотвращение строится на комбинации трёх сигналов: энергетический порог (обычно от -45 до -35 dBFS), классификатор голоса, например Silero VAD или WebRTC VAD, отличающий речь от шума, и защитный интервал минимальной длительности – обычно 200-300 мс – в течение которого голос должен сохраняться, прежде чем будет зафиксировано перебивание. Одиночный громкий кашель не должен останавливать агента; именно для этого и нужен временной фильтр.
# bargein_detector.py
# Prerequisites: Python 3.10+, standard library only
# Run: python bargein_detector.py
from dataclasses import dataclass
@dataclass
class AudioChunk:
energy_dbfs: float # signal energy in dBFS
voice_confidence: float # 0.0-1.0, output of a voice classifier like Silero VAD
timestamp_ms: int
class BargeInDetector:
"""
Combines three signals to decide whether the user is genuinely interrupting the agent,
or whether background noise, a cough, or a side conversation is being mistaken for real speech.
Missing any one of these three checks is what causes false-barge-in.
"""
def __init__(
self,
energy_threshold_dbfs: float = -40.0, # within the -45 to -35 production range
voice_confidence_threshold: float = 0.6,
min_duration_ms: int = 250, # within the 200-300ms production range
):
self.energy_threshold = energy_threshold_dbfs
self.voice_threshold = voice_confidence_threshold
self.min_duration_ms = min_duration_ms
self._candidate_start_ms: int | None = None
def process_chunk(self, chunk: AudioChunk) -> bool:
"""
Returns True the instant a real barge-in should fire -- i.e. all three conditions
have held continuously for at least min_duration_ms.
"""
passes_energy = chunk.energy_dbfs > self.energy_threshold
passes_voice = chunk.voice_confidence > self.voice_threshold
if not (passes_energy and passes_voice):
# Signal dropped below threshold -- reset the candidate window so a
# brief loud noise can't accumulate duration across separate bursts.
self._candidate_start_ms = None
return False
if self._candidate_start_ms is None:
self._candidate_start_ms = chunk.timestamp_ms
sustained_duration = chunk.timestamp_ms - self._candidate_start_ms
return sustained_duration >= self.min_duration_ms
if __name__ == "__main__":
# A genuine interruption: strong, sustained voice signal for 300ms
detector_1 = BargeInDetector()
real_interruption = [AudioChunk(-30, 0.9, t) for t in range(0, 350, 50)]
fires_1 = [detector_1.process_chunk(c) for c in real_interruption]
print(f"Real interruption (sustained 300ms): fired={any(fires_1)}")
# A single short cough: high energy but drops immediately, never sustains
detector_2 = BargeInDetector()
cough = [
AudioChunk(-28, 0.8, 0),
AudioChunk(-50, 0.1, 50),
AudioChunk(-50, 0.1, 100),
]
fires_2 = [detector_2.process_chunk(c) for c in cough]
print(f"Single cough (<100ms): fired={any(fires_2)}")
# Loud background noise: passes the energy threshold but fails voice classification
detector_3 = BargeInDetector()
background_noise = [AudioChunk(-32, 0.25, t) for t in range(0, 400, 50)]
fires_3 = [detector_3.process_chunk(c) for c in background_noise]
print(f"Loud non-voice background noise: fired={any(fires_3)}")Как запустить: python bargein_detector.py, внешние зависимости не требуются.
Детектор срабатывает на настоящем продолжительном перебивании и корректно молчит как при коротком кашле, так и при громком, но не речевом фоновом шуме. На третьем случае стоит остановиться отдельно: шум, достаточно громкий, чтобы преодолеть только энергетический порог, вызывал бы постоянные ложные срабатывания в шумном помещении. Именно поэтому проверка голосовым классификатором существует как второй независимый фильтр, а не полагается лишь на громкость.
Стоит прямо назвать один известный из практики провальный режим: процесс отмены при перебивании становится по-настоящему опасным, когда нижележащая автоматизация уже сработала ещё до фиксации перебивания – например, вызов платёжного шлюза или запись в базу данных, которая уже ушла. Остановить поток токенов LLM безопасно – токены просто прекращаются. Отменить ушедший платёж – задача совсем иного рода, и именно поэтому в следующем разделе появляется буферизация результатов инструментов.
# Вызов инструментов посреди разговора
В голосовом контексте у вызова инструментов есть проблема, которой просто не существует в текстовом чате: промежуток между отправкой вызова и получением результата слышен. Тишина во время телефонного разговора заставляет пользователя думать, что соединение прервалось, и он начинает говорить, перебивая всё ещё выполняющийся вызов. В текстовом чате трёхсекундная пауза на выполнение функции невидима. В телефонном звонке это разница между ощущением отзывчивости и ощущением поломки.
Документированное решение имеет название: техника преамбулы. Модель инструктируется проговаривать, что она делает перед и во время вызова, например: «Сейчас проверю» или «Минуточку, подгружаю информацию». Это удерживает разговор на слуху, пока реально выполняется функция. Это паттерн промтинга, а не кода, но он решает специфичную для голоса проблему и заслуживает упоминания, потому что напрямую связан со второй сложной задачей этого раздела.
Вторая задача – что делать с результатом инструмента, если пользователь перебил до его получения. Известный производственный шаблон: накапливать результаты по мере поступления, но реально отправлять их только после чистого завершения текущей реплики; если реплика была прервана – все ожидающие результаты отбрасываются. Отправка устаревшего результата в уже ушедший вперёд разговор создаёт ровно ту путаницу состояний, для предотвращения которой и существует обработка перебивания из предыдущего раздела.
# tool_result_buffer.py
# Prerequisites: Python 3.10+, standard library only
# Run: python tool_result_buffer.py
from dataclasses import dataclass
from enum import Enum
class TurnOutcome(Enum):
CLEAN_COMPLETION = "clean_completion"
INTERRUPTED = "interrupted"
@dataclass
class PendingToolResult:
call_id: str
result: dict
class ToolResultBuffer:
"""
Implements the documented production pattern:
accumulate tool results as they arrive mid-turn, but only send them once the current turn
finishes cleanly. If the turn was interrupted instead, discard everything pending -- sending
a stale tool result into a conversation that already moved on is worse than not responding
to the tool call at all.
"""
def __init__(self):
self._pending: list[PendingToolResult] = []
def accumulate(self, call_id: str, result: dict) -> None:
self._pending.append(PendingToolResult(call_id, result))
def resolve_turn(self, outcome: TurnOutcome) -> list[PendingToolResult]:
"""
Called when the current conversational turn ends.
Flushes every pending result downstream on a clean completion,
or discards all of them on an interruption -- there's no partial-credit path here.
"""
pending = list(self._pending)
self._pending.clear()
if outcome == TurnOutcome.CLEAN_COMPLETION:
return pending
return [] # interrupted -- discard everything, send nothing
if __name__ == "__main__":
# Tool call resolves, turn completes cleanly -- result is sent
buffer_1 = ToolResultBuffer()
buffer_1.accumulate("call_abc123", {"temp_c": 22, "condition": "sunny"})
flushed_1 = buffer_1.resolve_turn(TurnOutcome.CLEAN_COMPLETION)
print(f"Clean completion: {len(flushed_1)} result(s) sent -> {flushed_1}")
# Tool call resolves, but user interrupts before the turn completes --
# the result must be discarded, not sent into a conversation that moved on
buffer_2 = ToolResultBuffer()
buffer_2.accumulate("call_def456", {"confirmation_code": "CONF7821"})
flushed_2 = buffer_2.resolve_turn(TurnOutcome.INTERRUPTED)
print(f"Interrupted turn: {len(flushed_2)} result(s) sent (correctly discarded)")
# Multiple parallel tool calls in one turn, resolved together
buffer_3 = ToolResultBuffer()
buffer_3.accumulate("call_weather", {"temp_c": 18})
buffer_3.accumulate("call_calendar", {"next_slot": "2026-06-22T14:00"})
flushed_3 = buffer_3.resolve_turn(TurnOutcome.CLEAN_COMPLETION)
print(f"Parallel tool calls, clean completion: {len(flushed_3)} result(s) sent")Как запустить: python tool_result_buffer.py, внешние зависимости не требуются.
Сценарий с прерыванием возвращает ноль результатов, хотя сам вызов инструмента успешно завершился и сгенерировал корректный код подтверждения. Это осознанное поведение: пользователь уже перевёл разговор в иное русло, и принудительная вставка устаревшего результата означала бы ответ на вопрос, который больше не задают. Третий сценарий подтверждает, что паттерн работает и для параллельных вызовов: оба результата сбрасываются вместе после чистового завершения реплики, что важно, поскольку современные голосовые модели поддерживают параллельный вызов инструментов – в рамках одного хода могут сработать сразу несколько функций.
# Как компоненты соединяются воедино
Соберём всё в целостную картину: поступает аудио, потоковый STT выдаёт частичные расшифровки по мере появления слов, детектор окончания реплики следит за паттерном тишины в том же аудиопотоке и решает, когда пользователь действительно закончил. LLM стримит ответ, а TTS начинает произносить первое готовое предложение задолго до завершения всей генерации. Перебивание может сработать в любой момент после того, как пользователь заговорит снова, а вызов инструмента, когда модели нужно что-то уточнить или выполнить действие, встраивается в этот поток с преамбулой и буфером, а не просто оставляет мёртвую тишину.
Вендоры всё чаще упаковывают всю эту цепочку в единую WebSocket-точку: Voice Agent API от AssemblyAI, OpenAI Realtime API и аналогичные решения берут на себя STT, оркестровку LLM, TTS, детекцию окончания реплики и обработку перебивания на серверной стороне через одно соединение. Поэтому в 2026 году большинство команд, создающих голосовых агентов, разумно интегрируются с одним из таких сервисов, а не пишут все пять компонентов вручную. Это здравое решение по умолчанию. Но понимание того, за что конкретно отвечает каждый компонент, а не просто «голосовой агент это делает», превращает ощущение «агент работает странно» из загадки в отлаживаемую проблему: слишком агрессивный порог перебивания, отсутствующая преамбула при медленном вызове, ошибка распознавания номера заказа, которую чуть более точная настройка VAD могла бы предотвратить.
# Заключение
Голосовой агент – это не чат-бот с приклеенным микрофоном и динамиком. Это пять компонентов, решающих пять проблем, которых в текстовом диалоге просто не существует: потоковая транскрипция вместо блокирующего вызова, детекция окончания реплики как настраиваемая политика, а не фиксированный таймаут, поэлементная передача от LLM к TTS на уровне предложений, детектор перебивания на основе трёх комбинированных сигналов и буферизация результатов инструментов с учётом того, что пользователь может уйти вперёд.
Бюджет задержки, лежащий в основе всего этого, неумолим: 500 мс – примерно та граница, за которой естественность сменяется ощутимой медлительностью. Каждый из перечисленных компонентов либо оберегает этот бюджет, либо незаметно его разрушает. Большинство команд резонно возьмут готовый realtime API вместо реализации с нуля. Но точное знание зоны ответственности каждой части – это и есть то, что позволяет по-настоящему исправить «странно работающего» голосового агента, а не просто перезапустить его и надеяться на лучшее.
Полезные ссылки: