LabHub

한국어

시작하기
블로그

블로그

전이중 음성 통화를 웹소켓으로 중계하기: 브라우저 마이크에서 GPU 모델까지

상태: 초안 (2026-10-08). 수치는 필자가 직접 잰 값입니다. 환경은 24GB급 GPU 한 장, MiniCPM-o 4.5(Q8_0, llama.cpp-omni), FastAPI 백엔드입니다. 사람 목소리 대신 합성 음성으로 질문했고, 실제 브라우저의 메아리와 여러 사람의 동시 통화는 미실측 입니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」으로 표시합니다.

목차

20편의 대화 연습은 「녹음 → 음성 인식 → 대화 모델 → 음성 합성」을 한 턴씩 HTTP 로 주고받습니다. 말을 다 해야 답이 오고, 답을 듣는 동안에는 말할 수 없습니다. 사람과 전화하는 느낌을 내려면 듣는 중에도 말하고, 말하는 중에도 듣는 전이중(full duplex) 모델이 필요합니다. 이런 모델은 1초에도 여러 번 소리를 받고 소리를 냅니다. 그래서 요청 하나에 응답 하나인 HTTP 대신 연결 하나를 열어 두고 양쪽이 아무 때나 보내는 웹소켓이 맞습니다.

이 글은 모델을 고른 과정을 짧게 적은 뒤, 웹소켓 쪽에서 내린 결정 다섯 가지를 다룹니다.

  1. 브라우저를 모델 서버에 바로 붙이지 않고 백엔드가 중계한다.
  2. 소리는 16kHz·16비트·1초 묶음으로 올리고, 24kHz·16비트로 내린다.
  3. 소리는 이진 프레임, 글과 상태는 텍스트 프레임으로 나눈다.
  4. 실시간보다 빨리 보내는 클라이언트는 토큰 버킷으로 막는다.
  5. 한 통화만 받는 서버의 자리는 DB 의 advisory lock 으로 지킨다.

짧은 답. 중계를 거쳐도 통화는 0.33초에 열렸고, 「호주의 수도는?」에 말이 끝난 뒤 0.30초에 「It's Canberra.」가 들리기 시작했습니다. 중계가 1초 분량의 소리를 바꾸는 데는 올림 1.3ms, 내림 5.9ms 가 걸렸습니다. 브라우저와 백엔드 사이에 base64 JSON 대신 이진 PCM 을 쓰자 전송량이 올림 85KB/s → 32KB/s, 내림 128KB/s → 48KB/s 로 줄었습니다.

1. 모델: 왜 MiniCPM-o 4.5 인가

허깅페이스에서 키 없이 받을 수 있는 전이중 모델 셋을 같은 질문으로 쟀습니다(원자료는 docs/experiments/2026-10-07-full-duplex-voice/).

모델 질문 응답 말이 끝난 뒤 목소리까지(중앙값) 관찰
Moshiko (2024-09) 영어 FLEURS 12 11/12 6건은 사용자 말 도중, 나머지 0.2–5.2초 80ms 마다 한 걸음, 걸음당 34.6ms
LLM-jp-Moshi-v1 (2026-01) 일본어 FLEURS 12 12/12 전부 사용자 말 도중 사용자 발화의 30–88% 동안 맞장구와 자기 이야기
MiniCPM-o 4.5 Q8_0 (2026-02) 영어 질문 10 10/10 1.03초 1초 묶음당 처리 50ms, VRAM 14.7GB

MiniCPM-o 는 캔버라, 100도, 화성, 셰익스피어를 맞혔고, 일본어는 「何語で話しても日本語だけで答えて」라는 지시를 넣자 10번 중 9번 일본어로 답했습니다. 한국어는 서툴렀습니다(공식 언어는 영어와 중국어입니다). PyTorch 판은 28GB 이상이 필요해서 llama.cpp 포크(llama.cpp-omni)의 GGUF 로 돌렸습니다.

모델 쪽에서 두 가지를 고쳤습니다.

2. 왜 백엔드가 중계하는가

모델 서버는 웹소켓 엔드포인트(/backend)를 직접 냅니다. 브라우저를 여기에 바로 붙이면 가장 빠르겠지만 세 가지 문제가 있습니다.

