
로컬 음성 비서가 즉각적으로 반응하게 만든 방법
요약
사용자의 음성 명령에 즉각적으로 반응하는 로컬 음성 비서 SpeakoFlow의 개발 과정과 지연 시간(latency) 최적화 방법을 다룹니다. 순차적인 파이프라인 방식의 한계를 극복하고 사용자 경험을 개선하기 위한 아키텍처 설계 전략을 설명합니다.
핵심 포인트
- 순차적 파이프라인 방식은 로컬 환경에서도 심각한 지연을 유발함
- 모델 교체보다 프로세스 간 병렬 처리 및 오케스트레이션이 성능 개선에 핵심적임
- 녹음 시간 내에 콜드 스타트 문제를 숨기는 설계가 중요함
- 음성 인식, 화면 인식, LLM, TTS를 통합한 데스크톱 음성 레이어 구축
나는 단 하나의 단축키를 원했습니다.
그것을 누르고, 말하고, 비서가 내 화면을 보게 한 뒤, 6개의 탭을 열거나 내 앞에 이미 있는 것을 설명할 필요 없이 답변을 얻는 것 말이죠.
간단하죠?
내 머릿속의 아키텍처(Architecture)는 다음과 같았습니다:
마이크(microphone) → 로컬 AI 매직(local AI magic) → 유용한 답변(useful answer)
코드상으로는 다음과 더 비슷했습니다:
global shortcut
→ 오디오 레코더(audio recorder)
→ 음성 활동 감지(voice activity detection)
...
모델(Models)은 쉬운 부분이었습니다.
이 모든 것들이 하나의 빠르고 차분한 비서처럼 작동하게 만드는 과정이 흥미로워지는 지점이었습니다.
나는 시험을 위해 혼자 공부하던 중에 이것을 만들었습니다. 나는 작업 창과 챗봇(chatbot) 탭 사이를 계속 오가며, 컨텍스트(context)를 복사하고, 질문을 던지고, 답변을 다시 복사해 오는 과정을 반복하며 서서히 의욕을 잃어갔습니다.
또한 나는 말하는 것이 타이핑보다 빠르기 때문에 받아쓰기(dictation) 소프트웨어에 비용을 지불하고 있었습니다. 받아쓰기는 작동했지만, 텍스트에서 멈췄습니다. 그것은 내 말을 들을 수는 있었지만, 나를 도와줄 수는 없었습니다.
그래서 나는 내가 실제로 원하는 것, 즉 데스크톱 위의 음성 레이어(voice layer)를 만들기 시작했습니다.
SpeakoFlow는 Handy의 포크(fork)로 시작되었으며, 이는 나에게 견고한 로컬 받아쓰기(local dictation) 기반을 제공해주었습니다. 나는 그 부분을 새로 만들지 않았으며, 공로를 명확히 하고 싶습니다. 나는 비서(assistant), 화면 인식(screen vision), 음성 답변(spoken answers), 번역(translation), 메모리(memory), 그리고 이 조각들을 하나의 경험으로 바꾸는 오케스트레이션(orchestration)을 구축했습니다.
다음은 나를 가장 놀라게 했던 문제들입니다.
왜 완전한 로컬 파이프라인(pipeline)이 여전히 느리게 느껴졌는가
나의 첫 번째 멘탈 모델(mental model)은 완전히 순차적이었습니다:
- 사용자 녹음.
- 녹음 중지.
- 전사(transcription) 모델 로드.
- 오디오 전사.
- 화면 캡처.
- LLM 시작.
- 답변 생성.
- 텍스트 음성 변환(text-to-speech) 시작.
이 파이프라인에서 기술적으로 틀린 부분은 없었습니다.
그저 느낌이 최악이었을 뿐입니다.

