Голосовые системы

Как сейчас проходят аудио и диалог chat-agent

Сверка двух пользовательских схем, 06.10.2026. Код chat-agent проверен на e4bfe285306fc225c3d445ade16296021bd934f3, voice - на работающей ревизии 5d1d9591d57f0b5535884425567c0440b62a91d1. В Insight в 18:49 UTC подтверждены режим ASR streaming, один экземпляр Core, каждого ingress и Ruby runtime, их subjects, общий broker и адрес Ollama proxy. Это проверка контрактов и конфигурации; новые аудиопрогоны и замеры производительности здесь не выполнялись.

Схемы встроены в обзорную презентацию и техническую презентацию. Числа C3/C4 в них сохраняют прежнюю базу сравнения.

Что пришлось уточнить

На присланной схеме Как устроено сейчас
WebRTC/Opus как единственный фиксированный формат WebRTC передаёт защищённый RTP; codec согласуется в SDP. Opus поддерживается, но код не закрепляет его как единственный codec.
WSS сразу даёт тот же PCM 16 kHz Браузер отправляет mono PCM16LE с фактической частотой AudioContext. Resampler ingress переводит его в 16 kHz.
Общий PCM-блок можно принять за отдельный сервис Это общий код src_shared/audio.py, работающий отдельно внутри каждого ingress, со своим FIFO и очередью.
Core управляет всем подключением браузера SDP/ICE обслуживает WebRTC ingress; start/end PCM - WSS ingress. Core выдаёт lease через NATS и принимает ASR final. Настройки микрофона применяет frontend.
Voice и GPU объединены одной стрелкой Streaming bridge делает VAD и отправляет PCM по NATS в inference account; GPU worker возвращает JSON через другой subject. Файловый HTTP API в этот путь не входит.
Runtime → LLM: "ответ или tool_calls" К LLM идёт LlmRequest с mode/context/tools. Текст и tool calls возвращаются в LlmResponse. Активный runtime - Ruby + AsyncGraph.
Все части ответа возвращаются через runtime/Core У LLM есть отдельный pub-sub канал chat.agent.stream к Agent. Итоговый RPC-ответ и UI-события - разные пути.
Всё состояние Agent находится в PostgreSQL История и workflow state сохраняются в БД. Очередь, текущая попытка и накопленный snapshot находятся в памяти Agent.

1. Аудио: сначала транспорт, затем единый PCM-контракт

1. Аудио: сначала транспорт, затем единый PCM-контракт
1. Аудио: сначала транспорт, затем единый PCM-контракт · Открыть в полном размере
Исходный Mermaid
flowchart LR
  B["Браузер: chat-agent.gis-master.ru"]
  R["webrtc_ingress"]
  W["audio_ws_ingress"]
  C["Core: admission и единственный ASR listener"]
  N["Общий NATS: chat-agent_audio_nats:4222"]
  V["voice: stt_whisper_to_nats / streaming bridge + VAD"]
  G["Внешний inference: GPU ASR worker"]
  A["Agent"]
  B -->|"HTTPS /core/offer: JSON с SDP"| R
  B <-->|"WSS /core/ice/{connection_id}: JSON ICE"| R
  B -->|"DTLS-SRTP: RTP audio, codec из SDP"| R
  B -->|"WSS /audio/ws: start JSON, затем binary PCM16LE"| W
  R -->|"Resampler + FIFO внутри ingress: mono 16 kHz"| N
  W -->|"Resampler + FIFO внутри ingress: mono 16 kHz"| N
  R <-.->|"chat.agent.audio.control: JSON request/reply"| C
  W <-.->|"chat.agent.audio.control: JSON request/reply"| C
  N -->|"webrtc.komaroff.asr.stream.{session_id}: header + PCM"| V
  V -->|"to.inference.webrtc.asr.audio.{stream_id}: header + PCM"| G
  G -->|"from.inference.webrtc.asr.events.{stream_id}: JSON"| V
  V -->|"webrtc.komaroff.asr.text.{session_id}: JSON result"| N
  N -->|"partial / final / rejected / error"| C
  C -->|"Только непустой final: nats.agent.user123 RPC"| A
  C -->|"nats.events.user123: ASR и audio_status"| N
  A -->|"nats.events.user123: ответ и состояние запроса"| N
  N -->|"NATS protocol over WSS /ws"| B

Все нарисованные межсервисные NATS-стрелки проходят через broker, даже когда ради читаемости он не повторён между каждой парой. Переход к GPU включает account import/remap и leafnode. Нельзя заменять его HTTP-загрузкой файла или рисовать direct RPC между ingress и GPU.