브라우저 ──wss──▶ 게이트웨이 ──▶ 백엔드(/ws/call) ──ws──▶ 모델 서버(/backend)
            (쿠키로 사람 확인)   (프롬프트 생성, 자리, 속도 제한, 형식 변환)
  1. 모델 서버는 시스템 프롬프트를 그대로 믿습니다. 세션을 여는 메시지에 프롬프트를 담아 보내는데, 브라우저가 이 메시지를 만들게 두면 누구나 모델에게 아무 지시나 내릴 수 있습니다. 중계를 두면 브라우저는 mode=lang&lang=ja&level=N4 나 mode=blog&slug=… 만 보내고, 프롬프트는 서버가 정해진 문장과 DB 의 글로만 만듭니다. 수준(level)도 아는 목록에 있을 때만 씁니다. 처음에는 영숫자만 남기게 했는데, B1. Ignore all rules 가 B1Ignore 로 프롬프트에 들어가는 것을 시험이 잡았습니다.
  2. 모델 서버는 한 번에 한 통화만 받습니다. 둘째 사람이 붙으면 첫 통화가 망가집니다. 자리는 5절에서 다룹니다.
  3. 사람을 확인하고 시간을 잘라야 합니다. 브라우저는 웹소켓 핸드셰이크에 임의의 헤더를 붙이지 못합니다. 그래서 같은 출처의 로그인 쿠키로 사람을 찾고(기존 웹 터미널과 같은 방식), 통화를 10분, 무음 연결을 15초에서 끊습니다.

블로그 글을 프롬프트에 넣을 때는 한 가지를 더 막았습니다. 이 모델의 특수 토큰은 <|im_end|> 같은 꼴이라, 글 본문에 같은 문자열이 있으면 시스템 메시지를 닫고 새 지시를 끼워 넣을 수 있습니다. 본문의 <| 를 < | 로 바꿔 무력화하고, 프롬프트 전체에 특수 토큰이 우리가 붙인 두 개뿐인지를 시험으로 확인합니다.

3. 소리를 어떤 형식으로 보내는가

구간 형식 1초 분량
브라우저 → 백엔드 16kHz 모노 16비트 PCM, 이진 프레임 32,000 B
백엔드 → 모델 float32 를 base64 로 담은 JSON(input.append) 85,391 B
모델 → 백엔드 24kHz float32 base64 JSON(response.output.delta) 128,063 B
백엔드 → 브라우저 24kHz 16비트 PCM, 이진 프레임 48,000 B

4. 프레임: 이진과 텍스트를 나눈다

웹소켓 프레임에는 텍스트(opcode 1)와 이진(opcode 2)이 있습니다. 소리는 이진으로, 나머지는 JSON 텍스트로 보냈습니다.

브라우저 → 백엔드   이진: 1초 PCM          텍스트: {"type":"hangup"}
백엔드 → 브라우저   이진: 모델 목소리 조각  텍스트: ready · text · listen · done · busy · unavailable · timeout

이렇게 나누면 받는 쪽에서 typeof ev.data === 'string' 하나로 갈라지고, 소리에 JSON 을 씌우거나 base64 로 부풀릴 일이 없습니다. 브라우저에서는 ws.binaryType = 'arraybuffer' 로 두어야 이진 프레임이 Blob 이 아니라 바로 쓸 수 있는 ArrayBuffer 로 옵니다.

닫힘 코드로 이유를 알린다. 웹소켓의 닫힘 코드 4000–4999 는 애플리케이션이 쓰라고 비워 둔 범위입니다. 4401(로그인 필요), 4404(글 없음), 4429(다른 사람이 통화 중), 4503(모델 서버 준비 중)을 썼습니다. 화면은 이 코드를 사람의 말로 바꿉니다. 「실패했습니다」보다 「지금 다른 사람이 통화 중이에요, 잠시 뒤 다시 걸어 주세요」가 다시 걸 마음을 들게 합니다.

