Голосовой ИИ-бот к базе 1С: конвейер, в котором всё решает задержка

Голосовой ИИ-бот, который отвечает про погоду и анекдоты, собирается за вечер. Голосовой ИИ-бот, у которого можно спросить «какие заказы у Видео маркета» и получить настоящие номера, даты и суммы из рабочей УТ, — это уже инженерная задача, и упирается она в две вещи сразу. Первая: данные закрыты в 1С, а модель про них ничего не знает и с удовольствием придумает. Вторая: разговор — это реальное время. Полторы секунды паузы собеседник ещё терпит, три — уже переспрашивает «алло».

Ниже — разбор голосового агента, который я собрал под заказы клиентов в 1С: браузер снимает звук, VAD режет реплики, GigaAM распознаёт, LangGraph думает, данные берутся строго через MCP-сервер внутри 1С, F5-TTS отвечает голосом. Плюс замеры задержки на каждом участке — с указанием, на каком железе они сняты.

Сборка такого агента под свою учётную базу — одна из практик курса «ИИ для 1С: разработка ИИ-агентов, RAG и MCP-серверов».

Почему обычный голосовой бот не может ответить «что по заказу ТД00-000007»?

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

Первое — честный отказ: «у меня нет доступа к вашей учётной системе». Второе, гораздо более неприятное, — модель отвечает уверенно и мимо. В моём журнале разговоров есть показательный ход: на законный вопрос «а по другим заказам какая информация» агент переписал свой же предыдущий ответ, поменял дату и назвал кабели в заказе, где на самом деле была бытовая техника. Ни одного вызова инструмента при этом не было — модель просто продолжила связный текст.

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

Из чего на самом деле состоит голосовой агент

Расхожая схема «STT → промпт → TTS» описывает игрушку. Рабочий конвейер выглядит так:

браузер (микрофон, AudioWorklet) → WebSocket → VAD (silero) → STT (GigaAM)
→ LLM (LangGraph) → MCP-сервер 1С → TTS (F5-TTS_RUSSIAN)
→ WebSocket → браузер (динамик)

Семь участков, и у каждого свой счёт в миллисекундах. Цель проекта была записана заранее: «голос-в-голос» не больше 1.8 с на первом этапе и не больше 1.5 с на втором. Бюджет расписан по этапам — 0.40 с на определение конца фразы, 0.20 с на распознавание, 1.00 с до первого токена модели, 0.20 с до первого куска синтеза, 0.10 с на транспорт и буфер. Такой бюджет — не бюрократия, а единственный способ понять, какой участок чинить: без него оптимизация превращается в перебор.

Захват звука делает AudioWorklet в браузере — код, который исполняется не в главном потоке страницы, а в звуковом потоке, со своими правилами: ни document, ни window, ни возможности подождать. Опоздание там означает не подтормаживание интерфейса, а пропуск куска звука. Работы у него ровно одна — копить отсчёты до чанка и отдавать наверх:

process(inputs) {
    const channel = inputs[0] && inputs[0][0];
    if (!channel || channel.length === 0) {
        return true;   // граф ещё собирается или уже разбирается — это норма
    }
    for (let i = 0; i < channel.length; i++) {
        this._chunk[this._chunkLen] = channel[i];
        this._chunkLen += 1;
        if (this._chunkLen === CHUNK_SIZE) {
            const out = this._chunk;
            // память не копируется, а переезжает в главный поток
            this.port.postMessage(out, [out.buffer]);
            this._chunk = new Float32Array(CHUNK_SIZE);
            this._chunkLen = 0;
        }
    }
    return true;
}

Частоту пересчитывает сам браузер — контекст создан на 16 кГц, звук приходит уже в ней. Формат чисел тоже не меняем: распознавание работает с float32 в диапазоне −1…1, а Web Audio ровно такие числа и отдаёт. Переводить их в целые здесь, чтобы сервер поделил обратно, — работа ради работы в самом неудобном для отладки месте. Размер чанка — 512 отсчётов, то есть 32 мс звука: это требование silero VAD, у него окно фиксированное.

Где заканчивается реплика и почему без VAD агент либо перебивает, либо молчит?

Микрофон не сообщает, что человек договорил. Он отдаёт непрерывный поток, и решение «фраза кончилась» принимает код. Ошибиться можно в обе стороны, и обе ошибки слышны.

Порог слишком короткий — агент рвёт реплику на паузах между словами и начинает отвечать на половину вопроса. Порог слишком длинный — человек договорил, а агент ещё три секунды думает, что продолжение будет. Автомат границ выглядит компактно, вся суть — в накоплении тишины:

    def feed(self, chunk: array.array) -> dict | None:
        if self._is_speech(chunk):
            self.silent_chunks = 0
            if not self.speaking:
                self.speaking = True
                self.speech_chunks = 1
                return {"event": "speech_start"}
            self.speech_chunks += 1
            return None

        if not self.speaking:
            return None                      # тишина в тишине — событий нет

        # Тишина посреди реплики: копим, но фразу не рвём —
        # пауза между словами это ещё не конец.
        self.silent_chunks += 1
        self.speech_chunks += 1
        if self.silent_chunks < self.silence_limit:
            return None
        ...