Публичные адреса и кто принимает запрос

База чата: https://chat-agent.gis-master.ru. HTTPS и WSS защищены штатным OAuth ingress; у ICE есть также token конкретного подключения, у audio WSS - проверка Origin. NATS WebSocket использует отдельную аутентификацию NATS.

Адрес / метод Получатель Что передаётся
GET /core/rtc-capabilities WebRTC ingress JSON {"trickle_ice":1}. При 404 frontend может использовать legacy offer.
POST /core/offer WebRTC ingress application/json: type:"offer", sdp, session_id, необязательный trickle:true. Ответ: type:"answer", sdp, session_id, stream_id; при Trickle также connection_id, ice_endpoint, ice_token.
wss://chat-agent.gis-master.ru/core/ice/{connection_id} WebRTC ingress TEXT JSON: auth, ready, candidate, end, error; candidate содержит RTCIceCandidateInit. Token идёт первым auth-сообщением, не в URL.
DELETE /core/ice/{connection_id} WebRTC ingress Остановка конкретного peer с проверкой header X-ICE-Token; успешный ответ HTTP 204.
wss://chat-agent.gis-master.ru/audio/ws WSS ingress TEXT JSON start за 3 с; затем binary PCM16LE mono. В конце TEXT JSON end. В обратную сторону: ready, ending, stopped, error.
wss://chat-agent.gis-master.ru/ws Общий NATS, порт 9222 за ingress Протокол NATS через WSS; payload событий - UTF-8 JSON. Это другой socket, не канал PCM.
POST /core/message Core JSON: text, session_id, необязательные request_id, turn_id, edit. Ответ - итоговый AgentInboundResponse JSON.
GET /core/history?session_id={uuid} Core → Agent → PostgreSQL JSON истории; Core не читает БД напрямую.
GET / DELETE /core/requests/{request_id}?session_id={uuid} Core → Agent Snapshot / отмена ответа. DELETE не выключает микрофон.

Upstreams ingress в общей сети (внутренние адреса, не публичные URL):