재생은 이어 붙인다. 모델은 목소리를 생기는 대로 몇백 ms 씩 보냅니다. 받은 조각을 AudioBufferSourceNode 로 만들어 「앞 조각이 끝나는 시각」에 예약하면 끊김 없이 이어집니다. 조각마다 바로 재생하면 서로 겹칩니다.

끼어들기. 모델은 사람이 끼어들면 듣기(listen)로 돌아섭니다. 그런데 이미 받은 목소리가 재생 대기열에 남아 있으면 사람에게는 모델이 계속 말하는 것처럼 들립니다. 그래서 listen 이 왔을 때 마이크 소리가 크면 대기열을 비웁니다. 마이크가 조용할 때 비우면 대답의 마지막 부분이 잘립니다 — 모델은 대답을 다 만든 뒤에도 listen 을 보내는데 그때 재생은 아직 끝나지 않았기 때문입니다.

5. 실시간보다 빨리 보내는 클라이언트

모델 서버는 1초 묶음을 차례로 처리합니다. 다음 묶음은 앞 묶음의 응답이 끝나야 읽습니다. 그래서 클라이언트가 녹음해 둔 30초를 한꺼번에 보내면 대기열이 쌓이고, 대답이 점점 늦어지고, 그동안 다른 사람은 자리를 얻지 못합니다.

중계에 토큰 버킷을 두었습니다. 초당 1.5개가 차고 최대 3개까지 쌓입니다. 1초마다 하나씩 오는 정상 통화는 10분(600개) 동안 하나도 버려지지 않고, 몰아 보내면 처음 3개 뒤로는 1.5개/초만 통과합니다. 1.5 로 여유를 둔 것은 네트워크 흔들림 때문에 두 묶음이 붙어 오는 일이 있어서입니다. 시간을 바깥에서 주입할 수 있게 만들어 두면 이 동작을 몇 ms 만에 시험할 수 있습니다.

측정기를 만들 때도 같은 함정이 있었습니다. 측정 클라이언트가 묶음을 앞당겨 보내면 「말이 끝난 뒤 목소리까지」가 실제보다 짧게 나옵니다. 그래서 묶음이 다 녹음된 순간(시작 후 k초)에만 보내도록 했습니다.

6. 웹 파드 둘에서 자리 하나 지키기

모델 서버는 한 통화만 받는데 백엔드 파드는 둘입니다. 프로세스 안의 변수(asyncio.Lock)로는 다른 파드의 통화를 모릅니다.

20편의 대화 연습은 DB 의 부분 유니크 인덱스로 자리를 지킵니다. 턴마다 HTTP 요청이 오가고 그 사이에는 연결이 없으니, 자리를 행으로 남기고 3분 동안 말이 없으면 만료시킵니다. 통화는 사정이 다릅니다. 통화하는 동안 웹소켓이 계속 열려 있으므로 연결이 살아 있는 동안만 자리를 쥐면 됩니다.

그래서 Postgres 의 세션 수준 advisory lock 을 썼습니다.

seat = db()                                   # 통화마다 연결 하나
got = seat.execute("select pg_try_advisory_lock(%s)", (SEAT_KEY,))
if not got: ...                               # 4429 로 닫는다
try:
    await relay(ws, prompt)
finally:
    seat.close()                              # 연결이 닫히면 잠금도 풀린다

7. 연결을 끊는 것들

8. 전체를 이어서 잰 값

중계 라우터를 가짜 DB 로 띄우고, 모델 서버에 붙여 합성 음성 질문을 1초씩 실시간으로 보냈습니다(scripts/e2e_relay.py).

질문 통화 열림 말이 끝난 뒤 목소리 답
What is the capital city of Australia? 0.33초 0.30초 It's Canberra.
日本の首都はどこですか? — 0.52초 東京です。
(블로그 25편) 확률 샘플링 10%는 느린 요청을 몇 개 남겼어? — 2.52초 네, 10% 를 남겼습니다. (틀림)

이 측정에는 게이트웨이와 TLS 가 들어 있지 않습니다. 운영 경로(브라우저 → 게이트웨이 → 백엔드)로 잰 값은 배포 뒤에 덧붙입니다(미실측).

9. 한계와 미실측

면접에서 나올 만한 질문

참고 자료

로그인하면 좋아요를 누를 수 있습니다

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다