В учебной сборке _is_speech — просто RMS выше порога, и этого хватает, чтобы увидеть механику. В боевой версии на его месте стоит silero: инференс на одном окне занимает доли миллисекунды, поэтому детектор синхронный и зовётся прямо из цикла событий, без вынесения в поток. Хвост тишины в длительность реплики не входит — он не часть речи, а способ понять, что она кончилась.

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

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

Откуда агент берёт данные: MCP-сервер живёт внутри 1С

Данные не выгружаются, не дублируются и не индексируются заранее. Агент ходит за ними в живую базу через MCP-сервер, который написан на BSL и опубликован как HTTP-сервис самой 1С — JSON-RPC поверх Streamable HTTP, отдельного шлюза на Python между агентом и базой нет.

Инструмент в такой конструкции — это обычный общий модуль. Одна функция описывает контракт (имя, описание, JSON-схему аргументов), вторая выполняет работу. Причём описание инструмента — не документация, а часть промпта: модель читает именно его и по нему решает, звать ли инструмент и с какими аргументами.

Инструмент.Вставить("name",        "get_orders_report");
Инструмент.Вставить("description",
    "Список заказов клиентов с номерами, датами, суммами и итогом.
    |Этого достаточно для ответа на вопросы вида «покажи заказы»,
    |«заказы партнёра», «информация по заказам».
    |НЕ вызывай другие инструменты после этого, если пользователь
    |не просил детали по конкретному заказу. Все параметры необязательные.");
Инструмент.Вставить("inputSchema",  Схема);

Формулировки в описании — не украшение. Строка «не вызывай другие инструменты после этого» появилась после того, как агент на простой вопрос устраивал цепочку из трёх походов в базу; строка про необязательность параметров — после того, как он самовольно добавлял фильтр по датам, которого никто не просил. Как из BSL собирается полноценный MCP-сервер — со схемами, маршрутизацией JSON-RPC и публикацией — я разбирал отдельно в статье про MCP-сервер для 1С.

На стороне агента инструменты подтягиваются один раз при старте и подмешиваются в граф:

mcp_tools = await load_mcp_tools()
# Недоступная 1С не роняет старт: вернётся пустой список, и агент
# честно скажет, что базы под рукой нет, вместо выдуманных заказов.
app.state.graph = build_graph(InMemorySaver(), mcp_tools=mcp_tools)

Два решения тут стоит забрать себе. Первое: пустой список инструментов — валидное состояние, а не авария. База недоступна — агент об этом говорит, а не фантазирует. Второе: белый список. У сервера 1С есть свой get_current_datetime — ровно то же имя, что у служебного инструмента агента, и в реестре имя одно: второй молча затирает первый. Плюс демо-инструменты вроде add_numbers, которые ничего не делают, но занимают место в описании, а описание инструментов уезжает в модель на каждом ходе.

Объём ответа — вообще отдельная статья расходов. Сырой list_partners на 192 партнёрах отдаёт около 16 тыс. байт, это примерно 7 тыс. токенов, и они попадали бы в историю на каждом ходе. Обёртка, которая ищет партнёра кодом и возвращает только найденного, сокращает ответ до 78 байт. Как устроен сам графовый «мозг» с состоянием и инструментами — в разборе агента на LangGraph для 1С.

Почему промпта мало и что заставляет модель идти в базу?

Правило «первое действие в ходе — вызов инструмента» стояло в системном промпте первой строкой и было продублировано в конце. Модель всё равно иногда отвечала из головы — и именно на тех ходах, где память графа уже не пуста, то есть где придумать связный ответ проще всего.

Лечится это не уговорами, а запретом до генерации. Если реплика похожа на вопрос про данные базы, модель вызывается не обычная, а связанная с tool_choice="required":

must_call_tool = forced is not None and _is_data_question(state["messages"])
if must_call_tool:
    obs.info("вопрос про базу — инструмент обязателен (tool_choice=required)")
response = await (forced if must_call_tool else llm).ainvoke(prompt)

_is_data_question намеренно грубая: проверяет, что последнее сообщение пришло от человека (а не вернулось из инструментов — иначе модель гоняло бы по кругу на пересказе результата) и что в нём есть слова домена — заказ, товар, сумма, клиент, дата, сколько, когда. Без проверки слов домена tool_choice="required" на «здравствуйте» заставил бы модель схватить случайный инструмент вслепую.

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

Где набирается задержка и как её отдают обратно?

Теперь цифры. Все — из журнала замеров проекта, с условиями.

Первая версия работала на облачной модели qwen-flash. Ход с двумя обращениями в 1С: время до первого токена 5.39 с, до первого звука 0.96 с, «голос-в-голос» — 6.35 с. Разговором это не назвать. При этом сама 1С в бюджете почти не видна: медиана вызова MCP-инструмента — 137–144 мс, подключение с получением списка инструментов — 333 мс один раз при старте. То есть суммарно поход в чужую базу занял 0.32 с из 8.5 — дорога не база, а цепочка обращений к модели.

Что дало результат:

  • Свой сервер вместо облака. LLM (SGLang, Qwen3.6-35B-A3B в NVFP4) и сервис F5-TTS на TensorRT-LLM подняты на отдельной машине с картами NVIDIA; VAD и STT считаются на машине клиента. Тот же ход с двумя вызовами MCP: время до первого токена 0.96–1.33 с вместо 5.39 с, до первого звука 0.33 с вместо 0.96 с, «голос-в-голос» 1.29–1.66 с вместо 6.35 с.
  • Ранний старт синтеза. Штатная сегментация отдаёт предложение, только когда началось следующее, — для ответа в одну фразу синтез стартовал бы после полной генерации. Первый кусок режется раньше: по паузной пунктуации либо по первому пробелу после заданной длины. «Привет,» уходит в синтез, пока модель догенерирует остальное. Просодия на стыке слегка страдает, речь начинается на сотни миллисекунд раньше.
  • Меньше ходов и меньше истории. Из графа убран диспетчер, который тратил отдельный вызов модели только на то, чтобы передать реплику специалисту, — первый ход разговора подешевел с 5.6 до 3.8 с ещё на облаке. Данные из 1С перестали складываться в историю целиком: в неё попадает короткая сводка, а сами заказы живут в состоянии графа. Ход агента с обращением в базу и вызовом субагента-аналитика после этого укладывается в 0.62–0.74 с.

Итоговый живой замер от 28.07.2026, три реплики подряд: «голос-в-голос» 0.495, 0.559 и 0.698 с. Против 1.6–1.8 с до переноса на свой сервер. Локальный F5-TTS на 3080 Ti на машине клиента отдавал первый кусок за 0.60 с — на сервере с TensorRT-LLM тот же кусок выходит за 0.32 с, и это при том, что сеть между ними стоит всего 0.01–0.03 с.

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

И совсем не техническая находка, которую я бы без живых прогонов не получил. После того как числа перед синтезом стали проговариваться словами (иначе «520 406.50 руб.» звучит как попало), реплика, где агент перечислял пять заказов с датами и суммами, выросла до 55.6 с звука. Сумма с копейками — это девять слов вместо одного числа. Лечится это не постобработкой, а краткостью: жёсткое правило «максимум три предложения, список не зачитывать» вернуло те же ответы к 12–17 с. Голосовой интерфейс требует переписать сам формат ответа, а не только ускорить конвейер.

Что забрать себе, если строите свой голосовой агент

  • Бюджет задержки расписывается по участкам до начала работы. Иначе непонятно, что чинить: суммарные «три секунды» ничего не говорят о том, где они набрались.
  • Единственный источник фактов — внешняя система, и поход в неё принудительный. Промпт эту дисциплину не держит, держит tool_choice и белый список инструментов.
  • Объём ответа инструмента — это задержка. Всё, что попадает в историю, оплачивается заново на каждом следующем ходе.
  • Границы речи важнее качества распознавания. Кривой транскрипт агент переспросит, порванную на середине реплику — нет.
  • Мерить надо живым голосом. Текстовый прогон графа не поймает ни того, что STT слышит «Видео маркет» слитно как «Видеомаркет» и поиск партнёра из-за этого не срабатывает, ни того, что ответ стал звучать почти минуту.

Синтез речи — сам по себе большой участок этого конвейера, и я разбирал его отдельно: локальная модель, ударения, потоковая отдача аудио — в статье про свой сервис синтеза речи. Здесь он намеренно взят как готовый блок.

Как собрать связку целиком — граф агента, инструменты, промпты и MCP-сервер на стороне 1С — мы проходим на курсе «ИИ для 1С: разработка ИИ-агентов, RAG и MCP-серверов».

Где проходит граница этой статьи

В статье — архитектура и работающий скелет: контракт участков конвейера, устройство детектора границ речи, роль MCP-сервера внутри 1С, механика принудительного вызова инструмента и разложенный по участкам бюджет задержки. Этого достаточно, чтобы собрать свой стенд и понять, на что смотреть в замерах.

За кадром осталось то, что определяет, поедет ли конструкция на чужой базе: тюнинг VAD под конкретный микрофон и шумную комнату, спекулятивный запуск модели по частичному транскрипту, устройство producer-consumer внутри сессии и корректная отмена задач при перебивании, банк голосов и подмена референса на лету, деплой за nginx с WebSocket и TLS, сценарные тесты многоходовых диалогов с детекторами выдуманных дат. Всё это разбирается на курсе «ИИ для 1С: разработка ИИ-агентов, RAG и MCP-серверов» — там мы собираем голосового агента целиком и прогоняем его на живой базе, а не на демо-данных.

Частые вопросы