Маршруты Upstream
/core/offer, /core/rtc-capabilities, /core/ice/*, priority 200 http://chat-agent_webrtc_ingress:8000
/audio/ws, priority 200 http://chat-agent_audio_ws_ingress:8000
Остальные /core/* http://chat-agent_src_core:8000
/api/* http://chat-agent_src_api_gateway:8100
/, /docs/, /presentations/ http://chat-agent_src_front:8000
Chat /ws и точный voice /ws chat-agent_audio_nats:9222, WebSocket без внутреннего TLS; внешний ingress даёт WSS и OAuth

Сами WebRTC audio packets идут к IP:port выбранной ICE-пары, а не в POST /core/offer. На 06.10 в работающем ingress настроены stun:stun.gis-master.ru:3478 и turn:stun.gis-master.ru:3478?transport=tcp. TURN credentials остаются на сервере. Браузерный default STUN - тот же stun:stun.gis-master.ru:3478. Codec определяется согласованным SDP; код не вызывает setCodecPreferences, поэтому без SDP конкретного peer нельзя обещать "всегда Opus" или постоянный публичный media port.

WSS start:

{"type":"start","session_id":"<uuid>","format":"pcm16le","channels":1,"sample_rate":48000}

48000 - пример фактической частоты браузера, не фиксированное требование. Допустимы 8000-96000 Hz. Один binary chunk содержит чётное число байтов, не более 8192. ASR и ответ чата возвращаются через NATS /ws, а не через /audio/ws.

Что именно ingress публикует в NATS

Subject: webrtc.komaroff.asr.stream.{session_id}. Формат binary payload:

[4 байта: длина JSON, uint32 big-endian]
[JSON metadata, UTF-8]
[PCM16 little-endian, mono, 16000 Hz]

Один обычный frame - 20 ms: 320 samples / 640 PCM bytes, не считая header. Metadata: type:"frame", seq, sample_rate:16000, channels:1, sample_width:2, session_id, stream_id и безопасный trace carrier. Для type:"end" / "abort" PCM отсутствует. FIFO сохраняет остатки, padding только у последнего хвоста и меньше 20 ms. Общий модуль не означает общую очередь между двумя ingress.

Voice и граница inference

На стороне chat/voice broker:

  • К GPU: to.inference.webrtc.asr.audio.{stream_id}, тот же binary envelope с PCM16LE 16 kHz; bridge добавляет phrase_id, свою seq и speech_samples.
  • От GPU: from.inference.webrtc.asr.events.{stream_id}, UTF-8 JSON.
  • На inference account эти subjects называются соответственно inference.webrtc.asr.audio.{stream_id} и inference.webrtc.asr.events.{stream_id}. Prefix меняет broker import/remap.
  • Желаемый leafnode в DRS: nats://192.168.28.134:37422. В этой сверке не снималась свежая конфигурация удалённого H100; адрес leafnode подтверждён source, не отдельной сетевой пробой.

Bridge возвращает в webrtc.komaroff.asr.text.{session_id}:

{"version":1,"result_type":"final","session_id":"<uuid>","stream_id":"<uuid>","phrase_id":"<id>","revision":2,"text":"<распознанный текст>"}

result_type: partial, final, rejected, error; возможен code, traceparent, tracestate. Core проверяет session/stream/phrase/revision, отбрасывает дубликаты и результаты закрытого потока. Только непустой final запускает диалог. Live-диаризации на этом пути нет. Файловые transcription / diarization UI на https://voice.gis-master.ru/whisper-trasncription/ и других сохранённых путях работают отдельно.

Источники аудио: WebRTC ingress, ICE signaling, WSS ingress, PCM module, Core ASR handler, voice bridge, GPU contract, worker source.

2. Диалог: RPC-итог и части ответа идут разными путями

2. Диалог: RPC-итог и части ответа идут разными путями
2. Диалог: RPC-итог и части ответа идут разными путями · Открыть в полном размере
Исходный Mermaid
flowchart LR
  B["Браузер"]
  C["Core"]
  A["Agent: очередь и текущая попытка в памяти"]
  P[("PostgreSQL: история, entities, artifacts, workflow state")]
  R["Ruby + AsyncGraph: src_langgraph_rb_node"]
  L["LLM adapter"]
  T["API gateway: пять fixture tools"]
  O["Ollama-compatible proxy"]
  N["Общий NATS"]
  B -->|"POST /core/message: JSON; либо ASR final в Core"| C
  C -->|"nats.agent.user123: AgentInboundRequest"| A
  A -->|"AgentInboundResponse: JSON reply"| C
  C -->|"Итог HTTP JSON для текстового POST"| B
  A <-->|"PostgreSQL wire protocol: TCP 5432"| P
  A -->|"nats.workflow.run.ruby.node: WorkflowRunRequest"| R
  R -->|"WorkflowRunResponse: result + next_runtime"| A
  R -->|"nats.llm.user123: LlmRequest mode/input/constraints"| L
  L -->|"LlmResponse: response_text / tool_calls / error"| R
  R -->|"nats.tools.discover и nats.tools.{name}: JSON RPC"| T
  T -->|"Каталог или ToolCallResponse: JSON reply"| R
  L -->|"HTTPS POST /api/chat: JSON messages/tools/format"| O
  O -->|"NDJSON stream; structured mode - JSON"| L
  L -->|"chat.agent.stream: delta + request/attempt/generation/sequence"| A
  A -->|"nats.events.user123: response_fragment/request_state/message/client"| N
  N -->|"wss://chat-agent.gis-master.ru/ws: NATS + JSON payload"| B

На схеме broker опущен между RPC-парами; прямых HTTP-вызовов Agent → Ruby или Ruby → API gateway в этом пути нет. Ответ на RPC отправляется в одноразовый reply inbox NATS, а не обратно в subject запроса.

Внутренние адреса и request/reply payload

Общий адрес для наших NATS clients: nats://chat-agent_audio_nats:4222. Неизменённый voice использует совместимый alias nats://web-rtc-komaroff_audio_nats:4222 того же broker. Это не старый внешний application host. Во всех адресах намеренно опущены credentials.

Кто → кто Subject / адрес Запрос → ответ
Core → Agent nats.agent.user123 AgentInboundRequest: text, turn_id, edit, user_id → AgentInboundResponse: ok, result, status, client_handler/client_events, errors.
Core → Agent, история nats.agent.history.user123 HistoryGetRequest → HistoryGetResponse.
Agent → Ruby nats.workflow.run.ruby.node WorkflowRunRequest: text, runtime(active_workflow_id/pending/context/version) → WorkflowRunResponse: status/result/next_runtime/client events/errors.
Ruby → LLM nats.llm.user123 LlmRequest: mode, input, constraints → LlmResponse: ok, data, error. Для dialog_step data содержит response_text и tool_calls(id/name/arguments).
Ruby → tools nats.tools.discover, nats.tools.{tool_name} {} → каталог JSON-схем; ToolCallRequest(tool_name/args) → ToolCallResponse(ok/artifact_key/data/error).
Ingress → Core chat.agent.audio.control JSON admit/heartbeat/ending/ended/abort → JSON lease/ACK/error. Lease token и epoch - только backend.
Core → Agent, snapshot/cancel chat.agent.requests JSON action/request_id/session_id → состояние/результат/отказ.
Agent → downstream, cancel chat.agent.cancel.workflow, .llm, .tools JSON с идентификаторами попытки → acknowledgement.
Agent ↔ PostgreSQL chat-agent_src_postgres:5432, database mcp PostgreSQL wire protocol, не JSON RPC.

Typed RPC payload - UTF-8 JSON, с общим envelope trace_id, correlation_id, request_id, session_id, ts_ms. NATS headers несут traceparent и execution carrier; это не поля NATS subject. У control/stream сообщений свои компактные схемы, они не обязаны повторять весь TraceEnvelope. Дочерний request_id LLM-вызова и внешний request_id хода не следует считать одним ID; поток дополнительно связывается attempt/generation.

Что LLM передаёт провайдеру

Адрес: https://nats-proxy-ollama.gis-master.ru/api/chat, HTTPS POST. application/json: model:"qwen3:30b", messages, tools, think:false, options:{num_ctx:8192,num_predict:2048,temperature:0}.

  • dialog_step: native tools и stream:true; HTTP-ответ разбирается как NDJSON, с message.content, message.tool_calls, завершением и usage.
  • Структурированные режимы: format:<JSON Schema>, stream:false; один JSON.
  • Полный набор tool calls проверяется сервером до исполнения. Потом Ruby выполняет инструменты последовательно и возвращает результаты модели. Обычный разговор обходится одной генерацией, инструментальный - может несколькими.
  • Внутренний маршрут proxy → Ollama не исследован заново и не представлен как прямое соединение к конкретному inference host.

Пять tools: search_airports_nearby, get_weather, get_flight_status, get_current_position, build_route. Сейчас API gateway отдаёт демонстрационные fixtures; схема не означает подключение к настоящим погодным/авиационным API.

Как ответ доходит до браузера

chat.agent.stream - внутренний pub-sub LLM → Agent, UTF-8 JSON: request_id, session_id, attempt_id, generation_id, generation_index, sequence, delta. Agent проверяет владельца/попытку/порядок, накапливает текст и публикует UI-события в nats.events.user123:

  • response_fragment: принятый delta и идентификаторы;
  • request_state: накопленный text, state и идентификаторы; активный snapshot раз в секунду;
  • message: итоговый текст и trace;
  • client: проверяемая frontend команда, например изменения карты;
  • Core публикует там же asr_result и audio_status.

Envelope UI-события: time, service, type, kind, data, name, uid. Это JSON payload внутри протокола NATS через WSS, не произвольный JSON WebSocket. Frontend проверяет session/stream либо request/attempt/generation/sequence до отображения и выполнения команды. Subject user123 всё ещё общий; фильтрация UI не заменяет отдельные NATS ACL для пользователей.

RPC сохраняет полный результат; частичные события не являются его заменой. После reconnect frontend запрашивает snapshot через Core или историю через Agent. Общий Core NATS не даёт replay этих UI-событий.

Источники диалога: Agent service, request state, RPC contracts, Ruby runtime IO, dialog tool loop, LLM service, HTTP provider, API gateway, frontend event filter, DRS routes, names, account remap.

Граница подтверждения

Слой Подтверждено этой сверкой
Source Producers/consumers, обе формы аудио, binary header, JSON contracts, directions, RPC и stream. Git diff e9122e0..e4bfe28 для Agent/LLM/API/Ruby/contracts пуст.
Runtime Insight 06.10.2026: Core и ingress 0c403307, Agent/LLM/API e9122e0, Ruby/Frontend e4bfe28; voice bridge 5d1d959, ASR_MODE=streaming. Также проверены действующие router rules, priorities, upstream ports и OAuth middleware. Не ожидается одинаковый SHA у выборочно собранных сервисов.
Внешний inference Worker source и account remap подтверждены; свежий inspect H100 и внутренних hops Ollama proxy не выполнялся.
Поведение / скорость Эта правка меняет документацию. Результаты C3/C4 относятся к прежним прогонам, а не к повторной приёмке аудио сегодня.

Исходник

Скачать Markdown · К материалам исследования