상태: 초안 (2026-10-08). 수치는 필자가 직접 잰 값입니다. 환경은 24GB급 GPU 한 장, MiniCPM-o 4.5(Q8_0, llama.cpp-omni), FastAPI 백엔드입니다. 사람 목소리 대신 합성 음성으로 질문했고, 실제 브라우저의 메아리와 여러 사람의 동시 통화는 미실측 입니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」으로 표시합니다.
목차
- 1. 모델: 왜 MiniCPM-o 4.5 인가
- 2. 왜 백엔드가 중계하는가
- 3. 소리를 어떤 형식으로 보내는가
- 4. 프레임: 이진과 텍스트를 나눈다
- 5. 실시간보다 빨리 보내는 클라이언트
- 6. 웹 파드 둘에서 자리 하나 지키기
- 7. 연결을 끊는 것들
- 8. 전체를 이어서 잰 값
- 9. 한계와 미실측
- 면접에서 나올 만한 질문
- 참고 자료
20편의 대화 연습은 「녹음 → 음성 인식 → 대화 모델 → 음성 합성」을 한 턴씩 HTTP 로 주고받습니다. 말을 다 해야 답이 오고, 답을 듣는 동안에는 말할 수 없습니다. 사람과 전화하는 느낌을 내려면 듣는 중에도 말하고, 말하는 중에도 듣는 전이중(full duplex) 모델이 필요합니다. 이런 모델은 1초에도 여러 번 소리를 받고 소리를 냅니다. 그래서 요청 하나에 응답 하나인 HTTP 대신 연결 하나를 열어 두고 양쪽이 아무 때나 보내는 웹소켓이 맞습니다.
이 글은 모델을 고른 과정을 짧게 적은 뒤, 웹소켓 쪽에서 내린 결정 다섯 가지를 다룹니다.
- 브라우저를 모델 서버에 바로 붙이지 않고 백엔드가 중계한다.
- 소리는 16kHz·16비트·1초 묶음으로 올리고, 24kHz·16비트로 내린다.
- 소리는 이진 프레임, 글과 상태는 텍스트 프레임으로 나눈다.
- 실시간보다 빨리 보내는 클라이언트는 토큰 버킷으로 막는다.
- 한 통화만 받는 서버의 자리는 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 로 돌렸습니다.
모델 쪽에서 두 가지를 고쳤습니다.
- 말을 시작하는 문턱. 기본 설정에서는 말이 끝난 뒤 목소리가 나오기까지 중앙값 4.0초(0.9–8.3)였습니다. 모델은 1초마다 「들을지 말할지」를 정하는데, 세션 설정
listen_prob_scale을 1.0 에서 0.0 으로 낮추면 「듣기」 토큰의 점수가 2만큼 깎입니다. 그러자 1.03초(−0.7–2.4)가 되었고 대답하지 않은 질문도 없어졌습니다. - 통화를 열 때마다 41초. 서버 로그를 보니 세션마다 특수 토큰 7개의 임베딩을 얻으려고 LLM 가중치 파일 전체(8.7GB)를 메모리로 읽고, 세션이 끝나면 버렸습니다. 값은 모델 파일에만 달려 있으므로 프로세스 안에 한 번만 만들도록 고치자 0.11초에 열렸습니다(본문 6,500토큰짜리 블로그 글을 넣으면 1.6초).
2. 왜 백엔드가 중계하는가
모델 서버는 웹소켓 엔드포인트(/backend)를 직접 냅니다. 브라우저를 여기에 바로 붙이면 가장 빠르겠지만 세 가지 문제가 있습니다.
브라우저 ──wss──▶ 게이트웨이 ──▶ 백엔드(/ws/call) ──ws──▶ 모델 서버(/backend)
(쿠키로 사람 확인) (프롬프트 생성, 자리, 속도 제한, 형식 변환)
- 모델 서버는 시스템 프롬프트를 그대로 믿습니다. 세션을 여는 메시지에 프롬프트를 담아 보내는데, 브라우저가 이 메시지를 만들게 두면 누구나 모델에게 아무 지시나 내릴 수 있습니다. 중계를 두면 브라우저는
mode=lang&lang=ja&level=N4나mode=blog&slug=…만 보내고, 프롬프트는 서버가 정해진 문장과 DB 의 글로만 만듭니다. 수준(level)도 아는 목록에 있을 때만 씁니다. 처음에는 영숫자만 남기게 했는데,B1. Ignore all rules가B1Ignore로 프롬프트에 들어가는 것을 시험이 잡았습니다. - 모델 서버는 한 번에 한 통화만 받습니다. 둘째 사람이 붙으면 첫 통화가 망가집니다. 자리는 5절에서 다룹니다.
- 사람을 확인하고 시간을 잘라야 합니다. 브라우저는 웹소켓 핸드셰이크에 임의의 헤더를 붙이지 못합니다. 그래서 같은 출처의 로그인 쿠키로 사람을 찾고(기존 웹 터미널과 같은 방식), 통화를 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 |
- 16kHz 로 줄이는 곳은 브라우저입니다. 모델의 음성 인코더(Whisper 계열)는 16kHz 를 받습니다.
new AudioContext({sampleRate: 16000})으로 처음부터 16kHz 로 받으면 간단하지만 일부 브라우저가 이 옵션을 거절합니다. 그래서 기기의 기본 표본율(보통 48kHz)로 받아 선형 보간으로 줄입니다. 말소리는 4kHz 아래에 대부분 있어서 이 정도로 충분합니다. 48kHz 1초가 정확히 16,000 표본이 되는지, 44.1kHz 도 같은지를 node 시험으로 확인합니다 — 표본율이 틀리면 모델이 빨라지거나 느려진 목소리를 듣고 엉뚱하게 알아듣는데, 눈으로는 알 수 없습니다. - 묶음은 1초입니다. 이 모델은 1초 단위로 듣고 판단합니다. 더 잘게 보내도 서버가 1초를 채울 때까지 기다리므로 지연이 줄지 않습니다. 묶음 크기가 정확히 32,000바이트가 아니면 중계가 버립니다.
- 16비트로 줄이는 곳은 중계입니다. 모델의 형식(float32 base64)을 브라우저까지 그대로 보내면 내림이 초당 128KB 입니다. 16비트로 줄이면 48KB 로 63% 줄어듭니다. 사람 귀에 들리는 차이는 없습니다(16비트는 CD 와 같은 깊이입니다).
- 변환 비용. 표준 라이브러리
array로 1초 분량을 바꾸는 데 올림 1.3ms, 내림 5.9ms 였습니다(맥 arm64 의 파이썬 3.12, 5번 반복의 중앙값. 운영 파드의 CPU 에서는 미실측). 모델이 1초 묶음 하나를 처리하는 50ms 에 비해 작습니다.
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() # 연결이 닫히면 잠금도 풀린다
- 통화가 끝나면 연결을 닫고, 연결이 닫히면 잠금이 풀립니다.
- 파드가 죽어도 자리가 묶이지 않습니다. 연결이 끊기면 Postgres 가 그 세션의 잠금을 저절로 풉니다. 행으로 자리를 남기면 이때 만료 시간을 기다려야 합니다.
- 비용은 통화마다 DB 연결 하나입니다. 동시에 한 통화뿐이니 연결도 하나입니다.
- 「지금 걸 수 있는가」를 묻는 API 는 잠금을 잡았다가 바로 놓아 확인합니다.
7. 연결을 끊는 것들
- 유휴 연결. Cilium 게이트웨이의 Envoy 는 기본 설정에서 양쪽이 조용한 연결을 60초에 끊습니다(
proxy-idle-timeout-seconds). 웹 터미널은 이 때문에 25초마다 하트비트를 보냅니다. 통화는 브라우저가 1초마다 소리를 보내므로 하트비트가 따로 필요 없습니다. 오히려 15초 동안 소리가 오지 않으면 중계가 먼저 끊습니다(창을 닫았거나 탭이 잠들었다고 봅니다). - 배포. 백엔드를 새로 띄우면 열려 있던 웹소켓은 끊깁니다. 통화는 10분이 상한이라 다시 걸면 됩니다. 자리는 위의 이유로 저절로 풀립니다.
- 모델 서버가 준비되지 않았을 때. 모델 서버의
/health는 모델을 올리기 전부터 200 을 냅니다(모델은 첫 세션에서 올라가고 70–80초가 걸립니다). 그래서 서버를 띄운 직후 세션을 한 번 열어 모델을 올리고, 그 뒤에 만든 파일로 준비 여부를 판단하게 했습니다. 중계는 세션 열기를 20초까지만 기다리고 넘으면 4503 으로 「준비 중」을 알립니다.
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. 한계와 미실측
- 합성 음성으로만 질문했습니다. 사람 목소리, 사투리, 잡음은 미실측 입니다.
- 브라우저의 메아리 제거(
echoCancellation)가 Web Audio 로 재생한 소리까지 지우는지는 브라우저와 기기마다 다르고 재지 않았습니다. 화면에서 이어폰을 권합니다. - 서버가 1:1 이라 동시 통화는 설계상 하나입니다. 여러 통화를 받으려면 GPU 를 늘리거나 묶음 처리를 지원하는 서버가 필요합니다(미실측).
- 같은 계열의 Realtime-Venus(MiniCPM-o 4.5 기반, BF16 약 20GB)와 NemotronLabs VoiceChat-11B 의 GGUF(영어 전용)는 미실측 입니다.
ScriptProcessorNode는 폐기 예정입니다.AudioWorklet으로 옮기면 메인 스레드가 바쁠 때의 끊김이 줄 수 있지만 재지 않았습니다.
면접에서 나올 만한 질문
- SSE 가 아니라 웹소켓인 이유는? SSE 는 서버 → 클라이언트 한 방향입니다. 전이중 통화는 1초마다 소리를 올리고 동시에 목소리를 받습니다. 올림을 HTTP 요청으로 따로 보내면 초당 요청 하나와 그 연결 비용이 생깁니다.
- WebRTC 를 쓰지 않은 이유는? WebRTC 는 UDP·지터 버퍼·메아리 제거까지 갖춰 통화에 더 맞습니다. 대신 TURN/STUN 서버와 미디어 서버(예: LiveKit)가 필요합니다. 이 모델은 1초 묶음으로 판단하므로 수십 ms 의 지연 차이가 체감에 덜 중요했고, 이미 운영하던 웹소켓 경로(웹 터미널)를 다시 쓰는 편이 단순했습니다. 사람이 늘거나 모바일 회선을 고려하면 WebRTC 로 옮기는 것이 맞습니다.
- base64 를 피한 이유는? base64 는 4/3 배로 부풉니다. float32 는 16비트의 두 배입니다. 둘을 합치면 같은 소리가 2.7배가 됩니다(48KB → 128KB/s).
- advisory lock 대신 Redis 락은? 이 사이트는 이미 Postgres 를 쓰고 있고, 연결이 끊기면 잠금이 저절로 풀리는 성질이 필요했습니다. Redis 락은 만료 시간을 정해야 하고, 통화가 그보다 길어지면 연장해야 합니다.
참고 자료
- RFC 6455 The WebSocket Protocol — 프레임 opcode(5.2절), 닫힘 코드 범위(7.4.2절)
- PostgreSQL 문서, Advisory Locks(13.3.5절),
pg_try_advisory_lock - MiniCPM-o 4.5 GGUF: huggingface.co/openbmb/MiniCPM-o-4_5-gguf, llama.cpp-omni: github.com/tc-mb/llama.cpp-omni
- MDN, Web Audio API —
AudioBufferSourceNode.start(when),BaseAudioContext.createBuffer - 20편 언어학습 실시간 음성 대화, 부품별 실측, 19편 실시간 음성 대화 앱용 NVIDIA 오픈소스