#DGXSpark #로컬LLM #음성비서 #TTS #STT #voicecloning #OpenClaw
DGX Spark에 음성 비서를 올렸다: TTS·LLM·STT를 전부 로컬로 구성한 기록
DGX Spark 한 대에서 로컬 LLM 세 개를 상주시켜 쓰고 있었다. GB10에 통합 메모리가 달려 있고(사양 128 GB, 운영체제가 잡는 값 121 GiB), 생성용 26B 모델(16.8 GB), 오디오까지 받는 E4B 모델(4.2 GB), 그리고 별도 용도의 27B 모델(17.6 GB)이 각각 포트를 잡고 있는 구성이다. 이 셋의 가중치만 합쳐도 38.6 GB이고, 여기에 각 서버의 KV 캐시와 여유분이 얹힌다. 그 상태에서 남은 자리는 또 하나의 대형 언어 모델을 올리기에는 애매하고, 비워 두기에는 아까운 크기였다.
그래서 남은 자리에 TTS를 올려 보기로 했다. 목표는 커스텀 보이스로 말하는 음성 비서를 만드는 것이고, 조건은 세 단계를 전부 로컬에서 처리한다는 것이었다. 음성을 텍스트로 바꾸는 단계, 대답을 만드는 단계, 대답을 소리로 바꾸는 단계 중 어느 하나도 클라우드로 내보내지 않는다.
이 글은 그 구성을 마칠 때까지 무엇을 고르고 무엇에 걸렸는지에 대한 기록이다. 응답 지연은 처음 34초였고 지금은 약 5초다. 그 사이에 결정한 것들과, 한 번 실패해서 되돌린 과정을 순서대로 적는다.
0. 배경: 로컬 구성의 이유와 지연 기준
본론에 들어가기 전에 두 가지를 깔아 둔다. 이 구성을 왜 로컬로 하는지, 그리고 어느 정도면 빠른 것인지다. 뒤에 나오는 숫자는 전부 이 기준에 대고 읽어야 의미가 있다.
로컬로 돌리는 이유
2026년 기준으로 자체 GPU에 음성 스택을 올리는 이유는 대체로 네 가지로 정리된다. (Asterisk 커뮤니티, TechFuel)
| 이유 | 내용 |
|---|---|
| 데이터 주권 | 음성과 대화 내용이 집 밖으로 나가지 않는다. 규제 대응이나 민감한 환경에서 가장 큰 이유가 된다 |
| 비용 예측 가능성 | 사용량 과금이 아니라 하드웨어와 전기다. 상시 대기하는 비서처럼 사용량이 꾸준하면 유리해진다 |
| 지연의 일관성 | 네트워크 변동과 속도 제한이 없다. 느려도 일정하게 느리다 |
| 통제 | 모델을 마음대로 고르고 바꾼다. 어느 날 API가 없어지거나 정책이 바뀌는 일이 없다 |
대신 넘겨받는 것이 있다. 운영 책임이 통째로 이쪽으로 온다. 이 글의 나머지가 대부분 그 이야기다.
지연 기준 두 가지
여기서 기준을 잘못 잡으면 글 전체가 틀어진다. 흔히 인용되는 목표는 사용자가 말을 멈춘 시점부터 첫 소리까지 500~800밀리초이고, 1초를 넘기면 상대가 자기 말을 못 들은 줄 알고 다시 말하기 시작한다고 본다. 계층별로는 음성 인식 60~120밀리초, 모델 첫 토큰 100~250밀리초, 음성 합성 첫 조각 40~100밀리초로 잡힌다. (Prodinit, The Prompt Bench)
그런데 그 숫자는 상용 실시간 음성 서비스의 목표다. 자체 호스팅한 연쇄 구조에 대해서는 기준이 다르게 인용된다. 첫 응답까지 1.5~2초 미만이 자연스럽게 느껴지는 선으로 통용되고, 단계를 하나로 합치는 음성 대 음성 모델은 연쇄 구조의 대략 절반이라고 본다. (Dograh)
이 글의 결과는 약 5초다. 둘 중 어느 기준으로 봐도 아직 그 안에 들어가지 못했고, 마지막 절에서 왜 못 넘는지를 구간별로 적는다. 미리 말하면 이유가 장비가 아니다.
장비
DGX Spark는 GB10 슈퍼칩을 얹은 작은 데스크톱 장비다. Arm 코어 20개와 Blackwell 계열 GPU가 통합 메모리를 함께 본다. 사양은 128 GB이고 운영체제가 쓸 수 있게 잡히는 값은 121 GiB다. 이 글에서 121이라고 적은 곳은 전부 실제로 읽은 값이다. CPU와 GPU가 메모리를 나눠 갖지 않는 구조라 큰 모델을 여러 개 상주시키기에 유리하다. 가격은 4,699달러이고 GB10의 TDP는 140와트, 전원 공급은 240와트다. (NVIDIA, Kubesimplify)
이 글에서 중요한 것은 성능 수치가 아니라 통합 메모리 쪽이다. 모델 세 개를 동시에 올려 두고 그중 하나에 오디오를 밀어 넣는 구성이 가능한 이유가 여기에 있다.
음성 복제 TTS 지형
커스텀 보이스를 로컬로 만들려는 사람이 2026년에 실제로 고를 수 있는 선택지는 꽤 넓다. 우리가 검토한 넷은 이 지형의 일부다. (BentoML, Inworld)
| 모델 | 성격 |
|---|---|
| XTTS-v2 | 6초 남짓 샘플로 제로샷 복제. 다국어와 교차 언어 전이가 강하다 |
| Fish Speech / S2 | 80개 넘는 언어. 제로샷 복제 품질이 좋다 |
| CosyVoice 2 계열 | 실시간과 스트리밍에 강하고 감정·억양 제어가 있다 |
| IndexTTS-2, NeuTTS Air | 제로샷에 길이 같은 세밀한 제어. NeuTTS Air는 0.5B로 온디바이스를 노린다 |
| Chatterbox 계열 | 실시간 생성, 감정 제어, 복제 |
| Piper | 복제는 없지만 가볍고 품질이 좋다. 스마트홈 쪽에서 많이 쓴다 |
흐름을 한 줄로 적으면 짧은 레퍼런스로 하는 제로샷 복제가 표준이 됐다. 예전처럼 목소리마다 모델을 학습시키는 방식은 특별한 이유가 있을 때만 고른다. 뒤에서 그 두 방식을 실제로 비교한다.
1. TTS 모델 선정 과정
커스텀 보이스가 목표였으므로, 후보는 전부 특정 화자의 목소리를 낼 수 있는 방식이어야 했다. 그 조건 아래 네 가지를 검토했다. 앞의 세 개는 레퍼런스 음성을 넣으면 그 목소리로 읽어 주는 방식이고, 마지막 하나는 데이터를 모아 직접 학습시키는 방식이다.
| 후보 | 방식 | 결과 |
|---|---|---|
| Higgs Audio v3 TTS (Boson AI) | 제로샷 음성 복제 | 채택 |
| CosyVoice 3 | 제로샷 음성 복제 | 이 장비에서 실행 불가 |
| Fish Audio S2 Pro | 레퍼런스 음성 복제 | 품질에서 밀림. 직전까지 쓰던 것 |
| GPT-SoVITS 직접 파인튜닝 | 데이터 수집 후 학습 | 성공했으나 운영 비용이 큼 |
순서대로 적는다.
후보 1: Higgs Audio v3, 품질은 앞섰지만 스택이 달랐다
Boson AI가 2026년 6월에 공개한 4B 모델이다(bosonai/higgs-audio-v3-tts-4b). 아래 표의 수치는 모델 카드와 우리 자체 검토 기록에서 가져왔다. Qwen3-4B를 백본으로 쓰고, 자체 오디오 토크나이저가 초당 25프레임으로 코드북 8개를 생성해 24 kHz로 복원한다.
| 항목 | 내용 |
|---|---|
| 파라미터 | 약 4B, Qwen3-4B 백본 (36층, 히든 2560, GQA 32/8) |
| 오디오 | 코드북 8개 @ 25 fps → 24 kHz |
| 기능 | 제로샷 음성 복제, 감정 21종, 억양과 효과음 인라인 태그, 스트리밍 |
| 언어 | 102개 지원, 그중 85개가 오류율 5퍼센트 미만이고 한국어가 여기 포함된다 |
| 라이선스 | 연구·비상업용이 기본이고, 별도로 크리에이터 이용 조항이 있다. 출처를 밝히면 수익화하는 창작 용도까지는 허용된다 |
사실 이 모델은 6월에 한 번 검토했다가 채택하지 않았다. 품질은 그때도 분명히 앞섰지만, 우리 llama.cpp 계열 스택 안에서 돌지 않는다는 것이 걸렸다. 당시에는 클라우드 TTS로 충분했으므로 별도 서빙 스택을 하나 더 운영할 이유가 없었다.
이번에는 조건이 달라졌다. 전부 로컬로 처리한다는 것이 목표였으므로 클라우드는 선택지가 아니었고, 그러자 6월에 기각 사유였던 항목이 이번에는 받아들일 수 있는 조건이 됐다.
서빙이 실제로 어려웠던 부분을 적어 둔다. llama.cpp는 이 아키텍처를 지원하지 않는다. SGLang-Omni 도커 이미지에는 arm64 매니페스트가 없어서 GB10에서 쓸 수 없다. transformers도 5.12.1 기준으로 이 아키텍처를 인식하지 못한다. 결국 NVIDIA가 배포하는 PyTorch 컨테이너 안에서 vLLM 계열 서빙을 붙이는 경로로 갔다.
그리고 기본 온도에서, 레퍼런스 없이 생성하면 이상한 출력이 나온다. 두 조건이 겹칠 때의 이야기다. 감정·효과음 토큰이 제어 없이 흘러서, 한국어 샘플에서 문장 앞에 비명이 붙고 꼬리가 길게 늘어졌다. 정상 9.3초짜리가 18.9초로 나왔다. 온도를 0.4로 낮추거나, 화자를 지정하는 시스템 프롬프트를 주거나, 레퍼런스 클립으로 목소리를 고정하면 사라진다. 마지막 것이 원래 의도된 사용 방식이고 우리가 쓰는 방식이다.
원인은 단순하다. 레퍼런스가 없으면 모델이 매번 학습된 분포에서 화자를 새로 뽑는다. 그 무작위성이 그대로 아티팩트가 된다. 운영에서는 반드시 레퍼런스나 시스템 프롬프트로 목소리를 고정해야 한다.
바로 아래 Fish S2와의 비교표는 레퍼런스 없이 만든 값이다. 두 모델을 같은 조건에 두려면 어느 쪽에도 레퍼런스를 주지 않아야 했기 때문이고, 대신 Higgs 쪽은 위에서 말한 완화 조건인 온도 0.4를 적용했다. 실제 운영 설정과는 다르며, 그래서 그 표는 두 모델의 상대 비교로만 읽어야 한다.
후보 2: CosyVoice 3은 이 장비에서 돌지 않는다
후보 중 하나였고, 결론부터 적으면 실패했다. 코드가 torch 2.3.1을 대상으로 쓰였는데 GB10은 sm_121이라 CUDA 12.8 이상이 필요하다. 고정된 의존성 조합으로는 이 GPU를 구동하는 것 자체가 불가능하다.
핀을 풀고 torch 2.13으로 올리면 모델까지는 도달한다. 그 다음에 이 오류에서 멈춘다.
mat1 and mat2 must have the same dtype, but got Float and BFloat16
여기서 판단이 갈릴 수 있었다. 한국어 입력이 문제인지, 우리 설정이 문제인지 알 수 없기 때문이다. 모델 카드에 실린 예제 코드를 그대로 돌려서 동일하게 실패하는 것을 확인했다. 우리 입력이나 설정과 무관한 실패라는 뜻이고, 그것으로 후보에서 뺐다.
여기까지 오는 데 통과해야 했던 벽이 두 개 더 있었다. pkg_resources가 없어서 setuptools<81을 따로 설치해야 했고, torchaudio 2.13이 load()를 torchcodec으로 넘기는 변경도 걸렸다. 다시 시도한다면 torch 2.8 cu128 조합이 더 싸게 붙을 것으로 본다.
후보 3: Fish Audio S2 Pro, 스택은 맞는데 품질이 밀렸다
직전까지 실제로 쓰던 것이 이 모델이다. 44.1 kHz로 출력하고 레퍼런스 오디오로 음성 복제가 된다. 아래 내용은 우리 자체 검토 기록에서 가져온 것이고, 구조에 대한 서술은 그 기록이 적어 둔 범위까지만 적는다.
가장 큰 장점은 우리 스택에 그대로 들어맞는다는 것이었다. 순수 C++와 GGML로 구현돼 있어서 파이썬 서빙 스택이 필요 없고, 시스템 서비스 하나로 올라간다. 운영 부담이 거의 없다.
Higgs와 나란히 놓고 같은 문장을 언어별로 생성해 비교했다. 레퍼런스 없이 기본 보이스로, Higgs는 온도 0.4로 맞춘 조건이다.
| 샘플 | Higgs v3 (24 kHz) | Fish S2 Pro (44.1 kHz) |
|---|---|---|
| 한국어 | 9.3초 | 8.2초 |
| 영어 | 6.5초 | 7.8초 |
| 일본어 | 8.7초 | 10.4초 |
생성 속도는 Higgs가 세 언어 중 둘에서 빨랐고 한국어에서만 느렸다. 샘플링 레이트만 보면 Fish S2가 44.1 kHz로 더 높다. 그런데 실제로 들어 보면 차이가 분명했다. 억양과 리듬이 Higgs 쪽이 확연히 자연스럽고 한국어 발음이 매끄러웠다. 숫자로는 뒤집히지 않는 항목이라 듣고 판단할 수밖에 없었고, 판단은 명확했다.
후보 4: 직접 파인튜닝, 되기는 되는데 비용이 다르다
나머지 셋과 성격이 다른 선택지다. 앞의 셋은 레퍼런스 몇 초로 목소리를 흉내 내지만, 이쪽은 목소리 데이터를 모아서 모델을 직접 학습시킨다. GPT-SoVITS로 실제로 해 봤고 결과물도 나왔다.
파이프라인은 이랬다.
영상에서 오디오 추출
→ Demucs로 보컬 분리 (배경음악과 효과음 제거)
→ Whisper large-v3로 전사 (단어 단위 타임스탬프)
→ 화자 임베딩 + 군집화로 목표 화자 구간만 분리
→ 수동 큐레이션
→ GPT-SoVITS 학습
학습을 세 번 돌렸고 그 경과가 이 방식의 성격을 잘 보여 준다.
| 회차 | 데이터 | 결과 |
|---|---|---|
| 1차 | 클립 341개 | 음질 매우 나쁨. 예능 배경음악 아티팩트가 학습 데이터에 섞여 있었다 |
| 2차 | 클립 229개 (수동 큐레이션) | 음질과 유사도 개선. 다만 감정이 밋밋하다 |
| 3차 | 같은 229개, LoRA 방식 | 감정과 억양이 크게 개선. 음질도 향상 |
1차와 2차 사이에 바꾼 것은 학습 설정이 아니라 데이터다. 노이즈와 배경음 잔향이 심한 클립 112개를 손으로 지웠다. 클립 수는 341개에서 229개로 줄었고 결과는 좋아졌다. 이 방식에서 시간을 가장 많이 쓰는 곳이 학습이 아니라 데이터 정리라는 뜻이다.
추론 속도는 나쁘지 않았다. RTX 3080에서 3.5초짜리 오디오를 1~2초에 만든다.
그런데도 이번 구성에 쓰지 않았다. 이유는 품질이 아니라 운영 형태다. 목소리를 하나 바꾸려면 데이터를 다시 모으고 다시 학습시켜야 한다. 반면 제로샷 방식은 레퍼런스 클립을 교체하면 끝난다. 그리고 학습 파이프라인은 별도 워크스테이션과 별도 파이썬 환경을 요구해서, 서버 한 대에 모아 두려는 이번 목표와 방향이 맞지 않았다.
정리하면
| 후보 | 장점 | 단점 |
|---|---|---|
| Higgs Audio v3 | 품질이 가장 좋다. 한국어 오류율 5퍼센트 미만. 스트리밍 지원. 레퍼런스 교체만으로 목소리 변경 | 별도 파이썬 서빙 스택이 필요하다. 기본 온도에서 아티팩트가 난다. 비상업용 라이선스 |
| CosyVoice 3 | 후보 자격은 있었다 | 이 GPU에서 실행되지 않는다 |
| Fish Audio S2 Pro | 스택 네이티브라 운영이 가장 간단하다. 44.1 kHz | 억양과 한국어 발음이 밀린다 |
| GPT-SoVITS 파인튜닝 | 학습만 잘되면 유사도가 높다. 추론이 가볍다 | 데이터 수집과 큐레이션 비용. 목소리 변경에 재학습 필요. 환경이 분리된다 |
결정에 쓴 조건은 세 가지였고, 그 셋을 모두 만족한 것은 Higgs 하나였다. 한국어 품질이 오류율 5퍼센트 미만일 것, 레퍼런스 클립만 바꾸면 목소리가 바뀔 것, 그리고 스트리밍 출력을 지원할 것이다. 마지막 항목은 이때는 부가 조건으로 봤는데, 뒤에서 가장 크게 쓰인다.
레퍼런스 클립은 귀로만 고르면 안 된다
목소리를 복제하려면 몇 초짜리 레퍼런스 클립이 필요하다. 처음에는 후보 구간을 자동으로 뽑아서 순위를 매겼다. 기준은 길이가 4초에서 8초 사이인지, 다이내믹 레인지가 충분한지, 레벨이 안정적인지였다.
그렇게 뽑은 1위 클립을 듣고 바로 반려됐다. 뒤에서 다른 사람들이 웃고 있었기 때문이다. 위 세 지표는 그것을 잡아내지 못한다.
그래서 축을 하나 더했다. 화자 순도(speaker purity)다. 구간을 1.5초 창으로 잘라 화자 임베딩을 뽑고, 창들 사이의 일관성을 본다. 중요한 것은 평균이 아니라 최솟값이다. 다른 목소리가 잠깐 끼어들면 평균에서는 씻겨 나가지만 최솟값에는 남는다.
이 축을 넣자 문제의 클립이 1위에서 22위로 내려갔다. 최소 순도가 0.701이었고 나머지 후보들은 0.85에서 0.95 사이였다. 귀로 내린 판단과 지표가 서로 독립적으로 같은 결론에 도달했고, 그래서 둘 다 신뢰할 수 있게 됐다.
이 과정에서 다시 쓸 만한 검사가 두 개 나왔다.
- 배경음악이 남아 있는지는 무음 구간의 바닥값으로 안다. 깨끗한 녹음은 −60에서 −67 dB까지 내려가고, 배경음이 깔려 있으면 −35에서 −45 dB 부근에서 멈춘다.
- 측정값이 지나치게 깨끗하게 0으로 나오면 도구가 말을 할 수 있었는지부터 확인한다.
ffmpeg -v error는silencedetect와volumedetect의 출력을 통째로 막는다. 이것 때문에 "모든 임계값에서 무음 구간 0개"라는 결과가 나왔고, 하마터면 확신을 갖고 틀린 결론을 낼 뻔했다.
1인용 서버에 동시성 기본값을 그대로 두면 안 된다
올리고 보니 서버가 16,893 MiB를 잡고 있었다. 이 서버는 나 혼자 쓰는 것이라 여러 사용자가 동시에 요청하는 상황을 고려할 필요가 없다. 그런데 기본값은 그 상황을 전제하고 메모리를 미리 잡아 둔다.
| 플래그 | 값 | 이유 |
|---|---|---|
--max-running-requests |
1 | 동시에 진행되는 대화가 하나다 |
--max-total-tokens |
8192 | 모델 자체의 학습 길이다. KV 풀이 한 발화보다 클 이유가 없다 |
--talker-cuda-graph |
off | CUDA 그래프는 정적 메모리 비율 바깥에 잡힌다. 배치 크기 1에서는 이득 없이 메모리만 쓴다 |
--mem-fraction-static |
0.30 | 0.40에서 내렸다 |
결과는 16,893 MiB에서 11,164 MiB로, 34퍼센트 감소했다. 스트리밍 첫 바이트는 1.147초로 그대로였다. 지연을 내주고 메모리를 산 것이 아니라, 쓰지 않을 여지를 잡고 있던 것을 회수한 것이다.
여기서 배운 것을 한 줄로 적으면 이렇다. 메모리처럼 들리는 이름의 값을 조정하기 전에, 그 서비스가 실제로 무엇을 하고 있는지부터 본다. 이번 절감은 비율을 낮춰서 나온 것이 아니라 1인용 서비스가 동시성을 전제하고 있다는 것을 알아채서 나왔다.
참고로 이 파이프라인에는 --thinker-cuda-graph와 --thinker-max-running-requests가 존재하지 않는다. 넣으면 Stage 'thinker' not found in pipeline으로 인자 해석 단계에서 죽는다. talker 접두사가 붙은 것과 접두사가 없는 것만 유효하다.
2. LLM 선정과 추론 끄기
이미 상주 중인 모델이 있으므로 답은 간단했다. 첫 번째 슬롯에 올라가 있는 Gemma-4 26B-A4B QAT를 그대로 쓴다. Q4_K_M으로 16.8 GB를 쓰고 있고, MoE 구조라 활성 파라미터가 적어서 응답이 빠르다. 음성 비서에 필요한 것은 깊은 사고가 아니라 짧은 왕복이므로 요건이 맞았다.
속도를 재 보면 실제로 빠르다.
첫 토큰까지 0.035초 / 0.004초
문장 완성까지 1.42초 / 1.74초
그런데 실제 음성 대화에 붙이자 한 턴에 32초가 걸렸다. 구간을 나눠 보니 그중 27.95초가 응답 생성이었다. 23자짜리 대답에 그만한 시간이 들 이유가 없다.
모델이 대답이 아니라 추론에 시간을 쓰고 있었다
모델을 직접 불러서 비교하니 원인이 나왔다.
추론 켬 (기본값) 2.36초 221토큰 추론 내용 565자
추론 끔 0.27초 8토큰 "네, 잘 들려요!"
짧은 프롬프트에서 아홉 배 차이가 났고, 실제 대화에서는 인물 설정과 대화 이력이 전부 붙기 때문에 28초까지 벌어졌다. 실시간 음성 파이프라인에서 모델이 전체 지연의 70퍼센트를 차지한다는 관측은 알려져 있지만, 추론을 기본으로 켜는 로컬 모델에 대화 이력을 붙이면 차이가 두 자릿수 배로 벌어진다.
끄는 방법 자체는 있다. 요청에 reasoning_effort: "none"을 넣으면 0.27초에 정확히 대답한다. 문제는 OpenClaw가 그 값을 요청에 실어 주지 않는다는 것이었다.
설정으로는 끌 수 없었다
요청 본문을 그대로 출력하는 프록시를 세워 두고 여섯 가지 조합을 시험했다. 추측이 아니라 실제로 전송된 본문을 읽은 결과다.
| 모델 항목 설정 | 실제로 전송된 내용 |
|---|---|
thinkingLevelMap 전부 none |
아무것도 없음 |
thinkingLevelMap 전부 low |
아무것도 없음. 이 경로는 맵을 참조하지 않는다 |
reasoning: false |
아무것도 없음 |
compat.thinkingFormat |
chat_template_kwargs {enable_thinking: true} |
위 설정에 레벨을 off로 |
여전히 true. 요청한 레벨을 무시한다 |
모델 항목의 params |
본문에 실리지 않음 |
"아무것도 전송되지 않음"은 추론이 켜진다는 뜻이다. 요청에 지시가 없으면 서버 기본값이 적용되고, 그 기본값이 추론을 켜는 쪽이기 때문이다. 그래서 이 표에는 정답이 없다.
여기서 잘못된 보고를 할 뻔한 지점이 있다. OpenClaw의 명령줄 도구로 부르면 reasoning_effort가 제대로 실린다. 에이전트 경로는 다른 코드를 타서 실리지 않는다. 명령줄에서만 확인하고 끝냈다면 아무 효과도 없는 수정을 완료로 보고했을 것이다. 기능이 실제로 사용하는 경로에서 확인해야 한다.
붙인 방법
설정으로 표현할 수 없으므로 요청이 지나가는 자리에 주입하기로 했다. 로컬에 작은 중간 서버를 두고 음성 요청만 그곳을 거치게 한다.
if self.path.endswith("/chat/completions"):
payload = json.loads(raw)
payload["chat_template_kwargs"] = {"enable_thinking": False}
# reasoning_effort가 남아 있으면 챗 템플릿에서 추론이 다시 켜진다.
# 이 중간 단계가 존재하는 이유가 그것을 막는 것이므로 제거한다.
payload.pop("reasoning_effort", None)
payload["model"] = UPSTREAM_MODEL
raw = json.dumps(payload).encode("utf-8")
그리고 모델 항목을 하나 더 등록해서 이 중간 서버를 가리키게 하고, 음성 설정만 그 항목을 쓰게 했다. 같은 모델을 텍스트 대화에서 부를 때는 추론이 그대로 켜져 있다.
models.providers.<provider>.models += { "id": "model1-fast",
"baseUrl": "http://127.0.0.1:8097/v1" }
agents.defaults.models["<provider>/model1-fast"] = { "streaming": true }
channels.discord.accounts.<account>.voice.model = "<provider>/model1-fast"
가운데 줄이 필요한 이유를 적어 둔다. 에이전트에는 사용 가능한 모델 목록이 따로 있고, 거기에 등록하지 않으면 Model override "..." is not allowed for agent "main"으로 거절된다. 오류 메시지가 허용 목록이 아니라 에이전트를 가리켜서 원인을 찾는 데 시간이 걸렸다.
결과는 27.95초에서 2.22초다. 12.6배 차이이고, 전체 응답 시간은 34초에서 12초가 됐다. 이 글의 총합은 전부 침묵 판정 대기를 포함한 값이다.
3. STT 선정과 CLI 전사 엔트리
로컬 구성의 이점을 살리려면 이 단계도 로컬이어야 한다. 음성은 텍스트보다 개인적인 데이터이고, 이것만 클라우드로 내보내면 로컬로 구성한 의미가 절반으로 줄어든다.
전용 음성 인식 모델을 따로 올리는 방법도 있었지만, 그러면 네 번째 서버가 생기고 메모리를 다시 나눠야 한다. 그런데 이미 올라가 있는 두 번째 모델이 오디오 입력을 받는다. RAG 사실 추출에 쓰던 Gemma-4 E4B-it QAT이고 Q4_K_XL로 4.2 GB를 쓴다. 전용 음성 인식 모델은 아니지만 오디오를 넣고 받아쓰라고 지시할 수 있다.
실제로 정확했다. 24 kHz 한국어 발화를 넣으면 1.5초 안에 정확히 받아썼다. 서버를 늘리지 않고 이미 있는 자원으로 한 단계를 해결한 것이다.
같은 슬롯에 다른 모델을 넣었다가 되돌린 기록
이 슬롯에 검열 해제 파인튜닝 모델을 올려 본 적이 있고, 같은 시간 안에 되돌렸다. 서버는 정상적으로 시작했고 /props는 여전히 audio: true를 보고했는데, 전사 결과가 음절 단위로 무너졌다.
| 출처 | 같은 6.2초 클립 |
|---|---|
| whisper | …거짓말 아니고 샤넬 립밤 꼭 챙기고요 |
| 원본 QAT 모델 | …거짓말 아니고 선의 립밤 꼭 챙기고 |
| 파인튜닝 모델 | 나 파차나 커피스러드라가나 가르마나니고 차나리빠 고팅기고 |
결론을 내리기 전에 다른 원인부터 지웠다. 우리 슬롯의 플래그와 KV 캐시 양자화를 의심해서 모델 카드가 권장하는 설정으로도 돌려 봤고 동일하게 실패했다. 샘플링 값도 카드 권장값과 그리디 양쪽 다 실패했다. 파일 손상 가능성은 두 다운로드의 크기와 해시가 정확히 일치하는 것으로 제외했다.
결정적인 검사는 비전이었다. 같은 모델을 같은 프로젝터로 통과시켜 이미지에서 글자를 읽게 했더니 0.6초에 정확히 읽었다. 비전 타워는 멀쩡하고 오디오 타워만 파인튜닝 과정에서 어긋난 것이다.
여기서 남길 만한 일반형이 두 개 있다. 한 가지 모달리티가 이상하게 동작하면, 프로젝터나 설정을 의심하기 전에 같은 프로젝터를 쓰는 다른 모달리티를 먼저 시험한다. 그리고 /props의 플래그가 정상이고 /health가 200을 반환하는 것은 그 모달리티가 아직 동작한다는 근거가 되지 않는다. 실제 출력만이 근거가 된다.
모델은 되는데 OpenClaw가 부르지 못했다
모델 쪽 준비가 끝나서 설정에 등록했다. 이렇게 적었다.
tools.media.audio.models = [
{ "provider": "<local>", "model": "<audio-model>", "capabilities": ["audio"], "type": "provider" }
]
이 설정은 config validate를 통과한다. 그런데 전사는 한 번도 되지 않았다. 음성 채널에 비서는 들어와 있고, 말을 걸어도 아무 반응이 없었다.
원인을 찾는 데 시간이 걸린 첫 번째 이유는 로그가 없었기 때문이다. 로그 파일 크기 상한이 1024바이트로 잡혀 있어서 파일이 736바이트마다 교체됐고, 실시간으로 따라가도 읽는 사이에 줄이 사라졌다. 50 MB로 올리고 나서야 시스템이 무엇을 하는지 볼 수 있었다.
보이게 된 원인은 이것이었다.
if (!provider.transcribeAudio)
throw new Error(`Audio transcription provider "${providerId}" not available.`);
대화 모델을 제공하는 객체와 전사를 제공하는 객체는 서로 다른 종류다. 전사를 하려면 그 제공자가 transcribeAudio를 구현해야 하는데, 구현한 것은 상용 서비스 일곱 개뿐이다. 우리가 직접 등록한 로컬 제공자에는 그 구현이 없다. 항목에 capabilities: ["audio"]를 적어도 구현이 생기지는 않는다. 그 필드는 의도를 적는 자리이고 전사 경로는 참조하지 않는다.
진단 과정에서 한 번 더 확인한 것이 있다. 명령줄 도구로 같은 오류를 재현했는데, 그 도구가 음성 경로와 같은 함수를 부르는지 코드로 확인했다. 둘 다 transcribeAudioFile을 부르고 있었으므로 명령줄에서 나온 결과가 음성에 대한 증거가 된다. 앞 절에서 반대 사례를 겪었기 때문에 이번에는 먼저 확인했다.
붙인 방법
스키마를 읽어 보니 오디오 항목이 type: "cli"를 받았다. 명령을 실행하고 그 표준 출력을 전사문으로 쓰는 방식이다. HTTP 어댑터를 만들 필요가 없어서 스크립트 하나로 끝냈다. 상주 프로세스도, 포트도, 감시할 서비스도 늘지 않았다.
tools.media.audio.models = [
{ "type": "cli",
"command": "/path/to/stt.py",
"args": ["{{MediaPath}}", "{{Language}}"],
"capabilities": ["audio"] }
]
스크립트는 음성 파일을 base64로 인코딩해서 모델에 넣고 전사문만 출력한다. 여기서도 추론을 꺼야 했다. 켜져 있으면 전사문이 content가 아니라 reasoning_content 쪽으로 나가고, 토큰 예산이 떨어지면 content가 빈 채로 응답이 끝나기 때문이다.
그리고 폴백을 넣지 않았다.
if finish == "length" and not text:
print("STT hit the token limit before producing any transcript",
file=sys.stderr)
return 1
# 침묵은 오류가 아니라 실제 결과다. 아무것도 출력하지 않고 정상 종료해서
# 실패한 구간이 아니라 "전사 결과 없음"으로 기록되게 한다.
if text:
sys.stdout.write(text + "\n")
return 0
음성 비서에서 잘못된 전사문은 조용히 지나간다. 사용자가 하지 않은 말에 모델이 그럴듯하게 대답하기 때문이다. 그래서 실패는 시끄럽게 끝나야 하고, 진짜 침묵만 정상 종료로 구분한다.
4. 전체 구조
세 서버가 준비되면 남는 일은 이것들을 하나의 대화 흐름으로 잇는 것이다. 구성은 이렇게 됐다.
사용자 발화
│
├─ 음성 캡처, 발화 구간 분리
│
├─ STT Gemma-4 E4B-it QAT (4.2 GB) ← 이미 RAG에 쓰던 슬롯
│
├─ 생성 Gemma-4 26B-A4B QAT (16.8 GB) ← 추론 끄기 중간 서버 경유
│
├─ TTS Higgs TTS v3 (11.2 GB) ← 커스텀 보이스, 스트리밍
│
└─ 재생
음성에 쓰이는 세 모델이 합쳐서 약 33 GB다. 네 모델을 모두 올리고 KV 캐시까지 잡힌 지금 이 장비는 121 GiB 중 108 GiB를 쓰고 있다. 16.8 + 4.2 + 11.2에 26B 모델의 mmproj 1.2 GB를 더한 값이다. 여기에 앞에서 말한 27B 모델 17.6 GB가 따로 상주하고, KV 캐시와 여유분이 그 위에 얹힌다. 네 모델 전부가 121 GiB 안에서 동시에 돌아간다.
구성에서 중요한 부분은 OpenClaw 자체는 한 줄도 고치지 않았다는 것이다. 세 번 모두 바깥에 작은 조각을 붙였다. 포크하지 않은 이유는 단순하다. 업스트림이 갱신될 때마다 병합해야 하는 코드를 늘리고 싶지 않았고, 세 조각 전부 되돌리는 데 1분이면 되기 때문이다. 실제로 뒤에서 한 번 되돌린다.
5. 구성 중에 걸린 것들
스트리밍을 지원하는 제공자가 하나뿐이었다
추론을 끄고 나자 음성 합성이 가장 큰 덩어리로 남았다. 로그가 매 턴 같은 줄을 남기고 있었다.
TTS stream: provider openai skipped (openai does not support streaming TTS)
합성 서버는 스트리밍을 할 수 있고 첫 바이트가 1.15초에 나온다. 그런데 OpenClaw가 그 경로를 쓰지 못해서 대답 전체가 만들어질 때까지 기다렸다가 재생을 시작했다. 측정값으로 7.35초였다.
코드베이스 전체를 검색해 보니 streamSynthesize를 구현한 제공자가 하나뿐이었다. 다른 제공자를 스트리밍하게 만드는 설정은 없다. 그래서 그 하나의 요청 형식을 흉내 내는 중간 서버를 만들었다.
변환하지 않는 것이 설계다
OpenClaw는 음성 합성 스트림을 받아서 그대로 ffmpeg에 파이프로 넘긴다.
const ffmpeg = spawn(resolveFfmpegBin(), ["-i", "pipe:0", ...FFMPEG_PCM_ARGUMENTS, "pipe:1"]);
ffmpeg는 컨테이너 형식을 스스로 판별하므로 형식 변환은 필요 없다. 다만 헤더 없는 PCM은 판별하지 못하고, OpenClaw가 -f나 -ar 같은 인자를 넘기지 않으므로 알려 줄 방법도 없다.
그래서 인코더를 끼우는 대신 44바이트짜리 WAV 헤더만 앞에 붙이고 PCM을 그대로 흘려보낸다. 전체 길이를 모르는 상태에서 헤더를 써야 하므로 크기 필드는 미상 값으로 채운다.
def wav_header():
"""전체 길이를 모르는 스트림용 RIFF 헤더. ffmpeg가 이 형태를 받아들인다."""
byte_rate = SAMPLE_RATE * CHANNELS * SAMPLE_WIDTH
block_align = CHANNELS * SAMPLE_WIDTH
return b"".join([
b"RIFF", struct.pack("<I", UNKNOWN_SIZE), b"WAVE",
b"fmt ", struct.pack("<IHHIIHH", 16, 1, CHANNELS, SAMPLE_RATE,
byte_rate, block_align, SAMPLE_WIDTH * 8),
b"data", struct.pack("<I", UNKNOWN_SIZE),
])
이 중간 단계에 인코더를 넣으면 이 단계가 없애려던 지연을 다시 만들게 된다.
제약이 두 가지 더 있었다. 합성 서버는 스트리밍을 response_format: "pcm"일 때만 허용하고, stream: true만 주면 400으로 거절하면서 그 조건을 응답 본문에 적어 준다. 그리고 OpenClaw가 음성 이름을 /^[a-zA-Z0-9]{10,40}$/로 검사하기 때문에 여섯 글자짜리 이름을 그대로 쓸 수 없었다. 열 글자가 넘는 이름으로 등록하고 중간 서버에서 원래 이름으로 되돌렸다.
측정 결과는 이렇다.
| 길이 | 오디오 | 통째로 생성 | 스트리밍 첫 바이트 |
|---|---|---|---|
| 짧음 | 1.48초 | 1.86초 (1.25배) | 1.16초 |
| 중간 | 4.60초 | 5.08초 (1.10배) | 1.15초 |
| 긺 | 9.48초 | 10.21초 (1.08배) | 1.15초 |
통째로 만들면 말하는 시간의 약 1.1배가 걸리므로 대답이 길어질수록 기다리는 시간이 비례해서 늘어난다. 반면 첫 바이트는 길이와 무관하게 1.15초 안팎이다. 짧은 문장만 1.25배로 비싼 이유는 호출당 고정 비용이 0.4초쯤 붙기 때문이고, 이 값은 마지막 절에서 다시 나온다.
말을 빠르게 시키는 방법은 통하지 않았다
오디오가 짧아지면 생성도 빨라질 것 같아서 재 봤다.
speed=1.0 오디오 5.32초 생성 5.84초
speed=1.2 오디오 4.97초 생성 6.52초
오디오는 6퍼센트만 짧아졌고 생성 시간은 오히려 늘었다. 이 방향은 버렸다.
그리고 실패했다
스트리밍을 붙인 직후 측정한 턴은 좋았다. 발화 구간이 확정된 시점부터 첫 소리까지 3.67초였고, 여기에 침묵 판정 대기 1.0초를 더하면 약 4.7초다. 뒤의 결과표는 그 대기를 포함한 값으로 적는다. 그런데 얼마 뒤에 대화가 멈췄다.
10:50:02 TTS stream: starting
10:50:32 30초 타임아웃, 실패
10:50:54 이전 방식으로 다시 합성해서 재생
사용자가 기다린 시간 52초
중간 서버 쪽 기록에는 이 줄이 있었다.
client stopped draining after 5.13s, 49152 pcm bytes (1.02s audio)
받는 쪽이 1초치만 읽고 멈췄다. 실제 실패 때 45,056바이트, 재현했을 때 49,152바이트, 그 다음 발생 때 53,248바이트였다. 세 번 모두 약 1초 분량이다. 세 번 모두 4 KiB 단위로만 달랐고, 읽어 들이는 단위가 4 KiB다. 우연이 아니라 받는 쪽이 담아 둘 수 있는 대략의 한계였다.
코드를 따라가서 이유를 알았다. OpenClaw는 대답 텍스트가 준비되면 스트림을 열고, 실제로 읽어 들이는 것은 재생이 시작된 다음이다. 그 사이에 데이터를 담아 둘 곳은 ffmpeg 표준 입력 파이프와 소켓 버퍼뿐이고, 합쳐서 1초 분량이다. 그보다 많이 흘려보내면 쓰기가 막히고, 그동안 30초 타임아웃이 흘러간다.
먼저 되돌렸다
이 시점의 선택지는 원인을 계속 파는 것과 확인된 상태로 돌아가는 것이었다.
되돌렸다. 스트리밍이 벌어 주는 것은 첫 소리가 7.35초에서 1.15초로 당겨지는 것, 즉 한 턴에 약 6초다. 적지 않은 값이다. 그래도 그 6초를 벌자고 대화가 가끔 50초씩 멈추는 것을 받아들일 수는 없다. 말이 겹치는 것은 대화에서 예외 상황이 아니라 정상이다. 스트리밍을 끄면 매번 12초가 걸리지만 멈추지는 않는다. 일정하게 느린 편이 가끔 멈추는 것보다 낫다.
중간 서버와 설정은 지우지 않고 이름만 바꿔서 보관했고, 다시 켤 때 1분이 걸렸다.
다시 붙일 때 바꾼 것 네 가지
가장 중요한 변경은 생산과 소비를 분리한 것이다. 이전에는 합성 서버 소켓에서 읽어서 받는 쪽으로 바로 썼기 때문에, 받는 쪽이 멈추면 그 정체가 합성 서버까지 거슬러 올라갔다. 아직 듣지도 않는 클라이언트 때문에 GPU의 합성 슬롯이 잡혀 있는 상태가 된다.
# 상류는 전속력으로 비우고, 하류는 제 속도로 가져가게 한다.
chunks = queue.Queue()
def pump():
try:
with upstream:
while True:
chunk = upstream.read(4096)
if not chunk:
break
chunks.put(chunk)
finally:
chunks.put(None)
threading.Thread(target=pump, daemon=True).start()
한 발화는 길어야 80초 분량이고 24 kHz 모노이므로 4 MB를 넘지 않는다. 버퍼에 상한을 둘 필요가 없었다. 그 밖에 연결 재사용을 껐고, 쓰기에 타임아웃을 걸어서 막힌 쓰기가 조용히 매달려 있지 않고 기록에 남게 했다.
아직 고쳤다고 쓰지 않는다
다시 켠 뒤 여섯 턴을 연속으로 돌렸고 한 번도 멈추지 않았다. 10.24초짜리 긴 대답도 끊김 없이 흘렀고, 문제가 나던 조건인 끼어들기도 두 번 발생했지만 아무 일도 일어나지 않았다.
그래도 고쳤다고 쓰지 않는다. 마지막 실패 이후에 네 가지를 동시에 바꿨기 때문이다. 읽기 스레드 분리, 연결 재사용 차단, 쓰기 타임아웃, 그리고 음성 합성 타임아웃을 30초에서 120초로 올린 것이다.
마지막 항목이 의심스럽다. 52초 정지는 30초에서 타임아웃이 발생한 결과였다. 120초로 올렸다면 같은 대기가 그냥 성공했을 수 있고, 그러면 원인은 그대로 남아 있고 증상만 드러나지 않게 된 것이다. 이번 측정으로는 두 경우를 구분할 수 없다. 대기 자체가 발생하지 않았기 때문이다.
그래서 계측을 그대로 두었다. 다시 멈추면 한 줄로 판가름 난다. OpenClaw가 TTS stream: starting을 남긴 시각과 중간 서버가 요청을 받은 시각의 차이다.
덧붙이면 조사 중에 타임아웃을 30초에서 10초로 낮춘 적이 있는데 방향이 반대였다. 재생 차례를 기다리던 스트림을 짧은 마감이 죽이는 구조이므로, 막으려던 실패를 스스로 만드는 설정이었다. 원인을 알고 나서 되돌렸다.
6. 자체 음성 서버를 구성한다면
결과부터 적는다.
마지막 열은 스트리밍을 다시 켠 뒤 연속으로 돌린 여섯 턴에서 나온 값의 범위다.
| 구간 | 처음 | 추론 끈 뒤 | 스트리밍까지 |
|---|---|---|---|
| 음성 인식 | 1.15초 | 0.42초 | 0.37~0.46초 |
| 응답 생성 | 27.95초 | 2.22초 | 2.07~2.70초 |
| 음성 합성 | 2.89초 | 7.35초 | 1.15~1.27초 |
| 침묵 판정 대기 | 2.0초 | 2.0초 | 1.0초 |
| 말을 멈춘 뒤 첫 소리까지 | 34초 | 12초 | 약 4.9초 |
침묵 판정 대기는 설정값 하나였다. 발화가 끝났는지를 가리는 이 구간을 음성 처리에서는 VAD(voice activity detection) 구간이라고 부르고, 실시간 음성 에이전트에서는 여기에 200밀리초 정도를 잡는다. 우리는 1.0초를 쓰고 있으니 그 다섯 배다. 사용자가 말을 멈춘 뒤 발화가 끝났다고 판단하기까지 기다리는 시간이 기본값 2초로 잡혀 있었고, 1초로 줄여도 2.1초와 3.2초짜리 발화가 온전히 잡혔다. 순수하게 체감 지연 1초를 줄인 변경이다.
같은 구성을 시도한다면 고려할 것을 정리하면 다음과 같다.
먼저 그 장비에서 돌아가는지부터 본다
모델 선택에서 품질 지표보다 앞서는 조건이 실행 가능성이다. CosyVoice 3은 벤치마크가 아니라 의존성 고정 조합이 이 GPU를 지원하지 않는다는 이유로 탈락했다. 그리고 실패했을 때는 모델 카드의 예제부터 돌려 본다. 우리 입력이 원인인지 아닌지가 그 한 번으로 갈린다.
이미 있는 자원을 먼저 본다
STT를 전용 모델로 올렸다면 네 번째 서버가 생기고 메모리를 다시 나눠야 했다. 실제로는 RAG에 쓰던 모델이 오디오 입력을 받았고, 그것으로 충분했다. 전용 모델이 필요하다는 판단은 이미 있는 것을 시험한 다음에 내려도 늦지 않다.
1인용 서비스에 동시성 기본값을 두지 않는다
기본값은 여러 사용자를 전제한다. 음성 비서는 대개 한 번에 한 대화만 처리하므로, 동시 요청 수와 KV 풀 크기를 실제 사용량에 맞추면 지연을 내주지 않고 메모리를 회수할 수 있다. 이 구성에서는 34퍼센트였다.
지연의 대부분은 모델이 아니라 배치에서 나온다
남은 약 5초를 뜯어 보면 더 빠른 장비를 사는 것으로는 해결되지 않는다는 것이 드러난다.
| 남은 약 5초 | 정체 | 더 빠른 장비가 줄여 주는가 |
|---|---|---|
| 침묵 판정 1.0초 | 설정값 | 아니다. 이 구간에 흔히 잡히는 예산은 200밀리초 수준이다 |
| 음성 인식 0.45초 | 전용 모델이 아님 | 조금 |
| 응답 생성 2.1~2.7초 | 대답을 다 쓸 때까지 대기 | 아니다. 구조 문제다 |
| 음성 합성 1.15초 | 모델과 서빙 | 조금 |
세 번째 줄이 핵심이다. 모델은 0.035초와 0.004초에 첫 토큰을 내놓고 있었다. 2초 넘게 기다리는 이유는 모델이 느려서가 아니라 대답이 전부 나올 때까지 기다렸다가 음성 합성으로 넘기기 때문이다. 실시간 음성 에이전트가 1초 미만을 내는 방식은 토큰이 나오는 대로 잘라서 합성에 밀어 넣어 두 단계를 겹치는 것이다.
다만 겹치기에는 상한이 있다
그렇다면 잘게 잘라서 겹치면 되겠다고 생각하기 쉬운데, 우리 구성에서는 그렇지 않다.
복제 방식 합성 모델은 레퍼런스 클립을 조건으로 목소리를 만들기 때문에 호출마다 그 조건을 다시 잡는다. 문장보다 잘게 자르면 조각마다 억양이 새로 시작되고 이음매가 들린다. 산수도 맞지 않는다. 조각 하나가 재생되는 동안 다음 조각의 첫 바이트가 도착해야 끊기지 않는데, 첫 바이트가 1.15초이므로 조각이 그보다 짧으면 재생이 따라잡지 못한다. 이 합성 모델의 발화 속도를 재 보니 문장이 길어질수록 초당 5음절 근처로 수렴했다(5음절 1.48초, 11음절 1.96초, 22음절 4.68초, 44음절 8.84초). 그러면 1.15초는 대여섯 음절이다. 긴 절은 이 선을 넘지만 "네", "그래서", "알겠어요" 같은 짧은 조각은 넘지 못하고, 그런 조각이 하나만 끼어도 그 자리에서 소리가 끊긴다. 게다가 호출당 고정 비용 0.4초가 조각 수만큼 곱해진다.
그래서 문장 단위가 상한이고, 그렇게 해도 1.8초 정도로 추정된다. 계산에 쓴 값은 실측이지만 1.8초 자체는 아직 확인하지 않은 추정이다. 그리고 1초 미만은 이 합성 모델로는 나오지 않는다. 첫 바이트 1.15초가 그 자체로 1초를 넘기 때문이다. §0에서 인용한 계층별 예산이 음성 합성 첫 조각에 40~100밀리초를 잡는 자리에, 우리는 1.15초를 쓰고 있다. 거기가 이 구성의 바닥이다.
두 대안: 상용 API와 음성 대 음성 모델
이 글에서 가장 정직하게 답해야 할 반론이다. 지연만 놓고 보면 두 대안이 모두 이 구성을 이긴다.
| 선택지 | 첫 소리까지 | 대신 내주는 것 |
|---|---|---|
| 상용 실시간 음성 API | 500~800밀리초 목표 | 음성이 밖으로 나간다. 사용량 과금. 서비스 정책과 모델 변경에 종속된다 |
| 음성 대 음성 모델 | 연쇄 구조의 대략 절반 | 단계가 하나로 합쳐져서 중간에 끼어들 자리가 없다. 모델 교체와 세밀한 제어를 포기한다 |
| 이 구성 (연쇄, 전부 로컬) | 약 5초 | 지연 |
음성 대 음성 모델이 연쇄 구조의 절반 정도라는 것은 여러 곳에서 같은 방향으로 인용된다. 단계 사이의 변환과 대기가 통째로 사라지기 때문이다. (Dograh, Softcery)
그래서 이 구성을 고르는 근거는 속도가 아니다. 음성이 집 밖으로 나가지 않는 것, 모델을 마음대로 바꾸는 것, 그리고 단계 사이에 손을 넣을 수 있는 것이다. 이 글의 세 조각이 전부 단계 사이에 손을 넣어서 만들어졌다는 점이 그 근거를 그대로 보여 준다. 음성 대 음성 모델이었다면 추론을 끄는 프록시도, 스트리밍 shim도 넣을 자리가 없다.
반대로 말하면 속도가 가장 중요한 용도라면 이 구성은 답이 아니다. 전화 응대처럼 상대가 기다려 주지 않는 곳이라면 상용 API나 음성 대 음성 모델을 쓰는 편이 맞다. 이 구성은 상대가 나 하나이고, 5초를 기다려 줄 수 있고, 대신 대화 내용이 밖으로 나가지 않기를 원하는 경우에 맞는다.
추론은 경로별로 끈다
추론을 끄면 모델이 덜 똑똑해지는 것이 맞다. 그래서 음성 경로에만 껐고 텍스트 대화에서는 그대로 켜 두었다. 음성 대화는 대체로 짧은 왕복이고 거기에 필요한 것은 깊이가 아니라 속도이며, 긴 사고가 필요한 요청은 텍스트로 하면 된다. 이 구분을 설정 하나로 표현할 수 없어서 모델 항목을 나눈 것이지 판단을 포기한 것은 아니다.
마지막으로
이 구성에서 프레임워크를 고친 부분은 없다. 세 번 모두 바깥에 조각을 하나씩 붙였고, 각각 되돌리는 데 1분이 걸리며 실제로 한 번 되돌렸다. 프레임워크에 그 기능을 적을 칸이 없을 때, 칸을 만드는 것만이 방법은 아니다.
한 가지는 분명히 해 둔다. 이 글의 약 5초는 앞에서 "아직 고쳤다고 쓰지 않는다"고 적은 그 스트리밍 위에서 나온 값이다. 같은 여섯 턴에서 나왔고, 그 여섯 턴이 고쳤다는 증거가 되지 못한다면 이 숫자도 같은 만큼만 확실하다. 지금까지 멈춘 적은 없다는 것이 현재로서 말할 수 있는 전부다.
그리고 이 글에는 아직 확인하지 못한 것이 두 가지 남아 있다. 스트리밍 정지가 정말 고쳐졌는지, 그리고 문장 단위로 겹쳤을 때 1.8초가 나오는지다. 둘 다 확인되면 그때 다시 적는다.
'Computing, Engineering > Computing' 카테고리의 다른 글
| llama.cpp 플래그 하나를 2에서 6으로: speculative decoding 초안 길이를 실측으로 정한 기록 (0) | 2026.08.23 |
|---|---|
| DGX Spark 한 대로 로컬 LLM 네 개를 직접 측정했습니다 (0) | 2026.08.12 |
| MS Office 365 Copilot 사용 시 Windows Copilot 연동 (0) | 2024.05.11 |
| INTEL Stick PC Windows 10 업데이트 model STCK1A32WFC (0) | 2016.03.15 |
| Windows 8.1 문제… (0) | 2013.10.28 |