모든 단계가 순차적으로 실행되면 완전한 로컬 파이프라인(fully local pipeline)이라도 여전히 느리게 느껴질 수 있습니다. 장치에서 실행되는 구성 요소의 수보다 임계 경로(critical path)가 더 중요합니다.
가장 큰 지연 시간(latency) 개선은 모델을 교체해서 얻은 것이 아니었습니다. 그것은 기다리기를 거부함으로써 얻은 결과였습니다.
녹음 시간(recording window) 안에 콜드 스타트(cold start) 숨기기
사람은 말을 하는 데 몇 초를 소비할 것입니다. 그 시간은 무료로 사용할 수 있는 지연 시간 예산(latency budget)입니다.
비서의 녹음이 시작되자마자, SpeakoFlow는 병렬로 유용한 작업을 시작할 수 있습니다:
// 실제 흐름의 단순화된 버전
fn recording_started() {
initiate_transcription_model_load();
...
사용자가 “이 터미널의 오류를 설명해 줄래?”라고 말하는 동안, 앱은 이미 로컬 모델을 로드하고 시각적 컨텍스트(visual context)를 준비하고 있습니다.
음성-텍스트 변환(speech-to-text)이 완료될 때쯤이면, 콜드 스타트 작업의 상당 부분이 이미 완료되었을 수 있습니다.
“Hey Flow” 프리웜(prewarm) 경로는 더 좁습니다. 이 경로는 Flow가 활성화되어 있고, 스트리밍 전사(streaming transcription) 모델이 실시간 텍스트를 생성하며, 활성화 문구(activation phrase)가 확정된 전사 내용(committed transcript)을 이끌고, 선택된 비서 제공자가 내장 엔진일 때만 실행됩니다. 배치 전사(batch transcription) 모델은 실시간 텍스트를 내보내지 않으며, 클라우드 제공업체는 로드할 로컬 모델이 없습니다.
이를 통해 일반적인 받아쓰기 중에 수 기가바이트 크기의 LLM을 깨우는 것을 방지하면서도, 실제 비서 요청과 시작 단계를 중첩(overlap)시킬 수 있습니다.
교훈은 간단했습니다:
음성 인터페이스에서 사용자의 말하기 시간은 당신의 지연 시간 예산의 일부입니다.
임계 경로(critical-path) 타임라인
콜드 스타트는 모델, 하드웨어, 가속기(accelerator)에 따라 다르기 때문에 하나의 보편적인 지연 시간 수치를 인용하는 것은 피합니다. 더 유용한 측정 지표는 어떤 작업이 여전히 첫 번째 가시적 토큰(visible token)을 차단하고 있는가입니다.
| 단계 | 시작된 작업 | 중요한 이유 |
|---|---|---|
| 비서 녹음 시작 | 전사 모델 로드 시작, VAD 프리로드, 이전 TTS 중지, 그리고 선택된 경우 내장 LLM 프리웜(단, 언로드 정책이 '즉시'가 아닌 경우) | 녹음 후 경로에서 설정 작업을 제거함 |
| ... |
이것이 모델 추론(inference)을 무료로 만들어주는 것은 아닙니다. 단계 사이의 피할 수 있는 유휴 간격(idle gaps)을 제거하는 것입니다.
글로벌 핫키(Global hotkeys)에는 직렬화된 상태 머신(serialized state machine)이 필요합니다
일반적인 버튼은 보통 하나의 이벤트만 발생시킵니다. 하지만 글로벌 단축키(Global shortcut)는 반복적인 누름 이벤트(press events), 지연된 떼기 이벤트(delayed releases), 또는 떼기 이벤트가 아예 발생하지 않는 상황을 만들 수 있습니다.
떼기 이벤트가 늦게 도착할 수도 있습니다. 전사(transcription)가 여전히 실행 중인 동안 사용자가 버튼을 다시 누를 수도 있습니다. 핸즈프리(hands-free) 녹음이 해당 녹음을 시작한 키의 생명주기보다 더 오래 지속될 수도 있습니다. 오래된 타임아웃(timeout)이 깨어나서 완전히 다른 녹음을 중단시키려 할 수도 있습니다.

결국 저는 명시적인 상태(states)를 가진 하나의 코디네이터(coordinator)를 통해 녹음을 라우팅(route)했습니다:
Idle → Recording → Processing → Idle
이 코디네이터는 시작(start), 중지(stop), 취소(cancel), 커밋(commit), 그리고 핸즈프리(hands-free) 전환(transitions)을 관리합니다. 코디네이터는 중복된 트리거(trigger)를 거부하며, 안전 타이머(safety timers)를 태깅하여 오래된 타이머가 새로운 녹음을 중단시키지 못하도록 합니다.
이러한 전환(transitions)을 직렬화(serializing)하는 것이 중요한 부분입니다. 모든 생명주기(lifecycle) 변화는 하나의 코디네이터를 거치기 때문에, 중복된 키 이벤트가 비동기(asynchronous) 전사 및 붙여넣기 파이프라인(pipeline)과 경합(race)을 벌일 수 없습니다.
음성 소프트웨어는 모호함이 들어설 자리가 거의 없습니다. 앱이 두 번 실행되거나, 문장을 무시하거나, 사용자가 멈춘 후에도 계속 녹음된다면 신뢰도는 즉시 사라집니다.
화면 인식(Screen vision)은 이미지 예산 편성(image-budgeting) 문제입니다
화면 인식(Screen vision)은 쉬워 보입니다:
let screenshot = capture_screen();
send_to_model(screenshot);
이 방식은 스크린샷이 엄격한 API 게이트웨이(gateway), 작은 로컬 컨텍스트 윈도우(context window), 또는 다중 모니터 설정에 부딪히기 전까지는 아주 잘 작동합니다.
SpeakoFlow는 마우스 커서 아래의 모니터를 캡처하며, 기본 모니터를 폴백(fallback)으로 사용합니다. 커서는 사용자가 실제로 어디에서 작업하고 있는지에 대한 가장 좋은 단서가 됩니다.
그 다음 이미지는 제공자별 압축 단계(compression ladder)를 거칩니다.
현재 코드에는 다음과 같은 서로 다른 프로필(profiles)이 있습니다:
- Azure와 같은 엄격한 게이트웨이(gateways)
- 로컬 llama.cpp 비전 모델(vision models)
- 페이로드(payload) 제한이 더 큰 클라우드 모델(cloud models)
각 프로필은 base64 페이로드가 대상에 맞을 때까지 이미지 해상도(dimensions)와 JPEG 품질의 조합을 시도합니다.
for (max_dimension, quality) in profile.ladder {
let jpeg = resize_and_encode(&image, max_dimension, quality);
...
만약 어떤 단계(rung)도 대상에 맞지 않는 경우, 인코더는 캡처를 실패하는 대신 가장 작은 시도 결과물을 유지합니다.
현재 대략적인 목표치는 엄격한 게이트웨이(gateways)의 경우 약 48 KB, 로컬 비전(local vision)의 경우 200 KB, 그리고 더 관대한 클라우드 제공업체의 경우 384 KB 범위입니다.
로컬 모델(local models)의 경우, 이는 단순히 전송 속도만의 문제가 아닙니다. 스크린샷은 비전 토큰(vision tokens)을 소비합니다. 스크린샷이 너무 크면 컨텍스트 윈도우(context window)에서 대화 내용을 밀어내게 됩니다. 반대로 너무 작으면 모델이 도움을 받고자 하는 에러 메시지를 읽을 수 없습니다.
이로 인해 화면 캡처는 단순한 스크린샷 호출이 아니라, 제약 조건이 있는 인코딩 문제(constrained encoding problem)가 됩니다.
화면 접근과 캡처 타이밍은 별개의 결정 사항입니다
SpeakoFlow는 권한(permission)과 타이밍(timing)을 분리합니다. 이 둘은 동일한 설정이 아닙니다.
1. 화면 캡처 허용 여부를 누가 결정하는가
- Off (꺼짐): 화면 캡처가 비활성화됩니다.
- Manual (수동): 사용자가 세션을 위해 화면 공유를 명시적으로 활성화하거나, 특정 턴(turn)에 이미지를 첨부합니다.
- Agent decides (에이전트 결정): 요청이 이미지 없이 시작되며, 모델은
capture_screen도구(tool)를 받습니다. 모델은 질문이 가시적인 컨텍스트(visible context)에 진정으로 의존할 때만 해당 도구를 호출할 수 있으며, 메시지당 최대 한 번만 호출할 수 있습니다.
2. 수동으로 활성화된 음성 턴이 캡처되는 시점
- Immediate (즉시): 녹음이 시작될 때 캡처하여, 사용자가 질문을 시작할 때 보고 있던 화면을 보존합니다.
- On send (전송 시): 전사(transcription) 후에 캡처하여, 요청이 전송될 때 보이는 화면을 사용합니다.
텍스트 입력 메시지는 녹음 시작 이벤트가 없기 때문에 전송 시에 캡처됩니다. 'Agent decides' 모드에서는 모델이 도구를 호출할 때 캡처가 발생하므로, 'Immediate' 및 'On send' 설정은 해당 경로를 제어하지 않습니다.
즉각적인 캡처 (Immediate capture)는 사용자가 말하는 동안 백그라운드 스레드 (background thread)에서 실행됩니다. 해당 프레임 (frame)은 생성 토큰 (generation token)을 포함합니다. 만약 사용자가 취소하거나, 다른 턴 (turn)을 시작하거나, 화면 권한을 변경하면 해당 토큰은 무효화되며, 이전 프레임은 이후의 요청에 첨부될 수 없습니다.
에이전트가 결정한 경로 (agent-decided path)는 도구 호출 (tool call)이 성공했을 때 대화창에 시각적인 스크린샷 마커와 소형 썸네일 (thumbnail)을 추가합니다. 이는 모델이 언제 화면을 볼지 직접 결정하더라도 감사 추적 (audit trail)을 보존할 수 있게 해줍니다.
취소 (Cancellation)는 버튼이 아닙니다. 그것은 모든 백그라운드 작업이 반드시 이해해야 하는 규칙입니다.
전체 스크린샷은 한 번만 전송하고, 썸네일만 유지하기
대화 기록에 전체 해상도의 스크린샷을 보관하는 것은 편리해 보였지만, 그것이 무엇을 의미하는지 고려해 보기 전까지는 그랬습니다.
이후의 모든 요청이 동일한 이미지를 다시 전송할 수 있습니다. 컨텍스트 (Context) 사용량이 늘어날 것입니다. 히스토리 파일 (History files)이 무거워질 것입니다. 앱이 필요 이상의 시각적 데이터를 보유하게 될 것입니다.
따라서 전체 이미지는 단 하나의 모델 턴 (model turn)에만 속해야 합니다.
SpeakoFlow는 보이는 채팅 기록을 위해 더 작은 썸네일을 생성합니다. 사용자는 무엇이 공유되었는지 여전히 확인할 수 있지만, 원본 프레임이 모델로 반복해서 다시 전송되지는 않습니다.
이를 통해 반복적인 이미지 페이로드 (image payloads)가 컨텍스트를 소모하고 저장된 대화 기록을 비대하게 만드는 것을 방지하면서도, 시각적인 감사 추적 (audit trail)을 보존할 수 있습니다.
llama.cpp를 관리형 로컬 서비스로 취급하기
내장된 어시스턴트는 llama.cpp를 로컬 루프백 서비스 (local loopback service)로 실행합니다.
애플리케이션은 이를 관리해야 합니다:
- 호환 가능한 엔진을 찾거나 다운로드합니다.
- 운영 체제 (operating system)에 맞는 적절한 빌드 (build)를 선택합니다.
127.0.0.1에서 실행합니다.- 선택된 GGUF 모델을 로드합니다.
- 필요할 때 비전 프로젝터 (vision projector)를 연결합니다.
- 상태 확인 (health check)을 기다립니다.
- 활성 요청 (active requests) 동안 프로세스를 유지합니다.
- 설정된 유휴 기간 (idle period) 이후에 언로드 (unload)합니다.
- 앱이 종료될 때 프로세스도 반드시 종료되도록 합니다.
또한 두 개의 빠른 요청이 서버의 두 개 복사본을 동시에 시작하도록 허용해서는 안 됩니다. 시작 프로세스는 직렬화 (serialized)되며, 모델 전환 (model switches)은 이전 프로세스가 포트 (port)를 해제할 때까지 대기합니다.
놀랍게도 단 하나의 작은 플래그가 매우 큰 개선을 가져왔습니다:
--parallel 1
lama-server는 보통 여러 개의 생성 슬롯 (generation slots)을 지원합니다. 이는 공유 서버의 경우에는 합리적입니다. 하지만 SpeakoFlow는 단일 사용자 데스크톱 앱입니다.
여러 개의 슬롯은 가용 컨텍스트 (context)를 동시 요청들 사이에 분할합니다. 스크린샷 하나만으로도 작은 로컬 모델의 컨텍스트 윈도우 (context window) 중 상당 부분을 이미 소비할 수 있으므로, 남은 부분을 분할하면 조기 절단 (truncation)이나 KV-캐시 (KV-cache) 할당 실패가 발생할 수 있습니다.
단일 슬롯을 사용하면 활성화된 데스크톱 대화에 설정된 전체 컨텍스트를 부여할 수 있습니다. 드물게 발생하는 중복 요청은 파편화된 캐시 공간을 두고 경쟁하는 대신 대기열 (queue)에 쌓이게 됩니다.
컨텍스트 실패는 모델 자체의 문제가 아니라, 다중 사용자 워크로드에 최적화된 서버 기본 설정 때문에 발생한 것이었습니다.

하나의 API 클라이언트가 로컬 및 클라우드 모델 모두와 통신합니다
로컬 엔진은 OpenAI 호환 엔드포인트 (endpoint)를 노출합니다. 동일한 Rust 클라이언트로 다음 서비스들과 통신할 수 있습니다:
- 내장된 llama.cpp 엔진
- Ollama
- LM Studio
- OpenAI 호환 클라우드 서비스
- 약간의 인증 조정이 필요한 기타 제공업체
응답은 SSE 이벤트 (SSE events)로 도착합니다. Rust 백엔드는 생성(generation)이 진행되는 동안 텍스트를 React 어시스턴트 패널로 전달하지만, 델타 (deltas)를 약 40ms마다 최대 한 번씩 방출하도록 병합 (coalesce)합니다. 각 방출은 패널 WebView 내에서 evaluate_script 호출이 되는데, 이 상위 경로(upstream path)는 호출당 메모리 누수 (memory leak)가 발생하므로, 토큰당 한 번씩 WebView 호출을 실행하지 않으면서 스트리밍 효과를 유지하기 위해 배칭 (batching)을 사용합니다. 대화 차례 (turn)가 끝나면, 권위 있는 (authoritative) 대화 스냅샷이 임시 스트리밍 텍스트를 대체합니다.
어시스턴트는 또한 작고 제한된 도구 루프 (tool loop)를 실행할 수 있습니다. 사용자의 설정에 따라 모델은 웹 검색, 현재 날짜 가져오기 또는 화면 캡처를 결정할 수 있습니다.
라운드 캡 (round cap)은 모델이 최종 응답을 생성하지 않고 동일한 도구를 반복해서 호출하는 것을 방지합니다.
TTS 중단은 동시성 문제입니다
Text-to-speech (TTS)는 생성 (generation) 단계 이후에 위치하지만, 두 가지 별개의 책임을 가집니다: 음성을 위해 텍스트를 정규화 (normalize) 하는 것과 재생을 취소 가능 (cancellable) 하게 만드는 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기